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