Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Tracking attribute changes helps me understand how a complex class behaves and s
- **Container support**: Tracks value modifications inside container
- **Flexible logging**: Use good ol `print` statement or any custom logger function
- **View changelog**: Query complete change history for any attribute
- **Exclude specific attributes**: Optionally exclude specific attributes from tracking

## Installation

Expand Down Expand Up @@ -98,6 +99,10 @@ The `@selv` decorator has a few parameters to customize its behavior.
- Function to use for logging change messages (e.g., `logging.info`, `logging.debug`)
- Can be any function that accepts a string argument

3. **`exclude`** (`List[str]`, default: `None`)
- List of attribute names to exclude from tracking
- Useful for exclude sensitive data or unimportant attributes

## License

MIT License - see [LICENSE](LICENSE) file for details.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "selv"
version = "1.0.0"
version = "1.1.0"
description = "A Python decorator for logging attribute changes in classes"
readme = "README.md"
requires-python = ">=3.8"
Expand Down
70 changes: 42 additions & 28 deletions selv.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,11 @@ def __init__(
self,
track_private: bool = True,
logger: Optional[Callable[[str], None]] = None,
exclude: Optional[List[str]] = None,
):
self.track_private = track_private
self.logger = logger
self.exclude = exclude or []

def wrap_container(self, self_obj: Any, name: str, value: Any) -> Any:
"""Wrap dict/list/set values."""
Expand Down Expand Up @@ -435,42 +437,52 @@ def create_setattr(
"""Create the __setattr__ method for the decorated class."""

def new_setattr(self_obj: Any, name: str, value: Any) -> None:
if name.startswith("_") and not self.track_private:
if original_setattr:
original_setattr(self_obj, name, value)
else:
object.__setattr__(self_obj, name, value)
return

if name.startswith("_selv_"):
if original_setattr:
original_setattr(self_obj, name, value)
else:
object.__setattr__(self_obj, name, value)
# Handle attributes that should not be tracked
if self._should_skip_tracking(name):
self._set_attribute(self_obj, name, value, original_setattr)
return

has_old_value = hasattr(self_obj, name)
old_value = getattr(self_obj, name, None) if has_old_value else None

wrapped_value = self.wrap_container(self_obj, name, value)

if original_setattr:
original_setattr(self_obj, name, wrapped_value)
else:
object.__setattr__(self_obj, name, wrapped_value)

if not name.startswith("_selv_"):
self.log_change(
self_obj,
cls.__name__,
name,
old_value,
wrapped_value,
is_initial=not has_old_value,
)
self._set_attribute(self_obj, name, wrapped_value, original_setattr)

self.log_change(
self_obj,
cls.__name__,
name,
old_value,
wrapped_value,
is_initial=not has_old_value,
)

return new_setattr

def _should_skip_tracking(self, name: str) -> bool:
"""Check if an attribute should be skipped for tracking."""
if name.startswith("_") and not self.track_private:
return True
if name.startswith("_selv_"):
return True
if name in self.exclude:
return True
return False

def _set_attribute(
self,
self_obj: Any,
name: str,
value: Any,
original_setattr: Optional[Callable],
) -> None:
"""Set an attribute using the appropriate setattr method."""
if original_setattr:
original_setattr(self_obj, name, value)
else:
object.__setattr__(self_obj, name, value)

def create_view_changelog(self) -> Callable:
"""Create the view_changelog method for the decorated class."""

Expand Down Expand Up @@ -547,12 +559,14 @@ def _format_record(


def _selv(
track_private: bool = True, logger: Optional[Callable[[str], None]] = None
track_private: bool = True,
logger: Optional[Callable[[str], None]] = None,
exclude: Optional[List[str]] = None,
) -> Any:
"""Class decorator for logging attribute changes."""

def decorator(cls: Type[T]) -> Any:
decorator_instance = _SelvDecorator(track_private, logger)
decorator_instance = _SelvDecorator(track_private, logger, exclude)
original_setattr = getattr(cls, "__setattr__", None)

# Create bound methods for the class
Expand Down
20 changes: 20 additions & 0 deletions tests/test_exclude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Test exclude feature of selv decorator."""

from selv import selv


def test_exclude_feature():
"""Test that excluded attributes are not tracked."""

@selv(exclude=["password"])
class User:
def __init__(self):
self.username = "alice"
self.password = "secret123"

user = User()
user.password = "newsecret456"
changelog = user.view_changelog(format="attr")

assert "password" not in changelog
assert "username" in changelog