From 1c757472f77cc20639e89065ad1ee7be7f05a495 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 17 Aug 2026 10:53:51 +0530 Subject: [PATCH 1/5] Fix Python 3.14 event annotation resolution --- news/6890.bugfix.md | 1 + packages/reflex-base/news/6890.bugfix.md | 1 + .../src/reflex_base/event/__init__.py | 48 ++++++++++++++----- tests/units/test_event.py | 22 ++++++++- 4 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 news/6890.bugfix.md create mode 100644 packages/reflex-base/news/6890.bugfix.md 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 919e1f2fd1f..0439b4f87f8 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -341,8 +341,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) @@ -378,8 +377,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: @@ -488,6 +486,38 @@ class EventHandler(EventActionsMixin): state: "type[BaseState] | None" = dataclasses.field(default=None, repr=False) + _type_hints: dict[str, Any] | None = dataclasses.field( + default=None, init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + """Resolve handler annotations while the state class is stable.""" + 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. @@ -2035,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] @@ -2073,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..14ca851f0c4 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,27 @@ 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) + + @pytest.mark.parametrize( ("arg1", "arg2"), [ From d4b09d90c1578846e96f93a8fdbefe4ed5a0282e Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 17 Aug 2026 11:13:55 +0530 Subject: [PATCH 2/5] Avoid eager annotation work for unbound handlers --- packages/reflex-base/src/reflex_base/event/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 0439b4f87f8..1acd0072ff4 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -492,7 +492,8 @@ class EventHandler(EventActionsMixin): def __post_init__(self) -> None: """Resolve handler annotations while the state class is stable.""" - self._get_type_hints() + 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. From 6aed59ffb3817dbcdb071846d316e915bc29549e Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 17 Aug 2026 12:01:48 +0530 Subject: [PATCH 3/5] Preserve cached event handler annotations --- .../src/reflex_base/event/__init__.py | 4 ++-- tests/units/test_event.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 70ea64a682f..7ab28d6ef41 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -486,7 +486,7 @@ class EventHandler(EventActionsMixin): state: "type[BaseState] | None" = dataclasses.field(default=None, repr=False) _type_hints: dict[str, Any] | None = dataclasses.field( - default=None, init=False, repr=False, compare=False + default=None, repr=False, compare=False ) def __post_init__(self) -> None: @@ -514,7 +514,7 @@ def _get_type_hints(self) -> dict[str, Any]: try: type_hints = get_type_hints(func) except NameError: - return {} + type_hints = {} object.__setattr__(self, "_type_hints", type_hints) return type_hints diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 14ca851f0c4..88499aea641 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -163,6 +163,24 @@ 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 not be re-evaluated after class patches.""" + + class S(BaseState): + @event + def on_event(self, event: "MissingType"): # noqa: F821 + pass + + handler = cast(EventHandler, S.on_event) + cached_hints = handler._type_hints + assert cached_hints == {} + + type.__setattr__(S, "MissingType", dict) + + assert handler._get_type_hints() is cached_hints @pytest.mark.parametrize( From cad5a6cb961670e3887f498d4165ecfd6861d63a Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 17 Aug 2026 12:22:18 +0530 Subject: [PATCH 4/5] Retry unresolved event annotations --- .../src/reflex_base/event/__init__.py | 2 +- tests/units/test_event.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 7ab28d6ef41..1e4fcc51cdf 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -514,7 +514,7 @@ def _get_type_hints(self) -> dict[str, Any]: try: type_hints = get_type_hints(func) except NameError: - type_hints = {} + return {} object.__setattr__(self, "_type_hints", type_hints) return type_hints diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 88499aea641..1d4edadabc4 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -167,20 +167,22 @@ def args_spec(value: Var[dict]) -> list[Var[dict]]: def test_state_event_handler_caches_unresolved_type_hints(): - """Unresolved annotations should not be re-evaluated after class patches.""" + """Unresolved annotations should be retried after their type is defined.""" class S(BaseState): @event - def on_event(self, event: "MissingType"): # noqa: F821 + def on_event(self, event: "_LateBoundEventType"): # noqa: F821 pass handler = cast(EventHandler, S.on_event) - cached_hints = handler._type_hints - assert cached_hints == {} - - type.__setattr__(S, "MissingType", dict) - - assert handler._get_type_hints() is cached_hints + 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( From d3092cbb4468b3b5344882445bfa6d8e3f23f5e7 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 17 Aug 2026 12:38:22 +0530 Subject: [PATCH 5/5] Annotate deliberate unresolved type test --- tests/units/test_event.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 1d4edadabc4..dcea89011b1 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -171,7 +171,10 @@ def test_state_event_handler_caches_unresolved_type_hints(): class S(BaseState): @event - def on_event(self, event: "_LateBoundEventType"): # noqa: F821 + def on_event( + self, + event: "_LateBoundEventType", # pyright: ignore[reportUndefinedVariable] # noqa: F821 + ): pass handler = cast(EventHandler, S.on_event)