Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from typing_extensions import assert_never, get_args, get_origin # noqa: UP035

from . import typing_objects
from .. import typing_objects

__all__ = (
'AnnotationSource',
Expand Down
209 changes: 209 additions & 0 deletions src/typing_inspection/introspection/_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import types
import sys
import functools
import operator
import collections.abc
from typing import Any, ForwardRef, Literal, Union, cast

from typing_extensions import TypeForm, get_origin

from ._types import ParameterExpr, ParameterizedAnnotationExpr
from ._utils import callable_parameter_expr
from typing_inspection import typing_objects


class TypeHintVisitorException(Exception):
pass


class InvalidExpression(TypeHintVisitorException):
"""The expression isn't valid."""

invalid_expression: Any
"""The encountered invalid expression."""

def __init__(self, invalid_expression: Any) -> None:
self.invalid_expression = invalid_expression


class UnevaluatedTypeHint(TypeHintVisitorException):
"""The type hint wasn't evaluated as it still contains forward references."""

forward_arg: ForwardRef | str
"""The forward reference that wasn't evaluated."""

def __init__(self, forward_arg: ForwardRef | str) -> None:
self.forward_arg = forward_arg


class TypeHintVisitor:
def visit(self, annotation_expr: TypeForm[Any]) -> Any:
origin = get_origin(annotation_expr)
if origin is not None:
if typing_objects.is_generic(origin):
# `get_origin()` returns `Generic` if `annotation_expr` is `typing.Generic` (or `Generic[...]`).
raise InvalidExpression(annotation_expr)

if (
# For *bare* deprecated aliases (such as `typing.List`), `get_origin()` returns the
# actual type (such as `list`). As such, we treat `annotation_expr` as a bare hint.
id(annotation_expr) in typing_objects.DEPRECATED_ALIASES_IDS
# For `ParamSpecArgs`/`ParamSpecKwargs`, `get_origin()` returns the `ParamSpec` instance.
# As such, treat the `ParamSpecArgs`/`ParamSpecKwargs` as a bare annotation_expr.
or typing_objects.is_paramspec(origin)
):
return self.visit_bare_annotation_expr(annotation_expr)

# Otherwise, `annotation_expr` is a generic alias or a `UnionType`. We call these two "parameterized annotation expressions":
parameterized_ann_expr = cast(ParameterizedAnnotationExpr, annotation_expr)
return self.visit_parameterized_annotation_expr(parameterized_ann_expr, origin)
else:
return self.visit_bare_annotation_expr(annotation_expr)

def visit_parameterized_annotation_expr(self, annotation_expr: ParameterizedAnnotationExpr, origin: Any) -> Any:
if typing_objects.is_literal(origin):
return

# Note: it is important to use `hint.__args__` instead of `get_args()` as
# they differ for some typing forms (e.g. `Annotated`, `Callable`).
# `hint.__args__` should be guaranteed to only contain other annotation expressions.
args = annotation_expr.__args__
if origin is collections.abc.Callable: # pyright: ignore[reportUnknownMemberType]
self.visit_parameter_expr(callable_parameter_expr(args))
self.visit(args[-1])
else:
for arg in args:
self.visit(arg)

def visit_parameter_expr(self, parameter_expr: ParameterExpr) -> Any:
if isinstance(parameter_expr, list):
for parameter in parameter_expr:
self.visit(parameter)
elif parameter_expr is not ...:
# A `ParamSpec` or a `Concatenate` form:
self.visit(cast('TypeForm[Any]', parameter_expr))

def visit_bare_annotation_expr(self, annotation_expr: Any) -> Any:
if typing_objects.is_forwardref(annotation_expr) or isinstance(annotation_expr, str):
return self.visit_forward_expr(annotation_expr)

def visit_forward_expr(self, forward_expr: ForwardRef | str) -> Any:
raise UnevaluatedTypeHint(forward_expr)



class TypeHintTransformer(TypeHintVisitor):
def visit_parameterized_annotation_expr(self, annotation_expr: ParameterizedAnnotationExpr, origin: Any) -> Any:
if typing_objects.is_literal(origin):
return annotation_expr

args = annotation_expr.__args__

if origin is collections.abc.Callable: # pyright: ignore[reportUnknownMemberType]
visited_parameter_expr = self.visit_parameter_expr(callable_parameter_expr(args))
visited_return = self.visit(args[-1])
visited_args: tuple[Any, ...]
if isinstance(visited_parameter_expr, list):
# Flatten back, to match the form of `__args__`:
visited_args = (*cast('list[Any]', visited_parameter_expr), visited_return)
else:
visited_args = (visited_parameter_expr, visited_return)
if visited_args == args:
return annotation_expr
if isinstance(annotation_expr, types.GenericAlias):
return annotation_expr.__origin__[visited_parameter_expr, visited_return]
# `typing.Callable[...]` aliases store (and thus expect) the flattened form:
return annotation_expr.copy_with(visited_args) # pyright: ignore

visited_args = tuple(self.visit(arg) for arg in args)
if visited_args == args:
return annotation_expr

if origin is types.UnionType:
if sys.version_info >= (3, 14):
# In Python >= 3.14, types.UnionType and typing.Union are the same types. If any of
# the `visited_args` are strings (e.g. `('Forward', int)`), using `functools.reduce()`
# with `or_` fails with a `TypeError` (Encountering forward refs results in a
# `UnevaluatedTypeHint` by default, but users can override this by implementing
# `visit_forward_expr()`).
return Union[visited_args]
# Otherwise we use `functools.reduce()` to preserve `UnionType` instances, distinct
# from the `typing.Union` form:
return functools.reduce(operator.or_, visited_args)
elif isinstance(annotation_expr, types.GenericAlias):
# Logic from `typing._eval_type()`:
if sys.version_info >= (3, 11):
is_unpacked = annotation_expr.__unpacked__
else:
is_unpacked = False
t = annotation_expr.__origin__[visited_args]
if is_unpacked:
# While `Unpack[T]` and `*T` are equivalent for (static/runtime) type checkers,
# we preserve the 3.11 native way of expressing unpacking. This uses the same logic
# as `_rewrite_star_unpack()` in https://github.com/python/cpython/blob/v3.15.0rc1/Lib/annotationlib.py#L1138
t = (*t,)[0]
return t
else:
# `.copy_with()` is a method present on the private `typing._GenericAlias` class.
# Many generic aliases (e.g. `Concatenate[]`) have special logic in this method,
# so we can't just do `hint.__origin__[transformed_args]`.
return annotation_expr.copy_with(visited_args) # pyright: ignore

def visit_parameter_expr(self, parameter_expr: ParameterExpr) -> Any:
if isinstance(parameter_expr, list):
return [self.visit(parameter) for parameter in parameter_expr]
elif parameter_expr is ...:
return parameter_expr
else:
# A `ParamSpec` or a `Concatenate` form:
return self.visit(cast('TypeForm[Any]', parameter_expr))

def visit_bare_annotation_expr(self, annotation_expr: Any) -> Any:
if typing_objects.is_forwardref(annotation_expr) or isinstance(annotation_expr, str):
return self.visit_forward_expr(annotation_expr)
else:
return annotation_expr


class MultiTransformer(TypeHintTransformer):
def __init__(
self,
unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'skip',
type_replacements: dict[Any, Any] = {},
) -> None:
self.unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = unpack_type_aliases
self.type_replacements = type_replacements

def visit_parameterized_annotation_expr(
self, annotation_expr: ParameterizedAnnotationExpr, origin: Any
) -> TypeForm[Any]:
args = annotation_expr.__args__
if self.unpack_type_aliases != 'skip' and typing_objects.is_typealiastype(origin):
try:
value = origin.__value__
except NameError:
if self.unpack_type_aliases == 'eager':
raise
else:
return self.visit(value[tuple(self.visit(arg) for arg in args)])
return super().visit_parameterized_annotation_expr(annotation_expr, origin)

def visit_bare_annotation_expr(self, annotation_expr: Any) -> Any:
annotation_expr = super().visit_bare_annotation_expr(annotation_expr)
new_annotation_expr = self.type_replacements.get(annotation_expr, annotation_expr)
if self.unpack_type_aliases != 'skip' and typing_objects.is_typealiastype(new_annotation_expr):
try:
value = new_annotation_expr.__value__
except NameError:
if self.unpack_type_aliases == 'eager':
raise
else:
return self.visit(value)
return new_annotation_expr


def transform_hint(
hint: Any,
unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'skip',
type_replacements: dict[Any, Any] = {},
) -> Any: ...
48 changes: 48 additions & 0 deletions src/typing_inspection/introspection/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from types import EllipsisType, UnionType
from typing import Any, Protocol

from typing_extensions import TypeVar, TypeAlias, ParamSpec, TypeVarTuple

OriginT = TypeVar('OriginT', default=Any)


class HasParameters(Protocol):
"""An object holding `__parameters__`.

This can be a [user-defined generic class][user-defined-generics],
or a [`typing.TypeAliasType`][] instance.
"""
# Note: `__parameters__` on user-defined generics/TypeAliasType isn't documented,
# but exposed in `typeshed` and its usage is somewhat widespread already.

# In theory, this should be `tuple[TypeVarLike, ...]`, but `TypeVarTuple`s get
# expanded as `typing.Unpack[...]` *only* in type aliases
# (see https://github.com/python/typeshed/blob/1da0afef/stdlib/typing_extensions.pyi#L622-L624
# and https://github.com/python/typeshed/pull/13671#discussion_r2003207961):
__parameters__: tuple[Any, ...]

class GenericAliasLike(Protocol[OriginT]):
"""An instance of a parameterized [generic type][] or typing form.

Depending on the alias, this may be an instance of [`types.GenericAlias`][]
(e.g. `list[int]`) or a private `typing` class (`typing._GenericAlias`).
"""
__origin__: OriginT
__args__: tuple[Any, ...]
__parameters__: tuple[Any, ...]


TypeVarLike: TypeAlias = TypeVar | TypeVarTuple | ParamSpec

ParameterizedAnnotationExpr: TypeAlias = GenericAliasLike[Any] | UnionType

ParameterExpr: TypeAlias = list[Any] | EllipsisType | ParamSpec | GenericAliasLike[Any]
"""A parameter expression, as allowed as the first argument of `Callable` forms.

Can be one of:

* A list of annotation expressions, as in `Callable[[int, str], int]`.
* The [ellipsis][], as in `Callable[..., int]`.
* A [`ParamSpec`][typing.ParamSpec] instance, as in `Callable[P, int]`.
* A [`Concatenate`][typing.Concatenate] form, as in `Callable[Concatenate[int, P], int]`.
"""
Loading
Loading