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/6944.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Resolve `TypeAliasType` annotations (PEP 695 `type` statements and the `typing_extensions` backport) to their underlying value in `Var.guess_type`, so state vars annotated with an alias like `type Key = Literal["day", "week"]` compile instead of raising `TypeError: Unsupported type ... for guess_type`. Parameterized generic aliases (`Keys[str]` for `type Keys[T] = list[T]`) and aliases nested in unions (`Key | None`) are resolved as well.
91 changes: 90 additions & 1 deletion packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import sys
import types
import typing
from collections.abc import Callable, Iterable, Mapping, Sequence
from enum import Enum
from functools import cached_property, lru_cache
Expand Down Expand Up @@ -36,7 +37,7 @@
from typing import get_type_hints as get_type_hints_og

from typing_extensions import Self as Self
from typing_extensions import TypeAliasType
from typing_extensions import TypeAliasType, TypeVarTuple
from typing_extensions import override as override

from reflex_base import constants
Expand All @@ -49,6 +50,23 @@
# Potential Union types for isinstance checks.
UnionTypes = (Union, types.UnionType)

# Potential TypeAliasType classes for isinstance checks. On 3.12+ the native
# typing.TypeAliasType (produced by the `type` statement) and the
# typing_extensions backport are distinct classes.
TypeAliasTypes: tuple[type, ...] = (
(TypeAliasType, typing.TypeAliasType)
if sys.version_info >= (3, 12)
else (TypeAliasType,)
)

# Potential TypeVarTuple classes for isinstance checks (native on 3.11+,
# typing_extensions backport otherwise).
TypeVarTuples: tuple[type, ...] = (
(TypeVarTuple, typing.TypeVarTuple)
if sys.version_info >= (3, 11)
else (TypeVarTuple,)
)

# Union of generic types.
GenericType = type | _GenericAlias

Expand Down Expand Up @@ -351,6 +369,77 @@ def is_classvar(a_type: Any) -> bool:
)


def _match_type_args(
type_params: tuple[Any, ...], args: tuple[Any, ...]
) -> dict[Any, Any]:
"""Match subscription arguments to type parameters.

A TypeVarTuple absorbs the middle arguments (mapped to a tuple); plain
parameters before and after it match positionally from either end.

Args:
type_params: The alias's type parameters.
args: The subscription arguments.

Returns:
A mapping from each type parameter to its argument(s).
"""
tvt_index = next(
(i for i, p in enumerate(type_params) if isinstance(p, TypeVarTuples)), None
)
if tvt_index is None:
return dict(zip(type_params, args, strict=False))
n_after = len(type_params) - tvt_index - 1
substitution: dict[Any, Any] = dict(
zip(type_params[:tvt_index], args[:tvt_index], strict=False)
)
substitution[type_params[tvt_index]] = args[tvt_index : len(args) - n_after]
if n_after:
substitution.update(zip(type_params[-n_after:], args[-n_after:], strict=False))
return substitution


def resolve_type_alias(cls: GenericType) -> GenericType:
"""Resolve a TypeAliasType (PEP 695 ``type`` statement) to its underlying value.

Handles 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:
cls: The type to resolve.

Returns:
The resolved type, or the original type if it contains no alias.
"""
while isinstance(cls, TypeAliasTypes):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
cls = cls.__value__
origin = get_origin(cls)
if isinstance(origin, TypeAliasTypes):
value = resolve_type_alias(origin.__value__)
if params := getattr(value, "__parameters__", ()):
# Map via the alias's type parameters: the value's __parameters__
# are in appearance order, which may differ.
substitution = _match_type_args(origin.__type_params__, get_args(cls))
flattened: list[Any] = []
for param in params:
if isinstance(param, TypeVarTuples):
flattened.extend(substitution.get(param, (param,)))
else:
flattened.append(substitution.get(param, param))
value = value[tuple(flattened)] # pyright: ignore[reportIndexIssue]
return resolve_type_alias(value)
if is_union(cls):
args = get_args(cls)
resolved_args = tuple(resolve_type_alias(arg) for arg in args)
if any(
resolved is not arg
for resolved, arg in zip(resolved_args, args, strict=True)
):
return unionize(*resolved_args)
return cls


