django-filthyfields 2.1.0__tar.gz → 2.2.0__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 (18) hide show
  1. {django_filthyfields-2.1.0/src/django_filthyfields.egg-info → django_filthyfields-2.2.0}/PKG-INFO +2 -2
  2. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/README.md +1 -1
  3. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/pyproject.toml +1 -1
  4. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0/src/django_filthyfields.egg-info}/PKG-INFO +2 -2
  5. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/filthyfields/__init__.py +7 -1
  6. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/filthyfields/_descriptor.c +18 -22
  7. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/filthyfields/filthyfields.py +42 -16
  8. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/LICENSE +0 -0
  9. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/MANIFEST.in +0 -0
  10. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/setup.cfg +0 -0
  11. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/setup.py +0 -0
  12. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/django_filthyfields.egg-info/SOURCES.txt +0 -0
  13. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/django_filthyfields.egg-info/dependency_links.txt +0 -0
  14. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/django_filthyfields.egg-info/requires.txt +0 -0
  15. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/django_filthyfields.egg-info/top_level.txt +0 -0
  16. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/filthyfields/_descriptor.py +0 -0
  17. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/filthyfields/compare.py +0 -0
  18. {django_filthyfields-2.1.0 → django_filthyfields-2.2.0}/src/filthyfields/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-filthyfields
3
- Version: 2.1.0
3
+ Version: 2.2.0
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
@@ -33,6 +33,6 @@ Dynamic: license-file
33
33
  Tracking dirty fields on a Django model instance.
34
34
  Dirty means that field in-memory and database values are different.
35
35
 
