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
1 change: 1 addition & 0 deletions packages/reflex-base/news/7189.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 39 additions & 3 deletions packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
52 changes: 51 additions & 1 deletion tests/units/reflex_base/utils/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
)
45 changes: 44 additions & 1 deletion tests/units/vars/test_object.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading