diff --git a/news/6890.bugfix.md b/news/6890.bugfix.md new file mode 100644 index 00000000000..5ed10a5507e --- /dev/null +++ b/news/6890.bugfix.md @@ -0,0 +1 @@ +Cache event handler annotations before runtime state-class patches can shadow builtin names on Python 3.14. diff --git a/packages/reflex-base/news/6890.bugfix.md b/packages/reflex-base/news/6890.bugfix.md new file mode 100644 index 00000000000..076b1f7a3b7 --- /dev/null +++ b/packages/reflex-base/news/6890.bugfix.md @@ -0,0 +1 @@ +Resolve event handler annotations before runtime state-class patches can shadow builtin names on Python 3.14. diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 659f503803a..1e4fcc51cdf 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -340,8 +340,7 @@ def resolve_upload_handler_param(handler: "EventHandler") -> tuple[str, Any]: ) raise UploadTypeError(msg) - func = handler.fn.func if isinstance(handler.fn, partial) else handler.fn - for name, annotation in get_type_hints(func).items(): + for name, annotation in handler._get_type_hints().items(): if name == "return" or get_origin(annotation) is not list: continue args = get_args(annotation) @@ -377,8 +376,7 @@ def resolve_upload_chunk_handler_param(handler: "EventHandler") -> tuple[str, ty msg = f"@rx.event(background=True) is required for upload_files_chunk handler `{handler_name}`." raise UploadTypeError(msg) - func = handler.fn.func if isinstance(handler.fn, partial) else handler.fn - for name, annotation in get_type_hints(func).items(): + for name, annotation in handler._get_type_hints().items(): if name == "return": continue if annotation is UploadChunkIterator: @@ -487,6 +485,39 @@ class EventHandler(EventActionsMixin): state: "type[BaseState] | None" = dataclasses.field(default=None, repr=False) + _type_hints: dict[str, Any] | None = dataclasses.field( + default=None, repr=False, compare=False + ) + + def __post_init__(self) -> None: + """Resolve handler annotations while the state class is stable.""" + if self.state is not None: + self._get_type_hints() + + def _get_type_hints(self) -> dict[str, Any]: + """Get and cache the type hints for the handler function. + + Caching successful resolution at handler creation avoids deferred + annotation evaluation observing attributes assigned to the owning + state class after the handler was registered. + + Returns: + The resolved type hints, or an empty mapping when forward references + cannot be resolved yet. + """ + if self._type_hints is not None: + return self._type_hints + if self.fn is None: + object.__setattr__(self, "_type_hints", {}) + return {} + func = self.fn.func if isinstance(self.fn, partial) else self.fn + try: + type_hints = get_type_hints(func) + except NameError: + return {} + object.__setattr__(self, "_type_hints", type_hints) + return type_hints + @property def state_full_name(self) -> str: """Get the full name of the state class this event handler is attached to. @@ -2034,10 +2065,7 @@ def call_event_handler( event_callback_spec_args = list(parameters) - try: - type_hints_of_provided_callback = get_type_hints(event_callback.handler.fn) - except NameError: - type_hints_of_provided_callback = {} + type_hints_of_provided_callback = event_callback.handler._get_type_hints() argument_names = [str(arg) for arg, value in event_callback.args] @@ -2072,10 +2100,7 @@ def call_event_handler( if event_spec_return_types: event_callback_spec_args = list(parameters) - try: - type_hints_of_provided_callback = get_type_hints(event_callback.fn) - except NameError: - type_hints_of_provided_callback = {} + type_hints_of_provided_callback = event_callback._get_type_hints() _check_event_args_subclass_of_callback( event_callback_spec_args[n_self_args:], diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 83193b9e19c..dcea89011b1 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -131,7 +131,6 @@ def spec(a2: Var[str]) -> list[Var[str]]: assert ( format.format_event(event_spec) == 'ReflexEvent("fn_with_args", {arg1:first})' ) - assert event_spec2 is not event_spec assert event_spec2.handler == handler assert len(event_spec2.args) == 2 @@ -145,6 +144,50 @@ def spec(a2: Var[str]) -> list[Var[str]]: ) +def test_state_event_handler_type_hints_are_stable_after_class_patch(): + """Runtime state-class patches must not change handler annotations.""" + + class S(BaseState): + @event + def on_event(self, event: dict): + pass + + handler = cast(EventHandler, S.on_event) + assert handler._get_type_hints()["event"] is dict + + # Python 3.14 evaluates deferred method annotations in the owning class + # namespace, so this assignment would shadow the builtin ``dict``. + type.__setattr__(S, "dict", lambda self: {}) + + def args_spec(value: Var[dict]) -> list[Var[dict]]: + return [value] + + call_event_handler(handler(), args_spec) + assert handler.prevent_default._type_hints is handler._type_hints + + +def test_state_event_handler_caches_unresolved_type_hints(): + """Unresolved annotations should be retried after their type is defined.""" + + class S(BaseState): + @event + def on_event( + self, + event: "_LateBoundEventType", # pyright: ignore[reportUndefinedVariable] # noqa: F821 + ): + pass + + handler = cast(EventHandler, S.on_event) + assert handler._type_hints is None + assert handler._get_type_hints() == {} + + globals()["_LateBoundEventType"] = dict + try: + assert handler._get_type_hints()["event"] is dict + finally: + del globals()["_LateBoundEventType"] + + @pytest.mark.parametrize( ("arg1", "arg2"), [