Get Type Argument of Arbitrarily High Generic Parent Class at Runtime

Viewed 1129

Given this:

from typing import Generic, TypeVar

T = TypeVar('T')

class Parent(Generic[T]):
    pass

I can get int from Parent[int] using typing.get_args(Parent[int])[0].

The problem becomes a bit more complicated with the following:

class Child1(Parent[int]):
    pass

class Child2(Child1):
    pass

To support an arbitrarily long inheritance hierarchy, I made the below solution:

import typing
from dataclasses import dataclass

@dataclass(frozen=True)
class Found:
    value: Any

def get_parent_type_parameter(child: type) -> Optional[Found]:
    for base in child.mro():
        # If no base classes of `base` are generic, then `__orig_bases__` is nonexistent causing an `AttributeError`.
        # Instead, we want to skip iteration.
        for generic_base in getattr(base, "__orig_bases__", ()):
            if typing.get_origin(generic_base) is Parent:
                [type_argument] = typing.get_args(generic_base)

                # Return `Found(type_argument)` instead of `type_argument` to differentiate between `Parent[None]` 
                # as a base class and `Parent` not appearing as a base class.
                return Found(type_argument)

    return None

such that get_parent_type_parameter(Child2) returns int. I am only interested in the type argument of one particular base class (Parent), so I've hardcoded that class into get_parent_type_parameter and ignore any other base classes.

But my above solution breaks down with chains like this:

class Child3(Parent[T], Generic[T]):
    pass

where get_parent_type_parameter(Child3[int]) returns T instead of int.

While any answers that tackle Child3 are already great, being able to deal with situations like Child4 would be even better:

from typing import Sequence

class Child4(Parent[Sequence[T]], Generic[T]):
    pass

so get_parent_type_parameter(Child4[int]) returns Sequence[int].

Is there a more robust way of accessing the type argument of a class X at runtime given an annotation A where issubclass(typing.get_origin(A), X) is True?

Why I need this:

Recent Python HTTP frameworks generate the endpoint documentation (and response schema) from the function's annotated return type. For example:

app = ...

@dataclass
class Data:
    hello: str

@app.get("/")
def hello() -> Data:
    return Data(hello="world")

I am trying to expand this to account for status code and other non-body components:

@dataclass
class Error:
    detail: str

class ClientResponse(Generic[T]):
    status_code: ClassVar[int]
    body: T

class OkResponse(ClientResponse[Data]):
    status_code: ClassVar[int] = 200

class BadResponse(ClientResponse[Error]):
    status_code: ClassVar[int] = 400

@app.get("/")
def hello() -> Union[OkResponse, BadResponse]:
    if random.randint(1, 2) == 1:
        return OkResponse(Data(hello="world"))

    return BadResponse(Error(detail="a_custom_error_label"))

To generate the OpenAPI documentation, my framework would evaluate get_parent_type_parameter(E) (with ClientResponse hardcoded as the parent in get_parent_type_parameter) on each E within the Union after inspecting the annotated return type of the function passed to app.get. So E would be OkResponse first resulting in Data. Then it would be ErrorResponse, resulting in Error. My framework then iterates through the __annotations__ of each of the body types and generates the response schema in the documentation for the client.

3 Answers

The following approach is based on __class_getitem__ and __init_subclass__. It might serve your use case, but it has some severe limitations (see below), so use at your own judgement.

from __future__ import annotations

from typing import Generic, Sequence, TypeVar


T = TypeVar('T')


NO_ARG = object()


class Parent(Generic[T]):
    arg = NO_ARG  # using `arg` to store the current type argument

    def __class_getitem__(cls, key):
        if cls.arg is NO_ARG or cls.arg is T:
            cls.arg = key 
        else:
            try:
                cls.arg = cls.arg[key]
            except TypeError:
                cls.arg = key
        return super().__class_getitem__(key)

    def __init_subclass__(cls):
        if Parent.arg is not NO_ARG:
            cls.arg, Parent.arg = Parent.arg, NO_ARG


class Child1(Parent[int]):
    pass


class Child2(Child1):
    pass


class Child3(Parent[T], Generic[T]):
    pass


class Child4(Parent[Sequence[T]], Generic[T]):
    pass


def get_parent_type_parameter(cls):
    return cls.arg


classes = [
    Parent[str],
    Child1,
    Child2,
    Child3[int],
    Child4[float],
]
for cls in classes:
    print(cls, get_parent_type_parameter(cls))

Which outputs the following:

__main__.Parent[str] <class 'str'>
<class '__main__.Child1'> <class 'int'>
<class '__main__.Child2'> <class 'int'>
__main__.Child3[int] <class 'int'>
__main__.Child4[float] typing.Sequence[float]

This approach requires that every Parent[...] (i.e. __class_getitem__) is followed by an __init_subclass__ because otherwise the former information may be overwritten by a second Parent[...]. For that reasons it won't work with type aliases for example. Consider the following:

classes = [
    Parent[str],
    Parent[int],
    Parent[float],
]
for cls in classes:
    print(cls, get_parent_type_parameter(cls))

which outputs:

__main__.Parent[str] <class 'float'>
__main__.Parent[int] <class 'float'>
__main__.Parent[float] <class 'float'>

