diff --git a/README.md b/README.md index 7acc9d7..f8577ea 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ Using the `ChangeDetectionMixin` the pydantic models are extended, so: * `obj.model_get_original_field_value("field_name")` will return the original value for just one field. It will call `model_restore_original()` on the current field value if the field is set to a `ChangeDetectionMixin` instance (or list/dict of those). +* `obj.model_mark_changed("marker_name")` and `obj.model_unmark_changed("marker_name")` + allow to add arbitrary change markers. An instance with a marker will be seen as changed + (`obj.model_has_changed == True`). Markers are stored in `obj.model_changed_markers` + as a set. ### Example @@ -72,6 +76,31 @@ value to `model_set_changed()` when you want to also keep track of the actual ch compared to the original value. Be advised to `.copy()` the original value as lists/dicts will always be changed in place. +### Changed markers + +You may also just mark the model as changed. This can be done using changed markers. +A change marker is just a string that is added as the marker, models with such an marker +will also be seen as changed. Changed markers also allow to mark models as changed when +related data was changed - for example to also update a parent object in the database +when some children were changed. + +```python +import pydantic +from pydantic_changedetect import ChangeDetectionMixin + +class Something(ChangeDetectionMixin, pydantic.BaseModel): + name: str + + +something = Something(name="something") +something.model_has_changed # = False +something.model_mark_changed("mood") +something.model_has_changed # = True +something.model_changed_markers # {"mood"} +something.model_unmark_changed("mood") # also will be reset on something.model_reset_changed() +something.model_has_changed # = False +``` + # Contributing If you want to contribute to this project, feel free to just fork the project, diff --git a/pydantic_changedetect/changedetect.py b/pydantic_changedetect/changedetect.py index b6f345e..f57b71c 100644 --- a/pydantic_changedetect/changedetect.py +++ b/pydantic_changedetect/changedetect.py @@ -54,8 +54,9 @@ class Something(ChangeDetectionMixin, pydantic.BaseModel): if TYPE_CHECKING: # pragma: no cover model_original: Dict[str, Any] model_self_changed_fields: Set[str] + model_changed_markers: set[str] - __slots__ = ("model_original", "model_self_changed_fields") + __slots__ = ("model_original", "model_self_changed_fields", "model_changed_markers") def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -63,11 +64,13 @@ def __init__(self, **kwargs: Any) -> None: def model_reset_changed(self) -> None: """ - Reset the changed state, this will clear model_self_changed_fields and model_original + Reset the changed state, this will clear model_self_changed_fields, model_original + and remove all changed markers. """ object.__setattr__(self, "model_original", {}) object.__setattr__(self, "model_self_changed_fields", set()) + object.__setattr__(self, "model_changed_markers", set()) @property def model_changed_fields(self) -> Set[str]: @@ -165,9 +168,9 @@ def model_changed_fields_recursive(self) -> Set[str]: @property def model_has_changed(self) -> bool: - """Return True, when some field was changed""" + """Return True, when some field was changed or some changed marker is set.""" - if self.model_self_changed_fields: + if self.model_self_changed_fields or self.model_changed_markers: return True return bool(self.model_changed_fields) @@ -229,6 +232,7 @@ def __getstate__(self) -> Dict[str, Any]: state = super().__getstate__() state["model_original"] = self.model_original.copy() state["model_self_changed_fields"] = self.model_self_changed_fields.copy() + state["model_changed_markers"] = self.model_changed_markers.copy() return state def __setstate__(self, state: Dict[str, Any]) -> None: @@ -241,6 +245,10 @@ def __setstate__(self, state: Dict[str, Any]) -> None: object.__setattr__(self, "model_self_changed_fields", state["model_self_changed_fields"]) else: object.__setattr__(self, "model_self_changed_fields", set()) + if "model_changed_markers" in state: + object.__setattr__(self, "model_changed_markers", state["model_changed_markers"]) + else: + object.__setattr__(self, "model_changed_markers", set()) def _get_changed_export_includes( self, @@ -265,6 +273,8 @@ def _get_changed_export_includes( kwargs["include"] = set(changed_fields) return kwargs + # Restore model/value state + @classmethod def model_restore_value(cls, value: Any, /) -> Any: """ @@ -327,6 +337,32 @@ def model_get_original_field_value(self, field_name: str, /) -> Any: current_value = getattr(self, field_name) return self.model_restore_value(current_value) + # Changed markers + + def model_mark_changed(self, marker: str) -> None: + """ + Add marker for something being changed. + + Markers can be used to keep information about things being changed outside + the model scope, but related to the model itself. This could for example + be a marker for related objects being added/updated/removed. + """ + + self.model_changed_markers.add(marker) + + def model_unmark_changed(self, marker: str) -> None: + """Remove one changed marker.""" + + self.model_changed_markers.discard(marker) + + def model_has_changed_marker( + self, + marker: str, + ) -> bool: + """Check whether one changed marker is set.""" + + return marker in self.model_changed_markers + # pydantic 2.0 only methods if PYDANTIC_V2: @@ -357,6 +393,7 @@ def model_copy( ) object.__setattr__(clone, "model_original", self.model_original.copy()) object.__setattr__(clone, "model_self_changed_fields", self.model_self_changed_fields.copy()) + object.__setattr__(clone, "model_changed_markers", self.model_changed_markers.copy()) return clone def model_dump( @@ -457,6 +494,7 @@ def copy( ) object.__setattr__(clone, "model_original", self.model_original.copy()) object.__setattr__(clone, "model_self_changed_fields", self.model_self_changed_fields.copy()) + object.__setattr__(clone, "model_changed_markers", self.model_changed_markers.copy()) return clone if PYDANTIC_V2: @@ -552,6 +590,7 @@ def _copy_and_set_values( ) object.__setattr__(clone, "model_original", self.model_original.copy()) object.__setattr__(clone, "model_self_changed_fields", self.model_self_changed_fields.copy()) + object.__setattr__(clone, "model_changed_markers", self.model_changed_markers.copy()) return clone def dict( # type: ignore[misc] diff --git a/tests/test_changedetect.py b/tests/test_changedetect.py index d74eabf..a35ddeb 100644 --- a/tests/test_changedetect.py +++ b/tests/test_changedetect.py @@ -487,7 +487,7 @@ def test_compatibility_methods_work(): assert something.__original__ == {"id": 1} -# Model restore +# Restore model/value state def test_restore_original(): @@ -581,3 +581,54 @@ def test_restore_field_value_nested(): nested.sub.id = 2 assert nested.model_has_changed is True assert nested.model_get_original_field_value("sub") == Something(id=1) + + +# Changed markers + + +def test_changed_markers_can_be_set(): + something = Something(id=1) + + something.model_mark_changed("test") + assert "test" in something.model_changed_markers + assert something.model_has_changed_marker("test") + + +def test_changed_markers_can_be_unset(): + something = Something(id=1) + + something.model_mark_changed("test") + assert something.model_has_changed_marker("test") + + something.model_unmark_changed("test") + assert not something.model_has_changed_marker("test") + + +def test_changed_markers_will_be_also_reset(): + something = Something(id=1) + + something.model_mark_changed("test") + assert something.model_has_changed_marker("test") + + something.model_reset_changed() + assert not something.model_has_changed_marker("test") + + +def test_model_is_changed_if_marker_or_change_exists(): + something = Something(id=1) + + assert not something.model_has_changed + something.model_mark_changed("test") + assert something.model_has_changed + something.model_reset_changed() + + assert not something.model_has_changed + something.model_set_changed("id") + assert something.model_has_changed + something.model_reset_changed() + + assert not something.model_has_changed + something.model_set_changed("id") + something.model_mark_changed("test") + assert something.model_has_changed + something.model_reset_changed()