36
- Started as a fork of [django-dirtyfields](https://github.com/romgar/django-dirtyfields) with a rewritten lazy, descriptor-based implementation; since diverged with its own feature set and release cadence. The mixin and method names (`DirtyFieldsMixin`, `get_dirty_fields`, …) are kept from upstream — hence the play-on-words package name.
36
+ Started as a fork of [django-dirtyfields](https://github.com/romgar/django-dirtyfields) with a rewritten lazy, descriptor-based implementation; since diverged with its own feature set and release cadence. The mixin and method names (`DirtyFieldsMixin`, `get_dirty_fields`, …) are mostly kept from upstream.
37
37
 
38
38
  See the [documentation](https://oliverhaas.github.io/django-filthyfields/) for more information.
@@ -6,6 +6,6 @@
6
6
  Tracking dirty fields on a Django model instance.
7
7
  Dirty means that field in-memory and database values are different.
8
8
 
9
- Started as a fork of [django-dirtyfields](https://github.com/romgar/django-dirtyfields) with a rewritten lazy, descriptor-based implementation; since diverged with its own feature set and release cadence. The mixin and method names (`DirtyFieldsMixin`, `get_dirty_fields`, …) are kept from upstream — hence the play-on-words package name.
9
+ Started as a fork of [django-dirtyfields](https://github.com/romgar/django-dirtyfields) with a rewritten lazy, descriptor-based implementation; since diverged with its own feature set and release cadence. The mixin and method names (`DirtyFieldsMixin`, `get_dirty_fields`, …) are mostly kept from upstream.
10
10
 
11
11
  See the [documentation](https://oliverhaas.github.io/django-filthyfields/) for more information.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-filthyfields"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "Tracking dirty fields on a Django model instance."
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-filthyfields
3
- Version: 2.1.0
3
+ Version: 2.2.0
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
@@ -33,6 +33,6 @@ Dynamic: license-file
33
33
  Tracking dirty fields on a Django model instance.
34
34
  Dirty means that field in-memory and database values are different.
35
35
 
36
- Started as a fork of [django-dirtyfields](https://github.com/romgar/django-dirtyfields) with a rewritten lazy, descriptor-based implementation; since diverged with its own feature set and release cadence. The mixin and method names (`DirtyFieldsMixin`, `get_dirty_fields`, …) are kept from upstream — hence the play-on-words package name.
36
+ Started as a fork of [django-dirtyfields](https://github.com/romgar/django-dirtyfields) with a rewritten lazy, descriptor-based implementation; since diverged with its own feature set and release cadence. The mixin and method names (`DirtyFieldsMixin`, `get_dirty_fields`, …) are mostly kept from upstream.
37
37
 
38
38
  See the [documentation](https://oliverhaas.github.io/django-filthyfields/) for more information.
@@ -3,10 +3,16 @@
3
3
  from importlib.metadata import version
4
4
 
5
5
  from filthyfields.compare import normalise_value, raw_compare, timezone_support_compare
6
- from filthyfields.filthyfields import DirtyFieldsMixin, capture_dirty_state, reset_dirty_state
6
+ from filthyfields.filthyfields import (
7
+ DirtyFieldsMixin,
8
+ DirtyStateNotCapturedError,
9
+ capture_dirty_state,
10
+ reset_dirty_state,
11
+ )
7
12
 
8
13
  __all__ = [
9
14
  "DirtyFieldsMixin",
15
+ "DirtyStateNotCapturedError",
10
16
  "capture_dirty_state",
11
17
  "normalise_value",
12
18
  "raw_compare",
@@ -1,4 +1,4 @@
1
- /* Generated by Cython 3.2.4 */
1
+ /* Generated by Cython 3.2.5 */
2
2
 
3
3
  /* BEGIN: Cython Metadata
4
4
  {
@@ -34,8 +34,8 @@ END: Cython Metadata */
34
34
  #elif PY_VERSION_HEX < 0x03080000
35
35
  #error Cython requires Python 3.8+.
36
36
  #else
37
- #define __PYX_ABI_VERSION "3_2_4"
38
- #define CYTHON_HEX_VERSION 0x030204F0
37
+ #define __PYX_ABI_VERSION "3_2_5"
38
+ #define CYTHON_HEX_VERSION 0x030205F0
39
39
  #define CYTHON_FUTURE_DIVISION 1
40
40
  /* CModulePreamble */
41
41
  #include <stddef.h>
@@ -2760,7 +2760,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
2760
2760
  {
2761
2761
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_klass,0};
2762
2762
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
2763
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 29, __pyx_L3_error)
2763
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 29, __pyx_L3_error)
2764
2764
  if (__pyx_kwds_len > 0) {
2765
2765
  switch (__pyx_nargs) {
2766
2766
  case 1:
@@ -2885,7 +2885,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
2885
2885
  {
2886
2886
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_fn,0};
2887
2887
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
2888
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 33, __pyx_L3_error)
2888
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 33, __pyx_L3_error)
2889
2889
  if (__pyx_kwds_len > 0) {
2890
2890
  switch (__pyx_nargs) {
2891
2891
  case 1:
@@ -3010,7 +3010,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
3010
3010
  {
3011
3011
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_fn,0};
3012
3012
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
3013
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 37, __pyx_L3_error)
3013
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 37, __pyx_L3_error)
3014
3014
  if (__pyx_kwds_len > 0) {
3015
3015
  switch (__pyx_nargs) {
3016
3016
  case 1:
@@ -3135,7 +3135,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
3135
3135
  {
3136
3136
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_fn,0};
3137
3137
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
3138
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 41, __pyx_L3_error)
3138
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 41, __pyx_L3_error)
3139
3139
  if (__pyx_kwds_len > 0) {
3140
3140
  switch (__pyx_nargs) {
3141
3141
  case 1:
@@ -3813,7 +3813,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
3813
3813
  {
3814
3814
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_value,0};
3815
3815
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
3816
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 81, __pyx_L3_error)
3816
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 81, __pyx_L3_error)
3817
3817
  if (__pyx_kwds_len > 0) {
3818
3818
  switch (__pyx_nargs) {
3819
3819
  case 1:
@@ -3917,7 +3917,7 @@ static int __pyx_pw_12filthyfields_11_descriptor_14DiffDescriptor_1__init__(PyOb
3917
3917
  {
3918
3918
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_field,&__pyx_mstate_global->__pyx_n_u_deferred_attr,&__pyx_mstate_global->__pyx_n_u_track_mutations,0};
3919
3919
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_VARARGS(__pyx_kwds) : 0;
3920
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 114, __pyx_L3_error)
3920
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 114, __pyx_L3_error)
3921
3921
  if (__pyx_kwds_len > 0) {
3922
3922
  switch (__pyx_nargs) {
3923
3923
  case 3:
@@ -5960,7 +5960,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
5960
5960
  {
5961
5961
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_pyx_state,0};
5962
5962
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
5963
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(1, 16, __pyx_L3_error)
5963
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(1, 16, __pyx_L3_error)
5964
5964
  if (__pyx_kwds_len > 0) {
5965
5965
  switch (__pyx_nargs) {
5966
5966
  case 1:
@@ -6103,7 +6103,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
6103
6103
  {
6104
6104
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_pyx_type,&__pyx_mstate_global->__pyx_n_u_pyx_checksum,&__pyx_mstate_global->__pyx_n_u_pyx_state,0};
6105
6105
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
6106
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(1, 4, __pyx_L3_error)
6106
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(1, 4, __pyx_L3_error)
6107
6107
  if (__pyx_kwds_len > 0) {
6108
6108
  switch (__pyx_nargs) {
6109
6109
  case 3:
@@ -7890,11 +7890,11 @@ __Pyx_PyTuple_FromArray(PyObject *const *src, Py_ssize_t n)
7890
7890
  res = PyTuple_New(n);
7891
7891
  if (unlikely(res == NULL)) return NULL;
7892
7892
  for (i = 0; i < n; i++) {
7893
+ Py_INCREF(src[i]);
7893
7894
  if (unlikely(__Pyx_PyTuple_SET_ITEM(res, i, src[i]) < (0))) {
7894
7895
  Py_DECREF(res);
7895
7896
  return NULL;
7896
7897
  }
7897
- Py_INCREF(src[i]);
7898
7898
  }
7899
7899
  return res;
7900
7900
  }
@@ -9221,7 +9221,7 @@ bad:
9221
9221
  }
9222
9222
 
9223
9223
  /* dict_iter */
9224
- #if CYTHON_COMPILING_IN_PYPY
9224
+ #if CYTHON_AVOID_BORROWED_REFS
9225
9225
  #include <string.h>
9226
9226
  #endif
9227
9227
  static CYTHON_INLINE PyObject* __Pyx_dict_iterator(PyObject* iterable, int is_dict, PyObject* method_name,
@@ -9229,7 +9229,7 @@ static CYTHON_INLINE PyObject* __Pyx_dict_iterator(PyObject* iterable, int is_di
9229
9229
  is_dict = is_dict || likely(PyDict_CheckExact(iterable));
9230
9230
  *p_source_is_dict = is_dict;
9231
9231
  if (is_dict) {
9232
- #if !CYTHON_COMPILING_IN_PYPY
9232
+ #if !CYTHON_AVOID_BORROWED_REFS
9233
9233
  *p_orig_length = PyDict_Size(iterable);
9234
9234
  Py_INCREF(iterable);
9235
9235
  return iterable;
@@ -9258,7 +9258,7 @@ static CYTHON_INLINE PyObject* __Pyx_dict_iterator(PyObject* iterable, int is_di
9258
9258
  iterable = __Pyx_PyObject_CallMethod0(iterable, method_name);
9259
9259
  if (!iterable)
9260
9260
  return NULL;
9261
- #if !CYTHON_COMPILING_IN_PYPY
9261
+ #if !CYTHON_AVOID_BORROWED_REFS
9262
9262
  if (PyTuple_CheckExact(iterable) || PyList_CheckExact(iterable))
9263
9263
  return iterable;
9264
9264
  #endif
@@ -11427,7 +11427,6 @@ __Pyx_CyFunction_get_is_coroutine_value(__pyx_CyFunctionObject *op) {
11427
11427
  PyList_SET_ITEM(fromlist, 0, marker);
11428
11428
  #else
11429
11429
  if (unlikely(PyList_SetItem(fromlist, 0, marker) < 0)) {
11430
- Py_DECREF(marker);
11431
11430
  Py_DECREF(fromlist);
11432
11431
  return NULL;
11433
11432
  }
@@ -11668,8 +11667,7 @@ __Pyx_CyFunction_clear(__pyx_CyFunctionObject *m)
11668
11667
  Py_CLEAR(m->func_doc);
11669
11668
  Py_CLEAR(m->func_globals);
11670
11669
  Py_CLEAR(m->func_code);
11671
- #if !CYTHON_COMPILING_IN_LIMITED_API
11672
- #if PY_VERSION_HEX < 0x030900B1
11670
+ #if PY_VERSION_HEX < 0x030900B1 || CYTHON_COMPILING_IN_LIMITED_API
11673
11671
  Py_CLEAR(__Pyx_CyFunction_GetClassObj(m));
11674
11672
  #else
11675
11673
  {
@@ -11677,7 +11675,6 @@ __Pyx_CyFunction_clear(__pyx_CyFunctionObject *m)
11677
11675
  ((PyCMethodObject *) (m))->mm_class = NULL;
11678
11676
  Py_XDECREF(cls);
11679
11677
  }
11680
- #endif
11681
11678
  #endif
11682
11679
  Py_CLEAR(m->defaults_tuple);
11683
11680
  Py_CLEAR(m->defaults_kwdict);
@@ -11729,11 +11726,10 @@ static int __Pyx_CyFunction_traverse(__pyx_CyFunctionObject *m, visitproc visit,
11729
11726
  Py_VISIT(m->func_doc);
11730
11727
  Py_VISIT(m->func_globals);
11731
11728
  __Pyx_VISIT_CONST(m->func_code);
11732
- #if !CYTHON_COMPILING_IN_LIMITED_API
11733
11729
  Py_VISIT(__Pyx_CyFunction_GetClassObj(m));
11734
- #endif
11735
11730
  Py_VISIT(m->defaults_tuple);
11736
11731
  Py_VISIT(m->defaults_kwdict);
11732
+ Py_VISIT(m->func_annotations);
11737
11733
  Py_VISIT(m->func_is_coroutine);
11738
11734
  Py_VISIT(m->defaults);
11739
11735
  return 0;
@@ -12708,8 +12704,8 @@ raise_neg_overflow:
12708
12704
  #if CYTHON_VECTORCALL
12709
12705
  static int __Pyx_VectorcallBuilder_AddArg(PyObject *key, PyObject *value, PyObject *builder, PyObject **args, int n) {
12710
12706
  (void)__Pyx_PyObject_FastCallDict;
12711
- if (__Pyx_PyTuple_SET_ITEM(builder, n, key) != (0)) return -1;
12712
12707
  Py_INCREF(key);
12708
+ if (__Pyx_PyTuple_SET_ITEM(builder, n, key) != (0)) return -1;
12713
12709
  args[n] = value;
12714
12710
  return 0;
12715
12711
  }
@@ -23,6 +23,10 @@ if TYPE_CHECKING:
23
23
  NormaliseFunction = tuple[Callable[..., Any], dict[str, Any]]
24
24
 
25
25
 
26
+ class DirtyStateNotCapturedError(RuntimeError):
27
+ """Raised when pre-save state is read before any save/asave/capture_dirty_state."""
28
+
29
+
26
30
  def _should_track_field(instance: models.Model, field_name: str, field_attname: str | None = None) -> bool:
27
31
  """Apply FIELDS_TO_CHECK / FIELDS_TO_CHECK_EXCLUDE. Accepts both name and attname (e.g. 'fkey'/'fkey_id')."""
28
32
  fields_to_check = getattr(instance, "FIELDS_TO_CHECK", None)
@@ -212,8 +216,15 @@ class DirtyFieldsMixin(models.Model, metaclass=_DirtyMeta):
212
216
 
213
217
  @property
214
218
  def was_adding(self) -> bool:
215
- """Whether the instance was unsaved before the last ``save()`` / ``capture_dirty_state()``."""
216
- return getattr(self, "_was_adding", False)
219
+ """Whether the instance was unsaved before the last ``save()`` / ``capture_dirty_state()``.
220
+
221
+ Raises ``DirtyStateNotCapturedError`` if no save or capture has happened yet.
222
+ """
223
+ if "_was_adding" not in self.__dict__:
224
+ raise DirtyStateNotCapturedError(
225
+ "was_adding read before any save()/asave()/capture_dirty_state() — nothing has been captured yet.",
226
+ )
227
+ return self._was_adding
217
228
 
218
229
  def _dirty_reset_state(self, fields: Iterable[str] | None = None) -> None:
219
230
  """Reset dirty state. ``fields=None`` resets everything; otherwise accepts name or attname."""
@@ -253,15 +264,12 @@ class DirtyFieldsMixin(models.Model, metaclass=_DirtyMeta):
253
264
  if name in current_m2m:
254
265
  self._original_m2m_state[name] = current_m2m[name]
255
266
 
256
- def save(self, *args: Any, **kwargs: Any) -> None:
257
- self._dirty_capture_was_dirty()
267
+ def save(self, *args: Any, dirty_capture: bool = True, dirty_reset: bool = True, **kwargs: Any) -> None:
268
+ if dirty_capture:
269
+ self._dirty_capture_was_dirty()
258
270
  super().save(*args, **kwargs)
259
- self._dirty_reset_state(fields=kwargs.get("update_fields"))
260
-
261
- async def asave(self, *args: Any, **kwargs: Any) -> None:
262
- self._dirty_capture_was_dirty()
263
- await super().asave(*args, **kwargs)
264
- self._dirty_reset_state(fields=kwargs.get("update_fields"))
271
+ if dirty_reset:
272
+ self._dirty_reset_state(fields=kwargs.get("update_fields"))
265
273
 
266
274
  def refresh_from_db( # ty: ignore[invalid-method-override]
267
275
  self,
@@ -450,20 +458,38 @@ class DirtyFieldsMixin(models.Model, metaclass=_DirtyMeta):
450
458
  return result
451
459
 
452
460
  def was_dirty(self, check_relationship: bool = False, check_m2m: bool = False) -> bool:
461
+ """Whether any tracked field was dirty before the last save.
462
+
463
+ Delegates to :meth:`get_was_dirty_fields`; raises ``DirtyStateNotCapturedError``
464
+ if no save or capture has happened yet.
465
+ """
453
466
  return bool(self.get_was_dirty_fields(check_relationship=check_relationship, check_m2m=check_m2m))
454
467
 
455
468
  def get_was_dirty_fields(self, check_relationship: bool = False, check_m2m: bool = False) -> dict[str, Any]:
456
- """Fields dirty before the last save (captured by save()/asave())."""
469
+ """Fields dirty before the last save (captured by save()/asave()).
470
+
471
+ Raises ``DirtyStateNotCapturedError`` if no save or capture has happened yet, or
472
+ if ``check_m2m=True`` is requested but ``ENABLE_M2M_CHECK`` was disabled at the
473
+ time of the last capture.
474
+ """
457
475
  if check_m2m and not self.ENABLE_M2M_CHECK:
458
476
  raise ValueError("You can't check m2m fields if ENABLE_M2M_CHECK is set to False")
459
477
 
460
- if check_relationship:
461
- result = dict(getattr(self, "_was_dirty_fields_rel", {}))
462
- else:
463
- result = dict(getattr(self, "_was_dirty_fields", {}))
478
+ if "_was_dirty_fields" not in self.__dict__:
479
+ raise DirtyStateNotCapturedError(
480
+ "get_was_dirty_fields() called before any save()/asave()/capture_dirty_state() — "
481
+ "nothing has been captured yet.",
482
+ )
483
+
484
+ result = dict(self._was_dirty_fields_rel) if check_relationship else dict(self._was_dirty_fields)
464
485
 
465
486
  if check_m2m:
466
- result.update(getattr(self, "_was_dirty_fields_m2m", {}))
487
+ if "_was_dirty_fields_m2m" not in self.__dict__:
488
+ raise DirtyStateNotCapturedError(
489
+ "check_m2m=True but no M2M state was captured — "
490
+ "ENABLE_M2M_CHECK was disabled at the time of the last save/capture.",
491
+ )
492
+ result.update(self._was_dirty_fields_m2m)
467
493
 
468
494
  return result
469
495