Skip to content

Add type hint parsing utilities #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
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
Empty file.
186 changes: 186 additions & 0 deletions src/typing_inspection/introspection/_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import types
import sys
import functools
import operator
import collections.abc
from typing import Any, ForwardRef, Literal

from typing_extensions import Unpack, get_origin

from ._types import GenericAliasProto
from ._utils import _is_param_expr
from typing_inspection import typing_objects



class UnevaluatedTypeHint(Exception):
"""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, hint: Any) -> None:
if typing_objects.is_paramspecargs(hint) or typing_objects.is_paramspeckwargs(hint):
return self.visit_bare_hint(hint)
origin = get_origin(hint)
if typing_objects.is_generic(origin):
# `get_origin()` returns `Generic` if `hint` is `typing.Generic` (or `Generic[...]`).
raise ValueError(f'{hint} is invalid in an annotation expression')

if origin is not None:
if hint in typing_objects.DEPRECATED_ALIASES:
# For *bare* deprecated aliases (such as `typing.List`), `get_origin()` returns the
# actual type (such as `list`). As such, we treat `hint` as a bare hint.
self.visit_bare_hint(hint)
elif sys.version_info >= (3, 10) and origin is types.UnionType:
self.visit_union(hint)
else:
self.visit_generic_alias(hint, origin)
else:
self.visit_bare_hint(hint)

# origin = get_origin(hint)
# if origin in DEPRECATED_ALIASES.values() and not isinstance(hint, types.GenericAlias):
# # hint is a deprecated generic alias, e.g. `List[int]`.
# # `get_origin(List[int])` returns `list`, but we want to preserve
# # `List` as the actual origin.

def visit_generic_alias(self, hint: GenericAliasProto, origin: Any) -> None:
if not typing_objects.is_literal(origin):
# 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.
for arg in hint.__args__:
self.visit(arg)

if sys.version_info >= (3, 10):
def visit_union(self, hint: types.UnionType) -> None:
for arg in hint.__args__:
self.visit(arg)

def visit_bare_hint(self, hint: Any) -> None:
if typing_objects.is_forwardref(hint) or isinstance(hint, str):
self.visit_forward_hint(hint)

def visit_forward_hint(self, hint: ForwardRef | str) -> None:
raise UnevaluatedTypeHint(hint)


# Backport of `typing._should_unflatten_callable_args`:
def _should_unflatten_callable_args(alias: types.GenericAlias, args: tuple[Any, ...]) -> bool:
return (
alias.__origin__ is collections.abc.Callable # pyright: ignore
and not (len(args) == 2 and _is_param_expr(args[0]))
)


class TypeHintTransformer:

def visit(self, hint: Any) -> Any:
if typing_objects.is_paramspecargs(hint) or typing_objects.is_paramspeckwargs(hint):
return self.visit_bare_hint(hint)
origin = get_origin(hint)
if typing_objects.is_generic(origin):
# `get_origin()` returns `Generic` if `hint` is `typing.Generic` (or `Generic[...]).
raise ValueError(f'{hint} is invalid in an annotation expression')

if origin is not None:
if hint in typing_objects.DEPRECATED_ALIASES:
# For *bare* deprecated aliases (such as `typing.List`), `get_origin()` returns the
# actual type (such as `list`). As such, we treat `hint` as a constant.
return self.visit_bare_hint(hint)
elif sys.version_info >= (3, 10) and origin is types.UnionType:
return self.visit_union(hint)
else:
return self.visit_generic_alias(hint, origin)
else:
return self.visit_bare_hint(hint)

def visit_generic_alias(self, hint: GenericAliasProto, origin: Any) -> Any:
if typing_objects.is_literal(origin):
return hint

visited_args = tuple(self.visit(arg) for arg in hint.__args__)
if visited_args == hint.__args__:
return hint

if isinstance(hint, types.GenericAlias):
# Logic from `typing._eval_type()`:
is_unpacked = hint.__unpacked__
if _should_unflatten_callable_args(hint, visited_args):
t = hint.__origin__[(visited_args[:-1], visited_args[-1])]
else:
t = hint.__origin__[visited_args]
if is_unpacked:
t = Unpack[t]
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 hint.copy_with(visited_args) # pyright: ignore

if sys.version_info >= (3, 10):
def visit_union(self, hint: types.UnionType) -> Any:
visited_args = tuple(self.visit(arg) for arg in hint.__args__)
if visited_args == hint.__args__:
return hint
return functools.reduce(operator.or_, visited_args)

def visit_bare_hint(self, hint: Any) -> Any:
if typing_objects.is_forwardref(hint) or isinstance(hint, str):
return self.visit_forward_hint(hint)
else:
return hint

def visit_forward_hint(self, hint: ForwardRef | str) -> Any:
raise UnevaluatedTypeHint(hint)


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_generic_alias(self, hint: GenericAliasProto, origin: Any) -> Any:
args = hint.__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_generic_alias(hint, origin)


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


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

from typing_extensions import TypeVar, TypeAlias, ParamSpec, TypeVarTuple

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

class GenericAliasProto(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'
124 changes: 124 additions & 0 deletions src/typing_inspection/introspection/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import sys

from typing import Any

from ._types import GenericAliasProto, TypeVarLike

from typing_inspection import typing_objects

from typing_extensions import NoDefault, ParamSpec, get_origin

def get_default(t: TypeVarLike, /) -> Any:
"""Get the default value of a type parameter, if it exists.

