From 1e4a644a6d4ce5ae6a58b5c609e5b15da0a771da Mon Sep 17 00:00:00 2001 From: David Danier Date: Fri, 10 Nov 2023 15:28:55 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20issue-10=20=E2=9C=A8=20Add=20change?= =?UTF-8?q?d=20marker=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pydantic_changedetect/changedetect.py | 40 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/pydantic_changedetect/changedetect.py b/pydantic_changedetect/changedetect.py index 363d230..09a7a3c 100644 --- a/pydantic_changedetect/changedetect.py +++ b/pydantic_changedetect/changedetect.py @@ -54,6 +54,7 @@ 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") @@ -68,6 +69,7 @@ def model_reset_changed(self) -> None: 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]: @@ -163,9 +165,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 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) @@ -227,6 +229,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: @@ -239,6 +242,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, @@ -263,6 +270,32 @@ def _get_changed_export_includes( kwargs["include"] = set(changed_fields) return kwargs + # 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 unmark_changed(self, marker: str) -> None: + """Remove one changed marker.""" + + self.model_changed_markers.discard(marker) + + def 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: @@ -293,6 +326,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( @@ -393,6 +427,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: @@ -488,6 +523,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] From 0e2f1f6b83aadd8fb49af33ff6f6c0f85bff1912 Mon Sep 17 00:00:00 2001 From: David Danier Date: Fri, 10 Nov 2023 15:33:56 +0100 Subject: [PATCH 2/5] =?UTF-8?q?test:=20issue-10=20=F0=9F=9A=A8=20Add=20tes?= =?UTF-8?q?ts=20for=20changed=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pydantic_changedetect/changedetect.py | 4 +-- tests/test_changedetect.py | 51 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/pydantic_changedetect/changedetect.py b/pydantic_changedetect/changedetect.py index 09a7a3c..b784a5b 100644 --- a/pydantic_changedetect/changedetect.py +++ b/pydantic_changedetect/changedetect.py @@ -283,12 +283,12 @@ def model_mark_changed(self, marker: str) -> None: self.model_changed_markers.add(marker) - def unmark_changed(self, marker: str) -> None: + def model_unmark_changed(self, marker: str) -> None: """Remove one changed marker.""" self.model_changed_markers.discard(marker) - def has_changed_marker( + def model_has_changed_marker( self, marker: str, ) -> bool: diff --git a/tests/test_changedetect.py b/tests/test_changedetect.py index 54329bc..9b34084 100644 --- a/tests/test_changedetect.py +++ b/tests/test_changedetect.py @@ -485,3 +485,54 @@ def test_compatibility_methods_work(): assert something.__changed_fields_recursive__ == {"id"} with pytest.warns(DeprecationWarning): assert something.__original__ == {"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() From 15a06dc7268eb52a8714c7f8f63aa0e7dc455d7c Mon Sep 17 00:00:00 2001 From: David Danier Date: Fri, 10 Nov 2023 15:40:50 +0100 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20issue-10=20=F0=9F=93=9A=20Add=20cha?= =?UTF-8?q?nged=20marker=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index f2a0c1d..c64b0dd 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Using the `ChangeDetectionMixin` the pydantic models are extended, so: changed fields. **Note:** When using pydantic 1.x you need to use `obj.dict()` and `obj.json()`. Both also accept `exclude_unchanged`. +* `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 @@ -63,6 +67,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, From ee46d54b421a381cb5bc165e7132b77cfc54e9d4 Mon Sep 17 00:00:00 2001 From: David Danier Date: Fri, 10 Nov 2023 15:44:19 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20issue-10=20=F0=9F=90=9B=20Add=20mode?= =?UTF-8?q?l=5Fchanged=5Fmarkers=20to=20=5F=5Fslots=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pydantic_changedetect/changedetect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_changedetect/changedetect.py b/pydantic_changedetect/changedetect.py index b784a5b..0db09a6 100644 --- a/pydantic_changedetect/changedetect.py +++ b/pydantic_changedetect/changedetect.py @@ -56,7 +56,7 @@ class Something(ChangeDetectionMixin, pydantic.BaseModel): 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) From 09a00d8c5e10983ef337b188d7f73621c132974e Mon Sep 17 00:00:00 2001 From: David Danier Date: Fri, 10 Nov 2023 15:47:54 +0100 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20issue-10=20=F0=9F=93=9A=20Better=20?= =?UTF-8?q?docstring=20for=20changed=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pydantic_changedetect/changedetect.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pydantic_changedetect/changedetect.py b/pydantic_changedetect/changedetect.py index 0db09a6..7033914 100644 --- a/pydantic_changedetect/changedetect.py +++ b/pydantic_changedetect/changedetect.py @@ -64,7 +64,8 @@ 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", {}) @@ -165,7 +166,7 @@ def model_changed_fields_recursive(self) -> Set[str]: @property def model_has_changed(self) -> bool: - """Return True, when some field was changed or changed marker is set.""" + """Return True, when some field was changed or some changed marker is set.""" if self.model_self_changed_fields or self.model_changed_markers: return True