| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587 |
- """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.
- ```
- """
- # `literal` is guaranteed to be a `Literal[...]` special form, so use
- # `__args__` directly instead of calling `get_args()`.
- if unpack_type_aliases == 'skip':
- _has_none = False
- # `Literal` parameters are already deduplicated, no need to do it ourselves.
- # (we only check for `None` and `NoneType`, which should be considered as duplicates).
- 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:
- # We'll need to manually deduplicate parameters, see the `Literal` implementation in `typing`.
- values_and_type: list[tuple[Any, type[Any]]] = []
- for arg in annotation.__args__:
- # Note: we could also check for generic aliases with a type alias as an origin.
- # However, it is very unlikely that this happens as type variables can't appear in
- # `Literal` forms, so the only valid (but unnecessary) use case would be something like:
- # `type Test[T] = Literal['a']` (and then use `Test[SomeType]`).
- if typing_objects.is_typealiastype(arg):
- try:
- alias_value = arg.__value__
- except NameError:
- if unpack_type_aliases == 'eager':
- raise
- # unpack_type_aliases == "lenient":
- 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) # pyright: ignore[reportUnknownArgumentType]
- 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))) # pyright: ignore[reportUnknownArgumentType]
- try:
- dct = dict.fromkeys(values_and_type)
- except TypeError:
- # Unhashable parameters, the Python implementation allows them
- 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))
- # TODO at some point, we could switch to an enum flag, so that multiple sources
- # can be combined. However, is there a need for this?
- class AnnotationSource(IntEnum):
- # TODO if/when https://peps.python.org/pep-0767/ is accepted, add 'read_only'
- # to CLASS and NAMED_TUPLE (even though for named tuples it is redundant).
- """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].
- """ # noqa: E501
- 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."""
- # TODO use a match statement when Python 3.9 support is dropped.
- 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: # pragma: no cover
- 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( # noqa: PLR0915
- 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:
- # origin is not None but not a type qualifier nor `Annotated` (e.g. `list[int]`):
- 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
- # `Final`, `ClassVar` and `InitVar` are type qualifiers allowed to be used as a bare annotation:
- 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__)
- # The annotated type might be a PEP 695 type alias, so we need to recursively
- # unpack it. Because Python already flattens `Annotated[Annotated[<type>, ...], ...]` forms,
- # we can skip the `is_annotated()` check in the next call:
- 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:
- # Having metadata means the type alias' `__value__` was an `Annotated` form
- # (or, recursively, a type alias to an `Annotated` form). It is important to check
- # for this, as we don't want to unpack other type aliases (e.g. `type MyInt = int`).
- return typ, metadata
- return annotation, []
- elif typing_objects.is_typealiastype(origin):
- # When parameterized, PEP 695 type aliases become generic aliases
- # (e.g. with `type MyList[T] = Annotated[list[T], ...]`, `MyList[int]`
- # is a generic alias).
- try:
- value = origin.__value__
- except NameError:
- if unpack_type_aliases == 'eager':
- raise
- else:
- # While Python already handles type variable replacement for simple `Annotated` forms,
- # we need to manually apply the same logic for PEP 695 type aliases:
- # - With `MyList = Annotated[list[T], ...]`, `MyList[int] == Annotated[list[int], ...]`
- # - With `type MyList[T] = Annotated[list[T], ...]`, `MyList[int].__value__ == Annotated[list[T], ...]`.
- try:
- # To do so, we emulate the parameterization of the value with the arguments:
- # with `type MyList[T] = Annotated[list[T], ...]`, to emulate `MyList[int]`,
- # we do `Annotated[list[T], ...][int]` (which gives `Annotated[list[T], ...]`):
- value = value[annotation.__args__]
- except TypeError:
- # Might happen if the type alias is parameterized, but its value doesn't have any
- # type variables, e.g. `type MyInt[T] = int`.
- 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, []
- # This could eventually be made public:
- 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)
|