django-filthyfields 2.0.0__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. django_filthyfields-2.0.2/MANIFEST.in +1 -0
  2. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/PKG-INFO +2 -2
  3. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/pyproject.toml +2 -2
  4. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/django_filthyfields.egg-info/PKG-INFO +2 -2
  5. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/django_filthyfields.egg-info/SOURCES.txt +2 -8
  6. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/filthyfields/filthyfields.py +8 -4
  7. django_filthyfields-2.0.0/tests/test_async.py +0 -105
  8. django_filthyfields-2.0.0/tests/test_benchmark.py +0 -225
  9. django_filthyfields-2.0.0/tests/test_core.py +0 -656
  10. django_filthyfields-2.0.0/tests/test_m2m_fields.py +0 -105
  11. django_filthyfields-2.0.0/tests/test_non_regression.py +0 -195
  12. django_filthyfields-2.0.0/tests/test_save_fields.py +0 -220
  13. django_filthyfields-2.0.0/tests/test_timezone_aware_fields.py +0 -109
  14. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/LICENSE +0 -0
  15. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/README.md +0 -0
  16. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/setup.cfg +0 -0
  17. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/setup.py +0 -0
  18. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/django_filthyfields.egg-info/dependency_links.txt +0 -0
  19. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/django_filthyfields.egg-info/requires.txt +0 -0
  20. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/django_filthyfields.egg-info/top_level.txt +0 -0
  21. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/filthyfields/__init__.py +3 -3
  22. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/filthyfields/_descriptor.c +0 -0
  23. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/filthyfields/_descriptor.py +0 -0
  24. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/filthyfields/compare.py +0 -0
  25. {django_filthyfields-2.0.0 → django_filthyfields-2.0.2}/src/filthyfields/py.typed +0 -0
@@ -0,0 +1 @@
1
+ prune tests
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-filthyfields
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Tracking dirty fields on a Django model instance.
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/oliverhaas/django-filthyfields
7
7
  Project-URL: Documentation, https://oliverhaas.github.io/django-filthyfields/
8
8
  Project-URL: Repository, https://github.com/oliverhaas/django-filthyfields.git
9
9
  Keywords: django,filthyfields,dirtyfields,track,model,changes
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Framework :: Django
12
12
  Classifier: Framework :: Django :: 6.0
13
13
  Classifier: Intended Audience :: Developers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-filthyfields"
3
- version = "2.0.0"
3
+ version = "2.0.2"
4
4
  description = "Tracking dirty fields on a Django model instance."
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -14,7 +14,7 @@ keywords = [
14
14
  "changes",
15
15
  ]
