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..13660b8f21a 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,60 @@ 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.""" - if replaced := self.__dict__.get(_REPLACED_DEFAULTS_ATTR): - self.default, self.default_factory = replaced.pop() + """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() + if entry is not None: + 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. A + deletion with no assignment left to undo is recorded too, so that its + own undo changes nothing. + """ + 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: """Record a failed class assignment, which leaves the default in place. @@ -4781,6 +4833,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 +4967,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 +4990,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 +5012,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..28ee95ed0cd 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -648,6 +648,68 @@ 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 + + # 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 + + def test_class_assignment_nested_patches_unwind(): """Nested patches of one default unwind to each previous default in turn.""" @@ -810,8 +872,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]