Skip to content
Merged
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.
164 changes: 163 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,27 @@
# 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,)
)

# Potential type parameter classes for isinstance checks. The typing_extensions
# ParamSpec instantiates the native class, so it needs no separate entry.
TypeParams: tuple[type, ...] = (TypeVar, typing.ParamSpec, *TypeVarTuples)

# Union of generic types.
GenericType = type | _GenericAlias

Expand Down Expand Up @@ -351,6 +373,146 @@ 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 _unpacked_type_var_tuple(arg: Any) -> Any | None:
"""Get the TypeVarTuple an unpacked argument (``*Ts``) refers to.

Args:
arg: The argument to inspect.

Returns:
The TypeVarTuple, or None if the argument does not unpack one.
"""
if isinstance(arg, TypeVarTuples):
return arg
args = get_args(arg)
return args[0] if len(args) == 1 and isinstance(args[0], TypeVarTuples) else None


def _substitute_type_params(
cls: GenericType, substitution: dict[Any, Any]
) -> GenericType:
"""Substitute type parameters by rebuilding the type, expanding unpacked TypeVarTuples.

Args:
cls: The type to substitute into.
substitution: Mapping from type parameter to argument(s).

Returns:
The type with its parameters replaced.
"""
if isinstance(cls, TypeParams):
return substitution.get(cls, cls)
if not getattr(cls, "__parameters__", ()):
return cls
args: list[Any] = []
for arg in get_args(cls):
if (tvt := _unpacked_type_var_tuple(arg)) is not None:
args.extend(substitution.get(tvt, (arg,)))
elif isinstance(arg, list): # a Callable's parameter list
args.append([_substitute_type_params(a, substitution) for a in arg])
else:
args.append(_substitute_type_params(arg, substitution))
if is_union(cls):
return unionize(*args)
return get_origin(cls)[tuple(args)]


def _apply_type_params(
value: GenericType, params: tuple[Any, ...], substitution: dict[Any, Any]
) -> GenericType:
"""Replace the type parameters of a generic type with their arguments.

Args:
value: The generic type to subscript.
params: The parameters of value, in appearance order.
substitution: Mapping from type parameter to argument(s).

Returns:
The type with its parameters replaced.
"""
flattened: list[Any] = []
for param in params:
if isinstance(param, TypeVarTuples):
flattened.extend(substitution.get(param, (param,)))
else:
flattened.append(substitution.get(param, param))
try:
return value[tuple(flattened)] # pyright: ignore[reportIndexIssue]
except TypeError:
# Python 3.10 subscription predates PEP 646, and 3.11 rejects a ParamSpec
# next to an unpacked TypeVarTuple, so substitute by hand instead.
return _substitute_type_params(value, 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.
"""
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
# check against TypeAliasType and would lose its arguments.
if isinstance(origin, TypeAliasTypes):
value = resolve_type_alias(origin.__value__)
if params := getattr(value, "__parameters__", ()):
value = _apply_type_params(
value,
params,
_match_type_args(origin.__type_params__, get_args(cls)),
)
return resolve_type_alias(value)
if isinstance(cls, TypeAliasTypes):
return resolve_type_alias(cls.__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
29 changes: 27 additions & 2 deletions tests/units/reflex_base/utils/test_types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
"""Tests for reflex_base.utils.types."""

from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send
from typing_extensions import TypeAliasType
from collections.abc import Callable

from reflex_base.utils.types import (
ASGIApp,
Message,
Receive,
Scope,
Send,
resolve_type_alias,
)
from typing_extensions import ParamSpec, TypeAliasType, TypeVarTuple, Unpack

P = ParamSpec("P")
Ts = TypeVarTuple("Ts")
Handlers = TypeAliasType(
"Handlers", tuple[Callable[P, int], Unpack[Ts]], type_params=(P, Ts)
)


def test_asgi_aliases_keep_their_names():
Expand All @@ -14,3 +29,13 @@ def test_asgi_aliases_keep_their_names():
assert Receive.__name__ == "Receive"
assert Send.__name__ == "Send"
assert ASGIApp.__name__ == "ASGIApp"


def test_resolve_type_alias_substitutes_param_spec():
"""A ParamSpec is substituted even next to a TypeVarTuple.

That combination falls back to manual substitution on 3.10 and 3.11, which
has to treat a ParamSpec as a type parameter too.
"""
resolved = resolve_type_alias(Handlers[[str], bool, float])
assert resolved == tuple[Callable[[str], int], bool, float]
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