Skip to content

Commit

Permalink
Merge branch 'main' into dev/issue-17-check-value-equality
Browse files Browse the repository at this point in the history
  • Loading branch information
ddanier committed Jan 4, 2024
2 parents 8887729 + bae6757 commit 5a4ad6f
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand Down
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ 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_restore_original()` will create a new instance of the model containing its
original state.
* `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 All @@ -52,6 +61,10 @@ something.model_changed_fields # = set()
something.name = "something else"
something.model_has_changed # = True
something.model_changed_fields # = {"name"}

original = something.model_restore_original()
original.name # = "something"
original.model_has_changed # = False
```

### When will a change be detected
Expand All @@ -70,7 +83,6 @@ 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.

Example:
```python
import pydantic
from pydantic_changedetect import ChangeDetectionMixin
Expand All @@ -83,8 +95,33 @@ todos = TodoList(items=["release new version"])
original_items = todos.items.copy()
todos.items.append("create better docs") # This change will NOT be seen yet
todos.model_has_changed # = False
todos.model_set_changed("items", original=original_items) # Mark field as changed
todos.model_has_changed # = False
todos.model_set_changed("items", original=original_items) # Mark field as changed and store original value
todos.model_has_changed # = True
```

### 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
Expand Down
115 changes: 109 additions & 6 deletions pydantic_changedetect/changedetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,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 @@ -99,8 +102,9 @@ def model_changed_fields(self) -> Set[str]:
field_value_list = field_value
elif isinstance(field_value, dict):
field_value_list = list(field_value.values())
else:
else: # pragma: no cover
# Continue on unsupported type
# (should be already filtered by is_pydantic_change_detect_annotation)
continue

# Check if any of the values has changed
Expand Down Expand Up @@ -145,8 +149,9 @@ def model_changed_fields_recursive(self) -> Set[str]:
field_value_list = list(enumerate(field_value))
elif isinstance(field_value, dict):
field_value_list = list(field_value.items())
else:
else: # pragma: no cover
# Continue on unsupported type
# (should be already filtered by is_pydantic_change_detect_annotation)
continue

# Check if any of the values has changed
Expand All @@ -164,9 +169,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 @@ -256,6 +261,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 @@ -268,6 +274,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 @@ -292,6 +302,96 @@ 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:
"""
Restore original state of value if it contains any ChangeDetectionMixin
instances.
Contain might be:
* value is a list containing such instances
* value is a dict containing such instances
* value is a ChangeDetectionMixin instance itself
"""

if isinstance(value, list):
return [
cls.model_restore_value(v)
for v
in value
]
elif isinstance(value, dict):
return {
k: cls.model_restore_value(v)
for k, v
in value.items()
}
elif (
isinstance(value, ChangeDetectionMixin)
and value.model_has_changed
):
return value.model_restore_original()
else:
return value

def model_restore_original(
self: "Model",
) -> "Model":
"""Restore original state of a ChangeDetectionMixin object."""

restored_values = {}
for key, value in self.__dict__.items():
restored_values[key] = self.model_restore_value(value)

return self.__class__(
**{
**restored_values,
**self.model_original,
},
)

def model_get_original_field_value(self, field_name: str, /) -> Any:
"""Return original value for a field."""

self_compat = PydanticCompat(self)

if field_name not in self_compat.model_fields:
raise AttributeError(f"Field {field_name} not available in this model")

if field_name in self.model_original:
return self.model_original[field_name]

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 @@ -322,6 +422,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 @@ -422,6 +523,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 @@ -517,6 +619,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
Loading

0 comments on commit 5a4ad6f

Please sign in to comment.