Readers who, like myself, are looking for a general-purpose solution that does not require modification of the affected classes, may be interested in the following somewhat simpler solution, tested in Python 3.10.5:

from types import GenericAlias
from typing import Generic, TypeVar, Type, Union, get_origin, get_args

def resolve_type_arguments(query_type: Type, target_type: Union[Type, GenericAlias]) -> tuple[Union[Type, TypeVar], ...]:
    """
    Resolves the type arguments of the query type as supplied by the target type of any of its bases.
    Operates in a tail-recursive fashion, and drills through the hierarchy of generic base types breadth-first in left-to-right order to correctly identify the type arguments that need to be supplied to the next recursive call.

    raises a TypeError if they target type was not an instance of the query type.

    :param query_type: Must be supplied without args (e.g. Mapping not Mapping[KT,VT]
    :param target_type: Must be supplied with args (e.g. Mapping[KT, T] or Mapping[str, int] not Mapping)
    :return: A tuple of the arguments given via target_type for the type parameters of for the query_type, if it has any parameters, otherwise an empty tuple. These arguments may themselves be TypeVars.
    """
    target_origin = get_origin(target_type)
    if target_origin is None:
        if target_type is query_type:
            return target_type.__parameters__
        else:
            target_origin = target_type
            supplied_args = None
    else:
        supplied_args = get_args(target_type)
        if target_origin is query_type:
            return supplied_args
    param_set = set()
    param_list = []
    for (i, each_base) in enumerate(target_origin.__orig_bases__):
        each_origin = get_origin(each_base)
        if each_origin is not None:
            # each base is of the form class[T], which is a private type _GenericAlias, but it is formally documented to have __parameters__
            for each_param in each_base.__parameters__:
                if each_param not in param_set:
                    param_set.add(each_param)
                    param_list.append(each_param)
            if issubclass(each_origin, query_type):
                if supplied_args is not None and len(supplied_args) > 0:
                    params_to_args = {key: value for (key, value) in zip(param_list, supplied_args)}
                    resolved_args = tuple(params_to_args[each] for each in each_base.__parameters__)
                    return resolve_type_arguments(query_type, each_base[resolved_args]) #each_base[args] fowards the args to each_base, it is not quite equivalent to GenericAlias(each_origin, resolved_args)
                else:
                    return resolve_type_arguments(query_type, each_base)
        elif issubclass(each_base, query_type):
            return resolve_type_arguments(query_type, each_base)
    if not issubclass(target_origin, query_type):
        raise ValueError(f'{target_type} is not a subclass of {query_type}')
    else:
        return ()

Unlike @a_guest's answer, this solution works on-demand and does not require modification of target classes.

Examples

T = TypeVar('T')
U = TypeVar('U')
Q = TypeVar('Q')
R = TypeVar('R')

W = TypeVar('W')
X = TypeVar('X')
Y = TypeVar('Y')
Z = TypeVar('Z')

class A(Generic[T, U, Q, R]):
    ...
class NestedA(Generic[T, U, Q]):
    ...
class NestedB(Generic[T]):
    ...
class NoParams:
    ...
class B(NoParams, NestedA[U, Q, U], A[int, NestedA[Q, Q, Q], Q, U], NestedB[R]):
    ...
class C(B[T, str, int]):
    ...
class D(C[int]):
    ...
class E(D):
    ...
class F(E):
    ...
class G(Generic[T]):
    ...
class H(Generic[T]):
    ...
class I(G[int]):
    ...
class J(I, H[str]):
    ...
 
print(resolve_type_arguments(A, A))                          # (~T, ~U, ~Q, ~R)
print(resolve_type_arguments(A, A[W,X,Y,Z]))                 # (~W, ~X, ~Y, ~Z)
print(resolve_type_arguments(A, B))                          # (<class 'int'>, __main__.NestedA[~Q, ~Q, ~Q], ~Q, ~U)
print(resolve_type_arguments(A, B[W,X,Y]))                   # (<class 'int'>, __main__.NestedA[~X, ~X, ~X], ~X, ~W)
print(resolve_type_arguments(B, B))                          # (~U, ~Q, ~R)
print(resolve_type_arguments(B, B[W,X,Y]))                   # (~W, ~X, ~Y)
print(resolve_type_arguments(A, C))                          # (<class 'int'>, __main__.NestedA[str, str, str], <class 'str'>, ~T)
print(resolve_type_arguments(A, C[W]))                       # (<class 'int'>, __main__.NestedA[str, str, str], <class 'str'>, ~W)
print(resolve_type_arguments(B, C))                          # (~T, <class 'str'>, <class 'int'>)
print(resolve_type_arguments(B, C[W]))                       # (~W, <class 'str'>, <class 'int'>)
print(resolve_type_arguments(C, C))                          # (~T,)
print(resolve_type_arguments(C, C[W]))                       # (~W,)
print(resolve_type_arguments(A, D))                          # (<class 'int'>, __main__.NestedA[str, str, str], <class 'str'>, <class 'int'>)
print(resolve_type_arguments(B, D))                          # (<class 'int'>, <class 'str'>, <class 'int'>)
print(resolve_type_arguments(C, D))                          # (<class 'int'>,)
print(resolve_type_arguments(D, D))                          # ()
print(resolve_type_arguments(A, E))                          # (<class 'int'>, __main__.NestedA[str, str, str], <class 'str'>, <class 'int'>)
print(resolve_type_arguments(B, E))                          # (<class 'int'>, <class 'str'>, <class 'int'>)
print(resolve_type_arguments(C, E))                          # (<class 'int'>,)
print(resolve_type_arguments(D, E))                          # ()
print(resolve_type_arguments(E, E))                          # ()
print(resolve_type_arguments(A, F))                          # (<class 'int'>, __main__.NestedA[str, str, str], <class 'str'>, <class 'int'>)
print(resolve_type_arguments(B, F))                          # (<class 'int'>, <class 'str'>, <class 'int'>)
print(resolve_type_arguments(C, F))                          # (<class 'int'>,)
print(resolve_type_arguments(D, F))                          # ()
print(resolve_type_arguments(E, F))                          # ()
print(resolve_type_arguments(F, F))                          # ()
print(resolve_type_arguments(G, J))                          # (<class 'int'>,)

