diff --git a/packages/reflex-base/news/7189.bugfix.md b/packages/reflex-base/news/7189.bugfix.md new file mode 100644 index 00000000000..cc1981b127f --- /dev/null +++ b/packages/reflex-base/news/7189.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..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) @@ -492,11 +494,24 @@ def _apply_type_params( return _substitute_type_params(value, substitution) +def _annotated_origin(cls: Any) -> Any: + """Get the type that ``Annotated[X, ...]`` annotates. + + Args: + cls: The type to inspect. + + Returns: + ``X`` for ``Annotated[X, ...]``, else None. + """ + return cls.__origin__ if type(cls) is _AnnotatedAlias else 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 +520,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 +1339,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..c70523d51d4 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,53 @@ 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_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) + 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