From 0a34f28378ce7cbd2fc72a214cbf4fe893bdb6a5 Mon Sep 17 00:00:00 2001 From: Ichsan Haryadi Date: Wed, 18 Feb 2026 16:41:26 +0800 Subject: [PATCH] feat: exclude attr tracking --- README.md | 5 ++++ pyproject.toml | 2 +- selv.py | 70 ++++++++++++++++++++++++++----------------- tests/test_exclude.py | 20 +++++++++++++ 4 files changed, 68 insertions(+), 29 deletions(-) create mode 100644 tests/test_exclude.py diff --git a/README.md b/README.md index 276e272..1742f82 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/pyproject.toml b/pyproject.toml index be97760..9fd9de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/selv.py b/selv.py index b99db78..6a684a2 100644 --- a/selv.py +++ b/selv.py @@ -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.""" @@ -435,18 +437,9 @@ 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) @@ -454,23 +447,42 @@ def new_setattr(self_obj: Any, name: str, value: Any) -> 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.""" @@ -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 diff --git a/tests/test_exclude.py b/tests/test_exclude.py new file mode 100644 index 0000000..a41c32b --- /dev/null +++ b/tests/test_exclude.py @@ -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