Additional Information

The key to this new solution is that get_origin returns None for types other than generic aliases. Notably, expressions of the form type[var,...] are generic aliases and are therefore documented to have the __parameters__ attribute, as of Python 3.9. Interestingly, they are not technically instances of GenericAlias as of yet. I presume this allows GenericAlias to be treated as an ordinary class.

Note, per the documentation, that:

Note: If the getitem() of the class’ metaclass is present, it will take precedence over the class_getitem() defined in the class (see PEP 560 for more details).

See the typing module and How to access the type arguments of typing.Generic? for additional information on the mechanisms that enable this solution and some important version-related caveats.

Updated 18/07/2022

Addressed caveat identified by @phiresky in the comments (incorporated new test case)

Updated 07/11/2021

Replaced with more robust solution and added more interesting examples. The previous version failed in some cases where part of the hierarchy was non-generic. Additionally, the new version is able to use only fully documented features in Python 3.9, where the typing library is now stable.

EDIT: The following solution implements a function called infer_type_args, which hardcodes default type parameters for standard generic types (anything in collections, collections.abc, or builtins which take type arguments) and otherwise recurses through the base types of an object to infer type parameters. Additionally, ParamSpecs are also handled with this solution. However, the solution has much added complexity, those without need for aggressive generalizations should check out Cosmo's solution which should be fine for the majority of cases.

from __future__ import annotations

import typing
from collections import *
from collections.abc import *
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from dataclasses import dataclass
from types import EllipsisType, GenericAlias, NoneType
from typing import _BaseGenericAlias, Any, Concatenate, Final, Generic, List, ParamSpec, TypeVar


# These type variables will be used to given default generic inheritance for standard library generic types.
T1 = TypeVar('T1')
T2 = TypeVar('T2')
T3 = TypeVar('T3')
P = ParamSpec('P')
TP = TypeVar('TP')

# Type of a generic alias which includes both user-defined and standard library aliases.
GeneralAlias = _BaseGenericAlias | GenericAlias

# Type of a generic alias which has accepted type arguments, even if they are type variables.
ParameterizedAlias = type(List[int]) | GenericAlias

# Type of an entity which is either a type or a type alias.
TypeOrAlias = type[object] | GeneralAlias

# Any kind of type parameter, including TupleParam.
TypeVariable = TypeVar | ParamSpec
TypeVariables = Sequence[TypeVariable, ...]

# Type of a generic type. Of course, it includes types that are not generic.
GenericType = type[
    type |
    AbstractAsyncContextManager |
    AbstractContextManager |
    AsyncIterable |
    Awaitable |
    Callable |
    Container |
    Generic |
    Iterable
]

# Type of an entity that can (sanely) be used as a type argument for a TypeVar.
TypeVarArg = type[object] | GeneralAlias | TypeVar | type(Any)

# Type of an entity that can (sanely) be used as a type argument for ParamSpec.
ParamSpecArg = tuple[TypeVarArg, ...] | list[TypeVarArg] | EllipsisType | ParamSpec | type(Concatenate)

# Type of an entity that represents sane type arguments to supply to a tuple.
TupleParamArg = tuple[TypeVarArg, ...] | tuple[TypeVarArg, EllipsisType]

# Type of any type parameter argument.
TypeArg = TypeVarArg | ParamSpecArg | TupleParamArg
TypeArgs = tuple[TypeArg, ...]

# Type of a type parameter argument which has been processed (see the process function below).
ProcessedArg = TypeVarArg | ParamSpec | tuple[TypeVarArg | ParamSpec, ...]
ProcessedArgs = Sequence[ProcessedArg]

# Type of mapping from type variables to their assignments.
TypeAssignments = dict[TypeVariable, ProcessedArg]

# Dummy ParamSpec to use to simplify substitution.
_P = ParamSpec('_P')

