Skip to content

Commit

Permalink
Merge branch 'main' into dev/issue-10-support-change-markers
Browse files Browse the repository at this point in the history
  • Loading branch information
ddanier committed Jan 4, 2024
2 parents 09a00d8 + 463c551 commit b40d0be
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 5 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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ 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`
Expand All @@ -56,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
```

### Restrictions
Expand Down
70 changes: 68 additions & 2 deletions pydantic_changedetect/changedetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,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 @@ -147,8 +148,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 Down Expand Up @@ -271,6 +273,70 @@ 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:
Expand Down
96 changes: 96 additions & 0 deletions tests/test_changedetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,102 @@ def test_compatibility_methods_work():
assert something.__original__ == {"id": 1}


# Restore model/value state


def test_restore_original():
something = Something(id=1)

something.id = 2
assert something.model_has_changed is True

old_something = something.model_restore_original()

assert something is not old_something
assert something.id == 2
assert old_something.id == 1


def test_restore_original_nested():
something = Something(id=1)
nested = Nested(sub=something)

nested.sub.id = 2
assert nested.sub.model_has_changed is True
assert nested.model_has_changed is True

old_nested = nested.model_restore_original()

assert nested.sub.id == 2
assert old_nested.sub.id == 1


def test_restore_original_nested_assignment():
something = Something(id=1)
nested = Nested(sub=something)

nested.sub = Something(id=2)
assert nested.sub.model_has_changed is False
assert nested.model_has_changed is True

old_nested = nested.model_restore_original()

assert nested.sub.id == 2
assert old_nested.sub.id == 1


def test_restore_original_nested_list():
something = Something(id=1)
nested = NestedList(sub=[something])

nested.sub[0].id = 2
assert nested.sub[0].model_has_changed is True
assert nested.model_has_changed is True

old_nested = nested.model_restore_original()

assert nested.sub[0].id == 2
assert old_nested.sub[0].id == 1


def test_restore_original_nested_dict():
something = Something(id=1)
nested = NestedDict(sub={"test": something})

nested.sub["test"].id = 2
assert nested.sub["test"].model_has_changed is True
assert nested.model_has_changed is True

old_nested = nested.model_restore_original()

assert nested.sub["test"].id == 2
assert old_nested.sub["test"].id == 1


def test_restore_field_value():
something = Something(id=1)

something.id = 2
assert something.model_has_changed is True
assert something.model_get_original_field_value("id") == 1


def test_restore_field_value_checks_field_availability():
something = Something(id=1)

with pytest.raises(AttributeError):
something.model_get_original_field_value("invalid_field")


def test_restore_field_value_nested():
something = Something(id=1)
nested = Nested(sub=something)

nested.sub.id = 2
assert nested.model_has_changed is True
assert nested.model_get_original_field_value("sub") == Something(id=1)


# Changed markers


Expand Down

0 comments on commit b40d0be

Please sign in to comment.