|
"""High-level introspection utilities, used to inspect type annotations.""" |
|
|
|
from __future__ import annotations |
|
|
|
import sys |
|
import types |
|
from collections.abc import Generator |
|
from dataclasses import InitVar |
|
from enum import Enum, IntEnum, auto |
|
from typing import Any, Literal, NamedTuple, cast |
|
|
|
from typing_extensions import TypeAlias, assert_never, get_args, get_origin |
|
|
|
from . import typing_objects |
|
|
|
__all__ = ( |
|
'AnnotationSource', |
|
'ForbiddenQualifier', |
|
'InspectedAnnotation', |
|
'Qualifier', |
|
'get_literal_values', |
|
'inspect_annotation', |
|
'is_union_origin', |
|
) |
|
|
|
if sys.version_info >= (3, 14) or sys.version_info < (3, 10): |
|
|
|
def is_union_origin(obj: Any, /) -> bool: |
|
"""Return whether the provided origin is the union form. |
|
|
|
```pycon |
|
>>> is_union_origin(typing.Union) |
|
True |
|
>>> is_union_origin(get_origin(int | str)) |
|
True |
|
>>> is_union_origin(types.UnionType) |
|
True |
|
``` |
|
|
|
!!! note |
|
Since Python 3.14, both `Union[<t1>, <t2>, ...]` and `<t1> | <t2> | ...` forms create instances |
|
of the same [`typing.Union`][] class. As such, it is recommended to not use this function |
|
anymore (provided that you only support Python 3.14 or greater), and instead use the |
|
[`typing_objects.is_union()`][typing_inspection.typing_objects.is_union] function directly: |
|
|
|
```python |
|
from typing import Union, get_origin |
|
|
|
from typing_inspection import typing_objects |
|
|
|
typ = int | str # Or Union[int, str] |
|
origin = get_origin(typ) |
|
if typing_objects.is_union(origin): |
|
... |
|
``` |
|
""" |
|
return typing_objects.is_union(obj) |
|
|
|
|
|
else: |
|
|
|
def is_union_origin(obj: Any, /) -> bool: |
|
"""Return whether the provided origin is the union form. |
|
|
|
```pycon |
|
>>> is_union_origin(typing.Union) |
|
True |
|
>>> is_union_origin(get_origin(int | str)) |
|
True |
|
>>> is_union_origin(types.UnionType) |
|
True |
|
``` |
|
|
|
!!! note |
|
Since Python 3.14, both `Union[<t1>, <t2>, ...]` and `<t1> | <t2> | ...` forms create instances |
|
of the same [`typing.Union`][] class. As such, it is recommended to not use this function |
|
anymore (provided that you only support Python 3.14 or greater), and instead use the |
|
[`typing_objects.is_union()`][typing_inspection.typing_objects.is_union] function directly: |
|
|
|
```python |
|
from typing import Union, get_origin |
|
|
|
from typing_inspection import typing_objects |
|
|
|
typ = int | str # Or Union[int, str] |
|
origin = get_origin(typ) |
|
if typing_objects.is_union(origin): |
|
... |
|
``` |
|
""" |
|
return typing_objects.is_union(obj) or obj is types.UnionType |
|
|
|
|
|
def _literal_type_check(value: Any, /) -> None: |
|
"""Type check the provided literal value against the legal parameters.""" |
|
if ( |
|
not isinstance(value, (int, bytes, str, bool, Enum, typing_objects.NoneType)) |
|
and value is not typing_objects.NoneType |
|
): |
|
raise TypeError(f'{value} is not a valid literal value, must be one of: int, bytes, str, Enum, None.') |
|
|
|
|
|
def get_literal_values( |
|
annotation: Any, |
|
/, |
|
*, |
|
type_check: bool = False, |
|
unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'eager', |
|
) -> Generator[Any]: |
|
"""Yield the values contained in the provided [`Literal`][typing.Literal] [special form][]. |
|
|
|
Args: |
|
annotation: The [`Literal`][typing.Literal] [special form][] to unpack. |
|
type_check: Whether to check if the literal values are [legal parameters][literal-legal-parameters]. |
|
Raises a [`TypeError`][] otherwise. |
|
unpack_type_aliases: What to do when encountering [PEP 695](https://peps.python.org/pep-0695/) |
|
[type aliases][type-aliases]. Can be one of: |
|
|
|
- `'skip'`: Do not try to parse type aliases. Note that this can lead to incorrect results: |
|
```pycon |
|
>>> type MyAlias = Literal[1, 2] |
|
>>> list(get_literal_values(Literal[MyAlias, 3], unpack_type_aliases="skip")) |
|
[MyAlias, 3] |
|
``` |
|
|
|
- `'lenient'`: Try to parse type aliases, and fallback to `'skip'` if the type alias can't be inspected |
|
(because of an undefined forward reference). |
|
|
|
- `'eager'`: Parse type aliases and raise any encountered [`NameError`][] exceptions (the default): |
|
```pycon |
|
>>> type MyAlias = Literal[1, 2] |
|
>>> list(get_literal_values(Literal[MyAlias, 3], unpack_type_aliases="eager")) |
|
[1, 2, 3] |
|
``` |
|
|
|
Note: |
|
While `None` is [equivalent to][none] `type(None)`, the runtime implementation of [`Literal`][typing.Literal] |
|
does not de-duplicate them. This function makes sure this de-duplication is applied: |
|
|
|
```pycon |
|
>>> list(get_literal_values(Literal[NoneType, None])) |
|
[None] |
|
``` |
|
|
|
Example: |
|
```pycon |
|
>>> type Ints = Literal[1, 2] |
|
>>> list(get_literal_values(Literal[1, Ints], unpack_type_alias="skip")) |
|
["a", Ints] |
|
>>> list(get_literal_values(Literal[1, Ints])) |
|
[1, 2] |
|
>>> list(get_literal_values(Literal[1.0], type_check=True)) |
|
Traceback (most recent call last): |
|
... |
|
TypeError: 1.0 is not a valid literal value, must be one of: int, bytes, str, Enum, None. |
|
``` |
|
""" |
|
|
|
|
|
|
|
if unpack_type_aliases == 'skip': |
|
_has_none = False |
|
|
|
|
|
for arg in annotation.__args__: |
|
if type_check: |
|
_literal_type_check(arg) |
|
if arg is None or arg is typing_objects.NoneType: |
|
if not _has_none: |
|
yield None |
|
_has_none = True |
|
else: |
|
yield arg |
|
else: |
|
|
|
values_and_type: list[tuple[Any, type[Any]]] = [] |
|
|
|
for arg in annotation.__args__: |
|
|
|
|
|
|
|
|
|
if typing_objects.is_typealiastype(arg): |
|
try: |
|
alias_value = arg.__value__ |
|
except NameError: |
|
if unpack_type_aliases == 'eager': |
|
raise |
|
|
|
if type_check: |
|
_literal_type_check(arg) |
|
values_and_type.append((arg, type(arg))) |
|
else: |
|
sub_args = get_literal_values( |
|
alias_value, type_check=type_check, unpack_type_aliases=unpack_type_aliases |
|
) |
|
values_and_type.extend((a, type(a)) for a in sub_args) |
|
else: |
|
if type_check: |
|
_literal_type_check(arg) |
|
if arg is typing_objects.NoneType: |
|
values_and_type.append((None, typing_objects.NoneType)) |
|
else: |
|
values_and_type.append((arg, type(arg))) |
|
|
|
try: |
|
dct = dict.fromkeys(values_and_type) |
|
except TypeError: |
|
|
|
yield from (p for p, _ in values_and_type) |
|
else: |
|
yield from (p for p, _ in dct) |
|
|
|
|
|
Qualifier: TypeAlias = Literal['required', 'not_required', 'read_only', 'class_var', 'init_var', 'final'] |
|
"""A [type qualifier][].""" |
|
|
|
_all_qualifiers: set[Qualifier] = set(get_args(Qualifier)) |
|
|
|
|
|
|
|
|
|
class AnnotationSource(IntEnum): |
|
|
|
|
|
|
|
"""The source of an annotation, e.g. a class or a function. |
|
|
|
Depending on the source, different [type qualifiers][type qualifier] may be (dis)allowed. |
|
""" |
|
|
|
ASSIGNMENT_OR_VARIABLE = auto() |
|
"""An annotation used in an assignment or variable annotation: |
|
|
|
```python |
|
x: Final[int] = 1 |
|
y: Final[str] |
|
``` |
|
|
|
**Allowed type qualifiers:** [`Final`][typing.Final]. |
|
""" |
|
|
|
CLASS = auto() |
|
"""An annotation used in the body of a class: |
|
|
|
```python |
|
class Test: |
|
x: Final[int] = 1 |
|
y: ClassVar[str] |
|
``` |
|
|
|
**Allowed type qualifiers:** [`ClassVar`][typing.ClassVar], [`Final`][typing.Final]. |
|
""" |
|
|
|
DATACLASS = auto() |
|
"""An annotation used in the body of a dataclass: |
|
|
|
```python |
|
@dataclass |
|
class Test: |
|
x: Final[int] = 1 |
|
y: InitVar[str] = 'test' |
|
``` |
|
|
|
**Allowed type qualifiers:** [`ClassVar`][typing.ClassVar], [`Final`][typing.Final], [`InitVar`][dataclasses.InitVar]. |
|
""" |
|
|
|
TYPED_DICT = auto() |
|
"""An annotation used in the body of a [`TypedDict`][typing.TypedDict]: |
|
|
|
```python |
|
class TD(TypedDict): |
|
x: Required[ReadOnly[int]] |
|
y: ReadOnly[NotRequired[str]] |
|
``` |
|
|
|
**Allowed type qualifiers:** [`ReadOnly`][typing.ReadOnly], [`Required`][typing.Required], |
|
[`NotRequired`][typing.NotRequired]. |
|
""" |
|
|
|
NAMED_TUPLE = auto() |
|
"""An annotation used in the body of a [`NamedTuple`][typing.NamedTuple]. |
|
|
|
```python |
|
class NT(NamedTuple): |
|
x: int |
|
y: str |
|
``` |
|
|
|
**Allowed type qualifiers:** none. |
|
""" |
|
|
|
FUNCTION = auto() |
|
"""An annotation used in a function, either for a parameter or the return value. |
|
|
|
```python |
|
def func(a: int) -> str: |
|
... |
|
``` |
|
|
|
**Allowed type qualifiers:** none. |
|
""" |
|
|
|
ANY = auto() |
|
"""An annotation that might come from any source. |
|
|
|
**Allowed type qualifiers:** all. |
|
""" |
|
|
|
BARE = auto() |
|
"""An annotation that is inspected as is. |
|
|
|
**Allowed type qualifiers:** none. |
|
""" |
|
|
|
@property |
|
def allowed_qualifiers(self) -> set[Qualifier]: |
|
"""The allowed [type qualifiers][type qualifier] for this annotation source.""" |
|
|
|
if self is AnnotationSource.ASSIGNMENT_OR_VARIABLE: |
|
return {'final'} |
|
elif self is AnnotationSource.CLASS: |
|
return {'final', 'class_var'} |
|
elif self is AnnotationSource.DATACLASS: |
|
return {'final', 'class_var', 'init_var'} |
|
elif self is AnnotationSource.TYPED_DICT: |
|
return {'required', 'not_required', 'read_only'} |
|
elif self in (AnnotationSource.NAMED_TUPLE, AnnotationSource.FUNCTION, AnnotationSource.BARE): |
|
return set() |
|
elif self is AnnotationSource.ANY: |
|
return _all_qualifiers |
|
else: |
|
assert_never(self) |
|
|
|
|
|
class ForbiddenQualifier(Exception): |
|
"""The provided [type qualifier][] is forbidden.""" |
|
|
|
qualifier: Qualifier |
|
"""The forbidden qualifier.""" |
|
|
|
def __init__(self, qualifier: Qualifier, /) -> None: |
|
self.qualifier = qualifier |
|
|
|
|
|
class _UnknownTypeEnum(Enum): |
|
UNKNOWN = auto() |
|
|
|
def __str__(self) -> str: |
|
return 'UNKNOWN' |
|
|
|
def __repr__(self) -> str: |
|
return '<UNKNOWN>' |
|
|
|
|
|
UNKNOWN = _UnknownTypeEnum.UNKNOWN |
|
"""A sentinel value used when no [type expression][] is present.""" |
|
|
|
_UnkownType: TypeAlias = Literal[_UnknownTypeEnum.UNKNOWN] |
|
"""The type of the [`UNKNOWN`][typing_inspection.introspection.UNKNOWN] sentinel value.""" |
|
|
|
|
|
class InspectedAnnotation(NamedTuple): |
|
"""The result of the inspected annotation.""" |
|
|
|
type: Any | _UnkownType |
|
"""The final [type expression][], with [type qualifiers][type qualifier] and annotated metadata stripped. |
|
|
|
If no type expression is available, the [`UNKNOWN`][typing_inspection.introspection.UNKNOWN] sentinel |
|
value is used instead. This is the case when a [type qualifier][] is used with no type annotation: |
|
|
|
```python |
|
ID: Final = 1 |
|
|
|
class C: |
|
x: ClassVar = 'test' |
|
``` |
|
""" |
|
|
|
qualifiers: set[Qualifier] |
|
"""The [type qualifiers][type qualifier] present on the annotation.""" |
|
|
|
metadata: list[Any] |
|
"""The annotated metadata.""" |
|
|
|
|
|
def inspect_annotation( |
|
annotation: Any, |
|
/, |
|
*, |
|
annotation_source: AnnotationSource, |
|
unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'skip', |
|
) -> InspectedAnnotation: |
|
"""Inspect an [annotation expression][], extracting any [type qualifier][] and metadata. |
|
|
|
An [annotation expression][] is a [type expression][] optionally surrounded by one or more |
|
[type qualifiers][type qualifier] or by [`Annotated`][typing.Annotated]. This function will: |
|
|
|
- Unwrap the type expression, keeping track of the type qualifiers. |
|
- Unwrap [`Annotated`][typing.Annotated] forms, keeping track of the annotated metadata. |
|
|
|
Args: |
|
annotation: The annotation expression to be inspected. |
|
annotation_source: The source of the annotation. Depending on the source (e.g. a class), different type |
|
qualifiers may be (dis)allowed. To allow any type qualifier, use |
|
[`AnnotationSource.ANY`][typing_inspection.introspection.AnnotationSource.ANY]. |
|
unpack_type_aliases: What to do when encountering [PEP 695](https://peps.python.org/pep-0695/) |
|
[type aliases][type-aliases]. Can be one of: |
|
|
|
- `'skip'`: Do not try to parse type aliases (the default): |
|
```pycon |
|
>>> type MyInt = Annotated[int, 'meta'] |
|
>>> inspect_annotation(MyInt, annotation_source=AnnotationSource.BARE, unpack_type_aliases='skip') |
|
InspectedAnnotation(type=MyInt, qualifiers={}, metadata=[]) |
|
``` |
|
|
|
- `'lenient'`: Try to parse type aliases, and fallback to `'skip'` if the type alias |
|
can't be inspected (because of an undefined forward reference): |
|
```pycon |
|
>>> type MyInt = Annotated[Undefined, 'meta'] |
|
>>> inspect_annotation(MyInt, annotation_source=AnnotationSource.BARE, unpack_type_aliases='lenient') |
|
InspectedAnnotation(type=MyInt, qualifiers={}, metadata=[]) |
|
>>> Undefined = int |
|
>>> inspect_annotation(MyInt, annotation_source=AnnotationSource.BARE, unpack_type_aliases='lenient') |
|
InspectedAnnotation(type=int, qualifiers={}, metadata=['meta']) |
|
``` |
|
|
|
- `'eager'`: Parse type aliases and raise any encountered [`NameError`][] exceptions. |
|
|
|
Returns: |
|
The result of the inspected annotation, where the type expression, used qualifiers and metadata is stored. |
|
|
|
Example: |
|
```pycon |
|
>>> inspect_annotation( |
|
... Final[Annotated[ClassVar[Annotated[int, 'meta_1']], 'meta_2']], |
|
... annotation_source=AnnotationSource.CLASS, |
|
... ) |
|
... |
|
InspectedAnnotation(type=int, qualifiers={'class_var', 'final'}, metadata=['meta_1', 'meta_2']) |
|
``` |
|
""" |
|
allowed_qualifiers = annotation_source.allowed_qualifiers |
|
qualifiers: set[Qualifier] = set() |
|
metadata: list[Any] = [] |
|
|
|
while True: |
|
annotation, _meta = _unpack_annotated(annotation, unpack_type_aliases=unpack_type_aliases) |
|
if _meta: |
|
metadata = _meta + metadata |
|
continue |
|
|
|
origin = get_origin(annotation) |
|
if origin is not None: |
|
if typing_objects.is_classvar(origin): |
|
if 'class_var' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('class_var') |
|
qualifiers.add('class_var') |
|
annotation = annotation.__args__[0] |
|
elif typing_objects.is_final(origin): |
|
if 'final' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('final') |
|
qualifiers.add('final') |
|
annotation = annotation.__args__[0] |
|
elif typing_objects.is_required(origin): |
|
if 'required' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('required') |
|
qualifiers.add('required') |
|
annotation = annotation.__args__[0] |
|
elif typing_objects.is_notrequired(origin): |
|
if 'not_required' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('not_required') |
|
qualifiers.add('not_required') |
|
annotation = annotation.__args__[0] |
|
elif typing_objects.is_readonly(origin): |
|
if 'read_only' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('not_required') |
|
qualifiers.add('read_only') |
|
annotation = annotation.__args__[0] |
|
else: |
|
|
|
break |
|
elif isinstance(annotation, InitVar): |
|
if 'init_var' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('init_var') |
|
qualifiers.add('init_var') |
|
annotation = cast(Any, annotation.type) |
|
else: |
|
break |
|
|
|
|
|
if typing_objects.is_final(annotation): |
|
if 'final' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('final') |
|
qualifiers.add('final') |
|
annotation = UNKNOWN |
|
elif typing_objects.is_classvar(annotation): |
|
if 'class_var' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('class_var') |
|
qualifiers.add('class_var') |
|
annotation = UNKNOWN |
|
elif annotation is InitVar: |
|
if 'init_var' not in allowed_qualifiers: |
|
raise ForbiddenQualifier('init_var') |
|
qualifiers.add('init_var') |
|
annotation = UNKNOWN |
|
|
|
return InspectedAnnotation(annotation, qualifiers, metadata) |
|
|
|
|
|
def _unpack_annotated_inner( |
|
annotation: Any, unpack_type_aliases: Literal['lenient', 'eager'], check_annotated: bool |
|
) -> tuple[Any, list[Any]]: |
|
origin = get_origin(annotation) |
|
if check_annotated and typing_objects.is_annotated(origin): |
|
annotated_type = annotation.__origin__ |
|
metadata = list(annotation.__metadata__) |
|
|
|
|
|
|
|
|
|
annotated_type, sub_meta = _unpack_annotated_inner( |
|
annotated_type, unpack_type_aliases=unpack_type_aliases, check_annotated=False |
|
) |
|
metadata = sub_meta + metadata |
|
return annotated_type, metadata |
|
elif typing_objects.is_typealiastype(annotation): |
|
try: |
|
value = annotation.__value__ |
|
except NameError: |
|
if unpack_type_aliases == 'eager': |
|
raise |
|
else: |
|
typ, metadata = _unpack_annotated_inner( |
|
value, unpack_type_aliases=unpack_type_aliases, check_annotated=True |
|
) |
|
if metadata: |
|
|
|
|
|
|
|
return typ, metadata |
|
return annotation, [] |
|
elif typing_objects.is_typealiastype(origin): |
|
|
|
|
|
|
|
try: |
|
value = origin.__value__ |
|
except NameError: |
|
if unpack_type_aliases == 'eager': |
|
raise |
|
else: |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
value = value[annotation.__args__] |
|
except TypeError: |
|
|
|
|
|
pass |
|
typ, metadata = _unpack_annotated_inner( |
|
value, unpack_type_aliases=unpack_type_aliases, check_annotated=True |
|
) |
|
if metadata: |
|
return typ, metadata |
|
return annotation, [] |
|
|
|
return annotation, [] |
|
|
|
|
|
|
|
def _unpack_annotated( |
|
annotation: Any, /, *, unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'eager' |
|
) -> tuple[Any, list[Any]]: |
|
if unpack_type_aliases == 'skip': |
|
if typing_objects.is_annotated(get_origin(annotation)): |
|
return annotation.__origin__, list(annotation.__metadata__) |
|
else: |
|
return annotation, [] |
|
|
|
return _unpack_annotated_inner(annotation, unpack_type_aliases=unpack_type_aliases, check_annotated=True) |
|
|