# Implicit values to use as standard collection bases, which do not supply type arguments to their bases.
IMPLICIT_BASES: dict[type[object], ParameterizedAlias | tuple[ParameterizedAlias, ...]] = {
    AbstractAsyncContextManager: Generic[T1],
    AbstractContextManager: Generic[T1],
    AsyncIterable: Generic[T1],
    AsyncIterator: AsyncIterable[T1],
    AsyncGenerator: (AsyncIterator[T1], Generic[T1, T2]),
    Awaitable: Generic[T1],
    Coroutine: (Awaitable[T3], Generic[T1, T2, T3]),
    Callable: Generic[P, T1],
    Container: Generic[T1],
    Iterable: Generic[T1],
    Iterator: Iterable[T1],
    Reversible: Iterable[T1],
    Generator: (Iterator[T1], Generic[T1, T2, T3]),
    Collection: (Container[T1], Iterable[T1]),
    ValuesView: Collection[T1],
    Sequence: (Collection[T1], Reversible[T1]),
    ByteString: Sequence[int],
    bytes: Sequence[int],
    memoryview: Sequence[int],
    MutableSequence: Sequence[T1],
    UserList: MutableSequence[T1],
    bytearray: MutableSequence[int],
    deque: MutableSequence[T1],
    list: MutableSequence[T1],
    range: Sequence[int],
    Set: Collection[T1],
    KeysView: Set[T1],
    ItemsView: Set[tuple[T1, T2]],
    frozenset: Set[T1],
    MutableSet: Set[T1],
    set: MutableSet[T1],
    Mapping: (Collection[T1], Generic[T1, T2]),
    MutableMapping: Mapping[T1, T2],
    ChainMap: MutableMapping[T1, T2],
    Counter: MutableMapping[T1, int],
    OrderedDict: MutableMapping[T1, T2],
    UserDict: MutableMapping[T1, T2],
    defaultdict: MutableMapping[T1, T2],
    dict: MutableMapping[T1, T2]
}

# Types whose assignments should be inherited from subclasses even if they don't appear in that class's mro (e.g., they
# are subclasses via a subclass hook).
HOOK_DEFAULTS: Final[tuple[TypeOrAlias, ...]] = ItemsView,


def all_vars(args: ProcessedArgs) -> TypeVariables:
    # Get all the unique parameters in order for each arg in args.
    seen = set()
    parameters = []
    for arg in args:
        for p in get_vars(arg):
            if p not in seen:
                seen.add(p)
                parameters.append(p)
    return tuple(parameters)


def apply(typ: GenericType, args: ProcessedArgs, vrs: TypeVariables) -> ParameterizedAlias:
    # Apply the given processed arguments to typ.
    # Prepare each argument so it is ready to be used in a type argument list.
    args = tuple(unprocess(arg) for arg in args)
    if typ is tuple:
        return tuple[args[0]]
    return typ[tuple(prepare(arg, var) for arg, var in zip(args, vrs))]


def clean(args: tuple[TypeVarArg | ProcessedArg, ...]) -> ProcessedArg:
    # After substitution, arguments are "cleaned" so that they are a flattened tuple where there are no consecutive
    # occurrences of _P, which is a placeholder for ellipsis objects (see "_process" below).
    new_arg = []
    last_was_ellipsis = False
    for arg in args:
        if arg is _P:
            if last_was_ellipsis:
                continue
            last_was_ellipsis = True
            new_arg.append(_P)
        else:
            last_was_ellipsis = False
            if isinstance(arg, tuple):
                new_arg += arg
            else:
                new_arg.append(arg)
    return tuple(new_arg)


def ensure_tuple(arg: T1) -> T1 | tuple[T1]:
    # Ensure that the given argument is a tuple.
    return arg if isinstance(arg, tuple) else (arg,)


def get_vars(arg: ProcessedArg) -> tuple[TypeVariable, ...]:
    # Get all the type parameters appearing in arg in order.
    # To accomplish this, arg, which is a tuple, is expanded inside a Concatenate between two instances of _P.
    # The first instance of _P ensures that the first parameter found is _P, allowing us to easily ignore it, even if
    # it appears in arg.
    # The second instance of _P ensures that the argument list ends in ParamSpec, without which the Concatenation will
    # fail to be created. [1:] discards the _P parameter.
    return Concatenate[(_P, *ensure_tuple(arg), _P)].__parameters__[1:]


def has_args(typ: TypeOrAlias) -> bool:
    # Determine if the given argument is a generic type alias with arguments.
    # "isinstance(base, type(List))" tests if base is a "_SpecialGenericAlias", the type of type aliases for
    # builtin collections. In this case, we want to treat base as though no type arguments were given.
    return typing.get_origin(typ) is not None and not isinstance(typ, type(List))


def is_generic(typ: type[object]) -> bool:
    # Determine if the given type is generic and can take type arguments.
    return (
        typ is type
        or typ is tuple
        or (typ in IMPLICIT_BASES and bool(Assignments.get(typ).vars))
        or (issubclass(typ, Generic) and bool(getattr(typ, '__parameters__', ())))
    )


def join(src: TypeAssignments, dest: TypeAssignments) -> TypeAssignments:
    # Join assignments in dest into assignments for src.
    return src | {var: sub_map(src, arg) for var, arg in dest.items()}


def orig_bases(typ: type[object]) -> tuple[TypeOrAlias, ...]:
    # Get typ.__orig_bases__ if it gave type arguments to its parameters. Otherwise, get typ.__bases__ (note that
    # typ.__orig_bases__ will be those for the class which previously gave type arguments to bases in the mro if typ
    # did not give any itself).
    if (implicit := IMPLICIT_BASES.get(typ)) is not None:
        return ensure_tuple(implicit)
    if (
        hasattr(typ, '__orig_bases__') and not
        any(getattr(base, '__orig_bases__', ()) is typ.__orig_bases__ for base in typ.__bases__)
    ):
        bases = typ.__orig_bases__
    else:
        bases = typ.__bases__
    mro = set(typ.mro(typ) if issubclass(typ, type) else typ.mro())
    bases_to_add = []
    for hook_default in HOOK_DEFAULTS:
        origin = typing.get_origin(hook_default) or hook_default
        if origin not in mro and issubclass(typ, origin):
            bases_to_add.append(hook_default)
    return bases + tuple(bases_to_add)


