From 71a95f9b82429edc5a26abefbb3877f1b19aa4bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 20:50:49 +0000 Subject: [PATCH 1/3] Unwrap `Annotated` hints when resolving types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `guess_type` crashed with `Unsupported type typing.Annotated[...] for guess_type.` on a var whose type is a pydantic discriminated union, which is spelled `Annotated[A | B, Field(discriminator=...)]`. Reading one out of a state var — `State.catalog.paint_colors[color_id]` — failed at compile time, as did reaching any attribute on it. `guess_type` normalizes its type through `resolve_type_alias` before inspecting it, so unwrap `Annotated` there: the metadata is never part of the type Reflex reasons about, and stripping it early also lets the var carry the annotated type, so attribute access and validation resolve through the union as usual. `_isinstance` and `typehint_issubclass` pick up the same treatment through their existing calls. `typehint_issubclass` also compared an `Annotated` hint structurally. Reflex's `get_origin` reports `X` rather than `Annotated` for `Annotated[X, ...]`, so the origin/args comparison read the hint as a bare `X` carrying the metadata as a type argument, and `Annotated[A | B, ...]` was not a subclass of `A | B` even though `A` was a subclass of `Annotated[A | B, ...]`. Unwrap both sides up front so the comparison is symmetric. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HxGHX7e4xnf1ehT1H1h6Nz --- .../news/+annotated-type-hints.bugfix.md | 1 + .../src/reflex_base/utils/types.py | 45 +++++++++++++++++-- tests/units/reflex_base/utils/test_types.py | 42 ++++++++++++++++- tests/units/vars/test_object.py | 45 ++++++++++++++++++- 4 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 packages/reflex-base/news/+annotated-type-hints.bugfix.md diff --git a/packages/reflex-base/news/+annotated-type-hints.bugfix.md b/packages/reflex-base/news/+annotated-type-hints.bugfix.md new file mode 100644 index 00000000000..cc1981b127f --- /dev/null +++ b/packages/reflex-base/news/+annotated-type-hints.bugfix.md @@ -0,0 +1 @@ +`Annotated[...]` hints now resolve to the type they annotate wherever Reflex inspects a type. A var typed with a pydantic discriminated union — `Annotated[Cat | Dog, Field(discriminator="kind")]` — no longer raises `Unsupported type ... for guess_type.` when read out of a state var, and its attributes resolve through the union as usual. diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 876f8c85b4a..9a23142b4ab 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -492,11 +492,29 @@ def _apply_type_params( return _substitute_type_params(value, substitution) +def _annotated_origin(cls: Any) -> Any: + """Get the type that ``Annotated[X, ...]`` annotates. + + Both attributes are required: a class of its own may happen to define + ``__metadata__``, and only an annotation pairs it with an ``__origin__``. + + Args: + cls: The type to inspect. + + Returns: + ``X`` for ``Annotated[X, ...]``, else None. + """ + if getattr(cls, "__metadata__", None) is None: + return None + return getattr(cls, "__origin__", None) + + def resolve_type_alias(cls: GenericType) -> GenericType: - """Resolve a TypeAliasType (PEP 695 ``type`` statement) to its underlying value. + """Resolve a type alias to its underlying value. - Handles bare aliases, subscripted generic aliases (``Keys[str]`` for - ``type Keys[T] = list[T]``, substituting the type parameters into the + Unwraps ``Annotated[X, ...]`` to ``X``, and resolves TypeAliasTypes (PEP 695 + ``type`` statement): bare aliases, subscripted generic aliases (``Keys[str]`` + for ``type Keys[T] = list[T]``, substituting the type parameters into the alias value), and aliases appearing as members of a union. Args: @@ -505,6 +523,12 @@ def resolve_type_alias(cls: GenericType) -> GenericType: Returns: The resolved type, or the original type if it contains no alias. """ + # ``Annotated`` metadata (a pydantic discriminator, a validator, a unit) is + # never part of the type Reflex reasons about, and ``__origin__`` already + # flattens nested annotations. Unwrapped before the alias branches so that + # ``Annotated[SomeAlias, ...]`` resolves both layers. + if (annotated := _annotated_origin(cls)) is not None: + return resolve_type_alias(annotated) origin = get_origin(cls) # The subscripted case is checked first: on Python 3.10 ``types.GenericAlias`` # proxies ``__class__`` to its origin, so ``Keys[str]`` passes an isinstance @@ -1318,6 +1342,21 @@ def typehint_issubclass( if possible_subclass is NoReturn: return True + # ``Annotated[X, ...]`` compares as ``X``. ``get_origin`` reports ``X`` + # rather than ``Annotated``, so the comparisons below would otherwise read + # the hint as a bare ``X`` carrying the metadata as a type argument. + if ( + _annotated_origin(possible_subclass) is not None + or _annotated_origin(possible_superclass) is not None + ): + return typehint_issubclass( + resolve_type_alias(possible_subclass), + resolve_type_alias(possible_superclass), + treat_mutable_superclasss_as_immutable=treat_mutable_superclasss_as_immutable, + treat_literals_as_union_of_types=treat_literals_as_union_of_types, + treat_any_as_subtype_of_everything=treat_any_as_subtype_of_everything, + ) + provided_type_origin = get_origin(possible_subclass) accepted_type_origin = get_origin(possible_superclass) diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index b42cb81a204..7e85a32a94c 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -5,7 +5,7 @@ import sys import typing from collections.abc import Callable -from typing import Literal, TypeVar +from typing import Annotated, Literal, TypeVar import pytest from reflex_base.utils.types import ( @@ -196,3 +196,43 @@ def test_typehint_issubclass_resolves_type_alias(alias_cls: type) -> None: assert typehint_issubclass(maybe, maybe) assert not typehint_issubclass(maybe, str) assert typehint_issubclass(str, maybe) + + +@pytest.mark.parametrize("alias_cls", _type_alias_types()) +def test_resolve_type_alias_unwraps_annotated(alias_cls: type) -> None: + """``Annotated`` metadata is stripped, including around and inside aliases.""" + assert resolve_type_alias(Annotated[int, "meta"]) is int + assert resolve_type_alias(Annotated[Annotated[int, "a"], "b"]) is int + assert resolve_type_alias(Annotated[int | str, "meta"]) == int | str + # An alias on either side of the annotation resolves through it. + assert resolve_type_alias(Annotated[alias_cls("Name", str), "meta"]) is str + assert resolve_type_alias(alias_cls("Meta", Annotated[str, "meta"])) is str + # A union member keeps resolving. + assert resolve_type_alias(Annotated[int, "meta"] | str) == int | str + + +def test_typehint_issubclass_unwraps_annotated() -> None: + """``Annotated`` compares as the type it annotates, on either side.""" + assert typehint_issubclass(Annotated[int, "meta"], int) + assert typehint_issubclass(int, Annotated[int, "meta"]) + assert typehint_issubclass(Annotated[int, "a"], Annotated[int, "b"]) + assert not typehint_issubclass(Annotated[str, "meta"], int) + # The union member-wise comparison must see a union, not the metadata. + assert typehint_issubclass(Annotated[int | str, "meta"], int | str) + assert typehint_issubclass(int, Annotated[int | str, "meta"]) + assert not typehint_issubclass(Annotated[int | str, "meta"], int) + assert typehint_issubclass(list[Annotated[int, "meta"]], list[int]) + + +def test_isinstance_unwraps_annotated() -> None: + """``_isinstance`` validates against the annotated type, not the metadata.""" + assert _isinstance(1, Annotated[int, "meta"], nested=1, treat_var_as_type=False) + assert not _isinstance( + "x", Annotated[int, "meta"], nested=1, treat_var_as_type=False + ) + assert _isinstance( + {"a": 1}, dict[str, Annotated[int, "meta"]], nested=2, treat_var_as_type=False + ) + assert not _isinstance( + {"a": "x"}, dict[str, Annotated[int, "meta"]], nested=2, treat_var_as_type=False + ) diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index 835bf9fbc72..a1e025ff569 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -1,6 +1,6 @@ import dataclasses from collections.abc import Sequence -from typing import Any +from typing import Annotated, Any, Literal import pytest from reflex_base.utils.exceptions import VarAttributeError @@ -336,3 +336,46 @@ def test_rest_prop_merge_propagates_var_data(): var_data = merged._get_all_var_data() assert var_data is not None assert "some-lib" in dict(var_data.imports) + + +def test_annotated_value_type_is_unwrapped() -> None: + """An ``Annotated`` value type resolves to the type it annotates. + + A pydantic discriminated union is spelled ``Annotated[A | B, Field(...)]``, + so reading one out of a mapping must guess the union rather than choke on + the metadata. + """ + + class GenericColor(pydantic.BaseModel): + kind: Literal["generic"] = "generic" + name: str = "" + + class BrandedColor(pydantic.BaseModel): + kind: Literal["branded"] = "branded" + name: str = "" + brand: str = "" + + any_color = Annotated[ + GenericColor | BrandedColor, pydantic.Field(discriminator="kind") + ] + colors = Var(_js_expr="colors", _var_type=dict[str, any_color]).guess_type() + assert isinstance(colors, ObjectVar) + + # A literal key reads as an attribute, a Var key as an item operation. + for color in ( + colors["red"], + colors[Var(_js_expr="key", _var_type=str).guess_type()], + ): + assert isinstance(color, ObjectVar) + assert color._var_type == GenericColor | BrandedColor + assert color.name._var_type is str + + +def test_annotated_var_type_is_unwrapped() -> None: + """``guess_type`` sees through ``Annotated`` on the var's own type.""" + annotated = Annotated[Base, "metadata"] + var = Var(_js_expr="obj", _var_type=annotated).guess_type() + + assert isinstance(var, ObjectVar) + assert var._var_type is Base + assert var.quantity._var_type is int From 8c5a997be91c5697ed3e9a4ed48dd21380894d44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 20:59:27 +0000 Subject: [PATCH 2/3] Name the news fragment after PR 7189 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HxGHX7e4xnf1ehT1H1h6Nz --- .../news/{+annotated-type-hints.bugfix.md => 7189.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-base/news/{+annotated-type-hints.bugfix.md => 7189.bugfix.md} (100%) diff --git a/packages/reflex-base/news/+annotated-type-hints.bugfix.md b/packages/reflex-base/news/7189.bugfix.md similarity index 100% rename from packages/reflex-base/news/+annotated-type-hints.bugfix.md rename to packages/reflex-base/news/7189.bugfix.md From 3a5463f41f876c9f92ed242c805a4c53fb0fed4b Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 18 Sep 2026 10:08:54 -0700 Subject: [PATCH 3/3] Recognize Annotated hints without generic alias attribute probes --- packages/reflex-base/src/reflex_base/utils/types.py | 9 +++------ tests/units/reflex_base/utils/test_types.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 9a23142b4ab..b7e77c6e55d 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -52,6 +52,8 @@ # Potential GenericAlias types for isinstance checks. GenericAliasTypes = (_GenericAlias, GenericAlias, _SpecialGenericAlias) +_AnnotatedAlias = type(typing.Annotated[int, ""]) + # Potential Union types for isinstance checks. UnionTypes = (Union, types.UnionType) @@ -495,18 +497,13 @@ def _apply_type_params( def _annotated_origin(cls: Any) -> Any: """Get the type that ``Annotated[X, ...]`` annotates. - Both attributes are required: a class of its own may happen to define - ``__metadata__``, and only an annotation pairs it with an ``__origin__``. - Args: cls: The type to inspect. Returns: ``X`` for ``Annotated[X, ...]``, else None. """ - if getattr(cls, "__metadata__", None) is None: - return None - return getattr(cls, "__origin__", None) + return cls.__origin__ if type(cls) is _AnnotatedAlias else None def resolve_type_alias(cls: GenericType) -> GenericType: diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index 7e85a32a94c..c70523d51d4 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -224,6 +224,16 @@ def test_typehint_issubclass_unwraps_annotated() -> None: assert typehint_issubclass(list[Annotated[int, "meta"]], list[int]) +def test_annotated_attributes_do_not_unwrap_user_classes() -> None: + """User-defined metadata attributes must not identify an Annotated hint.""" + + class MetadataType: + __metadata__ = ("custom",) + __origin__ = int + + assert resolve_type_alias(MetadataType) is MetadataType + + def test_isinstance_unwraps_annotated() -> None: """``_isinstance`` validates against the annotated type, not the metadata.""" assert _isinstance(1, Annotated[int, "meta"], nested=1, treat_var_as_type=False)