Coverage for src/typing_inspection/introspection.py: 99%
206 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-15 11:48 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-15 11:48 +0000
1"""High-level introspection utilities, used to inspect type annotations."""
3from __future__ import annotations
5import sys
6import types
7from collections.abc import Generator, Sequence
8from dataclasses import InitVar
9from enum import Enum, IntEnum, auto
10from typing import Any, Literal, NamedTuple, cast
12from typing_extensions import TypeAlias, assert_never, get_args, get_origin
14from . import typing_objects
16__all__ = (
17 'AnnotationSource',
18 'ForbiddenQualifier',
19 'InspectedAnnotation',
20 'Qualifier',
21 'get_literal_values',
22 'inspect_annotation',
23 'is_union_origin',
24)
26if sys.version_info >= (3, 14) or sys.version_info < (3, 10):
28 def is_union_origin(obj: Any, /) -> bool:
29 """Return whether the provided origin is the union form.
31 ```pycon
32 >>> is_union_origin(typing.Union)
33 True
34 >>> is_union_origin(get_origin(int | str))
35 True
36 >>> is_union_origin(types.UnionType)
37 True
38 ```
40 !!! note
41 Since Python 3.14, both `Union[<t1>, <t2>, ...]` and `<t1> | <t2> | ...` forms create instances
42 of the same [`typing.Union`][] class. As such, it is recommended to not use this function
43 anymore (provided that you only support Python 3.14 or greater), and instead use the
44 [`typing_objects.is_union()`][typing_inspection.typing_objects.is_union] function directly:
46 ```python
47 from typing import Union, get_origin
49 from typing_inspection import typing_objects
51 typ = int | str # Or Union[int, str]
52 origin = get_origin(typ)
53 if typing_objects.is_union(origin):
54 ...
55 ```
56 """
57 return typing_objects.is_union(obj)
60else:
62 def is_union_origin(obj: Any, /) -> bool:
63 """Return whether the provided origin is the union form.
65 ```pycon
66 >>> is_union_origin(typing.Union)
67 True
68 >>> is_union_origin(get_origin(int | str))
69 True
70 >>> is_union_origin(types.UnionType)
71 True
72 ```
74 !!! note
75 Since Python 3.14, both `Union[<t1>, <t2>, ...]` and `<t1> | <t2> | ...` forms create instances
76 of the same [`typing.Union`][] class. As such, it is recommended to not use this function
77 anymore (provided that you only support Python 3.14 or greater), and instead use the
78 [`typing_objects.is_union()`][typing_inspection.typing_objects.is_union] function directly:
80 ```python
81 from typing import Union, get_origin
83 from typing_inspection import typing_objects
85 typ = int | str # Or Union[int, str]
86 origin = get_origin(typ)
87 if typing_objects.is_union(origin):
88 ...
89 ```
90 """
91 return typing_objects.is_union(obj) or obj is types.UnionType
94def _literal_type_check(value: Any, /) -> None:
95 """Type check the provided literal value against the legal parameters."""
96 if (
97 not isinstance(value, (int, bytes, str, bool, Enum, typing_objects.NoneType))
98 and value is not typing_objects.NoneType
99 ):
100 raise TypeError(f'{value} is not a valid literal value, must be one of: int, bytes, str, Enum, None.')
103def get_literal_values(
104 annotation: Any,
105 /,
106 *,
107 type_check: bool = False,
108 unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'eager',
109) -> Generator[Any]:
110 """Yield the values contained in the provided [`Literal`][typing.Literal] [special form][].
112 Args:
113 annotation: The [`Literal`][typing.Literal] [special form][] to unpack.
114 type_check: Whether to check if the literal values are [legal parameters][literal-legal-parameters].
115 Raises a [`TypeError`][] otherwise.
116 unpack_type_aliases: What to do when encountering [PEP 695](https://peps.python.org/pep-0695/)
117 [type aliases][type-aliases]. Can be one of:
119 - `'skip'`: Do not try to parse type aliases. Note that this can lead to incorrect results:
120 ```pycon
121 >>> type MyAlias = Literal[1, 2]
122 >>> list(get_literal_values(Literal[MyAlias, 3], unpack_type_aliases="skip"))
123 [MyAlias, 3]
124 ```
126 - `'lenient'`: Try to parse type aliases, and fallback to `'skip'` if the type alias can't be inspected
127 (because of an undefined forward reference).
129 - `'eager'`: Parse type aliases and raise any encountered [`NameError`][] exceptions (the default):
130 ```pycon
131 >>> type MyAlias = Literal[1, 2]
132 >>> list(get_literal_values(Literal[MyAlias, 3], unpack_type_aliases="eager"))
133 [1, 2, 3]
134 ```
136 Note:
137 While `None` is [equivalent to][none] `type(None)`, the runtime implementation of [`Literal`][typing.Literal]
138 does not de-duplicate them. This function makes sure this de-duplication is applied:
140 ```pycon
141 >>> list(get_literal_values(Literal[NoneType, None]))
142 [None]
143 ```
145 Example:
146 ```pycon
147 >>> type Ints = Literal[1, 2]
148 >>> list(get_literal_values(Literal[1, Ints], unpack_type_alias="skip"))
149 ["a", Ints]
150 >>> list(get_literal_values(Literal[1, Ints]))
151 [1, 2]
152 >>> list(get_literal_values(Literal[1.0], type_check=True))
153 Traceback (most recent call last):
154 ...
155 TypeError: 1.0 is not a valid literal value, must be one of: int, bytes, str, Enum, None.
156 ```
157 """
158 # `literal` is guaranteed to be a `Literal[...]` special form, so use
159 # `__args__` directly instead of calling `get_args()`.
161 if unpack_type_aliases == 'skip':
162 _has_none = False
163 # `Literal` parameters are already deduplicated, no need to do it ourselves.
164 # (we only check for `None` and `NoneType`, which should be considered as duplicates).
165 for arg in annotation.__args__:
166 if type_check:
167 _literal_type_check(arg)
168 if arg is None or arg is typing_objects.NoneType:
169 if not _has_none:
170 yield None
171 _has_none = True
172 else:
173 yield arg
174 else:
175 # We'll need to manually deduplicate parameters, see the `Literal` implementation in `typing`.
176 values_and_type: list[tuple[Any, type[Any]]] = []
178 for arg in annotation.__args__:
179 # Note: we could also check for generic aliases with a type alias as an origin.
180 # However, it is very unlikely that this happens as type variables can't appear in
181 # `Literal` forms, so the only valid (but unnecessary) use case would be something like:
182 # `type Test[T] = Literal['a']` (and then use `Test[SomeType]`).
183 if typing_objects.is_typealiastype(arg):
184 try:
185 alias_value = arg.__value__
186 except NameError:
187 if unpack_type_aliases == 'eager':
188 raise
189 # unpack_type_aliases == "lenient":
190 if type_check:
191 _literal_type_check(arg)
192 values_and_type.append((arg, type(arg)))
193 else:
194 sub_args = get_literal_values(
195 alias_value, type_check=type_check, unpack_type_aliases=unpack_type_aliases
196 )
197 values_and_type.extend((a, type(a)) for a in sub_args) # pyright: ignore[reportUnknownArgumentType]
198 else:
199 if type_check:
200 _literal_type_check(arg)
201 if arg is typing_objects.NoneType:
202 values_and_type.append((None, typing_objects.NoneType))
203 else:
204 values_and_type.append((arg, type(arg))) # pyright: ignore[reportUnknownArgumentType]
206 try:
207 dct = dict.fromkeys(values_and_type)
208 except TypeError:
209 # Unhashable parameters, the Python implementation allows them
210 yield from (p for p, _ in values_and_type)
211 else:
212 yield from (p for p, _ in dct)
215Qualifier: TypeAlias = Literal['required', 'not_required', 'read_only', 'class_var', 'init_var', 'final']
216"""A [type qualifier][]."""
218_all_qualifiers: set[Qualifier] = set(get_args(Qualifier))
221# TODO at some point, we could switch to an enum flag, so that multiple sources
222# can be combined. However, is there a need for this?
223class AnnotationSource(IntEnum):
224 # TODO if/when https://peps.python.org/pep-0767/ is accepted, add 'read_only'
225 # to CLASS and NAMED_TUPLE (even though for named tuples it is redundant).
227 """The source of an annotation, e.g. a class or a function.
229 Depending on the source, different [type qualifiers][type qualifier] may be (dis)allowed.
230 """
232 ASSIGNMENT_OR_VARIABLE = auto()
233 """An annotation used in an assignment or variable annotation:
235 ```python
236 x: Final[int] = 1
237 y: Final[str]
238 ```
240 **Allowed type qualifiers:** [`Final`][typing.Final].
241 """
243 CLASS = auto()
244 """An annotation used in the body of a class:
246 ```python
247 class Test:
248 x: Final[int] = 1
249 y: ClassVar[str]
250 ```
252 **Allowed type qualifiers:** [`ClassVar`][typing.ClassVar], [`Final`][typing.Final].
253 """
255 DATACLASS = auto()
256 """An annotation used in the body of a dataclass:
258 ```python
259 @dataclass
260 class Test:
261 x: Final[int] = 1
262 y: InitVar[str] = 'test'
263 ```
265 **Allowed type qualifiers:** [`ClassVar`][typing.ClassVar], [`Final`][typing.Final], [`InitVar`][dataclasses-init-only-variables].
266 """ # noqa: E501
268 TYPED_DICT = auto()
269 """An annotation used in the body of a [`TypedDict`][typing.TypedDict]:
271 ```python
272 class TD(TypedDict):
273 x: Required[ReadOnly[int]]
274 y: ReadOnly[NotRequired[str]]
275 ```
277 **Allowed type qualifiers:** [`ReadOnly`][typing.ReadOnly], [`Required`][typing.Required],
278 [`NotRequired`][typing.NotRequired].
279 """
281 NAMED_TUPLE = auto()
282 """An annotation used in the body of a [`NamedTuple`][typing.NamedTuple].
284 ```python
285 class NT(NamedTuple):
286 x: int
287 y: str
288 ```
290 **Allowed type qualifiers:** none.
291 """
293 FUNCTION = auto()
294 """An annotation used in a function, either for a parameter or the return value.
296 ```python
297 def func(a: int) -> str:
298 ...
299 ```
301 **Allowed type qualifiers:** none.
302 """
304 ANY = auto()
305 """An annotation that might come from any source.
307 **Allowed type qualifiers:** all.
308 """
310 BARE = auto()
311 """An annotation that is inspected as is.
313 **Allowed type qualifiers:** none.
314 """
316 @property
317 def allowed_qualifiers(self) -> set[Qualifier]:
318 """The allowed [type qualifiers][type qualifier] for this annotation source."""
319 # TODO use a match statement when Python 3.9 support is dropped.
320 if self is AnnotationSource.ASSIGNMENT_OR_VARIABLE:
321 return {'final'}
322 elif self is AnnotationSource.CLASS:
323 return {'final', 'class_var'}
324 elif self is AnnotationSource.DATACLASS:
325 return {'final', 'class_var', 'init_var'}
326 elif self is AnnotationSource.TYPED_DICT:
327 return {'required', 'not_required', 'read_only'}
328 elif self in (AnnotationSource.NAMED_TUPLE, AnnotationSource.FUNCTION, AnnotationSource.BARE):
329 return set()
330 elif self is AnnotationSource.ANY:
331 return _all_qualifiers
332 else: # pragma: no cover
333 assert_never(self)
336class ForbiddenQualifier(Exception):
337 """The provided [type qualifier][] is forbidden."""
339 qualifier: Qualifier
340 """The forbidden qualifier."""
342 def __init__(self, qualifier: Qualifier, /) -> None:
343 self.qualifier = qualifier
346class _UnknownTypeEnum(Enum):
347 UNKNOWN = auto()
349 def __str__(self) -> str:
350 return 'UNKNOWN'
352 def __repr__(self) -> str:
353 return '<UNKNOWN>'
356UNKNOWN = _UnknownTypeEnum.UNKNOWN
357"""A sentinel value used when no [type expression][] is present."""
359_UnkownType: TypeAlias = Literal[_UnknownTypeEnum.UNKNOWN]
360"""The type of the [`UNKNOWN`][typing_inspection.introspection.UNKNOWN] sentinel value."""
363class InspectedAnnotation(NamedTuple):
364 """The result of the inspected annotation."""
366 type: Any | _UnkownType
367 """The final [type expression][], with [type qualifiers][type qualifier] and annotated metadata stripped.
369 If no type expression is available, the [`UNKNOWN`][typing_inspection.introspection.UNKNOWN] sentinel
370 value is used instead. This is the case when a [type qualifier][] is used with no type annotation:
372 ```python
373 ID: Final = 1
375 class C:
376 x: ClassVar = 'test'
377 ```
378 """
380 qualifiers: set[Qualifier]
381 """The [type qualifiers][type qualifier] present on the annotation."""
383 metadata: Sequence[Any]
384 """The annotated metadata."""
387def inspect_annotation( # noqa: PLR0915
388 annotation: Any,
389 /,
390 *,
391 annotation_source: AnnotationSource,
392 unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'skip',
393) -> InspectedAnnotation:
394 """Inspect an [annotation expression][], extracting any [type qualifier][] and metadata.
396 An [annotation expression][] is a [type expression][] optionally surrounded by one or more
397 [type qualifiers][type qualifier] or by [`Annotated`][typing.Annotated]. This function will:
399 - Unwrap the type expression, keeping track of the type qualifiers.
400 - Unwrap [`Annotated`][typing.Annotated] forms, keeping track of the annotated metadata.
402 Args:
403 annotation: The annotation expression to be inspected.
404 annotation_source: The source of the annotation. Depending on the source (e.g. a class), different type
405 qualifiers may be (dis)allowed. To allow any type qualifier, use
406 [`AnnotationSource.ANY`][typing_inspection.introspection.AnnotationSource.ANY].
407 unpack_type_aliases: What to do when encountering [PEP 695](https://peps.python.org/pep-0695/)
408 [type aliases][type-aliases]. Can be one of:
410 - `'skip'`: Do not try to parse type aliases (the default):
411 ```pycon
412 >>> type MyInt = Annotated[int, 'meta']
413 >>> inspect_annotation(MyInt, annotation_source=AnnotationSource.BARE, unpack_type_aliases='skip')
414 InspectedAnnotation(type=MyInt, qualifiers={}, metadata=[])
415 ```
417 - `'lenient'`: Try to parse type aliases, and fallback to `'skip'` if the type alias
418 can't be inspected (because of an undefined forward reference):
419 ```pycon
420 >>> type MyInt = Annotated[Undefined, 'meta']
421 >>> inspect_annotation(MyInt, annotation_source=AnnotationSource.BARE, unpack_type_aliases='lenient')
422 InspectedAnnotation(type=MyInt, qualifiers={}, metadata=[])
423 >>> Undefined = int
424 >>> inspect_annotation(MyInt, annotation_source=AnnotationSource.BARE, unpack_type_aliases='lenient')
425 InspectedAnnotation(type=int, qualifiers={}, metadata=['meta'])
426 ```
428 - `'eager'`: Parse type aliases and raise any encountered [`NameError`][] exceptions.
430 Returns:
431 The result of the inspected annotation, where the type expression, used qualifiers and metadata is stored.
433 Example:
434 ```pycon
435 >>> inspect_annotation(
436 ... Final[Annotated[ClassVar[Annotated[int, 'meta_1']], 'meta_2']],
437 ... annotation_source=AnnotationSource.CLASS,
438 ... )
439 ...
440 InspectedAnnotation(type=int, qualifiers={'class_var', 'final'}, metadata=['meta_1', 'meta_2'])
441 ```
442 """
443 allowed_qualifiers = annotation_source.allowed_qualifiers
444 qualifiers: set[Qualifier] = set()
445 metadata: list[Any] = []
447 while True:
448 annotation, _meta = _unpack_annotated(annotation, unpack_type_aliases=unpack_type_aliases)
449 if _meta:
450 metadata = _meta + metadata
451 continue
453 origin = get_origin(annotation)
454 if origin is not None:
455 if typing_objects.is_classvar(origin):
456 if 'class_var' not in allowed_qualifiers:
457 raise ForbiddenQualifier('class_var')
458 qualifiers.add('class_var')
459 annotation = annotation.__args__[0]
460 elif typing_objects.is_final(origin):
461 if 'final' not in allowed_qualifiers:
462 raise ForbiddenQualifier('final')
463 qualifiers.add('final')
464 annotation = annotation.__args__[0]
465 elif typing_objects.is_required(origin):
466 if 'required' not in allowed_qualifiers:
467 raise ForbiddenQualifier('required')
468 qualifiers.add('required')
469 annotation = annotation.__args__[0]
470 elif typing_objects.is_notrequired(origin):
471 if 'not_required' not in allowed_qualifiers:
472 raise ForbiddenQualifier('not_required')
473 qualifiers.add('not_required')
474 annotation = annotation.__args__[0]
475 elif typing_objects.is_readonly(origin):
476 if 'read_only' not in allowed_qualifiers:
477 raise ForbiddenQualifier('not_required')
478 qualifiers.add('read_only')
479 annotation = annotation.__args__[0]
480 else:
481 # origin is not None but not a type qualifier nor `Annotated` (e.g. `list[int]`):
482 break
483 elif isinstance(annotation, InitVar):
484 if 'init_var' not in allowed_qualifiers:
485 raise ForbiddenQualifier('init_var')
486 qualifiers.add('init_var')
487 annotation = cast(Any, annotation.type)
488 else:
489 break
491 # `Final`, `ClassVar` and `InitVar` are type qualifiers allowed to be used as a bare annotation:
492 if typing_objects.is_final(annotation):
493 if 'final' not in allowed_qualifiers:
494 raise ForbiddenQualifier('final')
495 qualifiers.add('final')
496 annotation = UNKNOWN
497 elif typing_objects.is_classvar(annotation):
498 if 'class_var' not in allowed_qualifiers:
499 raise ForbiddenQualifier('class_var')
500 qualifiers.add('class_var')
501 annotation = UNKNOWN
502 elif annotation is InitVar:
503 if 'init_var' not in allowed_qualifiers:
504 raise ForbiddenQualifier('init_var')
505 qualifiers.add('init_var')
506 annotation = UNKNOWN
508 return InspectedAnnotation(annotation, qualifiers, metadata)
511def _unpack_annotated_inner(
512 annotation: Any, unpack_type_aliases: Literal['lenient', 'eager'], check_annotated: bool
513) -> tuple[Any, list[Any]]:
514 origin = get_origin(annotation)
515 if check_annotated and typing_objects.is_annotated(origin):
516 annotated_type = annotation.__origin__
517 metadata = list(annotation.__metadata__)
519 # The annotated type might be a PEP 695 type alias, so we need to recursively
520 # unpack it. Because Python already flattens `Annotated[Annotated[<type>, ...], ...]` forms,
521 # we can skip the `is_annotated()` check in the next call:
522 annotated_type, sub_meta = _unpack_annotated_inner(
523 annotated_type, unpack_type_aliases=unpack_type_aliases, check_annotated=False
524 )
525 metadata = sub_meta + metadata
526 return annotated_type, metadata
527 elif typing_objects.is_typealiastype(annotation):
528 try:
529 value = annotation.__value__
530 except NameError:
531 if unpack_type_aliases == 'eager':
532 raise
533 else:
534 typ, metadata = _unpack_annotated_inner(
535 value, unpack_type_aliases=unpack_type_aliases, check_annotated=True
536 )
537 if metadata:
538 # Having metadata means the type alias' `__value__` was an `Annotated` form
539 # (or, recursively, a type alias to an `Annotated` form). It is important to check
540 # for this, as we don't want to unpack other type aliases (e.g. `type MyInt = int`).
541 return typ, metadata
542 return annotation, []
543 elif typing_objects.is_typealiastype(origin):
544 # When parameterized, PEP 695 type aliases become generic aliases
545 # (e.g. with `type MyList[T] = Annotated[list[T], ...]`, `MyList[int]`
546 # is a generic alias).
547 try:
548 value = origin.__value__
549 except NameError:
550 if unpack_type_aliases == 'eager':
551 raise
552 else:
553 # While Python already handles type variable replacement for simple `Annotated` forms,
554 # we need to manually apply the same logic for PEP 695 type aliases:
555 # - With `MyList = Annotated[list[T], ...]`, `MyList[int] == Annotated[list[int], ...]`
556 # - With `type MyList[T] = Annotated[list[T], ...]`, `MyList[int].__value__ == Annotated[list[T], ...]`.
558 try:
559 # To do so, we emulate the parameterization of the value with the arguments:
560 # with `type MyList[T] = Annotated[list[T], ...]`, to emulate `MyList[int]`,
561 # we do `Annotated[list[T], ...][int]` (which gives `Annotated[list[T], ...]`):
562 value = value[annotation.__args__]
563 except TypeError:
564 # Might happen if the type alias is parameterized, but its value doesn't have any
565 # type variables, e.g. `type MyInt[T] = int`.
566 pass
567 typ, metadata = _unpack_annotated_inner(
568 value, unpack_type_aliases=unpack_type_aliases, check_annotated=True
569 )
570 if metadata:
571 return typ, metadata
572 return annotation, []
574 return annotation, []
577# This could eventually be made public:
578def _unpack_annotated(
579 annotation: Any, /, *, unpack_type_aliases: Literal['skip', 'lenient', 'eager'] = 'eager'
580) -> tuple[Any, list[Any]]:
581 if unpack_type_aliases == 'skip':
582 if typing_objects.is_annotated(get_origin(annotation)):
583 return annotation.__origin__, list(annotation.__metadata__)
584 else:
585 return annotation, []
587 return _unpack_annotated_inner(annotation, unpack_type_aliases=unpack_type_aliases, check_annotated=True)