Skip to content

Commit

Permalink
Merge pull request #20 from team23/dev/issue-10-support-change-markers
Browse files Browse the repository at this point in the history
Solve #10: Support change markers
  • Loading branch information
ddanier authored Jan 4, 2024
2 parents 463c551 + b40d0be commit bae6757
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 5 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
47 changes: 43 additions & 4 deletions pydantic_changedetect/changedetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,23 @@ 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)
self.model_reset_changed()

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]:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
53 changes: 52 additions & 1 deletion tests/test_changedetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ def test_compatibility_methods_work():
assert something.__original__ == {"id": 1}


# Model restore
# Restore model/value state


def test_restore_original():
Expand Down Expand Up @@ -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()

0 comments on commit bae6757

Please sign in to comment.