def prepare(arg: TypeArgs, var: TypeVariable) -> TypeArg:
    # Depending on the type of var, prepare arg for parameterization of a type.
    return arg[0] if isinstance(var, TypeVar) or arg == (...,) else list(arg)


def _process(arg: TypeArg) -> ProcessedArg:
    # "Process" the given type parameter argument so that it is a ProcessedArg, a flatten tuple of type arguments of
    # which none are ellipses. This processing is done because a ProcessedArg can be easily expanded in Concatenate for
    # convenient substitution and detection of type parameters. It also allows us to treat all type parameter arguments
    # as the same up to the point before we actually need to substitute them in a type argument list.
    if typing.get_origin(arg) is Concatenate:
        return _process(typing.get_args(arg))
    if isinstance(arg, (list, tuple)):
        # Substitute all ellipses for _P and clean the resulting tuple.
        return clean(tuple(_P if a is ... else a for a in arg))
    if arg is ...:
        return _P
    # Otherwise, just return the original argument.
    return arg


def process(arg: TypeArg, proxies: dict[TypeVariable, TypeVariable]) -> ProcessedArg:
    # Process the given arguments. Calls _process, and then substitutes any type parameters in the processed value with
    # their corresponding proxies.
    processed = _process(arg)
    vrs = get_vars(processed)
    return sub(tuple(proxies.get(var, var) for var in vrs), processed)


def sub(args: ProcessedArgs, target: ProcessedArg) -> ProcessedArg:
    # Substitute the given arguments for the type parameters in target.
    # Like params above, this uses a trick of expanding the arguments inside Concatenate with a _P on both sides.
    # The first _P ensures that _P is the first parameter so that we do not have to determine its position, while the
    # second _P ensures that the type arguments end in a ParamSpec, which is a requirement for Concatenate before
    # substitution. [1:-1] discards the extraneous _Ps.
    if not args:
        return target
    result = clean(typing.get_args(Concatenate[(_P, *ensure_tuple(target), _P)][(_P, *args)])[1:-1])
    if not isinstance(target, tuple):
        return result[0]
    return result


def sub_map(assignments: TypeAssignments, target: ProcessedArg) -> ProcessedArg:
    # Like sub, but uses a mapping from variables to arguments and infers uses that to infer the order in which to pass
    # the arguments.
    return sub(tuple(assignments.get(var, var) for var in get_vars(target)), target)


def type_arg_split(typ: TypeOrAlias) -> tuple[type[object], TypeArgs | None]:
    # Split the given type or alias into the origin type and the type arguments, or None if there aren't any.
    origin = typing.get_origin(typ)
    if origin is None or isinstance(typ, type(List)):
        return origin or typ, None
    return origin, typing.get_args(typ)


def unprocess(arg: ProcessedArg) -> TypeArgs:
    # Undo the processing logic (replace _P with ...) to prepare the argument for parameterization of a type.
    return typing.get_args(Concatenate[(*ensure_tuple(arg), _P)][...])[:-1]


def var_default(var: TypeVariable) -> ProcessedArg:
    # Get the default value for a type variable.
    return _P if isinstance(var, ParamSpec) else (Any, _P) if var is TP else Any


