From 440f7ceab7e327b4d5287351177a42b343de8c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:45:45 +0000 Subject: [PATCH 1/2] Keep a configured class default through rejected patches and monkeypatch.delattr A class assignment that rejected a Var or a Field raised before it recorded its undo entry. unittest.mock and pytest-mock still restore the saved field after their own assignment raised, so that restore undid the default configured before the patch. Every failed assignment now records its undo entry, from one except clause around a new helper that computes the new default. monkeypatch.delattr deletes the attribute through the declaring state, which undoes the latest assignment, then assigns the saved field back, which undid one more. A deletion through the declaring state is now a change of its own: it records the assignment it undid, and assigning the field back puts that assignment back. Both histories share one ordering, so undoing always reverses the latest change, also with patches nested inside a deletion. Deleting through a subclass, as unittest.mock does to undo a patch made there, still undoes the latest change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- docs/vars/base_vars.md | 12 +- .../news/+class-default-patch-undo.bugfix.md | 1 + .../reflex-base/src/reflex_base/vars/base.py | 159 +++++++++++++----- tests/units/reflex_base/vars/test_base.py | 58 ++++++- 4 files changed, 183 insertions(+), 47 deletions(-) create mode 100644 packages/reflex-base/news/+class-default-patch-undo.bugfix.md diff --git a/docs/vars/base_vars.md b/docs/vars/base_vars.md index 07bee6faa48..cbd0b410bfd 100644 --- a/docs/vars/base_vars.md +++ b/docs/vars/base_vars.md @@ -105,11 +105,15 @@ through a subclass also changes that declaring state's default. Each generated [`get_component` to configure defaults](/docs/state-structure/component-state/#passing-props) independently for each component. +Deleting the class attribute undoes the most recent default assignment. Assigning a var's own field or Var back to its state class, as read through the -class before a change, or deleting the class attribute undoes the most recent -default assignment. Testing tools such as pytest's `monkeypatch.setattr` and -`unittest.mock.patch.object` restore a patched default this way, including a -patch made through a subclass. +class before a change, undoes the most recent assignment, or a deletion through +the state that declares the var. Testing tools such as pytest's +`monkeypatch.setattr` and `monkeypatch.delattr` and `unittest.mock.patch.object` +restore a patched default this way, including a patch made through a subclass +and one whose value was rejected. Undoing always reverses the latest change, so +a default assigned while a patch is applied is the one the patch's restore +undoes. ```md alert warning # Annotate the var, and keep shared objects out of defaults. diff --git a/packages/reflex-base/news/+class-default-patch-undo.bugfix.md b/packages/reflex-base/news/+class-default-patch-undo.bugfix.md new file mode 100644 index 00000000000..b70381451ec --- /dev/null +++ b/packages/reflex-base/news/+class-default-patch-undo.bugfix.md @@ -0,0 +1 @@ +Undoing a `mock.patch.object` or pytest-mock patch of a state var's class default that rejected its value (a Var or `rx.field(...)`), or a `monkeypatch.delattr` of the var, no longer drops the default configured before the patch. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 0e882cab07d..f66d3d487f9 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -10,6 +10,7 @@ import functools import hashlib import inspect +import itertools import json import logging import re @@ -3898,10 +3899,18 @@ def dispatch( # undoing an assignment restores, most recent last. _REPLACED_DEFAULTS_ATTR = "_replaced_defaults" +# The field attribute holding the assignments undone by deleting the class +# attribute through the declaring state, which undoing the deletion puts back, +# most recent last. +_UNDONE_DEFAULTS_ATTR = "_undone_defaults" + # How many replaced defaults a field keeps: undoing reaches back through as many # nested patches, and assigning a default repeatedly does not grow the history. _MAX_REPLACED_DEFAULTS = 16 +# Orders the entries of both histories, so that undoing reverses the latest change. +_default_changes = itertools.count() + # Custom attrs never copied from a source field: get_field_type duck-types # pydantic fields on `.annotation`, so carrying it over would shadow the # real class annotation; the binding attrs and the defaults replaced by class @@ -3915,6 +3924,7 @@ def dispatch( "_plain_types", "_var", _REPLACED_DEFAULTS_ATTR, + _UNDONE_DEFAULTS_ATTR, }) # Exact types of values that are never wrapped in a MutableProxy. Checking them @@ -4119,18 +4129,56 @@ def _assign_default( default: The new default value, or MISSING. default_factory: The new default factory, or None. """ - if (replaced := self.__dict__.get(_REPLACED_DEFAULTS_ATTR)) is None: - replaced = self.__dict__[_REPLACED_DEFAULTS_ATTR] = deque( - maxlen=_MAX_REPLACED_DEFAULTS - ) - replaced.append((self.default, self.default_factory)) + self._default_history(_REPLACED_DEFAULTS_ATTR).append(( + next(_default_changes), + self.default, + self.default_factory, + )) self.default = default self.default_factory = default_factory + def _default_history(self, attr: str) -> deque[tuple[Any, ...]]: + """Get one of the field's default histories, created on first use. + + Args: + attr: The field attribute holding the history. + + Returns: + The history, most recent last. + """ + if (history := self.__dict__.get(attr)) is None: + history = self.__dict__[attr] = deque(maxlen=_MAX_REPLACED_DEFAULTS) + return history + def _restore_default(self) -> None: - """Undo the most recent class assignment of the default, if any.""" + """Undo the most recent class assignment of the default, or its deletion. + + Undoing a deletion through the declaring state puts back the assignment + that the deletion undid. + """ + replaced = self.__dict__.get(_REPLACED_DEFAULTS_ATTR) + undone = self.__dict__.get(_UNDONE_DEFAULTS_ATTR) + if undone and not (replaced and replaced[-1][0] > undone[-1][0]): + _, self.default, self.default_factory, entry = undone.pop() + self.__dict__[_REPLACED_DEFAULTS_ATTR].append(entry) + elif replaced: + _, self.default, self.default_factory = replaced.pop() + + def _delete_default(self) -> None: + """Undo the most recent class assignment of the default, as its own change. + + Patching tools delete the attribute through the declaring state to patch + it, then assign the field back, which puts the assignment back. + """ if replaced := self.__dict__.get(_REPLACED_DEFAULTS_ATTR): - self.default, self.default_factory = replaced.pop() + entry = replaced.pop() + self._default_history(_UNDONE_DEFAULTS_ATTR).append(( + next(_default_changes), + self.default, + self.default_factory, + entry, + )) + _, self.default, self.default_factory = entry def _keep_default(self) -> None: """Record a failed class assignment, which leaves the default in place. @@ -4781,6 +4829,52 @@ def _keep_client_storage(declared: Field, value: Any) -> Any: return value +def _assigned_default_arguments( + declared: Field, name: str, value: Any +) -> dict[str, Any]: + """Get the default and default factory a class assignment gives a field. + + Args: + declared: The field assigned through its state class. + name: The field's name. + value: The assigned default value or zero-argument default factory. + + Returns: + The default and default factory arguments of the field. + + Raises: + TypeError: If the default is another Var or Field, does not satisfy + the field's annotation, or its factory fails. + """ + default = _keep_client_storage(declared, value) + accepted = _accepts_default(declared, default) + if not accepted and callable(value): + # The field cannot hold the callable itself, so it is a factory: call + # it once to validate what it produces. + try: + default = value() + except Exception as err: + msg = f"Default factory for field '{name}' failed: {err}" + raise TypeError(msg) from err + if inspect.iscoroutine(default): + default.close() + default = _keep_client_storage(declared, default) + accepted = _accepts_default(declared, default) + if accepted and not isinstance(default, declared._client_storage): + # Keep the callable to produce future defaults. + return {"default": MISSING, "default_factory": value} + # Browser storage is classified and configured by the value itself, and + # the browser supplies later values, so the value produced once here is + # the default rather than the factory. + if not accepted: + msg = ( + f"Invalid default for field '{name}': expected " + f"{declared.outer_type_}, got {default!r} of type {type(default)}." + ) + raise TypeError(msg) + return _default_arguments(default) + + def _assigned_field(cls: BaseStateMeta, name: str) -> Field | None: """Get the field a class attribute assignment or deletion configures. @@ -4869,9 +4963,9 @@ def __setattr__(cls, name: str, value: Any) -> None: a browser storage default, or of the value a frontend var's declared default factory produces, which is called once to find them. Assigning the field itself, or the Var read through the class, undoes the most - recent assignment, as patching tools do to restore what they saved; a - failed assignment is undone the same way and leaves the default as it - was. + recent assignment or deletion through the declaring state, as patching + tools do to restore what they saved; a failed assignment is undone the + same way and leaves the default as it was. Args: name: The class attribute being assigned. @@ -4892,42 +4986,21 @@ def __setattr__(cls, name: str, value: Any) -> None: # A value read from a state instance is proxied for dirty tracking; # the default must not retain that instance through the proxy. value = value.__wrapped__ - default = _keep_client_storage(declared, value) - accepted = _accepts_default(declared, default) - if not accepted and callable(value): - # The field cannot hold the callable itself, so it is a factory: - # call it once to validate what it produces. - try: - default = value() - except Exception as err: - declared._keep_default() - msg = f"Default factory for field '{name}' failed: {err}" - raise TypeError(msg) from err - if inspect.iscoroutine(default): - default.close() - default = _keep_client_storage(declared, default) - accepted = _accepts_default(declared, default) - if accepted and not isinstance(default, declared._client_storage): - # Keep the callable to produce future defaults. - declared._assign_default(MISSING, value) - return - # Browser storage is classified and configured by the value itself, - # and the browser supplies later values, so the value produced once - # here is the default rather than the factory. - if not accepted: + try: + arguments = _assigned_default_arguments(declared, name, value) + except Exception: + # Patching tools undo even an assignment that raised. declared._keep_default() - msg = ( - f"Invalid default for field '{name}': expected " - f"{declared.outer_type_}, got {default!r} of type {type(default)}." - ) - raise TypeError(msg) - declared._assign_default(**_default_arguments(default)) + raise + declared._assign_default(**arguments) def __delattr__(cls, name: str) -> None: """Undo the most recent assignment of a field's default, retaining its descriptor. Patching tools delete the attribute to undo a patch made through a - class that inherits the field. + class that inherits the field, which undoes the latest change. A + deletion through the declaring state is a change of its own, which + assigning the field back undoes. Args: name: The class attribute being deleted. @@ -4935,8 +5008,10 @@ class that inherits the field. declared = _assigned_field(cls, name) if declared is None: super().__delattr__(name) - return - declared._restore_default() + elif name in cls.__dict__: + declared._delete_default() + else: + declared._restore_default() def __new__( cls, diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index f14017356f6..778c4e9b371 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -648,6 +648,59 @@ def test_class_assignment_patch_round_trip(kind: str, mechanism: str): assert getattr(target(), name) == original +@pytest.mark.parametrize("rejected", ["var", "field"]) +def test_class_assignment_rejected_patch_round_trip(rejected: str): + """A rejected patch leaves the configured default once the patch is undone. + + ``unittest.mock`` assigns the saved field back even when its own assignment + raised, so the failed assignment must leave the entry that undo removes. + + Args: + rejected: Whether the patch assigns another state's Var or a fresh Field. + """ + + class OtherState(BaseState): + other: int = 1 + + class ConfigState(BaseState): + _value: int = 0 + + ConfigState._value = 10 + value = OtherState.other if rejected == "var" else field(5) + with pytest.raises(TypeError), mock.patch.object(ConfigState, "_value", value): + pass + assert ConfigState()._value == 10 + + # The configured default is still the one a later undo removes. + del ConfigState._value + assert ConfigState()._value == 0 + + +@pytest.mark.parametrize("name", ["_value", "public"]) +def test_class_assignment_monkeypatch_delattr_round_trip(name: str): + """``monkeypatch.delattr`` undoes the configured default only while applied. + + It deletes the attribute through the declaring state, which undoes the most + recent assignment, then assigns the saved field back to put it back. + + Args: + name: The var to delete. + """ + + class ConfigState(BaseState): + _value: int = 0 + public: int = 0 + + setattr(ConfigState, name, 10) + with pytest.MonkeyPatch.context() as patcher: + patcher.delattr(ConfigState, name) + assert getattr(ConfigState(), name) == 0 + assert getattr(ConfigState(), name) == 10 + + delattr(ConfigState, name) + assert getattr(ConfigState(), name) == 0 + + def test_class_assignment_nested_patches_unwind(): """Nested patches of one default unwind to each previous default in turn.""" @@ -810,8 +863,11 @@ class ConfigState(BaseState): assert declared.default_factory is factory assert ConfigState()._value == [1] + # Assigning the field back undoes the deletions in turn. ConfigState._value = declared # pyright: ignore[reportAttributeAccessIssue] - assert ConfigState()._value == [1] + assert ConfigState()._value == [2] + ConfigState._value = declared # pyright: ignore[reportAttributeAccessIssue] + assert ConfigState()._value == [3] with pytest.raises(TypeError, match="computed var"): ConfigState._value = ConfigState.get_fields()["_other"] # pyright: ignore[reportAttributeAccessIssue] From 4f34b16f6afb6cc207d83711391dcfa9edf0a602 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:54:08 +0000 Subject: [PATCH 2/2] Record a deletion with nothing to undo, so nested deletions unwind A deletion through the declaring state with no assignment left to undo recorded nothing, so the undo of a nested monkeypatch.delattr took the outer deletion's entry: it restored the configured default too early, and the outer undo then removed the configuration. Record such a deletion as a change of its own whose undo changes nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex-base/src/reflex_base/vars/base.py | 24 +++++++++++-------- tests/units/reflex_base/vars/test_base.py | 9 +++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index f66d3d487f9..13660b8f21a 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -4160,7 +4160,8 @@ def _restore_default(self) -> None: undone = self.__dict__.get(_UNDONE_DEFAULTS_ATTR) if undone and not (replaced and replaced[-1][0] > undone[-1][0]): _, self.default, self.default_factory, entry = undone.pop() - self.__dict__[_REPLACED_DEFAULTS_ATTR].append(entry) + if entry is not None: + self.__dict__[_REPLACED_DEFAULTS_ATTR].append(entry) elif replaced: _, self.default, self.default_factory = replaced.pop() @@ -4168,16 +4169,19 @@ def _delete_default(self) -> None: """Undo the most recent class assignment of the default, as its own change. Patching tools delete the attribute through the declaring state to patch - it, then assign the field back, which puts the assignment back. + it, then assign the field back, which puts the assignment back. A + deletion with no assignment left to undo is recorded too, so that its + own undo changes nothing. """ - if replaced := self.__dict__.get(_REPLACED_DEFAULTS_ATTR): - entry = replaced.pop() - self._default_history(_UNDONE_DEFAULTS_ATTR).append(( - next(_default_changes), - self.default, - self.default_factory, - entry, - )) + replaced = self._default_history(_REPLACED_DEFAULTS_ATTR) + entry = replaced.pop() if replaced else None + self._default_history(_UNDONE_DEFAULTS_ATTR).append(( + next(_default_changes), + self.default, + self.default_factory, + entry, + )) + if entry is not None: _, self.default, self.default_factory = entry def _keep_default(self) -> None: diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index 778c4e9b371..28ee95ed0cd 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -697,6 +697,15 @@ class ConfigState(BaseState): assert getattr(ConfigState(), name) == 0 assert getattr(ConfigState(), name) == 10 + # A nested deletion with nothing left to undo is undone on its own. + with pytest.MonkeyPatch.context() as outer: + outer.delattr(ConfigState, name) + with pytest.MonkeyPatch.context() as inner: + inner.delattr(ConfigState, name) + assert getattr(ConfigState(), name) == 0 + assert getattr(ConfigState(), name) == 0 + assert getattr(ConfigState(), name) == 10 + delattr(ConfigState, name) assert getattr(ConfigState(), name) == 0