def value_inside_optional(cls: GenericType) -> GenericType:
"""Get the value inside an Optional type or the original type.

Expand Down
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,10 @@ def guess_type(self) -> Var:
if var_type is NoReturn:
return self.to(Any)

resolved_type = types.resolve_type_alias(var_type)
if resolved_type is not var_type:
return dataclasses.replace(self, _var_type=resolved_type).guess_type()

var_type = types.value_inside_optional(var_type)

if var_type is Any:
Expand Down
98 changes: 96 additions & 2 deletions tests/units/reflex_base/vars/test_base.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
"""Tests for reflex_base.vars.base state metaclass field handling."""

import threading
from typing import Any
import typing
from typing import Any, Literal, TypeVar

import pytest
from reflex_base.utils.types import get_field_type
from reflex_base.vars.base import EvenMoreBasicBaseState, field
from reflex_base.vars.base import EvenMoreBasicBaseState, Var, field
from reflex_base.vars.object import ObjectVar
from reflex_base.vars.sequence import ArrayVar, StringVar
from typing_extensions import TypeAliasType, TypeVarTuple, Unpack

from reflex.state import State

_MARKER_ATTR = "_marker"

Expand Down Expand Up @@ -87,3 +94,90 @@ class MyState(EvenMoreBasicBaseState):

rebuilt = MyState.get_fields()["name"]
assert rebuilt._check is check # pyright: ignore[reportAttributeAccessIssue]


def _type_alias_types() -> list[type]:
native = getattr(typing, "TypeAliasType", None)
return (
[TypeAliasType] if native in (None, TypeAliasType) else [TypeAliasType, native]
)


@pytest.mark.parametrize("alias_cls", _type_alias_types())
def test_guess_type_resolves_type_alias(alias_cls: type) -> None:
"""A TypeAliasType (PEP 695 ``type`` statement) resolves to its value.

State var annotations like ``type Key = Literal[...]`` reach guess_type as
a TypeAliasType, which must be unwrapped instead of raising TypeError.
"""
alias = alias_cls("ChartKey", Literal["day", "week"])

var = Var(_js_expr="key", _var_type=alias).guess_type()
assert isinstance(var, StringVar)
assert var._var_type == Literal["day", "week"]

optional_var = Var(_js_expr="key", _var_type=alias | None).guess_type()
assert isinstance(optional_var, StringVar)


@pytest.mark.parametrize("alias_cls", _type_alias_types())
def test_guess_type_resolves_parameterized_type_alias(alias_cls: type) -> None:
"""A subscripted generic alias (``type Keys[T] = list[T]``) resolves.

The subscription keeps the TypeAliasType as the origin, so resolution has
to substitute the alias's type parameters into its value.
"""
t = TypeVar("t")
keys = alias_cls("Keys", list[t], type_params=(t,)) # pyright: ignore[reportGeneralTypeIssues]

var = Var(_js_expr="keys", _var_type=keys[str]).guess_type()
assert isinstance(var, ArrayVar)
assert var._var_type == list[str]

optional_var = Var(_js_expr="keys", _var_type=keys[str] | None).guess_type()
assert isinstance(optional_var, ArrayVar)

k = TypeVar("k")
v = TypeVar("v")
# value's __parameters__ order (v, k) differs from type_params (k, v)
pair = alias_cls("Pair", dict[v, k], type_params=(k, v)) # pyright: ignore[reportGeneralTypeIssues]
pair_var = Var(_js_expr="pair", _var_type=pair[str, int]).guess_type()
assert isinstance(pair_var, ObjectVar)
assert pair_var._var_type == dict[int, str]


@pytest.mark.parametrize("alias_cls", _type_alias_types())
def test_guess_type_resolves_variadic_type_alias(alias_cls: type) -> None:
"""A variadic alias (``type Tup[*Ts] = tuple[*Ts]``) keeps all arguments.

The TypeVarTuple must absorb every remaining subscription argument, not
just the one a plain positional zip would pair it with.
"""
ts = TypeVarTuple("ts")
tup = alias_cls("Tup", tuple[Unpack[ts]], type_params=(ts,)) # pyright: ignore[reportGeneralTypeIssues]
var = Var(_js_expr="t", _var_type=tup[str, int]).guess_type()
assert isinstance(var, ArrayVar)
assert var._var_type == tuple[str, int]

t = TypeVar("t")
prefixed = alias_cls("Prefixed", dict[t, tuple[Unpack[ts]]], type_params=(t, ts)) # pyright: ignore[reportGeneralTypeIssues]
prefixed_var = Var(_js_expr="p", _var_type=prefixed[str, int, float]).guess_type()
assert isinstance(prefixed_var, ObjectVar)
assert prefixed_var._var_type == dict[str, tuple[int, float]]

suffixed = alias_cls("Suffixed", dict[t, tuple[Unpack[ts]]], type_params=(ts, t)) # pyright: ignore[reportGeneralTypeIssues]
suffixed_var = Var(_js_expr="s", _var_type=suffixed[int, float, str]).guess_type()
assert isinstance(suffixed_var, ObjectVar)
assert suffixed_var._var_type == dict[str, tuple[int, float]]


@pytest.mark.parametrize("alias_cls", _type_alias_types())
def test_state_var_type_alias(alias_cls: type) -> None:
"""A state var annotated with a TypeAliasType compiles."""
chart_key = alias_cls("ChartKey", Literal["day", "week"])

class TypeAliasState(State):
key: chart_key = "day" # pyright: ignore[reportInvalidTypeForm]

assert isinstance(TypeAliasState.key, StringVar)
assert TypeAliasState.key._var_type == Literal["day", "week"]
Loading