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 @@
A state var annotation that names an undefined symbol, such as `items: list[Item] = []` under `from __future__ import annotations`, raises `NameError: name 'Item' is not defined` again instead of an unrelated `TypeError` or `AttributeError`. This also covers vars without a default, backend vars, and Python 3.14 annotations without the `__future__` import.
47 changes: 33 additions & 14 deletions packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1763,6 +1763,35 @@ def typehint_issubclass(
)


def _resolve_annotation(
value: Any, base_globals: dict[str, Any] | None, *, strict: bool = False
) -> Any:
"""Resolve a string or ForwardRef annotation into a type object if possible.

Args:
value: The raw annotation to resolve.
base_globals: The globals of the module declaring the annotation.
strict: Whether an annotation naming an undefined symbol raises,
unless it is a ClassVar.

Returns:
The resolved annotation, or the unresolved ForwardRef.

Raises:
NameError: If strict and the annotation names an undefined symbol.
"""
if isinstance(value, str):
value = ForwardRef(value, is_argument=False, is_class=True)
try:
if sys.version_info >= (3, 13):
return _eval_type(value, base_globals, None, type_params=())
return _eval_type(value, base_globals, None)
except NameError:
if strict and not is_classvar(value):
Comment thread
FarhanAliRaza marked this conversation as resolved.
raise
return value


def resolve_annotations(
raw_annotations: Mapping[str, type[Any]], module_name: str | None
) -> dict[str, type[Any]]:
Expand All @@ -1783,20 +1812,10 @@ def resolve_annotations(
module.__dict__ if module is not None else None
)

annotations = {}
for name, value in raw_annotations.items():
if isinstance(value, str):
value = ForwardRef(value, is_argument=False, is_class=True)
try:
if sys.version_info >= (3, 13):
value = _eval_type(value, base_globals, None, type_params=())
else:
value = _eval_type(value, base_globals, None)
except NameError:
# this is ok, it can be fixed with update_forward_refs
pass
annotations[name] = value
return annotations
return {
name: _resolve_annotation(value, base_globals)
for name, value in raw_annotations.items()
}


TYPES_THAT_HAS_DEFAULT_VALUE = (int, float, tuple, list, set, dict, str)
Expand Down
17 changes: 12 additions & 5 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import logging
import re
import string
import sys
import warnings
from abc import ABCMeta
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
Expand Down Expand Up @@ -4634,23 +4635,24 @@ def _annotated_fields(
namespace: Mapping[str, Any],
lookup_order: Sequence[type],
private: tuple[str, ...],
strict: bool = False,
) -> dict[str, Field]:
"""Get the fields a class namespace declares by annotation.

Args:
namespace: The class namespace.
lookup_order: The bases of the class in method resolution order.
private: The prefixes of names that are fields only when declared as such.
strict: Whether an annotation naming an undefined symbol raises.

Returns:
The fields by name.
"""
slots = _slot_names(namespace)
base_globals = getattr(sys.modules.get(namespace["__module__"]), "__dict__", None)
fields = {}
for key, annotation in types.resolve_annotations(
annotations_from_namespace(namespace), namespace["__module__"]
).items():
if types.is_classvar(annotation) or key in slots:
for key, annotation in annotations_from_namespace(namespace).items():
if key in slots:
continue
value = namespace.get(key, MISSING)
inherited = _inherited_value(lookup_order, key)
Expand All @@ -4674,6 +4676,11 @@ def _annotated_fields(
if isinstance(declared, Field) or not callable(declared):
# Declared by a mixin or plain base: its default applies here.
value = declared
# Only a field's annotation is resolved, so a symbol imported only for
# type checking may annotate any other attribute.
annotation = types._resolve_annotation(annotation, base_globals, strict=strict)
if types.is_classvar(annotation):
continue
if value is MISSING:
fields[key] = Field(annotated_type=annotation)
elif isinstance(value, Field):
Expand Down Expand Up @@ -4893,7 +4900,7 @@ def __new__(
)
private = _private_prefixes(name)
own_fields = _unannotated_fields(namespace, private) | _annotated_fields(
namespace, lookup_order, private
namespace, lookup_order, private, strict=True
Comment thread
FarhanAliRaza marked this conversation as resolved.
)
annotations = annotations_from_namespace(namespace)
for key, value in namespace.items():
Expand Down
92 changes: 91 additions & 1 deletion tests/units/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from pathlib import Path
from textwrap import dedent
from types import MethodType, ModuleType
from typing import Any, ClassVar, Literal, TypeVar, cast
from typing import Any, ClassVar, ForwardRef, Literal, TypeVar, cast
from unittest.mock import AsyncMock, Mock, patch

import pytest
Expand Down Expand Up @@ -7322,3 +7322,93 @@ def bump(self):
with pytest.raises(SetUndefinedStateVarError):
setattr(state, name, 1)
assert not state.dirty_vars


_NO_DEFAULT = object()


@pytest.mark.parametrize(
("name", "annotation", "default"),
[
("items", "list[MissingItem]", []),
("items", "dict[str, MissingItem]", {}),
("items", "MissingItem | None", None),
("items", "list[MissingItem]", _NO_DEFAULT),
("items", "dict[str, MissingItem]", _NO_DEFAULT),
("items", "MissingItem | None", _NO_DEFAULT),
("items", "MissingItem", _NO_DEFAULT),
("items", "MissingItem", None),
("items", list[ForwardRef("MissingItem")], []),
("_items", "list[MissingItem]", []),
],
ids=[
"list",
"dict",
"optional",
"list-no-default",
"dict-no-default",
"optional-no-default",
"bare-no-default",
"bare-none-default",
"nested-forward-ref",
"backend",
],
)
def test_var_annotation_with_undefined_name_raises_name_error(
name: str, annotation: Any, default: Any
) -> None:
"""An annotation naming an undefined symbol raises a NameError naming it.

With postponed annotations the unresolved annotation used to reach var
typing as a ForwardRef and fail with an unrelated TypeError or
AttributeError, whether or not the var has a default. On Python 3.14 an
annotation is evaluated lazily, and an undefined name inside a generic
stays a nested ForwardRef.

Args:
name: The name of the var.
annotation: The annotation, which names a symbol that does not exist.
default: The default value of the var, or _NO_DEFAULT for none.
"""
namespace: dict[str, Any] = {
"__module__": __name__,
"__annotations__": {name: annotation},
}
if default is not _NO_DEFAULT:
namespace[name] = default

with pytest.raises(NameError, match="MissingItem"):
type("UndefinedNameState", (BaseState,), namespace)


@pytest.mark.parametrize(
("name", "annotation", "value"),
[
("cache", "ClassVar[MissingItem]", _NO_DEFAULT),
("__client", "MissingItem | None", None),
("total", "MissingItem", property(lambda self: 0)),
],
ids=["classvar", "private", "descriptor"],
)
def test_non_var_annotation_with_undefined_name_is_allowed(
name: str, annotation: str, value: Any
) -> None:
"""An annotation that declares no var is not resolved, so its symbol may be undefined.

A symbol imported only under TYPE_CHECKING is undefined at runtime.

Args:
name: The name of the attribute.
annotation: The annotation, which names a symbol that does not exist.
value: The value of the attribute, or _NO_DEFAULT for none.
"""
namespace: dict[str, Any] = {
"__module__": __name__,
"__annotations__": {name: annotation},
}
if value is not _NO_DEFAULT:
namespace[name] = value

state = type("UndefinedNonVarState", (BaseState,), namespace)

assert name not in state.get_fields()
Loading