├── .python-version ├── pydantic_changedetect ├── py.typed ├── __init__.py ├── _compat.py ├── utils.py └── changedetect.py ├── .gitignore ├── tox.ini ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── lint.yml │ └── test.yml ├── commitlint.config.js ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── justfile ├── tests ├── test_utils.py └── test_changedetect.py └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /pydantic_changedetect/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /uv.lock 2 | /dist/* 3 | /.coverage 4 | /.tox 5 | 6 | __pycache__ 7 | *.pyc 8 | *.pyo 9 | -------------------------------------------------------------------------------- /pydantic_changedetect/__init__.py: -------------------------------------------------------------------------------- 1 | from .changedetect import ChangeDetectionMixin as ChangeDetectionMixin 2 | -------------------------------------------------------------------------------- /pydantic_changedetect/_compat.py: -------------------------------------------------------------------------------- 1 | from pydantic.version import VERSION as PYDANTIC_VERSION 2 | 3 | PYDANTIC_VERSION_TUPLE = tuple(map(int, PYDANTIC_VERSION.split('.'))) 4 | PYDANTIC_GE_V2_7 = PYDANTIC_VERSION_TUPLE >= (2, 7, 0) 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | py310-{2.6,2.x}, 5 | py311-{2.6,2.x}, 6 | py312-{2.6,2.x}, 7 | py313-{2.x} 8 | 9 | [testenv] 10 | deps = 11 | pytest 12 | 2.6: pydantic>=2.0,<2.7 13 | 2.x: pydantic>=2.0,<3.0 14 | commands = pytest 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | assignees: 8 | - "ddanier" 9 | - package-ecosystem: "uv" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | - dependency-name: "pydantic" 15 | assignees: 16 | - "ddanier" 17 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // See https://github.com/conventional-changelog/commitlint/blob/master/%40commitlint/config-conventional/index.js 3 | extends: ['@commitlint/config-conventional'], 4 | // Own rules 5 | rules: { 6 | 'subject-case': [ 7 | 2, 8 | 'never', 9 | ['start-case', 'pascal-case', 'upper-case'], 10 | ], 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "RELEASE: Upload Python Package to PyPI" 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | id-token: write 10 | steps: 11 | - uses: actions/checkout@v6 12 | - name: Install uv 13 | uses: astral-sh/setup-uv@v7 14 | - name: Build package 15 | run: uv build 16 | - name: Publish package 17 | run: uv publish 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "LINT: Run ruff & pyright" 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: '0 7 * * 1' 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - name: Set up Python 13 | uses: actions/setup-python@v6 14 | with: 15 | python-version-file: ".python-version" 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v7 18 | - name: Install the dependencies 19 | run: uv sync --all-extras --dev 20 | - name: Lint with ruff & pyright 21 | run: | 22 | uv run ruff check pydantic_changedetect tests 23 | uv run pyright pydantic_changedetect 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: check-added-large-files 7 | - id: check-merge-conflict 8 | - id: check-docstring-first 9 | - id: debug-statements 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.14.0 12 | hooks: 13 | - id: ruff 14 | args: [--fix, --exit-non-zero-on-fix] 15 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 16 | rev: v9.23.0 17 | hooks: 18 | - id: commitlint 19 | stages: [commit-msg] 20 | additional_dependencies: 21 | - "@commitlint/config-conventional" 22 | default_stages: 23 | - pre-commit 24 | default_install_hook_types: 25 | - pre-commit 26 | - commit-msg 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2022 TEAM23 GmbH 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pydantic-changedetect" 3 | version = "0.10.1" 4 | description = "Extend pydantic models to also detect and record changes made to the model attributes." 5 | authors = [{ name = "TEAM23 GmbH", email = "info@team23.de" }] 6 | license = "MIT" 7 | license-files = ["LICENSE"] 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | dependencies = [ 11 | "pydantic>=2.0.0,<3.0.0", 12 | ] 13 | 14 | [project.urls] 15 | Repository = "https://github.com/team23/pydantic-changedetect" 16 | 17 | [dependency-groups] 18 | dev = [ 19 | "pytest>=8.4.2,<10.0.0", 20 | "pytest-cov>=7.0.0,<8", 21 | "tox>=4.30.3,<5.0", 22 | "ruff>=0.14.0,<0.15.0", 23 | "pyright>=1.1.406,<1.2", 24 | ] 25 | 26 | [tool.ruff] 27 | line-length = 115 28 | target-version = "py310" 29 | output-format = "grouped" 30 | 31 | [tool.ruff.lint] 32 | select = ["F","E","W","C","I","N","UP","ANN","S","B","A","COM","C4","T20","PT","ARG","TD","RUF"] 33 | ignore = ["A001","A002","A003","ANN401","C901","N8","B008","F405","F821","UP035","UP006","PT030"] 34 | 35 | [tool.ruff.lint.per-file-ignores] 36 | "__init__.py" = ["F401"] 37 | "conftest.py" = ["S101","ANN","F401"] 38 | "test_*.py" = ["S101","ANN","F401"] 39 | 40 | [build-system] 41 | requires = ["uv_build>=0.8.22,<0.9.0"] 42 | build-backend = "uv_build" 43 | 44 | [tool.uv.build-backend] 45 | module-name = "pydantic_changedetect" 46 | module-root = "" 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "TEST: Run pytest using tox" 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: '0 7 * * 1' 7 | jobs: 8 | test-older-python: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: 13 | - "3.10" 14 | - "3.11" 15 | - "3.12" 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | - name: Set up Python 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v7 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install the dependencies 28 | run: uv sync --all-extras --dev 29 | - name: Test with pytest 30 | run: | 31 | uv run tox -e 'py-2.6,py-2.x' 32 | test: 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | python-version: 37 | - "3.13" 38 | 39 | steps: 40 | - uses: actions/checkout@v6 41 | - name: Set up Python 42 | uses: actions/setup-python@v6 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install uv 46 | uses: astral-sh/setup-uv@v7 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | - name: Install the dependencies 50 | run: uv sync --all-extras --dev 51 | - name: Test with pytest 52 | run: | 53 | uv run tox -e 'py-2.x' 54 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | just --list 3 | 4 | [unix] 5 | _install-pre-commit: 6 | #!/usr/bin/env bash 7 | if ( which pre-commit > /dev/null 2>&1 ) 8 | then 9 | pre-commit install --install-hooks 10 | else 11 | echo "-----------------------------------------------------------------" 12 | echo "pre-commit is not installed - cannot enable pre-commit hooks!" 13 | echo "Recommendation: Install pre-commit ('brew install pre-commit')." 14 | echo "-----------------------------------------------------------------" 15 | fi 16 | 17 | [windows] 18 | _install-pre-commit: 19 | #!powershell.exe 20 | Write-Host "Please ensure pre-commit hooks are installed using 'pre-commit install --install-hooks'" 21 | 22 | install: (uv "sync" "--group" "dev") && _install-pre-commit 23 | 24 | update: (uv "sync" "--group" "dev") 25 | 26 | uv *args: 27 | uv {{args}} 28 | 29 | test *args: (uv "run" "pytest" "--cov=pydantic_changedetect" "--cov-report" "term-missing:skip-covered" args) 30 | 31 | test-all: (uv "run" "tox") 32 | 33 | ruff *args: (uv "run" "ruff" "check" "pydantic_changedetect" "tests" args) 34 | 35 | pyright *args: (uv "run" "pyright" "pydantic_changedetect" args) 36 | 37 | lint: ruff pyright 38 | 39 | release version: (uv "version" version) 40 | git add pyproject.toml 41 | git commit -m "release: 🔖 v$(uv version --short)" --no-verify 42 | git tag "v$(uv version --short)" 43 | git push 44 | git push --tags 45 | 46 | version-bump version_bump: (uv "version" "--bump" version_bump) 47 | git add pyproject.toml 48 | git commit -m "release: 🔖 v$(uv version --short)" --no-verify 49 | git tag "v$(uv version --short)" 50 | git push 51 | git push --tags 52 | -------------------------------------------------------------------------------- /pydantic_changedetect/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections.abc import Mapping 3 | from types import UnionType 4 | from typing import Any, Dict, List, Set, Tuple, Union, get_args, get_origin 5 | 6 | import pydantic_changedetect 7 | 8 | 9 | def safe_issubclass(cls: Any, type_: Any) -> bool: 10 | """ 11 | Return True if the first argument is a subclass of the second argument. 12 | 13 | This is a safe version of issubclass() that returns False if the first 14 | argument is not a valid class. We are checking whether the first argument 15 | is a class first by doing a `isinstance(cls, type_)` check. But this still can 16 | raise issues with Python 3.9+ as we are allowed to use normal types as type 17 | definitions there. And somehow `isinstance(list[str], type)` returns True 18 | while `isinstance(List[str], type)` (List from typing here) returns False. 19 | 20 | This would not be an issue with `issubclass(cls, type_)` as `list[str]` is 21 | normally handled correctly. But if the `type_` is a child class of `abc.ABC` 22 | the method will fail with a `TypeError: issubclass() arg 2 must be a class` as 23 | `abc.ABC` overrides the `__subclasscheck__` magic method. 24 | 25 | This function will catch the `TypeError` and return False in that case. 26 | """ 27 | 28 | warnings.warn( 29 | "safe_issubclass() is deprecated and will be removed", 30 | DeprecationWarning, 31 | stacklevel=2, 32 | ) 33 | 34 | if not isinstance(cls, type): 35 | return False 36 | 37 | try: 38 | return issubclass(cls, type_) 39 | except TypeError: 40 | return False 41 | 42 | 43 | def is_class_type(annotation: Any) -> bool: 44 | # If the origin is None, it's likely a concrete class 45 | return get_origin(annotation) is None 46 | 47 | 48 | def is_pydantic_change_detect_annotation(annotation: type[Any] | UnionType | None) -> bool: 49 | """ 50 | Return True if the given annotation is a ChangeDetectionMixin annotation. 51 | """ 52 | 53 | if annotation is None: 54 | return False 55 | 56 | # if annotation is an ChangeDetectionMixin everything is easy 57 | if ( 58 | is_class_type(annotation) 59 | and isinstance(annotation, type) 60 | and issubclass(annotation, pydantic_changedetect.ChangeDetectionMixin) 61 | ): 62 | return True 63 | 64 | # Otherwise we may need to handle typing arguments 65 | origin = get_origin(annotation) 66 | if ( 67 | origin is List 68 | or origin is list 69 | or origin is Set 70 | or origin is set 71 | or origin is Tuple 72 | or origin is tuple 73 | ): 74 | return is_pydantic_change_detect_annotation(get_args(annotation)[0]) 75 | elif ( 76 | origin is Dict 77 | or origin is dict 78 | or origin is Mapping 79 | ): 80 | return is_pydantic_change_detect_annotation(get_args(annotation)[1]) 81 | elif ( 82 | origin is Union 83 | or origin is UnionType 84 | ): 85 | # Note: This includes Optional, as Optional[...] is just Union[..., None] 86 | return any( 87 | is_pydantic_change_detect_annotation(arg) 88 | for arg in get_args(annotation) 89 | ) 90 | 91 | # If we did not detect an ChangeDetectionMixin annotation, return False 92 | return False 93 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional, Union 3 | 4 | import pydantic 5 | import pytest 6 | 7 | from pydantic_changedetect import ChangeDetectionMixin 8 | from pydantic_changedetect.utils import is_pydantic_change_detect_annotation, safe_issubclass 9 | 10 | 11 | class BaseClass: 12 | pass 13 | 14 | 15 | class NormalClass(BaseClass): 16 | pass 17 | 18 | 19 | class AbstractClass(BaseClass, abc.ABC): 20 | pass 21 | 22 | 23 | def test_safe_issubclass(): 24 | with pytest.warns(DeprecationWarning): 25 | assert safe_issubclass(NormalClass, BaseClass) 26 | with pytest.warns(DeprecationWarning): 27 | assert safe_issubclass(AbstractClass, BaseClass) 28 | 29 | 30 | def test_safe_issubclass_for_type_definitions(): 31 | with pytest.warns(DeprecationWarning): 32 | assert safe_issubclass(list[str], BaseClass) is False 33 | with pytest.warns(DeprecationWarning): 34 | assert safe_issubclass(dict[str, str], BaseClass) is False 35 | 36 | with pytest.warns(DeprecationWarning): 37 | assert safe_issubclass(list[str], BaseClass) is False 38 | with pytest.warns(DeprecationWarning): 39 | assert safe_issubclass(dict[str, str], BaseClass) is False 40 | 41 | 42 | def test_ensure_normal_issubclass_raises_an_issue(): 43 | with pytest.raises(TypeError): 44 | issubclass(list[str], AbstractClass) 45 | 46 | 47 | def test_safe_issubclass_for_type_definitions_for_abstract(): 48 | with pytest.warns(DeprecationWarning): 49 | assert safe_issubclass(list[str], AbstractClass) is False 50 | with pytest.warns(DeprecationWarning): 51 | assert safe_issubclass(dict[str, str], AbstractClass) is False 52 | 53 | with pytest.warns(DeprecationWarning): 54 | assert safe_issubclass(list[str], AbstractClass) is False 55 | with pytest.warns(DeprecationWarning): 56 | assert safe_issubclass(dict[str, str], AbstractClass) is False 57 | 58 | 59 | class SomeModel(ChangeDetectionMixin, pydantic.BaseModel): 60 | pass 61 | 62 | 63 | class OtherModel(pydantic.BaseModel): 64 | pass 65 | 66 | 67 | def test_is_pydantic_change_detect_annotation_direct_types(): 68 | assert is_pydantic_change_detect_annotation(int) is False 69 | assert is_pydantic_change_detect_annotation(str) is False 70 | assert is_pydantic_change_detect_annotation(Union[int, str]) is False # noqa: UP007 71 | assert is_pydantic_change_detect_annotation(int | str) is False 72 | assert is_pydantic_change_detect_annotation(OtherModel) is False 73 | 74 | assert is_pydantic_change_detect_annotation(ChangeDetectionMixin) is True 75 | assert is_pydantic_change_detect_annotation(SomeModel) is True 76 | 77 | 78 | def test_is_pydantic_change_detect_annotation_optional_types(): 79 | assert is_pydantic_change_detect_annotation(Optional[int]) is False # noqa: UP045 80 | assert is_pydantic_change_detect_annotation(int | None) is False 81 | assert is_pydantic_change_detect_annotation(Optional[OtherModel]) is False # noqa: UP045 82 | assert is_pydantic_change_detect_annotation(OtherModel | None) is False 83 | 84 | assert is_pydantic_change_detect_annotation(Optional[SomeModel]) is True # noqa: UP045 85 | assert is_pydantic_change_detect_annotation(SomeModel | None) is True 86 | 87 | 88 | def test_is_pydantic_change_detect_annotation_union_types(): 89 | assert is_pydantic_change_detect_annotation(Union[int, None]) is False # noqa: UP007 90 | assert is_pydantic_change_detect_annotation(int | None) is False 91 | assert is_pydantic_change_detect_annotation(Union[OtherModel, int]) is False # noqa: UP007 92 | assert is_pydantic_change_detect_annotation(OtherModel | int) is False 93 | assert is_pydantic_change_detect_annotation(Union[OtherModel, None]) is False # noqa: UP007 94 | assert is_pydantic_change_detect_annotation(OtherModel | None) is False 95 | 96 | assert is_pydantic_change_detect_annotation(Union[SomeModel, None]) is True # noqa: UP007 97 | assert is_pydantic_change_detect_annotation(SomeModel | None) is True 98 | assert is_pydantic_change_detect_annotation(Union[SomeModel, int]) is True # noqa: UP007 99 | assert is_pydantic_change_detect_annotation(SomeModel | int) is True 100 | assert is_pydantic_change_detect_annotation(Union[SomeModel, OtherModel]) is True # noqa: UP007 101 | assert is_pydantic_change_detect_annotation(SomeModel | OtherModel) is True 102 | 103 | 104 | def test_is_pydantic_change_detect_annotation_list_types(): 105 | assert is_pydantic_change_detect_annotation(list[int]) is False 106 | assert is_pydantic_change_detect_annotation(list[OtherModel]) is False 107 | assert is_pydantic_change_detect_annotation(tuple[int]) is False 108 | assert is_pydantic_change_detect_annotation(tuple[OtherModel]) is False 109 | 110 | assert is_pydantic_change_detect_annotation(list[SomeModel]) is True 111 | assert is_pydantic_change_detect_annotation(tuple[SomeModel]) is True 112 | 113 | 114 | def test_is_pydantic_change_detect_annotation_dict_types(): 115 | assert is_pydantic_change_detect_annotation(dict[str, int]) is False 116 | assert is_pydantic_change_detect_annotation(dict[str, OtherModel]) is False 117 | 118 | assert is_pydantic_change_detect_annotation(dict[str, SomeModel]) is True 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pydantic change detection 2 | 3 | ## Installation 4 | 5 | Just use `pip install pydantic-changedetect` to install the library. 6 | 7 | **Note:** `pydantic-changedetect` is compatible with `pydantic` version `2.x` on Python `3.10`, `3.11`, 8 | `3.12` and `3.13`. This is also ensured running all tests on all those versions using `tox`. 9 | 10 | ## About 11 | 12 | When working with database models it is pretty common to want to detect changes 13 | to the model attributes. The `ChangeDetectionMixin` just provides this mechanism 14 | to any pydantic models. Changes will be detected and stored after the model 15 | was constructed. 16 | 17 | Using the `ChangeDetectionMixin` the pydantic models are extended, so: 18 | * `obj.model_changed_fields` contains a list of all changed fields 19 | - `obj.model_self_changed_fields` contains a list of all changed fields for the 20 | current object, ignoring all nested models. 21 | - `obj.model_changed_fields_recursive` contains a list of all changed fields and 22 | also include the named of the fields changed in nested models using a 23 | dotted field name syntax (like `nested.field`). 24 | * `obj.model_original` will include the original values of all changed fields in 25 | a dict. 26 | * `obj.model_has_changed` returns True if any field has changed. 27 | * `obj.model_set_changed()` manually sets fields as changed. 28 | - `obj.model_set_changed("field_a", "field_b")` will set multiple fields as changed. 29 | - `obj.model_set_changed("field_a", original="old")` will set a single field as 30 | changed and also store its original value. 31 | * `obj.model_reset_changed()` resets all changed fields. 32 | * `obj.model_dump()` and `obj.model_dump_json()` accept an additional parameter 33 | `exclude_unchanged`, which - when set to True - will only export the 34 | changed fields. 35 | **Note:** When using pydantic 1.x you need to use `obj.dict()` and `obj.json()`. Both 36 | also accept `exclude_unchanged`. 37 | * `obj.model_restore_original()` will create a new instance of the model containing its 38 | original state. 39 | * `obj.model_get_original_field_value("field_name")` will return the original value for 40 | just one field. It will call `model_restore_original()` on the current field value if 41 | the field is set to a `ChangeDetectionMixin` instance (or list/dict of those). 42 | * `obj.model_mark_changed("marker_name")` and `obj.model_unmark_changed("marker_name")` 43 | allow to add arbitrary change markers. An instance with a marker will be seen as changed 44 | (`obj.model_has_changed == True`). Markers are stored in `obj.model_changed_markers` 45 | as a set. 46 | 47 | ### Example 48 | 49 | ```python 50 | import pydantic 51 | from pydantic_changedetect import ChangeDetectionMixin 52 | 53 | class Something(ChangeDetectionMixin, pydantic.BaseModel): 54 | name: str 55 | 56 | 57 | something = Something(name="something") 58 | something.model_has_changed # = False 59 | something.model_changed_fields # = set() 60 | something.name = "something else" 61 | something.model_has_changed # = True 62 | something.model_changed_fields # = {"name"} 63 | 64 | original = something.model_restore_original() 65 | original.name # = "something" 66 | original.model_has_changed # = False 67 | ``` 68 | 69 | ### When will a change be detected 70 | 71 | `pydantic-changedetect` will see changes when an attribute value is changes using an 72 | attribute assignment like `something.name = "something else"` in the example above. Such 73 | an assignment will be seen as a change unless the new value is the same and the type of 74 | the value is `None` or of type `str`, `int`, `float`, `bool` or `decimal.Decimal`. 75 | 76 | #### Restrictions 77 | 78 | `ChangeDetectionMixin` currently cannot detect changes inside lists, dicts and 79 | other structured objects. In those cases you are required to set the changed 80 | state yourself using `model_set_changed()`. It is recommended to pass the original 81 | value to `model_set_changed()` when you want to also keep track of the actual changes 82 | compared to the original value. Be advised to `.copy()` the original value 83 | as lists/dicts will always be changed in place. 84 | 85 | ```python 86 | import pydantic 87 | from pydantic_changedetect import ChangeDetectionMixin 88 | 89 | class TodoList(ChangeDetectionMixin, pydantic.BaseModel): 90 | items: list[str] 91 | 92 | 93 | todos = TodoList(items=["release new version"]) 94 | original_items = todos.items.copy() 95 | todos.items.append("create better docs") # This change will NOT be seen yet 96 | todos.model_has_changed # = False 97 | todos.model_set_changed("items", original=original_items) # Mark field as changed and store original value 98 | todos.model_has_changed # = True 99 | ``` 100 | 101 | ### Changed markers 102 | 103 | You may also just mark the model as changed. This can be done using changed markers. 104 | A change marker is just a string that is added as the marker, models with such an marker 105 | will also be seen as changed. Changed markers also allow to mark models as changed when 106 | related data was changed - for example to also update a parent object in the database 107 | when some children were changed. 108 | 109 | ```python 110 | import pydantic 111 | from pydantic_changedetect import ChangeDetectionMixin 112 | 113 | class Something(ChangeDetectionMixin, pydantic.BaseModel): 114 | name: str 115 | 116 | 117 | something = Something(name="something") 118 | something.model_has_changed # = False 119 | something.model_mark_changed("mood") 120 | something.model_has_changed # = True 121 | something.model_changed_markers # {"mood"} 122 | something.model_unmark_changed("mood") # also will be reset on something.model_reset_changed() 123 | something.model_has_changed # = False 124 | ``` 125 | 126 | # Contributing 127 | 128 | If you want to contribute to this project, feel free to just fork the project, 129 | create a dev branch in your fork and then create a pull request (PR). If you 130 | are unsure about whether your changes really suit the project please create an 131 | issue first, to talk about this. 132 | -------------------------------------------------------------------------------- /tests/test_changedetect.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import pickle 4 | from typing import Any, Optional, Union 5 | 6 | import pydantic 7 | import pytest 8 | 9 | from pydantic_changedetect import ChangeDetectionMixin 10 | 11 | 12 | class Something(ChangeDetectionMixin, pydantic.BaseModel): 13 | id: int 14 | 15 | 16 | class SomethingMultipleFields(ChangeDetectionMixin, pydantic.BaseModel): 17 | id: int 18 | foo: str 19 | 20 | 21 | class Unsupported(pydantic.BaseModel): 22 | id: int 23 | 24 | 25 | class Nested(ChangeDetectionMixin, pydantic.BaseModel): 26 | sub: Something 27 | 28 | 29 | class NestedList(ChangeDetectionMixin, pydantic.BaseModel): 30 | sub: list[Something] 31 | 32 | 33 | class NestedTuple(ChangeDetectionMixin, pydantic.BaseModel): 34 | sub: tuple[Something, ...] 35 | 36 | 37 | class NestedDict(ChangeDetectionMixin, pydantic.BaseModel): 38 | sub: dict[str, Something] 39 | 40 | 41 | class NestedUnsupported(ChangeDetectionMixin, pydantic.BaseModel): 42 | sub: Unsupported | Something 43 | 44 | 45 | class NestedWithDefault(ChangeDetectionMixin, pydantic.BaseModel): 46 | sub: Something = Something(id=1) 47 | 48 | 49 | class SomethingWithBrokenPickleState(Something): 50 | def __getstate__(self) -> dict[str, Any]: 51 | # Skip adding changed state in ChangedDetectionMixin.__getstate__ 52 | return super(ChangeDetectionMixin, self).__getstate__() 53 | 54 | 55 | class SomethingWithDifferentValueTypes(ChangeDetectionMixin, pydantic.BaseModel): 56 | s: str | None = None 57 | i: int | None = None 58 | f: float | None = None 59 | b: bool | None = None 60 | d: decimal.Decimal | None = None 61 | ddt: datetime.datetime | None = None 62 | dd: datetime.date | None = None 63 | dt: datetime.time | None = None 64 | dtd: datetime.timedelta | None = None 65 | m: Something | None = None 66 | 67 | 68 | def test_initial_state(): 69 | obj = Something(id=1) 70 | 71 | assert not obj.model_has_changed 72 | assert obj.model_original == {} 73 | assert obj.model_changed_fields == set() 74 | 75 | 76 | def test_changed_state(): 77 | obj = Something(id=1) 78 | 79 | obj.id = 2 80 | 81 | assert obj.model_has_changed 82 | assert obj.model_original == {"id": 1} 83 | assert obj.model_changed_fields == {"id"} 84 | 85 | 86 | def test_set_changed_state(): 87 | obj = Something(id=1) 88 | 89 | obj.model_set_changed("id") 90 | 91 | assert obj.model_has_changed 92 | assert obj.model_original == {"id": 1} 93 | assert obj.model_changed_fields == {"id"} 94 | 95 | 96 | def test_set_changed_state_with_fixed_original(): 97 | obj = Something(id=1) 98 | 99 | obj.model_set_changed("id", original=7) 100 | 101 | assert obj.model_has_changed 102 | assert obj.model_original == {"id": 7} 103 | assert obj.model_changed_fields == {"id"} 104 | 105 | 106 | def test_set_changed_will_disallow_invalid_parameters(): 107 | obj = Something(id=1) 108 | 109 | with pytest.raises(RuntimeError): 110 | obj.model_set_changed("id", "id", original=7) # type: ignore 111 | 112 | 113 | def test_set_changed_will_disallow_invalid_field_names(): 114 | obj = Something(id=1) 115 | 116 | with pytest.raises(AttributeError): 117 | obj.model_set_changed("invalid_field_name") 118 | 119 | 120 | def test_copy_keeps_state(): 121 | obj = Something(id=1) 122 | 123 | assert not obj.model_copy().model_has_changed 124 | assert obj.model_copy().model_changed_fields == set() 125 | 126 | obj.id = 2 127 | 128 | assert obj.model_copy().model_has_changed 129 | assert obj.model_copy().model_changed_fields == {"id"} 130 | 131 | 132 | # Test on pydantic v2, too - pydantic has a compatibility layer for this 133 | def test_copy_keeps_state_with_v1_api(): 134 | obj = Something(id=1) 135 | 136 | with pytest.warns(DeprecationWarning): 137 | assert not obj.copy().model_has_changed 138 | with pytest.warns(DeprecationWarning): 139 | assert obj.copy().model_changed_fields == set() 140 | 141 | obj.id = 2 142 | 143 | with pytest.warns(DeprecationWarning): 144 | assert obj.copy().model_has_changed 145 | with pytest.warns(DeprecationWarning): 146 | assert obj.copy().model_changed_fields == {"id"} 147 | 148 | 149 | def test_export_as_dict(): 150 | obj = Something(id=1) 151 | 152 | assert obj.model_dump() == {"id": 1} 153 | assert obj.model_dump(exclude_unchanged=True) == {} 154 | 155 | obj.id = 2 156 | 157 | assert obj.model_dump(exclude_unchanged=True) == {"id": 2} 158 | 159 | 160 | def test_export_as_dict_with_v1_api_on_v2(): 161 | obj = Something(id=1) 162 | 163 | with pytest.warns(DeprecationWarning): 164 | assert obj.dict() == {"id": 1} 165 | with pytest.warns(DeprecationWarning): 166 | assert obj.dict(exclude_unchanged=True) == {} 167 | 168 | obj.id = 2 169 | 170 | with pytest.warns(DeprecationWarning): 171 | assert obj.dict(exclude_unchanged=True) == {"id": 2} 172 | 173 | 174 | def test_export_as_json(): 175 | obj = Something(id=1) 176 | 177 | assert obj.model_dump_json() == '{"id":1}' 178 | assert obj.model_dump_json(exclude_unchanged=True) == '{}' 179 | 180 | obj.id = 2 181 | 182 | assert obj.model_dump_json(exclude_unchanged=True) == '{"id":2}' 183 | 184 | 185 | def test_export_as_json_with_v1_api_on_v2(): 186 | obj = Something(id=1) 187 | 188 | with pytest.warns(DeprecationWarning): 189 | assert obj.json() == '{"id":1}' 190 | with pytest.warns(DeprecationWarning): 191 | assert obj.json(exclude_unchanged=True) == '{}' 192 | 193 | obj.id = 2 194 | 195 | with pytest.warns(DeprecationWarning): 196 | assert obj.json(exclude_unchanged=True) == '{"id":2}' 197 | 198 | 199 | def test_export_include_is_intersect(): 200 | something = Something(id=1) 201 | 202 | assert something.model_dump(exclude_unchanged=True, include={'name'}) == {} 203 | 204 | something.id = 2 205 | 206 | assert something.model_dump(exclude_unchanged=True, include=set()) == {} 207 | assert something.model_dump(exclude_unchanged=True, include={'id'}) == {"id": 2} 208 | 209 | 210 | def test_export_include_is_intersect_with_v1_api_on_v2(): 211 | something = Something(id=1) 212 | 213 | with pytest.warns(DeprecationWarning): 214 | assert something.dict(exclude_unchanged=True, include={'name'}) == {} 215 | 216 | something.id = 2 217 | 218 | with pytest.warns(DeprecationWarning): 219 | assert something.dict(exclude_unchanged=True, include=set()) == {} 220 | with pytest.warns(DeprecationWarning): 221 | assert something.dict(exclude_unchanged=True, include={'id'}) == {"id": 2} 222 | 223 | 224 | def test_changed_base_is_resetable(): 225 | something = Something(id=1) 226 | something.id = 2 227 | 228 | assert something.model_dump(exclude_unchanged=True) == {"id": 2} 229 | 230 | something.model_reset_changed() 231 | 232 | assert something.model_dump(exclude_unchanged=True) == {} 233 | 234 | 235 | def test_changed_base_is_resetable_with_v1_api_on_v2(): 236 | something = Something(id=1) 237 | something.id = 2 238 | 239 | with pytest.warns(DeprecationWarning): 240 | assert something.dict(exclude_unchanged=True) == {"id": 2} 241 | 242 | something.model_reset_changed() 243 | 244 | with pytest.warns(DeprecationWarning): 245 | assert something.dict(exclude_unchanged=True) == {} 246 | 247 | 248 | def test_pickle_keeps_state(): 249 | obj = Something(id=1) 250 | 251 | assert not pickle.loads(pickle.dumps(obj)).model_has_changed # noqa: S301 252 | assert pickle.loads(pickle.dumps(obj)).model_changed_fields == set() # noqa: S301 253 | 254 | obj.id = 2 255 | 256 | assert pickle.loads(pickle.dumps(obj)).model_has_changed # noqa: S301 257 | assert pickle.loads(pickle.dumps(obj)).model_changed_fields == {"id"} # noqa: S301 258 | 259 | 260 | def test_pickle_even_works_when_changed_state_is_missing(): 261 | obj = SomethingWithBrokenPickleState(id=1) 262 | obj.id = 2 263 | 264 | # Now we cannot use the changed state, but nothing fails 265 | assert not pickle.loads(pickle.dumps(obj)).model_has_changed # noqa: S301 266 | assert pickle.loads(pickle.dumps(obj)).model_changed_fields == set() # noqa: S301 267 | 268 | 269 | def test_stores_original(): 270 | something = Something(id=1) 271 | 272 | assert something.model_original == {} 273 | 274 | something.id = 2 275 | 276 | assert something.model_original == {"id": 1} 277 | 278 | 279 | def test_nested_changed_state(): 280 | parent = Nested(sub=Something(id=1)) 281 | 282 | parent.sub.id = 2 283 | 284 | assert parent.model_has_changed 285 | assert "sub" not in parent.model_original 286 | assert parent.model_self_changed_fields == set() 287 | assert parent.model_changed_fields == {"sub"} 288 | assert parent.model_changed_fields_recursive == {"sub", "sub.id"} 289 | 290 | assert parent.sub.model_has_changed 291 | assert "id" in parent.sub.model_original 292 | assert parent.sub.model_original == {"id": 1} 293 | assert parent.model_self_changed_fields == set() 294 | assert parent.sub.model_changed_fields == {"id"} 295 | assert parent.sub.model_changed_fields_recursive == {"id"} 296 | 297 | 298 | @pytest.mark.parametrize( 299 | ("parent_class", "list_type"), [ 300 | (NestedList, list), 301 | (NestedTuple, tuple), 302 | ], 303 | ) 304 | def test_nested_list(parent_class, list_type): 305 | something = Something(id=1) 306 | parent = parent_class(sub=list_type([something])) 307 | 308 | # Nothing changed so far 309 | assert something.model_has_changed is False 310 | assert parent.model_has_changed is False 311 | 312 | # Change something inside parent 313 | parent.sub[0].id = 2 314 | assert parent.sub[0].model_has_changed is True 315 | assert parent.model_has_changed is True 316 | assert parent.model_self_changed_fields == set() 317 | assert parent.model_changed_fields == {'sub'} 318 | assert parent.model_changed_fields_recursive == {'sub', 'sub.0', 'sub.0.id'} 319 | 320 | 321 | def test_nested_dict(): 322 | something = Something(id=1) 323 | parent = NestedDict(sub={"something": something}) 324 | 325 | # Nothing changed so far 326 | assert something.model_has_changed is False 327 | assert parent.model_has_changed is False 328 | 329 | # Change something inside parent 330 | parent.sub["something"].id = 2 331 | assert parent.sub["something"].model_has_changed is True 332 | assert parent.model_has_changed is True 333 | assert parent.model_self_changed_fields == set() 334 | assert parent.model_changed_fields == {'sub'} 335 | assert parent.model_changed_fields_recursive == {'sub', 'sub.something', 'sub.something.id'} 336 | 337 | 338 | def test_nested_unsupported(): 339 | unsupported = Unsupported(id=1) 340 | parent = NestedUnsupported(sub=unsupported) 341 | 342 | # Nothing changed so far 343 | assert parent.model_has_changed is False 344 | 345 | # Change unsupported inside parent 346 | parent.sub.id = 2 347 | assert parent.model_has_changed is False # we cannot detect this 348 | assert parent.model_self_changed_fields == set() 349 | assert parent.model_changed_fields == set() 350 | assert parent.model_changed_fields_recursive == set() 351 | 352 | 353 | def test_nested_with_default(): 354 | parent = NestedWithDefault() 355 | 356 | assert parent.sub is not None 357 | assert parent.model_has_changed is False 358 | 359 | parent.sub.id = 2 360 | assert parent.model_has_changed 361 | assert parent.model_self_changed_fields == set() 362 | assert parent.model_changed_fields == {"sub"} 363 | assert parent.model_changed_fields_recursive == {"sub", "sub.id"} 364 | 365 | 366 | def test_use_private_attributes_works(): 367 | class SomethingPrivate(Something): 368 | _private: int | None = pydantic.PrivateAttr(None) 369 | 370 | something = SomethingPrivate(id=1) 371 | 372 | assert something.model_has_changed is False 373 | 374 | something._private = 1 375 | 376 | assert something.model_has_changed is False 377 | 378 | 379 | @pytest.mark.parametrize( 380 | ("attr", "original", "changed", "expected"), 381 | [ 382 | ("s", "old", "new", True), 383 | ("s", "old", "old", False), 384 | ("i", 1, 2, True), 385 | ("i", 1, 1, False), 386 | ("f", 1.0, 2.0, True), 387 | ("f", 1.0, 1.0, False), 388 | ("b", True, False, True), 389 | ("b", True, True, False), 390 | ("d", decimal.Decimal(1), decimal.Decimal(2), True), 391 | ("d", decimal.Decimal(1), decimal.Decimal(1), False), 392 | ("ddt", datetime.datetime(1970, 1,1, 0, 0), datetime.datetime(1970, 1,1, 0, 1), True), 393 | ("ddt", datetime.datetime(1970, 1,1, 0, 0), datetime.datetime(1970, 1,2, 0, 0), True), 394 | ("ddt", datetime.datetime(1970, 1,1, 0, 0), datetime.datetime(1970, 1,1, 0, 0), False), 395 | ("dd", datetime.date(1970, 1,1), datetime.date(1970, 1,2), True), 396 | ("dd", datetime.date(1970, 1,1), datetime.date(1970, 1,1), False), 397 | ("dt", datetime.time(12, 34), datetime.time(12, 56), True), 398 | ("dt", datetime.time(12, 34), datetime.time(12, 34), False), 399 | ("dtd", datetime.timedelta(days=1), datetime.timedelta(seconds=1), True), 400 | ("dtd", datetime.timedelta(hours=1), datetime.timedelta(seconds=3600), False), 401 | ("dtd", datetime.timedelta(days=1), datetime.timedelta(days=1), False), 402 | ("m", Something(id=1), Something(id=2), True), 403 | ("m", Something(id=1), Something(id=1), False), 404 | ], 405 | ) 406 | def test_value_types_checked_for_equality( 407 | attr: str, 408 | original: Any, 409 | changed: Any, 410 | expected: bool, 411 | ): 412 | obj = SomethingWithDifferentValueTypes(**{attr: original}) 413 | setattr(obj, attr, changed) 414 | 415 | assert obj.model_has_changed is expected 416 | 417 | 418 | def test_model_construct_works(): 419 | something = Something.model_construct(id=1) 420 | 421 | assert something.model_has_changed is False 422 | 423 | something.id = 2 424 | 425 | assert something.model_has_changed is True 426 | 427 | 428 | def test_model_construct_works_for_models_loaded_with_few_fields(): 429 | something_multiple_fields = SomethingMultipleFields.model_construct(id=1) 430 | 431 | assert something_multiple_fields.model_has_changed is False 432 | 433 | something_multiple_fields.id = 2 434 | 435 | assert something_multiple_fields.model_has_changed is True 436 | 437 | 438 | def test_construct_works_on_v2(): 439 | with pytest.warns(DeprecationWarning): 440 | something = Something.construct(id=1) 441 | 442 | assert something.model_has_changed is False 443 | 444 | something.id = 2 445 | 446 | assert something.model_has_changed is True 447 | 448 | 449 | def test_compatibility_methods_work(): 450 | something = Something(id=1) 451 | 452 | with pytest.warns(DeprecationWarning): 453 | assert something.has_changed is False 454 | with pytest.warns(DeprecationWarning): 455 | assert not something.__self_changed_fields__ 456 | with pytest.warns(DeprecationWarning): 457 | assert not something.__changed_fields__ 458 | with pytest.warns(DeprecationWarning): 459 | assert not something.__changed_fields_recursive__ 460 | with pytest.warns(DeprecationWarning): 461 | assert something.__original__ == {} 462 | 463 | something.id = 2 464 | 465 | with pytest.warns(DeprecationWarning): 466 | assert something.has_changed is True 467 | with pytest.warns(DeprecationWarning): 468 | assert something.__self_changed_fields__ == {"id"} 469 | with pytest.warns(DeprecationWarning): 470 | assert something.__changed_fields__ == {"id"} 471 | with pytest.warns(DeprecationWarning): 472 | assert something.__changed_fields_recursive__ == {"id"} 473 | with pytest.warns(DeprecationWarning): 474 | assert something.__original__ == {"id": 1} 475 | 476 | with pytest.warns(DeprecationWarning): 477 | something.reset_changed() 478 | 479 | with pytest.warns(DeprecationWarning): 480 | assert something.has_changed is False 481 | with pytest.warns(DeprecationWarning): 482 | assert not something.__self_changed_fields__ 483 | with pytest.warns(DeprecationWarning): 484 | assert not something.__changed_fields__ 485 | with pytest.warns(DeprecationWarning): 486 | assert not something.__changed_fields_recursive__ 487 | with pytest.warns(DeprecationWarning): 488 | assert something.__original__ == {} 489 | 490 | with pytest.warns(DeprecationWarning): 491 | something.set_changed("id", original=1) 492 | 493 | with pytest.warns(DeprecationWarning): 494 | assert something.has_changed is True 495 | with pytest.warns(DeprecationWarning): 496 | assert something.__self_changed_fields__ == {"id"} 497 | with pytest.warns(DeprecationWarning): 498 | assert something.__changed_fields__ == {"id"} 499 | with pytest.warns(DeprecationWarning): 500 | assert something.__changed_fields_recursive__ == {"id"} 501 | with pytest.warns(DeprecationWarning): 502 | assert something.__original__ == {"id": 1} 503 | 504 | 505 | # Restore model/value state 506 | 507 | 508 | def test_restore_original(): 509 | something = Something(id=1) 510 | 511 | something.id = 2 512 | assert something.model_has_changed is True 513 | 514 | old_something = something.model_restore_original() 515 | 516 | assert something is not old_something 517 | assert something.id == 2 518 | assert old_something.id == 1 519 | 520 | 521 | def test_restore_original_nested(): 522 | something = Something(id=1) 523 | nested = Nested(sub=something) 524 | 525 | nested.sub.id = 2 526 | assert nested.sub.model_has_changed is True 527 | assert nested.model_has_changed is True 528 | 529 | old_nested = nested.model_restore_original() 530 | 531 | assert nested.sub.id == 2 532 | assert old_nested.sub.id == 1 533 | 534 | 535 | def test_restore_original_nested_assignment(): 536 | something = Something(id=1) 537 | nested = Nested(sub=something) 538 | 539 | nested.sub = Something(id=2) 540 | assert nested.sub.model_has_changed is False 541 | assert nested.model_has_changed is True 542 | 543 | old_nested = nested.model_restore_original() 544 | 545 | assert nested.sub.id == 2 546 | assert old_nested.sub.id == 1 547 | 548 | 549 | def test_restore_original_nested_list(): 550 | something = Something(id=1) 551 | nested = NestedList(sub=[something]) 552 | 553 | nested.sub[0].id = 2 554 | assert nested.sub[0].model_has_changed is True 555 | assert nested.model_has_changed is True 556 | 557 | old_nested = nested.model_restore_original() 558 | 559 | assert nested.sub[0].id == 2 560 | assert old_nested.sub[0].id == 1 561 | 562 | 563 | def test_restore_original_nested_dict(): 564 | something = Something(id=1) 565 | nested = NestedDict(sub={"test": something}) 566 | 567 | nested.sub["test"].id = 2 568 | assert nested.sub["test"].model_has_changed is True 569 | assert nested.model_has_changed is True 570 | 571 | old_nested = nested.model_restore_original() 572 | 573 | assert nested.sub["test"].id == 2 574 | assert old_nested.sub["test"].id == 1 575 | 576 | 577 | def test_restore_field_value(): 578 | something = Something(id=1) 579 | 580 | something.id = 2 581 | assert something.model_has_changed is True 582 | assert something.model_get_original_field_value("id") == 1 583 | 584 | 585 | def test_restore_field_value_checks_field_availability(): 586 | something = Something(id=1) 587 | 588 | with pytest.raises(AttributeError): 589 | something.model_get_original_field_value("invalid_field") 590 | 591 | 592 | def test_restore_field_value_nested(): 593 | something = Something(id=1) 594 | nested = Nested(sub=something) 595 | 596 | nested.sub.id = 2 597 | assert nested.model_has_changed is True 598 | assert nested.model_get_original_field_value("sub") == Something(id=1) 599 | 600 | 601 | # Changed markers 602 | 603 | 604 | def test_changed_markers_can_be_set(): 605 | something = Something(id=1) 606 | 607 | something.model_mark_changed("test") 608 | assert "test" in something.model_changed_markers 609 | assert something.model_has_changed_marker("test") 610 | 611 | 612 | def test_changed_markers_can_be_unset(): 613 | something = Something(id=1) 614 | 615 | something.model_mark_changed("test") 616 | assert something.model_has_changed_marker("test") 617 | 618 | something.model_unmark_changed("test") 619 | assert not something.model_has_changed_marker("test") 620 | 621 | 622 | def test_changed_markers_will_be_also_reset(): 623 | something = Something(id=1) 624 | 625 | something.model_mark_changed("test") 626 | assert something.model_has_changed_marker("test") 627 | 628 | something.model_reset_changed() 629 | assert not something.model_has_changed_marker("test") 630 | 631 | 632 | def test_model_is_changed_if_marker_or_change_exists(): 633 | something = Something(id=1) 634 | 635 | assert not something.model_has_changed 636 | something.model_mark_changed("test") 637 | assert something.model_has_changed 638 | something.model_reset_changed() 639 | 640 | assert not something.model_has_changed 641 | something.model_set_changed("id") 642 | assert something.model_has_changed 643 | something.model_reset_changed() 644 | 645 | assert not something.model_has_changed 646 | something.model_set_changed("id") 647 | something.model_mark_changed("test") 648 | assert something.model_has_changed 649 | something.model_reset_changed() 650 | -------------------------------------------------------------------------------- /pydantic_changedetect/changedetect.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import warnings 4 | from typing import ( 5 | TYPE_CHECKING, 6 | Any, 7 | Dict, # We still need to use this, as dict is a class method and pollutes the class scope 8 | Literal, 9 | TypeVar, 10 | Union, 11 | cast, 12 | no_type_check, 13 | overload, 14 | ) 15 | 16 | import pydantic 17 | 18 | from ._compat import PYDANTIC_GE_V2_7 19 | from .utils import is_pydantic_change_detect_annotation 20 | 21 | if TYPE_CHECKING: # pragma: no cover 22 | from pydantic import PrivateAttr 23 | from pydantic.main import IncEx 24 | from pydantic.typing import AbstractSetIntStr, MappingIntStrAny 25 | 26 | Model = TypeVar("Model", bound="ChangeDetectionMixin") 27 | 28 | NO_VALUE = object() 29 | COMPARABLE_TYPES = ( 30 | str, 31 | int, float, 32 | bool, 33 | decimal.Decimal, 34 | datetime.datetime, datetime.date, datetime.time, datetime.timedelta, 35 | pydantic.BaseModel, 36 | ) 37 | 38 | 39 | class ChangeDetectionMixin(pydantic.BaseModel): 40 | """ 41 | Utility mixin to allow pydantic models to detect changes to fields. 42 | 43 | Example: 44 | ```python 45 | class Something(ChangeDetectionMixin, pydantic.BaseModel): 46 | name: str 47 | 48 | something = Something(name="Alice") 49 | something.model_has_changed # False 50 | something.model_changed_fields # empty 51 | something.name = "Bob" 52 | something.model_has_changed # True 53 | something.model_changed_fields # {"name"} 54 | something.model_self_changed_fields # {"name": "Alice"} 55 | ``` 56 | """ 57 | 58 | if TYPE_CHECKING: # pragma: no cover 59 | # Note: Private attributes normally need to start with "_", but this code here 60 | # will only be seen by type checkers. So this is never an issue. The 61 | # usage of ` = PrivateAttr(...)` is also just here to make those type 62 | # checkers happy. Otherwise, they might "require" you to pass those 63 | # parameters to `__init__`, which is just plainly wrong. The private 64 | # attributes ChangeDetectionMixin uses are defined as __slots__ and 65 | # thus just new attributes the class has - not something you need to pass 66 | # anywhere. 67 | model_original: Dict[str, Any] = PrivateAttr(...) # pyright: ignore[reportAssignmentType] 68 | model_self_changed_fields: set[str] = PrivateAttr(...) # pyright: ignore[reportAssignmentType] 69 | model_changed_markers: set[str] = PrivateAttr(...) # pyright: ignore[reportAssignmentType] 70 | 71 | __slots__ = ("model_changed_markers", "model_original", "model_self_changed_fields") 72 | 73 | def __init__(self, **kwargs: Any) -> None: 74 | super().__init__(**kwargs) 75 | self.model_reset_changed() 76 | 77 | def model_reset_changed(self) -> None: 78 | """ 79 | Reset the changed state, this will clear model_self_changed_fields, model_original 80 | and remove all changed markers. 81 | """ 82 | 83 | object.__setattr__(self, "model_original", {}) 84 | object.__setattr__(self, "model_self_changed_fields", set()) 85 | object.__setattr__(self, "model_changed_markers", set()) 86 | 87 | @property 88 | def model_changed_fields(self) -> set[str]: 89 | """Return list of all changed fields, submodels are considered as one field""" 90 | 91 | changed_fields = self.model_self_changed_fields.copy() 92 | for field_name, model_field in self.__class__.model_fields.items(): 93 | # Support for instances created through model_construct, when not all fields have been defined 94 | if field_name not in self.__dict__: 95 | continue 96 | 97 | field_value = self.__dict__[field_name] 98 | 99 | # Value is a ChangeDetectionMixin instance itself 100 | if ( 101 | isinstance(field_value, ChangeDetectionMixin) 102 | and field_value.model_has_changed 103 | ): 104 | changed_fields.add(field_name) 105 | 106 | # Field contains ChangeDetectionMixin's, but inside list/dict structure 107 | elif ( 108 | field_value 109 | and is_pydantic_change_detect_annotation( 110 | model_field.annotation, 111 | ) 112 | ): 113 | # Collect all possible values 114 | if isinstance(field_value, (list, tuple)): 115 | field_value_list = field_value 116 | elif isinstance(field_value, dict): 117 | field_value_list = list(field_value.values()) 118 | else: # pragma: no cover 119 | # Continue on unsupported type 120 | # (should be already filtered by is_pydantic_change_detect_annotation) 121 | continue 122 | 123 | # Check if any of the values has changed 124 | for inner_field_value in field_value_list: 125 | if ( 126 | isinstance(inner_field_value, ChangeDetectionMixin) 127 | and inner_field_value.model_has_changed 128 | ): 129 | changed_fields.add(field_name) 130 | break 131 | 132 | return changed_fields 133 | 134 | @property 135 | def model_changed_fields_recursive(self) -> set[str]: 136 | """Return a list of all changed fields recursive using dotted syntax""" 137 | 138 | changed_fields = self.model_self_changed_fields.copy() 139 | for field_name, model_field in self.__class__.model_fields.items(): 140 | field_value = self.__dict__[field_name] 141 | 142 | # Value is a ChangeDetectionMixin instance itself 143 | if ( 144 | isinstance(field_value, ChangeDetectionMixin) 145 | and field_value.model_has_changed 146 | ): 147 | changed_fields.add(field_name) 148 | for changed_field in field_value.model_changed_fields_recursive: 149 | changed_fields.add(f"{field_name}.{changed_field}") 150 | 151 | # Field contains ChangeDetectionMixin's, but inside list/dict structure 152 | elif ( 153 | field_value 154 | and is_pydantic_change_detect_annotation( 155 | model_field.annotation, 156 | ) 157 | ): 158 | # Collect all possible values 159 | if isinstance(field_value, (list, tuple)): 160 | field_value_list = list(enumerate(field_value)) 161 | elif isinstance(field_value, dict): 162 | field_value_list = list(field_value.items()) 163 | else: # pragma: no cover 164 | # Continue on unsupported type 165 | # (should be already filtered by is_pydantic_change_detect_annotation) 166 | continue 167 | 168 | # Check if any of the values has changed 169 | for inner_field_index, inner_field_value in field_value_list: 170 | if ( 171 | isinstance(inner_field_value, ChangeDetectionMixin) 172 | and inner_field_value.model_has_changed 173 | ): 174 | for changed_field in inner_field_value.model_changed_fields_recursive: 175 | changed_fields.add(f"{field_name}.{inner_field_index}.{changed_field}") 176 | changed_fields.add(f"{field_name}.{inner_field_index}") 177 | changed_fields.add(f"{field_name}") 178 | 179 | return changed_fields 180 | 181 | @property 182 | def model_has_changed(self) -> bool: 183 | """Return True, when some field was changed or some changed marker is set.""" 184 | 185 | if self.model_self_changed_fields or self.model_changed_markers: 186 | return True 187 | 188 | return bool(self.model_changed_fields) 189 | 190 | @overload 191 | def model_set_changed(self, *fields: str) -> None: ... 192 | 193 | @overload 194 | def model_set_changed(self, field: str, /, *, original: Any = NO_VALUE) -> None: ... 195 | 196 | def model_set_changed(self, *fields: str, original: Any = NO_VALUE) -> None: 197 | """ 198 | Set fields as changed. 199 | 200 | Optionally provide an original value for the field. 201 | """ 202 | 203 | # Ensure we have a valid call 204 | if original is not NO_VALUE and len(fields) > 1: 205 | raise RuntimeError( 206 | "Original value can only be used when only " 207 | "changing one field.", 208 | ) 209 | 210 | # Ensure all fields exists 211 | for name in fields: 212 | if name not in self.__class__.model_fields: 213 | raise AttributeError(f"Field {name} not available in this model") 214 | 215 | # Mark fields as changed 216 | for name in fields: 217 | if original is NO_VALUE: 218 | self.model_original[name] = self.__dict__[name] 219 | else: 220 | self.model_original[name] = original 221 | self.model_self_changed_fields.add(name) 222 | 223 | def _model_value_is_comparable_type(self, value: Any) -> bool: 224 | if isinstance(value, (list, set, tuple)): 225 | return all( 226 | self._model_value_is_comparable_type(i) 227 | for i 228 | in value 229 | ) 230 | elif isinstance(value, dict): 231 | return all( 232 | ( 233 | self._model_value_is_comparable_type(k) 234 | and self._model_value_is_comparable_type(v) 235 | ) 236 | for k, v 237 | in value.items() 238 | ) 239 | 240 | return ( 241 | value is None 242 | or isinstance(value, COMPARABLE_TYPES) 243 | ) 244 | 245 | def _model_value_is_actually_unchanged(self, value1: Any, value2: Any) -> bool: 246 | return value1 == value2 247 | 248 | @no_type_check 249 | def __setattr__(self, name, value) -> None: # noqa: ANN001 250 | # Private attributes need not to be handled 251 | if ( 252 | self.__private_attributes__ # may be None 253 | and name in self.__private_attributes__ 254 | ): 255 | super().__setattr__(name, value) 256 | return 257 | 258 | # Get original value 259 | original_update = {} 260 | if name in self.__class__.model_fields and name not in self.model_original: 261 | original_update[name] = self.__dict__[name] 262 | 263 | # Store changed value using pydantic 264 | super().__setattr__(name, value) 265 | 266 | # Check if value has actually been changed 267 | has_changed = True 268 | if name in self.__class__.model_fields: 269 | # Fetch original from original_update so we don't have to check everything again 270 | original_value = original_update.get(name, None) 271 | # Don't use value parameter directly, as pydantic validation might have changed it 272 | # (when validate_assignment == True) 273 | current_value = self.__dict__[name] 274 | if ( 275 | self._model_value_is_comparable_type(original_value) 276 | and self._model_value_is_comparable_type(current_value) 277 | and self._model_value_is_actually_unchanged(original_value, current_value) 278 | ): 279 | has_changed = False 280 | 281 | # Store changed state 282 | if has_changed: 283 | self.model_original.update(original_update) 284 | self.model_self_changed_fields.add(name) 285 | 286 | def __getstate__(self) -> Dict[str, Any]: 287 | state = super().__getstate__() 288 | state["model_original"] = self.model_original.copy() 289 | state["model_self_changed_fields"] = self.model_self_changed_fields.copy() 290 | state["model_changed_markers"] = self.model_changed_markers.copy() 291 | return state 292 | 293 | def __setstate__(self, state: Dict[str, Any]) -> None: 294 | super().__setstate__(state) 295 | if "model_original" in state: 296 | object.__setattr__(self, "model_original", state["model_original"]) 297 | else: 298 | object.__setattr__(self, "model_original", {}) 299 | if "model_self_changed_fields" in state: 300 | object.__setattr__(self, "model_self_changed_fields", state["model_self_changed_fields"]) 301 | else: 302 | object.__setattr__(self, "model_self_changed_fields", set()) 303 | if "model_changed_markers" in state: 304 | object.__setattr__(self, "model_changed_markers", state["model_changed_markers"]) 305 | else: 306 | object.__setattr__(self, "model_changed_markers", set()) 307 | 308 | def _get_changed_export_includes( 309 | self, 310 | exclude_unchanged: bool, 311 | **kwargs: Any, 312 | ) -> Dict[str, Any]: 313 | """ 314 | Return updated kwargs for json()/dict(), so only changed fields 315 | get exported when exclude_unchanged=True 316 | """ 317 | 318 | if exclude_unchanged: 319 | changed_fields = self.model_changed_fields 320 | if "include" in kwargs and kwargs["include"] is not None: 321 | kwargs["include"] = { # calculate intersect 322 | i 323 | for i 324 | in kwargs["include"] 325 | if i in changed_fields 326 | } 327 | else: 328 | kwargs["include"] = set(changed_fields) 329 | return kwargs 330 | 331 | # Restore model/value state 332 | 333 | @classmethod 334 | def model_restore_value(cls, value: Any, /) -> Any: 335 | """ 336 | Restore original state of value if it contains any ChangeDetectionMixin 337 | instances. 338 | 339 | Contain might be: 340 | * value is a list containing such instances 341 | * value is a dict containing such instances 342 | * value is a ChangeDetectionMixin instance itself 343 | """ 344 | 345 | if isinstance(value, list): 346 | return [ 347 | cls.model_restore_value(v) 348 | for v 349 | in value 350 | ] 351 | elif isinstance(value, dict): 352 | return { 353 | k: cls.model_restore_value(v) 354 | for k, v 355 | in value.items() 356 | } 357 | elif ( 358 | isinstance(value, ChangeDetectionMixin) 359 | and value.model_has_changed 360 | ): 361 | return value.model_restore_original() 362 | else: 363 | return value 364 | 365 | def model_restore_original( 366 | self: "Model", 367 | ) -> "Model": 368 | """Restore original state of a ChangeDetectionMixin object.""" 369 | 370 | restored_values = {} 371 | for key, value in self.__dict__.items(): 372 | restored_values[key] = self.model_restore_value(value) 373 | 374 | return self.__class__( 375 | **{ 376 | **restored_values, 377 | **self.model_original, 378 | }, 379 | ) 380 | 381 | def model_get_original_field_value(self, field_name: str, /) -> Any: 382 | """Return original value for a field.""" 383 | 384 | if field_name not in self.__class__.model_fields: 385 | raise AttributeError(f"Field {field_name} not available in this model") 386 | 387 | if field_name in self.model_original: 388 | return self.model_original[field_name] 389 | 390 | current_value = getattr(self, field_name) 391 | return self.model_restore_value(current_value) 392 | 393 | # Changed markers 394 | 395 | def model_mark_changed(self, marker: str) -> None: 396 | """ 397 | Add marker for something being changed. 398 | 399 | Markers can be used to keep information about things being changed outside 400 | the model scope, but related to the model itself. This could for example 401 | be a marker for related objects being added/updated/removed. 402 | """ 403 | 404 | self.model_changed_markers.add(marker) 405 | 406 | def model_unmark_changed(self, marker: str) -> None: 407 | """Remove one changed marker.""" 408 | 409 | self.model_changed_markers.discard(marker) 410 | 411 | def model_has_changed_marker( 412 | self, 413 | marker: str, 414 | ) -> bool: 415 | """Check whether one changed marker is set.""" 416 | 417 | return marker in self.model_changed_markers 418 | 419 | @classmethod 420 | def model_construct(cls: type["Model"], *args: Any, **kwargs: Any) -> "Model": 421 | """Construct an unvalidated instance""" 422 | 423 | m = cast("Model", super().model_construct(*args, **kwargs)) 424 | m.model_reset_changed() 425 | return m 426 | 427 | def model_post_init(self, __context: Any) -> None: 428 | super().model_post_init(__context) 429 | self.model_reset_changed() 430 | 431 | def __copy__(self: "Model") -> "Model": 432 | clone = cast( 433 | "Model", 434 | super().__copy__(), 435 | ) 436 | object.__setattr__(clone, "model_original", self.model_original.copy()) 437 | object.__setattr__(clone, "model_self_changed_fields", self.model_self_changed_fields.copy()) 438 | object.__setattr__(clone, "model_changed_markers", self.model_changed_markers.copy()) 439 | return clone 440 | 441 | def __deepcopy__(self: "Model", memo: Dict[int, Any] | None = None) -> "Model": 442 | clone = cast( 443 | "Model", 444 | super().__deepcopy__(memo=memo), 445 | ) 446 | object.__setattr__(clone, "model_original", self.model_original.copy()) 447 | object.__setattr__(clone, "model_self_changed_fields", self.model_self_changed_fields.copy()) 448 | object.__setattr__(clone, "model_changed_markers", self.model_changed_markers.copy()) 449 | return clone 450 | 451 | if PYDANTIC_GE_V2_7: 452 | def model_dump( # pyright: ignore [reportRedeclaration] 453 | self, 454 | *, 455 | mode: Literal['json', 'python'] | str = 'python', 456 | include: "IncEx | None" = None, 457 | exclude: "IncEx | None" = None, 458 | context: Dict[str, Any] | None = None, # Available since 2.7 459 | by_alias: bool = False, 460 | exclude_unset: bool = False, 461 | exclude_defaults: bool = False, 462 | exclude_none: bool = False, 463 | exclude_unchanged: bool = False, 464 | round_trip: bool = False, 465 | warnings: bool = True, 466 | serialize_as_any: bool = False, # Available since 2.7 467 | ) -> Dict[str, Any]: 468 | """ 469 | Generate a dictionary representation of the model, optionally specifying 470 | which fields to include or exclude. 471 | 472 | Extends normal pydantic method to also allow to use `exclude_unchanged`. 473 | """ 474 | 475 | return super().model_dump( 476 | **self._get_changed_export_includes( 477 | mode=mode, 478 | include=include, 479 | exclude=exclude, 480 | context=context, 481 | by_alias=by_alias, 482 | exclude_unset=exclude_unset, 483 | exclude_defaults=exclude_defaults, 484 | exclude_none=exclude_none, 485 | exclude_unchanged=exclude_unchanged, 486 | round_trip=round_trip, 487 | warnings=warnings, 488 | serialize_as_any=serialize_as_any, 489 | ), 490 | ) 491 | 492 | def model_dump_json( # pyright: ignore [reportRedeclaration] 493 | self, 494 | *, 495 | indent: int | None = None, 496 | include: "IncEx | None" = None, 497 | exclude: "IncEx | None" = None, 498 | context: Dict[str, Any] | None = None, # Available since 2.7 499 | by_alias: bool = False, 500 | exclude_unset: bool = False, 501 | exclude_defaults: bool = False, 502 | exclude_none: bool = False, 503 | exclude_unchanged: bool = False, 504 | round_trip: bool = False, 505 | warnings: bool = True, 506 | serialize_as_any: bool = False, # Available since 2.7 507 | ) -> str: 508 | """ 509 | Generates a JSON representation of the model using Pydantic's `to_json` 510 | method. 511 | 512 | Extends normal pydantic method to also allow to use `exclude_unchanged`. 513 | """ 514 | 515 | return super().model_dump_json( 516 | **self._get_changed_export_includes( 517 | indent=indent, 518 | include=include, 519 | exclude=exclude, 520 | context=context, 521 | by_alias=by_alias, 522 | exclude_unset=exclude_unset, 523 | exclude_defaults=exclude_defaults, 524 | exclude_none=exclude_none, 525 | exclude_unchanged=exclude_unchanged, 526 | round_trip=round_trip, 527 | warnings=warnings, 528 | serialize_as_any=serialize_as_any, 529 | ), 530 | ) 531 | else: # Version 2.x < 2.7.0 532 | def model_dump( # pyright: ignore [reportIncompatibleMethodOverride] 533 | self, 534 | *, 535 | mode: Literal['json', 'python'] | str = 'python', 536 | include: "IncEx | None" = None, 537 | exclude: "IncEx | None" = None, 538 | by_alias: bool = False, 539 | exclude_unset: bool = False, 540 | exclude_defaults: bool = False, 541 | exclude_none: bool = False, 542 | exclude_unchanged: bool = False, 543 | round_trip: bool = False, 544 | warnings: bool = True, 545 | ) -> Dict[str, Any]: 546 | """ 547 | Generate a dictionary representation of the model, optionally specifying 548 | which fields to include or exclude. 549 | 550 | Extends normal pydantic method to also allow to use `exclude_unchanged`. 551 | """ 552 | 553 | return super().model_dump( 554 | **self._get_changed_export_includes( 555 | mode=mode, 556 | include=include, 557 | exclude=exclude, 558 | by_alias=by_alias, 559 | exclude_unset=exclude_unset, 560 | exclude_defaults=exclude_defaults, 561 | exclude_none=exclude_none, 562 | exclude_unchanged=exclude_unchanged, 563 | round_trip=round_trip, 564 | warnings=warnings, 565 | ), 566 | ) 567 | 568 | def model_dump_json( # pyright: ignore [reportIncompatibleMethodOverride] 569 | self, 570 | *, 571 | indent: int | None = None, 572 | include: "IncEx | None" = None, 573 | exclude: "IncEx | None" = None, 574 | by_alias: bool = False, 575 | exclude_unset: bool = False, 576 | exclude_defaults: bool = False, 577 | exclude_none: bool = False, 578 | exclude_unchanged: bool = False, 579 | round_trip: bool = False, 580 | warnings: bool = True, 581 | ) -> str: 582 | """ 583 | Generates a JSON representation of the model using Pydantic's `to_json` 584 | method. 585 | 586 | Extends normal pydantic method to also allow to use `exclude_unchanged`. 587 | """ 588 | 589 | return super().model_dump_json( 590 | **self._get_changed_export_includes( 591 | indent=indent, 592 | include=include, 593 | exclude=exclude, 594 | by_alias=by_alias, 595 | exclude_unset=exclude_unset, 596 | exclude_defaults=exclude_defaults, 597 | exclude_none=exclude_none, 598 | exclude_unchanged=exclude_unchanged, 599 | round_trip=round_trip, 600 | warnings=warnings, 601 | ), 602 | ) 603 | 604 | # Compatibility for pydantic 2.0 compatibility methods to support pydantic 1.0 migration 🙈 605 | 606 | def copy( 607 | self: "Model", 608 | *, 609 | include: "AbstractSetIntStr | MappingIntStrAny | None" = None, 610 | exclude: "AbstractSetIntStr | MappingIntStrAny | None" = None, 611 | update: Dict[str, Any] | None = None, 612 | deep: bool = False, 613 | ) -> "Model": 614 | warnings.warn( 615 | "copy(...) is deprecated even in pydantic v2, use model_copy(...) instead", 616 | DeprecationWarning, 617 | stacklevel=2, 618 | ) 619 | clone = cast( 620 | "Model", 621 | super().copy( 622 | include=include, 623 | exclude=exclude, 624 | update=update, 625 | deep=deep, 626 | ), 627 | ) 628 | object.__setattr__(clone, "model_original", self.model_original.copy()) 629 | object.__setattr__(clone, "model_self_changed_fields", self.model_self_changed_fields.copy()) 630 | object.__setattr__(clone, "model_changed_markers", self.model_changed_markers.copy()) 631 | return clone 632 | 633 | def dict( # type: ignore 634 | self, 635 | *, 636 | include: Union['AbstractSetIntStr', 'MappingIntStrAny'] | None = None, 637 | exclude: Union['AbstractSetIntStr', 'MappingIntStrAny'] | None = None, 638 | by_alias: bool = False, 639 | exclude_unset: bool = False, 640 | exclude_defaults: bool = False, 641 | exclude_none: bool = False, 642 | exclude_unchanged: bool = False, 643 | ) -> Dict[str, Any]: 644 | """ 645 | Generate a dictionary representation of the model, optionally 646 | specifying which fields to include or exclude. 647 | """ 648 | 649 | return super().dict( 650 | **self._get_changed_export_includes( 651 | include=include, 652 | exclude=exclude, 653 | by_alias=by_alias, 654 | exclude_unset=exclude_unset, 655 | exclude_defaults=exclude_defaults, 656 | exclude_none=exclude_none, 657 | exclude_unchanged=exclude_unchanged, 658 | ), 659 | ) 660 | 661 | def json( # type: ignore 662 | self, 663 | include: Union['AbstractSetIntStr', 'MappingIntStrAny'] | None = None, 664 | exclude: Union['AbstractSetIntStr', 'MappingIntStrAny'] | None = None, 665 | by_alias: bool = False, 666 | exclude_unset: bool = False, 667 | exclude_defaults: bool = False, 668 | exclude_none: bool = False, 669 | exclude_unchanged: bool = False, 670 | **dumps_kwargs: Any, 671 | ) -> str: 672 | """ 673 | Generate a JSON representation of the model, `include` and `exclude` 674 | arguments as per `dict()`. 675 | """ 676 | 677 | return super().json( 678 | **self._get_changed_export_includes( 679 | include=include, 680 | exclude=exclude, 681 | by_alias=by_alias, 682 | exclude_unset=exclude_unset, 683 | exclude_defaults=exclude_defaults, 684 | exclude_none=exclude_none, 685 | exclude_unchanged=exclude_unchanged, 686 | **dumps_kwargs, 687 | ), 688 | ) 689 | 690 | # Compatibility methods for older versions of pydantic-changedetect 691 | 692 | def reset_changed(self) -> None: 693 | warnings.warn( 694 | "reset_changed() is deprecated, use model_reset_changed() instead", 695 | DeprecationWarning, 696 | stacklevel=2, 697 | ) 698 | self.model_reset_changed() 699 | 700 | @property 701 | def __original__(self) -> Dict[str, Any]: 702 | warnings.warn( 703 | "__original__ is deprecated, use model_original instead", 704 | DeprecationWarning, 705 | stacklevel=2, 706 | ) 707 | return self.model_original 708 | 709 | @property 710 | def __self_changed_fields__(self) -> set[str]: 711 | warnings.warn( 712 | "__self_changed_fields__ is deprecated, use model_self_changed_fields instead", 713 | DeprecationWarning, 714 | stacklevel=2, 715 | ) 716 | return self.model_self_changed_fields 717 | 718 | @property 719 | def __changed_fields__(self) -> set[str]: 720 | warnings.warn( 721 | "__changed_fields__ is deprecated, use model_changed_fields instead", 722 | DeprecationWarning, 723 | stacklevel=2, 724 | ) 725 | return self.model_changed_fields 726 | 727 | @property 728 | def __changed_fields_recursive__(self) -> set[str]: 729 | warnings.warn( 730 | "__changed_fields_recursive__ is deprecated, use model_changed_fields_recursive instead", 731 | DeprecationWarning, 732 | stacklevel=2, 733 | ) 734 | return self.model_changed_fields_recursive 735 | 736 | @property 737 | def has_changed(self) -> bool: 738 | warnings.warn( 739 | "has_changed is deprecated, use model_has_changed instead", 740 | DeprecationWarning, 741 | stacklevel=2, 742 | ) 743 | return self.model_has_changed 744 | 745 | @overload 746 | def set_changed(self, *fields: str) -> None: ... 747 | 748 | @overload 749 | def set_changed(self, field: str, /, *, original: Any = NO_VALUE) -> None: ... 750 | 751 | def set_changed(self, *fields: str, original: Any = NO_VALUE) -> None: 752 | warnings.warn( 753 | "set_changed(...) is deprecated, use model_set_changed(...) instead", 754 | DeprecationWarning, 755 | stacklevel=2, 756 | ) 757 | self.model_set_changed(*fields, original=original) 758 | --------------------------------------------------------------------------------