16
16
  classifiers = [
17
- "Development Status :: 4 - Beta",
17
+ "Development Status :: 5 - Production/Stable",
18
18
  "Framework :: Django",
19
19
  "Framework :: Django :: 6.0",
20
20
  "Intended Audience :: Developers",
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-filthyfields
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Tracking dirty fields on a Django model instance.
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/oliverhaas/django-filthyfields
7
7
  Project-URL: Documentation, https://oliverhaas.github.io/django-filthyfields/
8
8
  Project-URL: Repository, https://github.com/oliverhaas/django-filthyfields.git
9
9
  Keywords: django,filthyfields,dirtyfields,track,model,changes
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Framework :: Django
12
12
  Classifier: Framework :: Django :: 6.0
13
13
  Classifier: Intended Audience :: Developers
@@ -1,4 +1,5 @@
1
1
  LICENSE
2
+ MANIFEST.in
2
3
  README.md
3
4
  pyproject.toml
4
5
  setup.py
@@ -12,11 +13,4 @@ src/filthyfields/_descriptor.c
12
13
  src/filthyfields/_descriptor.py
13
14
  src/filthyfields/compare.py
14
15
  src/filthyfields/filthyfields.py
15
- src/filthyfields/py.typed
16
- tests/test_async.py
17
- tests/test_benchmark.py
18
- tests/test_core.py
19
- tests/test_m2m_fields.py
20
- tests/test_non_regression.py
21
- tests/test_save_fields.py
22
- tests/test_timezone_aware_fields.py
16
+ src/filthyfields/py.typed
@@ -447,17 +447,21 @@ class DirtyFieldsMixin(models.Model, metaclass=_DirtyMeta):
447
447
  self.save(update_fields=list(dirty_fields))
448
448
 
449
449
 
450
- def capture_dirty_state(instances: Iterable[DirtyFieldsMixin]) -> None:
451
- """Snapshot dirty state on each instance — call before ``bulk_update()`` so ``was_dirty()`` works after."""
450
+ def capture_dirty_state(instances: DirtyFieldsMixin | Iterable[DirtyFieldsMixin]) -> None:
451
+ """Snapshot dirty state — call before ``bulk_update()`` so ``was_dirty()`` works after. Accepts a single instance or an iterable."""
452
+ if isinstance(instances, DirtyFieldsMixin):
453
+ instances = (instances,)
452
454
  for instance in instances:
453
455
  instance._dirty_capture_was_dirty()
454
456
 
455
457
 
456
458
  def reset_dirty_state(
457
- instances: Iterable[DirtyFieldsMixin],
459
+ instances: DirtyFieldsMixin | Iterable[DirtyFieldsMixin],
458
460
  fields: Iterable[str] | None = None,
459
461
  ) -> None:
460
- """Clear dirty state on each instance — call after ``bulk_update()``. ``fields`` accepts name or attname."""
462
+ """Clear dirty state — call after ``bulk_update()``. ``fields`` accepts name or attname. Accepts a single instance or an iterable."""
463
+ if isinstance(instances, DirtyFieldsMixin):
464
+ instances = (instances,)
461
465
  field_list = list(fields) if fields is not None else None
462
466
  for instance in instances:
463
467
  instance._dirty_reset_state(fields=field_list)
@@ -1,105 +0,0 @@
1
- import pytest
2
-
3
- from tests.models import ModelTest
4
-
5
- pytestmark = [pytest.mark.django_db(transaction=True)]
6
-
7
-
8
- @pytest.mark.asyncio
9
- async def test_asave_resets_dirty_state():
10
- tm = await ModelTest.objects.acreate()
11
- assert not tm.is_dirty()
12
-
13
- tm.boolean = False
14
- assert tm.is_dirty()
15
- assert tm.get_dirty_fields() == {"boolean": True}
16
-
17
- await tm.asave()
18
- assert not tm.is_dirty()
19
- assert tm.get_dirty_fields() == {}
20
-
21
-
22
- @pytest.mark.asyncio
23
- async def test_asave_captures_was_dirty():
24
- tm = await ModelTest.objects.acreate(boolean=True, characters="original")
25
-
26
- tm.characters = "modified"
27
- assert tm.is_dirty()
28
-
29
- await tm.asave()
30
-
31
- assert tm.was_dirty()
32
- assert tm.get_was_dirty_fields() == {"characters": "original"}
33
- assert not tm.is_dirty()
34
-
35
- # Save again with no changes
36
- await tm.asave()
37
- assert not tm.was_dirty()
38
- assert tm.get_was_dirty_fields() == {}
39
-
40
-
41
- @pytest.mark.asyncio
42
- async def test_asave_with_update_fields():
43
- tm = await ModelTest.objects.acreate(boolean=True, characters="original")
44
-
45
- tm.boolean = False
46
- tm.characters = "modified"
47
- assert tm.get_dirty_fields() == {"boolean": True, "characters": "original"}
48
-
49
- await tm.asave(update_fields=["boolean"])
50
-
51
- # `boolean` was persisted — clean. `characters` was not — still dirty.
52
- assert tm.get_dirty_fields() == {"characters": "original"}
53
-
54
-
55
- @pytest.mark.asyncio
56
- async def test_arefresh_from_db_resets_dirty_state():
57
- tm = await ModelTest.objects.acreate(boolean=True, characters="original")
58
- alias = await ModelTest.objects.aget(pk=tm.pk)
59
- alias.boolean = False
60
- await alias.asave()
61
-
62
- # tm still has stale local state
63
- assert tm.boolean is True
64
-
65
- await tm.arefresh_from_db()
66
- assert tm.boolean is False
67
- assert tm.get_dirty_fields() == {}
68
-
69
-
70
- @pytest.mark.asyncio
71
- async def test_arefresh_from_db_partial_fields():
72
- tm = await ModelTest.objects.acreate(characters="old value")
73
- tm.boolean = False
74
- tm.characters = "new value"
75
- assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"}
76
-
77
- await tm.arefresh_from_db(fields=["characters"])
78
- assert tm.boolean is False
79
- assert tm.characters == "old value"
80
- assert tm.get_dirty_fields() == {"boolean": True}
81
-
82
-
83
- @pytest.mark.asyncio
84
- async def test_aget_produces_clean_state():
85
- created = await ModelTest.objects.acreate(boolean=True, characters="hello")
86
- tm = await ModelTest.objects.aget(pk=created.pk)
87
-
88
- assert not tm.is_dirty()
89
- assert tm.get_dirty_fields() == {}
90
-
91
-
92
- @pytest.mark.asyncio
93
- async def test_arefresh_from_db_with_from_queryset():
94
- """arefresh_from_db(from_queryset=...) resets dirty state, mirroring sync version."""
95
- tm = await ModelTest.objects.acreate(boolean=True, characters="initial")
96
- alias = await ModelTest.objects.aget(pk=tm.pk)
97
- alias.characters = "updated"
98
- await alias.asave()
99
-
100
- tm.boolean = False
101
- assert tm.get_dirty_fields() == {"boolean": True}
102
-
103
- await tm.arefresh_from_db(from_queryset=ModelTest.objects.all())
104
- assert tm.characters == "updated"
105
- assert tm.get_dirty_fields() == {}
@@ -1,225 +0,0 @@
1
- """Benchmarks for dirty-field tracking overhead.
2
-
3
- Skipped by default. To run:
4
-
5
- uv run pytest tests/test_benchmark.py -m benchmark -s
6
-
7
- The ``-s`` flag disables output capture so the result table is visible.
8
-
9
- Two tests live here:
10
-
11
- * ``test_benchmark_suite`` — in-process, compares plain Django vs this fork.
12
- * ``test_benchmark_vs_upstream`` — 3-way comparison against upstream
13
- ``django-dirtyfields``. Spins up a throw-away venv via ``uv``, runs
14
- :mod:`tests._upstream_benchmark_runner` there, and merges its numbers with
15
- the in-process ones. First run is slow (venv setup); subsequent runs reuse
16
- the cached venv under ``/tmp/dirtyfields-bench-upstream``.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import json
22
- import shutil
23
- import subprocess
24
- import sys
25
- from pathlib import Path
26
-
27
- import pytest
28
- from django.db import models
29
-
30
- from filthyfields import DirtyFieldsMixin
31
- from tests._benchmark_common import (
32
- ITERATIONS,
33
- N_INSTANCES,
34
- SCENARIOS,
35
- populate,
36
- run_paired,
37
- )
38
-
39
- pytestmark = [pytest.mark.benchmark, pytest.mark.django_db]
40
-
41
-
42
- class PlainBenchmarkModel(models.Model):
43
- """Plain Django model without dirty tracking (baseline)."""
44
-
45
- char1 = models.CharField(max_length=100, default="")
46
- char2 = models.CharField(max_length=100, default="")
47
- char3 = models.CharField(max_length=100, default="")
48
- char4 = models.CharField(max_length=100, default="")
49
- text1 = models.TextField(default="")
50
- text2 = models.TextField(default="")
51
- int1 = models.IntegerField(default=0)
52
- int2 = models.IntegerField(default=0)
53
- int3 = models.IntegerField(default=0)
54
- float1 = models.FloatField(default=0.0)
55
- float2 = models.FloatField(default=0.0)
56
- decimal1 = models.DecimalField(max_digits=10, decimal_places=2, default=0)
57
- decimal2 = models.DecimalField(max_digits=10, decimal_places=2, default=0)
58
- bool1 = models.BooleanField(default=False)
59
- bool2 = models.BooleanField(default=False)
60
- bool3 = models.BooleanField(default=False)
61
- datetime1 = models.DateTimeField(null=True, default=None)
62
- datetime2 = models.DateTimeField(null=True, default=None)
63
- json1 = models.JSONField(default=dict)
64
- json2 = models.JSONField(default=dict)
65
-
66
- class Meta:
67
- app_label = "tests"
68
-
69
-
70
- class DirtyBenchmarkModel(DirtyFieldsMixin, models.Model):
71
- """Model using DirtyFieldsMixin."""
72
-
73
- char1 = models.CharField(max_length=100, default="")
74
- char2 = models.CharField(max_length=100, default="")
75
- char3 = models.CharField(max_length=100, default="")
76
- char4 = models.CharField(max_length=100, default="")
77
- text1 = models.TextField(default="")
78
- text2 = models.TextField(default="")
79
- int1 = models.IntegerField(default=0)
80
- int2 = models.IntegerField(default=0)
81
- int3 = models.IntegerField(default=0)
82
- float1 = models.FloatField(default=0.0)
83
- float2 = models.FloatField(default=0.0)
84
- decimal1 = models.DecimalField(max_digits=10, decimal_places=2, default=0)
85
- decimal2 = models.DecimalField(max_digits=10, decimal_places=2, default=0)
86
- bool1 = models.BooleanField(default=False)
87
- bool2 = models.BooleanField(default=False)
88
- bool3 = models.BooleanField(default=False)
89
- datetime1 = models.DateTimeField(null=True, default=None)
90
- datetime2 = models.DateTimeField(null=True, default=None)
91
- json1 = models.JSONField(default=dict)
92
- json2 = models.JSONField(default=dict)
93
-
94
- class Meta:
95
- app_label = "tests"
96
-
97
-
98
- @pytest.fixture
99
- def populated_benchmark_db():
100
- """Populate both benchmark models with N_INSTANCES rows of diverse data."""
101
- populate(PlainBenchmarkModel)
102
- populate(DirtyBenchmarkModel)
103
-
104
-
105
- def _run_in_process() -> dict[str, tuple[float, float]]:
106
- """Run all scenarios in-process, returning ``{key: (plain_ms, fork_ms)}``."""
107
- results: dict[str, tuple[float, float]] = {}
108
- for key, _name, func in SCENARIOS:
109
- results[key] = run_paired(
110
- lambda f=func: f(PlainBenchmarkModel),
111
- lambda f=func: f(DirtyBenchmarkModel),
112
- )
113
- return results
114
-
115
-
116
- def _py() -> str:
117
- return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
118
-
119
-
120
- def test_benchmark_suite(populated_benchmark_db, capsys):
121
- """Run all benchmark scenarios and print a summary table (plain vs fork)."""
122
- results = _run_in_process()
123
-
124
- with capsys.disabled():
125
- print()
126
- print("=" * 85)
127
- print(f"Dirty Field Tracking Benchmark — Python {_py()}")
128
- print("=" * 85)
129
- print(f"Instances: {N_INSTANCES:,} | Fields: 20 | Iterations: {ITERATIONS}")
130
- print()
131
- print(f"{'Scenario':40} | {'Plain':>12} | {'Dirty':>12} | {'Overhead':>11}")
132
- print("-" * 85)
133
- for key, name, _func in SCENARIOS:
134
- plain_ms, dirty_ms = results[key]
135
- overhead = dirty_ms - plain_ms
136
- print(f"{name:40} | {plain_ms:9.1f} ms | {dirty_ms:9.1f} ms | {overhead:+8.1f} ms")
137
- print()
138
-
139
-
140
- _UPSTREAM_VENV = Path("/tmp/dirtyfields-bench-upstream") # noqa: S108
141
-
142
-
143
- def _ensure_upstream_venv() -> Path:
144
- """Create (or reuse) a venv containing Django + upstream django-dirtyfields. Returns its python."""
145
- python = _UPSTREAM_VENV / "bin" / "python"
146
- marker = _UPSTREAM_VENV / ".installed"
147
- if python.exists() and marker.exists():
148
- return python
149
-
150
- if shutil.which("uv") is None:
151
- pytest.skip("`uv` not on PATH; can't set up the upstream comparison venv")
152
-
153
- if _UPSTREAM_VENV.exists():
154
- shutil.rmtree(_UPSTREAM_VENV)
155
-
156
- try:
157
- subprocess.run( # noqa: S603
158
- ["uv", "venv", "--quiet", "--python", _py(), str(_UPSTREAM_VENV)],
159
- check=True,
160
- )
161
- subprocess.run( # noqa: S603
162
- [
163
- "uv",
164
- "pip",
165
- "install",
166
- "--quiet",
167
- "--python",
168
- str(python),
169
- "Django",
170
- "django-dirtyfields",
171
- ],
172
- check=True,
173
- )
174
- except subprocess.CalledProcessError as exc:
175
- pytest.skip(f"Failed to set up upstream venv: {exc}")
176
-
177
- marker.write_text("ok")
178
- return python
179
-
180
-
181
- def _run_upstream(upstream_python: Path) -> dict[str, tuple[float, float]]:
182
- """Invoke the standalone upstream runner and parse its JSON output."""
183
- project_root = Path(__file__).parent.parent
184
- try:
185
- result = subprocess.run( # noqa: S603
186
- [str(upstream_python), "-m", "tests._upstream_benchmark_runner"],
187
- capture_output=True,
188
- text=True,
189
- check=True,
190
- cwd=str(project_root),
191
- )
192
- except subprocess.CalledProcessError as exc:
193
- pytest.skip(f"Upstream benchmark runner failed:\n{exc.stderr}")
194
-
195
- data = json.loads(result.stdout)
196
- return {key: (row["plain_ms"], row["dirty_ms"]) for key, row in data["results"].items()}
197
-
198
-
199
- def test_benchmark_vs_upstream(populated_benchmark_db, capsys):
200
- """3-way comparison: plain Django vs this fork vs upstream django-dirtyfields."""
201
- upstream_python = _ensure_upstream_venv()
202
- fork_results = _run_in_process()
203
- upstream_results = _run_upstream(upstream_python)
204
-
205
- with capsys.disabled():
206
- print()
207
- print("=" * 100)
208
- print(f"3-way Benchmark — Python {_py()}")
209
- print("=" * 100)
210
- print(f"Instances: {N_INSTANCES:,} | Fields: 20 | Iterations: {ITERATIONS}")
211
- print()
212
- print(
213
- f"{'Scenario':40} | {'Plain':>10} | {'Fork':>10} | {'Upstream':>10} | {'Fork Δ':>10} | {'Upstream Δ':>12}",
214
- )
215
- print("-" * 110)
216
- for key, name, _func in SCENARIOS:
217
- plain_ms, fork_ms = fork_results[key]
218
- _up_plain_ms, upstream_ms = upstream_results[key]
219
- fork_overhead = fork_ms - plain_ms
220
- upstream_overhead = upstream_ms - plain_ms
221
- print(
222
- f"{name:40} | {plain_ms:7.1f} ms | {fork_ms:7.1f} ms | {upstream_ms:7.1f} ms "
223
- f"| {fork_overhead:+7.1f} ms | {upstream_overhead:+9.1f} ms",
224
- )
225
- print()