class Assignments:
    # Represents the type variable assignments for a particular type.
    # A cache is used for convenience and to ensure proxies are not created more than once for the same type.
    cache: Final[dict[TypeOrAlias, Assignments]] = {}

    # Mapping from assigned type variables to their assignments.
    args: TypeAssignments

    # Tuple of type variables that remain unassigned.
    vars: TypeVariables

    @staticmethod
    def from_bases(
        bases: tuple[TypeOrAlias, ...],
        proxies: OrderedDict[TypeVariable, TypeVariable] | None = None
    ) -> Assignments:
        # Core logic for constructing assignments for a type.
        proxies = proxies or OrderedDict()
        # Start with Assignments object with no assignments and all unassigned variables.
        assignments = Assignments({}, tuple(proxies.values()))
        for base in bases:
            # Iterate over all origin bases.
            origin, args = type_arg_split(base)
            if origin is Generic:
                # Processing Generic would be problematic: it's just parameterized with the type parameters of origin.
                continue
            # If base has arguments, process them. Otherwise default them.
            base_assignments = (
                Assignments.get(origin).default() if args is None else Assignments.get(origin).process(args, proxies)
            )
            # Update the assignments from the base class.
            assignments.update(base_assignments)
        return assignments

    @staticmethod
    def from_tuple_args(args: TypeArgs) -> Assignments:
        # Create an Assignments instance from tuple arguments.
        args = tuple(process(arg, OrderedDict()) for arg in args)
        new_args = {TP: args}
        if len(args) == 1 or (len(args) == 2 and args[1] is _P):
            new_args |= Assignments.get(Sequence[new_args[TP][0]]).args
        return Assignments(new_args, all_vars(args))

    @staticmethod
    def from_type_or_alias(typ: TypeOrAlias) -> Assignments:
        # Get an Assignments instance from a type or a type alias.
        origin, args = type_arg_split(typ)
        if origin is tuple:
            if args is None:
                return Assignments({}, (TP,))
            return Assignments.from_tuple_args(args)
        if args is None:
            bases = orig_bases(origin)
            for base in bases:
                if typing.get_origin(base) is Generic:
                    params = base.__parameters__
                    break
            else:
                params = all_vars(bases)
            proxies = OrderedDict((p, type(p)(p.__name__)) for p in params)
            return Assignments.from_bases(bases, proxies)
        return Assignments.get(origin).process(args, OrderedDict())

    @staticmethod
    def get(typ: TypeOrAlias) -> Assignments:
        # Get the Assignments object for the given type.
        if typ in Assignments.cache:
            return Assignments.cache[typ]
        return Assignments.cache.setdefault(typ, Assignments.from_type_or_alias(typ))

    def __init__(self, args: TypeAssignments | None = None, vrs: TypeVariables = ()) -> None:
        self.args = args or {}
        self.vars = vrs

    def apply(self, typ: GenericType) -> ParameterizedAlias:
        # Apply these assignments to the given type.
        if self.vars:
            # Default any unset variables before applying.
            return self.default().apply(typ)
        # Get assignments for typ.
        assignments = Assignments.get(typ)
        # It might seem like var.kind.default() is unnecessary assuming self corresponds to assignments for a subclass
        # of typ. However, it could be a subclass due to __subclasshook__, so use var.kind.default() in case this is
        # true.
        return apply(typ, tuple(self.args.get(var, var_default(var)) for var in assignments.vars), assignments.vars)

    def assign(self, args: ProcessedArgs) -> Assignments:
        # Return a new Assignments object which is the result of assigning each variable in self to args, in order.
        if self.is_tuple():
            if len(args) == 1 and isinstance(args[0], tuple):
                args = args[0]
            return Assignments.get(tuple[args])
        if len(args) != len(self.vars):
            raise TypeError('Wrong number of type arguments.')
        if not args:
            return self
        return Assignments(join(dict(zip(self.vars, args)), self.args), all_vars(args))

    def default(self) -> Assignments:
        # Return a new Assignments object with all unset variables defaulted.
        if not self.vars:
            return self
        return self.assign(tuple(var_default(var) for var in self.vars))

    def is_tuple(self) -> bool:
        # Determine whether this Assignments instance is for a tuple.
        return len(self.vars) == 1 and self.vars[0] is TP

    def process(self, args: TypeArgs, proxies: OrderedDict[TypeVariable, TypeVariable]) -> Assignments:
        # Process the given raw arguments.
        return self.assign(tuple(process(arg, proxies) for arg in args))

    def update(self, other: Assignments) -> None:
        # Update our assignments with the assignments in other and check their consistency.
        new_args = join(other.args, self.args)
        if any(new_args[var] != other.args[var] for var in other.args):
            raise TypeError('Inconsistent inheritance.')
        self.args = new_args


def infer_type_args(child: TypeOrAlias, parent: GenericType) -> ParameterizedAlias:
    # Return a parameterized alias whose type assignments are inferrable from child.
    origin = typing.get_origin(child) or child
    if not is_generic(parent):
        raise TypeError('parent is not generic.')
    if parent is type:
        return type[child]
    if not isinstance(origin, type):
        raise TypeError('child is not a type.')
    if not issubclass(origin, parent):
        raise TypeError('child is not a subclass of parent.')
    # Get all the variable assignments for child and apply them to parent.
    return Assignments.get(child).apply(parent)

On Python3.10.0, infer_type_args passes the following tests:

import pytest
def test_infer_type_args() -> None:
    # These tests use some randomness in order to test a variety of types. Set a seed for consistent results.

    # Trivial test for built-in types first.
    assert infer_type_args(list, list) == list[Any]
    assert infer_type_args(list[int], list) == list[int]
    assert infer_type_args(dict, dict) == dict[Any, Any]
    assert infer_type_args(dict[int, str], dict) == dict[int, str]
    assert infer_type_args(dict[tuple[str, bool, float], dict[str, int]], dict) == (
        dict[tuple[str, bool, float], dict[str, int]]
    )

    # Thanks to get_origin, should also work with aliases defined in typing.
    assert infer_type_args(typing.List, list) == list[Any]
    assert infer_type_args(typing.List[str], list) == list[str]
    assert infer_type_args(typing.Dict, dict) == dict[Any, Any]
    assert infer_type_args(typing.Dict[int, str], dict) == dict[int, str]

    # Because issubclass(list, Iterable) is True due to subclass hooks, this works even though Iterable is not in
    # list.mro(). This is possible because infer_type_args recognizes that their type variables correspond to each
    # other.
    assert infer_type_args(list, Iterable) == Iterable[Any]
    assert infer_type_args(list[float], Iterable) == Iterable[float]
    assert infer_type_args(list[int], Sequence) == Sequence[int]
    assert infer_type_args(dict, Mapping) == Mapping[Any, Any]
    assert infer_type_args(dict[int, float], Mapping) == Mapping[int, float]
    assert infer_type_args(dict[str, bool], MutableMapping) == MutableMapping[str, bool]

    # Of course, collections defined in the standard modules collections and collections.abc do explicitly subclass each
    # other, so type parameters may be inferred for them too.
    assert infer_type_args(Iterable, Iterable) == Iterable[Any]
    assert infer_type_args(Iterable[int], Iterable) == Iterable[int]
    assert infer_type_args(Sequence[float], Iterable) == Iterable[float]
    assert infer_type_args(MutableMapping[str, Any], Mapping) == Mapping[str, Any]
    assert infer_type_args(OrderedDict[int, str], MutableMapping) == MutableMapping[int, str]

    # Try to infer tuple args, which are handled specially.
    # (Any, ...) are the default type arguments for tuple.
    assert infer_type_args(tuple, tuple) == tuple[Any, ...]
    assert infer_type_args(tuple[int, str, float], tuple) == tuple[int, str, float]
    assert infer_type_args(tuple, Iterable) == Iterable[Any]
    assert infer_type_args(tuple[int, str], Iterable) == Iterable[Any]
    # Tuples of uniform type correctly infer the element type argument for iterables.
    assert infer_type_args(tuple[int, ...], Iterable) == Iterable[int]
    assert infer_type_args(tuple[int], Iterable) == Iterable[int]

    # A few examples with subclasses of tuple.
    class TupleChildNoArgs(tuple): pass
    class TupleChildArgs(tuple[int, str, float]): pass
    class TupleChildArgsChild(TupleChildArgs): pass

    assert infer_type_args(TupleChildNoArgs, tuple) == tuple[Any, ...]
    assert infer_type_args(TupleChildArgs, tuple) == tuple[int, str, float]
    assert infer_type_args(TupleChildArgsChild, tuple) == tuple[int, str, float]

    # Do a few simple tests with Callable. More complicated tests are done below.
    assert infer_type_args(Callable, Callable) == Callable[..., Any]
    assert infer_type_args(Callable[[int, str], float], Callable) == Callable[[int, str], float]

    # Subclasses of Iterable or Iterator with additional type arguments.
    assert infer_type_args(Mapping, Iterable) == Iterable[Any]
    # First type argument of Mappings corresponds to Iterable, Collection, etc.
    assert infer_type_args(MutableMapping[int, float], Iterable) == Iterable[int]
    assert infer_type_args(Generator, Iterable) == Iterable[Any]
    # First type argument of Generator also corresponds to Iterable.
    assert infer_type_args(Generator[tuple[int, float], bool, NoneType], Iterable) == Iterable[tuple[int, float]]

    # Do the same with AsyncIterator.
    assert infer_type_args(AsyncGenerator, AsyncIterator) == AsyncIterator[Any]
    assert infer_type_args(AsyncGenerator[int, str], AsyncIterable) == AsyncIterable[int]

    # Coroutine is a subclass of Awaitable. The third type argument of Coroutine should be the same as the type
    # argument for Awaitable.
    assert infer_type_args(Coroutine, Awaitable) == Awaitable[Any]
    assert infer_type_args(Coroutine[int, str, float], Awaitable) == Awaitable[float]

    # Types.
    assert infer_type_args(int, type) == type[int]
    assert infer_type_args(str, type) == type[str]
    assert infer_type_args(AsyncGenerator[int, str], type) == type[AsyncGenerator[int, str]]

    # Test some special cases.
    assert infer_type_args(ByteString, Sequence) == Sequence[int]
    assert infer_type_args(Counter, Mapping) == Mapping[Any, int]
    assert infer_type_args(Counter[str], MutableMapping) == MutableMapping[str, int]
    assert infer_type_args(ItemsView, Iterable) == Iterable[tuple[Any, Any]]
    assert infer_type_args(ItemsView[int, str], Collection) == Collection[tuple[int, str]]
    assert infer_type_args(bytearray, MutableSequence) == MutableSequence[int]
    assert infer_type_args(bytes, Sequence) == Sequence[int]
    assert infer_type_args(memoryview, Sequence) == Sequence[int]
    assert infer_type_args(range, Sequence) == Sequence[int]

    # Thanks to subclass hooks, we can get desirable behavior by default even from non-generic subclasses.
    # Though, since they aren't actually generic, we can't actually check the results of parameterizations of them.
    assert infer_type_args(type(iter(())), Iterator) == Iterator[Any]
    assert infer_type_args(type(iter([])), Iterator) == Iterator[Any]
    assert infer_type_args(type(iter({})), Iterator) == Iterator[Any]
    assert infer_type_args(type(iter(set())), Iterator) == Iterator[Any]
    assert infer_type_args(type({}.keys()), KeysView) == KeysView[Any]
    assert infer_type_args(type({}.values()), ValuesView) == ValuesView[Any]
    assert infer_type_args(type({}.items()), ItemsView) == ItemsView[Any, Any]
    assert infer_type_args(type({}.items()), Iterable) == Iterable[tuple[Any, Any]]

    # No standard abstract base class specification is complete without this class.
    class VeryAbstractClass(
        AbstractAsyncContextManager, AbstractContextManager, AsyncGenerator, Coroutine, Generator, Mapping
    ): pass

    # Check that all relevant type parameters defaulted to Any.
    assert infer_type_args(VeryAbstractClass, AbstractAsyncContextManager) == AbstractAsyncContextManager[Any]
    assert infer_type_args(VeryAbstractClass, AbstractContextManager) == AbstractContextManager[Any]
    assert infer_type_args(VeryAbstractClass, AsyncGenerator) == AsyncGenerator[Any, Any]
    assert infer_type_args(VeryAbstractClass, Coroutine) == Coroutine[Any, Any, Any]
    assert infer_type_args(VeryAbstractClass, Generator) == Generator[Any, Any, Any]
    assert infer_type_args(VeryAbstractClass, Mapping) == Mapping[Any, Any]

    # Now with some type arguments specified.
    class LessAbstractClass(
        AbstractAsyncContextManager[NoneType],
        AbstractContextManager[bool],
        AsyncGenerator,
        Coroutine,
        Generator[tuple[bool, int], float, str],
        Mapping[tuple[bool, int], list[float]]
    ): pass

    # Check that type parameters with types omitted defaulted to Any, while explicitly specified ones were set
    # correctly.
    assert infer_type_args(LessAbstractClass, AbstractAsyncContextManager) == AbstractAsyncContextManager[NoneType]
    assert infer_type_args(LessAbstractClass, AbstractContextManager) == AbstractContextManager[bool]
    assert infer_type_args(LessAbstractClass, AsyncGenerator) == AsyncGenerator[Any, Any]
    assert infer_type_args(LessAbstractClass, Coroutine) == Coroutine[Any, Any, Any]
    assert infer_type_args(LessAbstractClass, Generator) == Generator[tuple[bool, int], float, str]
    assert infer_type_args(LessAbstractClass, Mapping) == Mapping[tuple[bool, int], list[float]]
    assert infer_type_args(LessAbstractClass, Iterable) == Iterable[tuple[bool, int]]

    # Now to actually try some examples with Generic.
    class Gen1(Generic[T1]): pass
    class Gen2(Gen1): pass
    class Gen3(Gen1[int]): pass
    class Gen4(Gen1[tuple[T1, T2]]): pass
    class Gen5(Gen4): pass
    class Gen6(Gen4[int, str]): pass
    class Gen7(Generic[T2]): pass
    class Gen8(Gen1, Gen7): pass
    class Gen9(Gen1[int], Gen7): pass
    class Gen10(Gen1, Gen7[int]): pass
    class Gen11(Gen1[int], Gen7[str]): pass

    assert infer_type_args(Gen1, Gen1) == Gen1[Any]
    assert infer_type_args(Gen1[int], Gen1) == Gen1[int]
    assert infer_type_args(Gen2, Gen1) == Gen1[Any]
    assert infer_type_args(Gen3, Gen1) == Gen1[int]
    assert infer_type_args(Gen4, Gen1) == Gen1[tuple[Any, Any]]
    assert infer_type_args(Gen4[int, str], Gen1) == Gen1[tuple[int, str]]
    assert infer_type_args(Gen5, Gen1) == Gen1[tuple[Any, Any]]
    assert infer_type_args(Gen5, Gen4) == Gen4[Any, Any]
    assert infer_type_args(Gen6, Gen1) == Gen1[tuple[int, str]]
    assert infer_type_args(Gen6, Gen4) == Gen4[int, str]
    assert infer_type_args(Gen8, Gen1) == Gen1[Any]
    assert infer_type_args(Gen8, Gen7) == Gen7[Any]
    assert infer_type_args(Gen9, Gen1) == Gen1[int]
    assert infer_type_args(Gen9, Gen7) == Gen7[Any]
    assert infer_type_args(Gen10, Gen1) == Gen1[Any]
    assert infer_type_args(Gen10, Gen7) == Gen7[int]
    assert infer_type_args(Gen11, Gen1) == Gen1[int]
    assert infer_type_args(Gen11, Gen7) == Gen7[str]

    # Try some more complicated examples with ParamSpec and Concatenate.
    # This does not work as expected if Generic appears before Callable as then this class inherits Callables behavior
    # when arguments are passed to it.
    # Note that inferred type param arguments will always be flattened and consequence occurrences of ... will collapse
    # into a single occurrence.
    P1 = ParamSpec('P1')
    P2 = ParamSpec('P2')
    P3 = ParamSpec('P3')
    class Complicated(Generic[P1, T1, T2, P2, P3], Callable[Concatenate[P1, T2, P2, P3], tuple[T1, T2]]): pass
    assert infer_type_args(Complicated, Complicated) == Complicated[..., Any, Any, ..., ...]
    assert infer_type_args(Complicated, Callable) == Callable[[..., Any, ...], tuple[Any, Any]]
    assert infer_type_args(Complicated[[int, str], bool, float, [NoneType, list[int]], [str, int]], Complicated) == (
        Complicated[[int, str], bool, float, [NoneType, list[int]], [str, int]]
    )
    assert infer_type_args(Complicated[[int, str], bool, float, [NoneType, list[int]], [str, int]], Callable) == (
        Callable[[int, str, float, NoneType, list[int], str, int], tuple[bool, float]]
    )
    assert infer_type_args(Complicated[[int, str], bool, float, ..., ...], Callable) == (
        Callable[[int, str, float, ...], tuple[bool, float]]
    )
Related