Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/vars/base_vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
165 changes: 122 additions & 43 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import functools
import hashlib
import inspect
import itertools
import json
import logging
import re
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -4892,51 +4990,32 @@ 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.
"""
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,
Expand Down
67 changes: 66 additions & 1 deletion tests/units/reflex_base/vars/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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]

Expand Down
Loading