diff --git a/docs/library/forms/form.md b/docs/library/forms/form.md index 8fdbda66583..1bd2480aa3f 100644 --- a/docs/library/forms/form.md +++ b/docs/library/forms/form.md @@ -298,7 +298,7 @@ class DynamicFormState(rx.State): ] @rx.event - def add_field(self, form_data: dict): + def add_form_field(self, form_data: dict): new_field = form_data.get("new_field") if not new_field: return @@ -331,7 +331,7 @@ def dynamic_form(): rx.input(placeholder="New Field", name="new_field"), rx.button("+", type="submit"), ), - on_submit=DynamicFormState.add_field, + on_submit=DynamicFormState.add_form_field, reset_on_submit=True, ), rx.divider(), diff --git a/docs/state/overview.md b/docs/state/overview.md index e5e040a5aee..bd6a297a112 100644 --- a/docs/state/overview.md +++ b/docs/state/overview.md @@ -52,6 +52,12 @@ A state class is made up of two parts: vars and event handlers. **Event handlers** are functions that modify these vars in response to events. +State declarations cannot reuse framework method or bookkeeping names, such as +`get_state`, `_get_was_touched`, or `dirty_vars`. Reflex checks these names when +creating a state class and when adding vars, event handlers, or route arguments +dynamically. Rename a conflicting declaration and update its references. Ordinary +backend names such as `_count` remain supported. + These are the main concepts to understand how state works in Reflex: ```python eval diff --git a/news/+reserved-state-names.breaking.md b/news/+reserved-state-names.breaking.md new file mode 100644 index 00000000000..81c4fadae44 --- /dev/null +++ b/news/+reserved-state-names.breaking.md @@ -0,0 +1 @@ +State vars, event handlers, and dynamic route arguments now reject names reserved by framework methods and bookkeeping before registration. Rename conflicting members. diff --git a/reflex/istate/validation.py b/reflex/istate/validation.py new file mode 100644 index 00000000000..85b83f071eb --- /dev/null +++ b/reflex/istate/validation.py @@ -0,0 +1,122 @@ +"""Validate the framework namespace before constructing or extending a state.""" + +from functools import cache +from types import FunctionType +from typing import Any + +from reflex_base.utils.compat import annotations_from_namespace +from reflex_base.utils.exceptions import ( + EventHandlerShadowsBuiltInStateMethodError, + StateValueError, +) +from reflex_base.vars.base import ( + BaseStateMeta, + EvenMoreBasicBaseState, + _linearize_bases, +) + +_FIELD_MAP_NAMES = frozenset({"__fields__", "__own_fields__", "__inherited_fields__"}) + + +@cache +def _reserved_state_members() -> dict[str, Any]: + """Return framework members, excluding state vars and Python protocols. + + Returns: + Reserved names and their original descriptors, without invoking them. + """ + # BaseState must exist before its namespace can be inspected. + from reflex.state import BaseState + + members = {} + for base in reversed(BaseState.__mro__[:-1]): + namespace = vars(base) + members.update( + (name, namespace.get(name)) + for name in namespace.keys() | annotations_from_namespace(namespace).keys() + if not name.startswith("__") or name in _FIELD_MAP_NAMES + ) + for name, field in BaseState.__fields__.items(): + if field.is_var: + members.pop(name, None) + return members + + +def _validate_state_name(name: str, value: Any = None) -> None: + """Reject declarations that replace framework methods or bookkeeping. + + Args: + name: The declared or dynamically registered name. + value: The raw class declaration, when available. + + Raises: + StateValueError: If a declaration uses a reserved name. + EventHandlerShadowsBuiltInStateMethodError: If a method overrides a builtin. + """ + members = _reserved_state_members() + if name not in members: + return + method = value.__func__ if isinstance(value, (classmethod, staticmethod)) else value + if isinstance(method, FunctionType): + if value is members[name] or getattr(method, "__override_base_method__", False): + return + msg = f"The event handler name `{name}` shadows a builtin State method; use a different name instead" + raise EventHandlerShadowsBuiltInStateMethodError(msg) + msg = f"State name `{name}` is reserved by BaseState; use a different name instead." + raise StateValueError(msg) + + +def _validate_inherited_members(base: type, seen: set[str]) -> None: + """Check the members a Python mixin or model base adds to a state. + + Args: + base: A base class that is not itself a validated state. + seen: Names an earlier base already provides in the MRO. + """ + is_model = isinstance(base, BaseStateMeta) + if is_model: + # Model fields are inherited even when an earlier base masks their + # class attributes in the MRO. + for member in base.__own_fields__: + _validate_state_name(member) + seen.update(base.__own_fields__) + for member, value in vars(base).items(): + if member not in seen and not ( + is_model and (member in _FIELD_MAP_NAMES or member == "_mixin") + ): + _validate_state_name(member, value) + + +class _StateMeta(BaseStateMeta): + """Check state declarations before field collection and subclass initialization.""" + + def __new__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + mixin: bool = False, + ) -> type: + """Construct a state after checking its declarations and Python mixins. + + Args: + name: The class name. + bases: The parent classes. + namespace: The unmodified class namespace. + mixin: Whether the class is a state mixin. + + Returns: + The validated state class. + """ + if any(isinstance(base, _StateMeta) for base in bases): + seen = namespace.keys() | annotations_from_namespace(namespace).keys() + for member in seen: + _validate_state_name(member, namespace.get(member)) + for base in _linearize_bases(bases): + if not isinstance(base, _StateMeta) and base not in ( + EvenMoreBasicBaseState, + object, + ): + _validate_inherited_members(base, seen) + seen.update(vars(base)) + return super().__new__(cls, name, bases, namespace, mixin=mixin) diff --git a/reflex/state.py b/reflex/state.py index 4a95a88569e..499e2895650 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -45,7 +45,6 @@ ComputedVarShadowsStateVarError, DynamicComponentInvalidSignatureError, DynamicRouteArgShadowsStateVarError, - EventHandlerShadowsBuiltInStateMethodError, ReflexRuntimeError, SetUndefinedStateVarError, StateMismatchError, @@ -78,6 +77,7 @@ from reflex.istate.proxy import ImmutableMutableProxy as ImmutableMutableProxy from reflex.istate.proxy import MutableProxy, is_mutable_type from reflex.istate.storage import ClientStorageBase +from reflex.istate.validation import _StateMeta, _validate_state_name from reflex.utils import console, format, types from reflex.utils.exec import is_testing_env @@ -382,8 +382,8 @@ def _is_user_descriptor(value: Any) -> bool: # Instance bookkeeping fields and framework methods read on every event. They # bypass the var-resolution logic below, so nothing stored in `_backend_vars` # (e.g. `_reflex_internal_links`) or delegated to the parent (`router_data`) -# may appear here. A subclass that defines one of these names itself (as a var -# or an event handler) drops it from its own `_fast_attr_names`. +# may appear here. A subclass that overrides one of these methods drops the +# name from its own `_fast_attr_names`. _FRAMEWORK_ATTR_NAMES = frozenset({ "dirty_vars", "dirty_substates", @@ -427,7 +427,7 @@ def _is_user_descriptor(value: Any) -> bool: }) -class BaseState(EvenMoreBasicBaseState): +class BaseState(EvenMoreBasicBaseState, metaclass=_StateMeta): """The state of the app.""" # A map from the var name to the var. @@ -617,9 +617,6 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): # Validate the module name. cls._validate_module_name() - # Event handlers should not shadow builtin state methods. - cls._check_overridden_methods() - # Computed vars should not shadow builtin state props. cls._check_overridden_basevars() @@ -784,38 +781,15 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): cls._var_dependencies = {} cls._init_var_dependency_dicts() - cls._prune_fast_attr_names() - - all_base_state_classes[cls.get_full_name()] = None - - @classmethod - def _prune_fast_attr_names(cls) -> None: - """Recompute which framework attribute names this state tree may fast-path. - - A name the state defines (as a var, a backend var, an event handler or - a marked method override) must keep going through the full lookup in - ``_get_attribute``. The set is rebuilt from the parent's current set - minus this class's own names, then recomputed for every substate, so a - var or handler registered after class creation (dynamic route args, - ``add_var``, ...) drops the name for the whole subtree that inherits it. - """ + # A marked override of a framework method must keep the full lookup. parent_state = cls.get_parent_state() - inherited = ( + cls._fast_attr_names = ( parent_state._fast_attr_names if parent_state is not None else _FRAMEWORK_ATTR_NAMES - ) - cls._fast_attr_names = inherited - ( - _FRAMEWORK_ATTR_NAMES - & ( - set(cls.__dict__) - | set(cls.vars) - | set(cls.backend_vars) - | set(cls.event_handlers) - ) - ) - for substate_class in cls.get_substates(): - substate_class._prune_fast_attr_names() + ) - cls.__dict__.keys() + + all_base_state_classes[cls.get_full_name()] = None @classmethod def _add_event_handler( @@ -829,10 +803,10 @@ def _add_event_handler( name: The name of the event handler. fn: The function to call when the event is triggered. """ + _validate_state_name(name) handler = cls._create_event_handler(fn) cls.event_handlers[name] = handler setattr(cls, name, handler) - cls._prune_fast_attr_names() @staticmethod def _copy_fn(fn: Callable) -> Callable: @@ -1063,29 +1037,6 @@ def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]: if isinstance(value, FunctionType): yield name, value - @classmethod - def _check_overridden_methods(cls): - """Check for shadow methods and raise error if any. - - Raises: - EventHandlerShadowsBuiltInStateMethodError: When an event handler shadows an inbuilt state method. - """ - overridden_methods = set() - state_base_functions = cls._get_base_functions() - for name, method in cls._iter_functions(): - # Check if the method is overridden and not a dunder method - if ( - not name.startswith("__") - and method.__name__ in state_base_functions - and state_base_functions[method.__name__] != method - and not getattr(method, "__override_base_method__", False) - ): - overridden_methods.add(method.__name__) - - for method_name in overridden_methods: - msg = f"The event handler name `{method_name}` shadows a builtin State method; use a different name instead" - raise EventHandlerShadowsBuiltInStateMethodError(msg) - @classmethod def _check_overridden_basevars(cls): """Check for shadow base vars and raise error if any. @@ -1332,6 +1283,18 @@ def _init_var(cls, name: str, prop: Var): cls._create_setter(name, prop) cls._set_default_value(name, prop) + @classmethod + def add_field(cls, name: str, var: Var, default_value: Any): + """Validate a dynamically added field before updating the field map. + + Args: + name: The name of the field to add. + var: The variable to add a field for. + default_value: The default value of the field. + """ + _validate_state_name(name) + super().add_field(name, var, default_value) + @classmethod def add_var(cls, name: str, type_: Any, default_value: Any = None): """Add dynamically a variable to the State. @@ -1373,7 +1336,6 @@ def add_var(cls, name: str, type_: Any, default_value: Any = None): # let substates know about the new variable for substate_class in cls.get_substates(): substate_class.vars.setdefault(name, var) - cls._prune_fast_attr_names() # Reinitialize dependency tracking dicts. cls._init_var_dependency_dicts() @@ -1484,19 +1446,6 @@ def _get_var_default(cls, name: str, annotation_value: Any) -> Any: except TypeError: return None - @staticmethod - def _get_base_functions() -> builtins.dict[str, FunctionType]: - """Get all functions of the state class excluding dunder methods. - - Returns: - The functions of rx.State class as a dict. - """ - return { - func[0]: func[1] - for func in inspect.getmembers(BaseState, predicate=inspect.isfunction) - if not func[0].startswith("__") - } - @classmethod def _update_substate_inherited_vars(cls, vars_to_add: builtins.dict[str, Var]): """Update the inherited vars of substates recursively when new vars are added. @@ -1517,7 +1466,6 @@ def _update_substate_inherited_vars(cls, vars_to_add: builtins.dict[str, Var]): substate_class._update_substate_inherited_vars(vars_to_add) # Reinitialize dependency tracking dicts. cls._init_var_dependency_dicts() - cls._prune_fast_attr_names() @classmethod def _dynamic_route_arg_types(cls) -> builtins.dict[str, str]: @@ -1549,6 +1497,8 @@ def setup_dynamic_args(cls, args: builtins.dict[str, str]): if not args: return + for name in args: + _validate_state_name(name) cls._check_overwritten_dynamic_args(list(args.keys())) def argsingle_factory(param: str): diff --git a/tests/units/istate/manager/test_redis.py b/tests/units/istate/manager/test_redis.py index 3a336501b84..5ec8de77be6 100644 --- a/tests/units/istate/manager/test_redis.py +++ b/tests/units/istate/manager/test_redis.py @@ -115,29 +115,6 @@ async def test_basic_get_set( ) -async def test_set_state_with_shadowed_touched_method(clean_registration_context): - """Persist a backend var that shadows the touched-state method. - - Args: - clean_registration_context: A fresh, empty registration context. - """ - - class ShadowState(BaseState): - """State with an intentional framework-method collision.""" - - _get_was_touched: int = 7 - - state = ShadowState() - state._get_was_touched = 8 - manager = StateManagerRedis(redis=mock_redis()) - token = BaseStateToken(ident="shadowed", cls=ShadowState) - - await manager.set_state(token, state) - - restored = BaseState._deserialize(data=await manager.redis.get(str(token))) - assert restored._get_was_touched == 8 - - async def test_modify( state_manager_redis: StateManagerRedis, root_state: type[RedisTestState], diff --git a/tests/units/istate/manager/test_token.py b/tests/units/istate/manager/test_token.py index a3c3c515371..d6f9411b608 100644 --- a/tests/units/istate/manager/test_token.py +++ b/tests/units/istate/manager/test_token.py @@ -137,34 +137,6 @@ class TouchState(BaseState): assert BaseStateToken.get_and_reset_touched_state(state) is False -def test_base_state_token_get_and_reset_touched_shadowed_by_var( - clean_registration_context, -): - """A state var named like the touched-state method does not break persistence. - - Args: - clean_registration_context: A fresh, empty registration context. - """ - from reflex.state import BaseState - - TouchState = type( - "TouchState", - (BaseState,), - { - "__module__": __name__, - "__qualname__": "TouchState", - "__annotations__": {"_get_was_touched": int, "x": int}, - "_get_was_touched": 7, - "x": 0, - }, - ) - state = TouchState() - state.x = 1 - assert BaseStateToken.get_and_reset_touched_state(state) is True - assert state._was_touched is False - assert state._get_was_touched == 7 - - def test_from_legacy_token(clean_registration_context): """from_legacy_token parses 'ident_state.path' into a BaseStateToken. diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 285349688b0..a1b09f1f358 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -1007,10 +1007,10 @@ def test_fast_path_skips_names_a_subclass_defines(): """A subclass defining a fast-pathed framework name keeps the full lookup for it. The fast path bypasses var resolution, so it must not apply to a name the - state itself defines (here a marked override of a BaseState method, and a - backend var named like a framework method). The class is a detached root - (not a substate of ``State``) so the shadowed method never reaches the - framework paths that other tests exercise on the shared state tree. + state itself defines (here a marked override of a BaseState method). The + class is a detached root (not a substate of ``State``) so the shadowed + method never reaches the framework paths that other tests exercise on the + shared state tree. """ from reflex.state import BaseState @@ -1025,51 +1025,11 @@ def get_value(self, key: str): { "__module__": __name__, "__qualname__": "ShadowState", - "__annotations__": {"_get_was_touched": int}, - "_get_was_touched": 7, "get_value": get_value, }, ) assert "get_value" in BaseState._fast_attr_names assert "get_value" not in ShadowState._fast_attr_names - assert "_get_was_touched" not in ShadowState._fast_attr_names assert "dirty_vars" in ShadowState._fast_attr_names state = ShadowState(_reflex_internal_init=True) # pyright: ignore [reportCallIssue] assert state.get_value("k") == "shadow:k" - assert state._get_was_touched == 7 - - -def test_fast_path_prunes_names_registered_after_class_creation(): - """Vars and handlers added after class creation also leave the fast path.""" - from reflex_base.constants import RouteArgType - - from reflex.state import BaseState - - DynamicState = type( - "DynamicState", - (BaseState,), - {"__module__": __name__, "__qualname__": "DynamicState"}, - ) - DynamicSubState = type( - "DynamicSubState", - (DynamicState,), - {"__module__": __name__, "__qualname__": "DynamicSubState"}, - ) - DynamicGrandChild = type( - "DynamicGrandChild", - (DynamicSubState,), - {"__module__": __name__, "__qualname__": "DynamicGrandChild"}, - ) - tree = (DynamicState, DynamicSubState, DynamicGrandChild) - for cls in tree: - assert {"get_value", "get_delta", "get_state"} <= cls._fast_attr_names - - DynamicState.setup_dynamic_args({"get_value": RouteArgType.SINGLE}) - DynamicState._add_event_handler("get_delta", lambda self: None) - DynamicState.add_var("get_state", int, 0) - # Registered on the root and inherited down the tree, so pruned everywhere. - for cls in tree: - assert "get_value" not in cls._fast_attr_names - assert "get_delta" not in cls._fast_attr_names - assert "get_state" not in cls._fast_attr_names - assert "dirty_vars" in cls._fast_attr_names diff --git a/tests/units/istate/test_shared.py b/tests/units/istate/test_shared.py index f87c7893eaa..c8b16c874dd 100644 --- a/tests/units/istate/test_shared.py +++ b/tests/units/istate/test_shared.py @@ -7,11 +7,7 @@ import pytest -from reflex.istate.shared import ( - SharedState, - SharedStateBaseInternal, - _do_update_other_tokens, -) +from reflex.istate.shared import _do_update_other_tokens from reflex.state import State from reflex.utils.token_manager import ( LocalTokenManager, @@ -113,43 +109,3 @@ async def test_update_other_tokens_redis_cross_instance(redis_manager, mock_redi # Locally owned sockets are authoritative and never require a redis lookup. local_key = redis_manager._get_redis_key("local") assert local_key not in [call.args[0] for call in mock_redis.get.call_args_list] - - -@pytest.mark.parametrize("held_lock", [False, True], ids=["direct", "linked"]) -async def test_shared_updates_with_shadowed_touched_method( - clean_registration_context, monkeypatch: pytest.MonkeyPatch, held_lock: bool -): - """Notify linked clients when a backend var shadows the touched-state method. - - Args: - clean_registration_context: A fresh, empty registration context. - monkeypatch: Restore shared-state defaults after the test. - held_lock: Whether to collect the shared state from held locks. - """ - monkeypatch.setitem(State.backend_vars, "_reflex_internal_links", {}) - monkeypatch.setattr( - State, "_always_dirty_substates", State._always_dirty_substates.copy() - ) - - class ShadowState(SharedState): - """State with an intentional framework-method collision.""" - - _get_was_touched: int = 7 - - root = State() - parent = await root.get_state(SharedStateBaseInternal) - state = await root.get_state(ShadowState) - state._linked_from = {"other-client"} - root._clean() - state._was_touched = False - state._previous_dirty_vars.clear() - - with patch("reflex.istate.shared._do_update_other_tokens") as update: - async with parent._modify_linked_states(): - if held_lock: - parent._held_locks = {"shared": {ShadowState: state}} - state._get_was_touched = 8 - - update.assert_called_once() - assert update.call_args.kwargs["affected_tokens"] == {"other-client"} - assert state._get_was_touched == 8 diff --git a/tests/units/istate/test_validation.py b/tests/units/istate/test_validation.py new file mode 100644 index 00000000000..ec567b60285 --- /dev/null +++ b/tests/units/istate/test_validation.py @@ -0,0 +1,188 @@ +"""Tests for reserved state names at class creation and dynamic registration.""" + +import pytest +from reflex_base.constants import RouteArgType +from reflex_base.utils.exceptions import StateValueError +from reflex_base.vars.base import EvenMoreBasicBaseState, LiteralVar, computed_var + +from reflex.state import BaseState, _override_base_method + + +@pytest.mark.parametrize( + "name", + [ + "_get_was_touched", + "_update_was_touched", + "_was_touched", + "dirty_vars", + "get_fields", + "get_full_name", + "backend_vars", + "__fields__", + "setvar", + ], +) +@pytest.mark.parametrize("annotated", [False, True]) +def test_reserved_state_var(name: str, annotated: bool, clean_registration_context): + """Reject framework names before state initialization can call them. + + Args: + name: The reserved member to shadow. + annotated: Whether to explicitly annotate the variable. + clean_registration_context: An isolated state registry. + """ + namespace = {"__module__": __name__, "__qualname__": "ShadowState", name: 7} + if annotated: + namespace["__annotations__"] = {name: int} + with pytest.raises(StateValueError, match=name): + type("ShadowState", (BaseState,), namespace) + + +def test_reserved_annotation_only(clean_registration_context): + """Reject a reserved var even when no default is declared. + + Args: + clean_registration_context: An isolated state registry. + """ + with pytest.raises(StateValueError, match="_get_was_touched"): + + class ShadowState(BaseState): + _get_was_touched: int + + +@pytest.mark.parametrize("state_mixin", [False, True]) +def test_reserved_mixin_var(state_mixin: bool, clean_registration_context): + """Reject collisions from both ordinary Python mixins and state mixins. + + Args: + state_mixin: Whether the mixin subclasses BaseState. + clean_registration_context: An isolated state registry. + """ + with pytest.raises(StateValueError, match="_update_was_touched"): + mixin = type( + "Mixin", + (BaseState,) if state_mixin else (), + {"__module__": __name__, "_update_was_touched": 7}, + **({"mixin": True} if state_mixin else {}), + ) + type("MixedState", (mixin, BaseState), {"__module__": __name__}) + + +@pytest.mark.parametrize("name", ["_get_was_touched", "get_fields"]) +def test_reserved_computed_var(name: str, clean_registration_context): + """Reject computed vars that replace framework methods. + + Args: + name: The reserved method to replace. + clean_registration_context: An isolated state registry. + """ + + def value(self) -> int: + """Return a constant computed value.""" + return 7 + + value.__name__ = name + with pytest.raises(StateValueError, match=name): + type( + "ComputedState", + (BaseState,), + {"__module__": __name__, name: computed_var(value)}, + ) + + +@pytest.mark.parametrize("registration", ["var", "route", "event", "field"]) +def test_dynamic_reserved_name(registration: str, clean_registration_context): + """Reject dynamic collisions before any field or event map is changed. + + Args: + registration: The dynamic registration path to exercise. + clean_registration_context: An isolated state registry. + """ + + class DynamicState(BaseState): + """State receiving a dynamic declaration.""" + + fields = dict(DynamicState.get_fields()) + with pytest.raises(StateValueError, match="get_state"): + if registration == "var": + DynamicState.add_var("get_state", int, 7) + elif registration == "route": + DynamicState.setup_dynamic_args({"get_state": RouteArgType.SINGLE}) + elif registration == "event": + DynamicState._add_event_handler("get_state", lambda self: None) + else: + DynamicState.add_field("get_state", LiteralVar.create(7), 7) + assert DynamicState.get_fields() == fields + assert "get_state" not in DynamicState.vars + assert "get_state" not in DynamicState.event_handlers + assert "get_state" not in DynamicState.__dict__ + + +def test_user_vars_and_marked_override(clean_registration_context): + """Keep normal vars, inherited vars, and explicitly marked method overrides. + + Args: + clean_registration_context: An isolated state registry. + """ + + class Parent(BaseState): + value: int = 1 + _backend: int = 2 + + class Child(Parent): + @_override_base_method + def get_value(self, key: str): + """Return a value through a supported framework override.""" + return f"override:{key}" + + parent = Parent() + child = parent.substates[Child.get_name()] + assert isinstance(child, Child) + assert child.value == 1 + assert child._backend == 2 + assert child.get_value("value") == "override:value" + + +def test_non_state_models_keep_their_namespace(): + """Do not reserve Reflex state names on unrelated base models.""" + + class Model(EvenMoreBasicBaseState): + get_state: int = 7 + + assert Model().get_state == 7 + + +@pytest.mark.parametrize("name", ["get_fields", "_get_was_touched"]) +@pytest.mark.parametrize("state_first", [False, True]) +def test_reserved_model_mixin(name: str, state_first: bool, clean_registration_context): + """Reject inherited model fields before the field collector sees them. + + Args: + name: The framework name declared as a model field. + state_first: Whether BaseState precedes the model in the MRO. + clean_registration_context: An isolated state registry. + """ + model = type("Model", (EvenMoreBasicBaseState,), {"__module__": __name__, name: 7}) + bases = (BaseState, model) if state_first else (model, BaseState) + with pytest.raises(StateValueError, match=name): + type("MixedState", bases, {"__module__": __name__}) + + +def test_reserved_descriptor(clean_registration_context): + """Reject a descriptor without executing its class access behavior. + + Args: + clean_registration_context: An isolated state registry. + """ + + class Descriptor: + def __get__(self, instance, owner): + """Fail if validation invokes this descriptor.""" + pytest.fail("Reserved descriptor was evaluated") + + with pytest.raises(StateValueError, match="get_fields"): + type( + "DescriptorState", + (BaseState,), + {"__module__": __name__, "get_fields": Descriptor()}, + )