diff --git a/packages/reflex-base/news/+annotation-probe-guard.bugfix.md b/packages/reflex-base/news/+annotation-probe-guard.bugfix.md new file mode 100644 index 00000000000..602b8a8b8c3 --- /dev/null +++ b/packages/reflex-base/news/+annotation-probe-guard.bugfix.md @@ -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. diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 5a43ec324a4..ae6fa243529 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -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], @@ -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] @@ -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:], diff --git a/packages/reflex-base/src/reflex_base/utils/compat.py b/packages/reflex-base/src/reflex_base/utils/compat.py index 03211e4d4e7..03e5126f3f2 100644 --- a/packages/reflex-base/src/reflex_base/utils/compat.py +++ b/packages/reflex-base/src/reflex_base/utils/compat.py @@ -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. @@ -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]: + """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) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 1298287ec6e..ecd25b7a84c 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -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__) @@ -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] @@ -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. diff --git a/tests/units/test_attribute_access_type.py b/tests/units/test_attribute_access_type.py index c250d9e48bc..d26ab141b06 100644 --- a/tests/units/test_attribute_access_type.py +++ b/tests/units/test_attribute_access_type.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from typing import List # noqa: UP035 import attrs @@ -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", + "", + "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 + ]