Skip to content
Merged
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
1 change: 1 addition & 0 deletions news/6890.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cache event handler annotations before runtime state-class patches can shadow builtin names on Python 3.14.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6890.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Resolve event handler annotations before runtime state-class patches can shadow builtin names on Python 3.14.
49 changes: 37 additions & 12 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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:],
Expand Down
45 changes: 44 additions & 1 deletion tests/units/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
[
Expand Down
Loading