Args:
t: The [`TypeVar`][typing.TypeVar], [`TypeVarTuple`][typing.TypeVarTuple] or
[`ParamSpec`][typing.ParamSpec] instance to get the default from.

Returns:
The default value, or [`typing.NoDefault`] if not default is set.
!!! warning
This function may return the [`NoDefault` backport][typing_extensions.NoDefault] backport
from `typing_extensions`. As such, [`typing_objects.is_nodefault()`][typing_inspection.typing_objects.is_nodefault]
should be used.
"""

try:
has_default = t.has_default()
except AttributeError:
return NoDefault
else:
if has_default:
return t.__default__
else:
return NoDefault


def alias_substitutions(alias: GenericAliasProto, /) -> dict[TypeVarLike, Any]:
params: tuple[TypeVarLike, ...] | None = getattr(alias.__origin__, '__parameters__', None)
if params is None:
raise ValueError

origin = alias.__origin__
args = alias.__args__

# TODO checks for invalid params (most of the checks are already performed
# by Python for generic classes, but aren't for type aliases)
...

if typing_objects.is_typealiastype(origin) and len(params) == 1 and typing_objects.is_paramspec(params[0]):
# The end of the documentation section at
# https://docs.python.org/3/library/typing.html#user-defined-generic-types
# says:
# a generic with only one parameter specification variable will accept parameter
# lists in the forms X[[Type1, Type2, ...]] and also X[Type1, Type2, ...].
# However, this convenience isn't applied for type aliases.
if len(args) == 0:
# Unlike user-defined generics, type aliases don't fallback to the default:
arg = get_default(params[0])
if typing_objects.is_nodefault(arg):
raise ValueError
elif len(args) == 1 and not _is_param_expr(args[0]):
arg = args[0]

if not _is_param_expr(arg):
arg = (arg,)
elif isinstance(arg, list):
arg = tuple(arg)

substitutions: dict[TypeVarLike, Any] = {}

typevartuple_param = next((p for p in params if typing_objects.is_typevartuple(p)), None)

if typevartuple_param is not None:
# HARD
pass
else:
strict = {'strict': True} if sys.version_info >= (3, 10) else {}
return dict(zip(params, args), **strict)


class A[*Ts, T]:
a: tuple[int, *Ts]

def func(self, *args: *Ts): pass



A[str, *tuple[*()]]

A[str, *tuple[int, ...]]().a


A[str, *tuple[int, *tuple[str, ...]]]().a


# Backports of private `typing` functions:

# Backport of `typing._is_param_expr`:
def _is_param_expr(arg: Any) -> bool:
return (
arg is ... # as in `Callable[..., Any]`
or isinstance(arg, (tuple, list)) # as in `Callable[[int, str], Any]`
or typing_objects.is_paramspec(arg) # as in `Callable[P, Any]`
or typing_objects.is_concatenate(get_origin(arg)) # as in `Callable[Concatenate[int, P], Any]`
)

# Backports of the `__typing_prepare_subst__` methods of type parameter classes,
# only available in 3.11+:

def _paramspec_prepare_subst(self: ParamSpec, alias: GenericAliasProto, args: tuple[Any, ...]):
params = alias.__parameters__
i = params.index(self)
if i == len(args) and not typing_objects.is_nodefault((default := get_default(self))):
args = (*args, default)
if i >= len(args):
raise TypeError(f"Too few arguments for {alias}")
# Special case where Z[[int, str, bool]] == Z[int, str, bool] in PEP 612.
if len(params) == 1 and not _is_param_expr(args[0]):
assert i == 0
args = (args,)
# Convert lists to tuples to help other libraries cache the results.
elif isinstance(args[i], list):
args = (*args[:i], tuple[args[i]], *args[i + 1: ])
return args
Loading