Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Attribute probes on vars (e.g. `inspect.iscoroutinefunction` via `unittest.mock`) no longer trigger ForwardRef resolution of unrelated annotations, silencing spurious "Failed to resolve ForwardRefs" warnings and avoiding `NameError` under PEP 649 lazy annotations on Python 3.14.
27 changes: 19 additions & 8 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,21 @@ def _check_event_args_subclass_of_callback(
raise delayed_exceptions[0]


def _type_hints_or_empty(fn: Callable) -> dict[str, Any]:
"""Type hints of ``fn``, or empty when its ForwardRefs cannot be resolved.

Args:
fn: The function to get the type hints of.

Returns:
The type hints, or an empty dict.
"""
try:
return get_type_hints(fn)
except NameError:
return {}


def call_event_handler(
event_callback: EventHandler | EventSpec,
event_spec: ArgsSpec | Sequence[ArgsSpec],
Expand Down Expand Up @@ -2035,10 +2050,9 @@ 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 = _type_hints_or_empty(
event_callback.handler.fn
)

argument_names = [str(arg) for arg, value in event_callback.args]

Expand Down Expand Up @@ -2073,10 +2087,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 = _type_hints_or_empty(event_callback.fn)

_check_event_args_subclass_of_callback(
event_callback_spec_args[n_self_args:],
Expand Down
52 changes: 46 additions & 6 deletions packages/reflex-base/src/reflex_base/utils/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@

import sys
from collections.abc import Mapping
from functools import lru_cache
from typing import Any

if sys.version_info >= (3, 14):
from annotationlib import (
Format,
call_annotate_function,
get_annotate_from_class_namespace,
get_annotations,
)


async def windows_hot_reload_lifespan_hack():
"""[REF-3164] A hack to fix hot reload on Windows.
Expand Down Expand Up @@ -39,12 +48,43 @@ def annotations_from_namespace(namespace: Mapping[str, Any]) -> dict[str, Any]:
The (forward-ref) annotations from the class namespace.
"""
if sys.version_info >= (3, 14) and "__annotations__" not in namespace:
from annotationlib import (
Format,
call_annotate_function,
get_annotate_from_class_namespace,
)

if annotate := get_annotate_from_class_namespace(namespace):
return call_annotate_function(annotate, format=Format.FORWARDREF)
return namespace.get("__annotations__", {})


@lru_cache
def _mro_annotation_names(cls: type) -> frozenset[str]:
Comment thread
benedikt-bartscher marked this conversation as resolved.
"""All annotation names declared across ``cls``'s MRO.

Never evaluates annotation values, which under PEP 649 lazy evaluation
(3.14+) may raise NameError. Cached: annotations added later are not seen.

Args:
cls: The class to inspect.

Returns:
The annotation names declared in the MRO.
"""
if sys.version_info >= (3, 14):
return frozenset(
name
for klass in cls.__mro__
for name in get_annotations(klass, format=Format.STRING)
)
return frozenset(
name for klass in cls.__mro__ for name in getattr(klass, "__annotations__", {})
)


def declares_annotation(cls: type, name: str) -> bool:
"""Whether ``name`` is annotated on any class in ``cls``'s MRO.

Args:
cls: The class to inspect.
name: The attribute name to look for.

Returns:
Whether ``name`` is annotated.
"""
return name in _mro_annotation_names(cls)
19 changes: 13 additions & 6 deletions packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from typing_extensions import override as override

from reflex_base import constants
from reflex_base.utils.compat import declares_annotation

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -526,6 +527,9 @@ def get_attribute_access_type(
isinstance(cls, type)
and not is_generic_alias(cls)
and issubclass(cls, sqlmodel_types)
# Probes for unannotated names must not trigger hint resolution,
# which may fail on unresolvable ForwardRefs.
and declares_annotation(cls, name)
):
# Check in the annotations directly (for sqlmodel.Relationship)
hints = get_type_hints(cls) # pyright: ignore [reportArgumentType]
Expand All @@ -541,13 +545,16 @@ def get_attribute_access_type(
*(get_attribute_access_type(arg, name) for arg in get_args(cls))
)
if isinstance(cls, type):
# Bare class
exceptions = NameError
# Bare class. Skip hint resolution entirely when the name is not
# annotated anywhere in the MRO: attribute probes (e.g. inspect's
# `_is_coroutine_marker` check) must not trigger, and warn about,
# ForwardRef resolution of unrelated annotations.
try:
hints = get_type_hints(cls) # pyright: ignore [reportArgumentType]
if name in hints:
return hints[name]
except exceptions as e:
if declares_annotation(cls, name):
hints = get_type_hints(cls) # pyright: ignore [reportArgumentType]
if name in hints:
return hints[name]
except NameError as e:
logger.warning(f"Failed to resolve ForwardRefs for {cls}.{name} due to {e}")
return None # Attribute is not accessible.

Expand Down
58 changes: 58 additions & 0 deletions tests/units/test_attribute_access_type.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import sys
from typing import List # noqa: UP035

import attrs
Expand Down Expand Up @@ -420,3 +421,60 @@ def test_get_attribute_access_type_no_default(cls: type) -> None:
cls: Class to test.
"""
assert get_attribute_access_type(cls, "no_default") == int | None


class UnresolvableRefClass:
"""Class with an unresolvable forward-ref annotation."""

broken: UndefinedElsewhere # noqa: F821 # pyright: ignore[reportUndefinedVariable]
count: int = 0


def test_get_attribute_access_type_unannotated_name_skips_hint_resolution(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unannotated name resolves to None without ForwardRef warnings."""
assert get_attribute_access_type(UnresolvableRefClass, "_is_coroutine_marker") is (
None
)
assert not [
r for r in caplog.records if "Failed to resolve ForwardRefs" in r.message
]


def test_get_attribute_access_type_unresolvable_annotation_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An annotated name whose hints cannot be resolved still warns and returns None."""
assert get_attribute_access_type(UnresolvableRefClass, "broken") is None
assert [r for r in caplog.records if "Failed to resolve ForwardRefs" in r.message]


@pytest.mark.skipif(
sys.version_info < (3, 14), reason="PEP 649 lazy annotations require 3.14+"
)
def test_get_attribute_access_type_probe_on_lazy_annotations(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Probing a class with lazy (PEP 649) annotations must not raise.

Compiled with ``dont_inherit=True``: ``exec`` inherits this module's
``from __future__ import annotations`` flag by default, which would
stringify the annotations instead of leaving them lazily evaluated.
"""
code = compile(
"class Lazy:\n broken: UndefinedElsewhere\n count: int = 0",
"<lazy>",
"exec",
dont_inherit=True,
)
ns: dict[str, object] = {}
exec(code, ns)
lazy_cls = ns["Lazy"]
assert isinstance(lazy_cls, type)
with pytest.raises(NameError):
lazy_cls.__annotations__ # prove annotations are genuinely lazy
assert get_attribute_access_type(lazy_cls, "_is_coroutine_marker") is None
assert not [
r for r in caplog.records if "Failed to resolve ForwardRefs" in r.message
]
Loading