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, cast
11
12from typing_extensions import TypeAlias, assert_never, get_args, get_origin
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) or sys.version_info < (3, 10):
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
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.')
101
102
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][].
111
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:
118
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 ```
125
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).
128
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 ```
135
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:
139
140 ```pycon
141 >>> list(get_literal_values(Literal[NoneType, None]))
142 [None]
143 ```
144
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()`.
160
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]]] = []
177
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]
205
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)
213
214
215Qualifier: TypeAlias = Literal['required', 'not_required', 'read_only', 'class_var', 'init_var', 'final']
216"""A [type qualifier][]."""
217
218_all_qualifiers: set[Qualifier] = set(get_args(Qualifier))
219
220
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).
226
227 """The source of an annotation, e.g. a class or a function.
228
229 Depending on the source, different [type qualifiers][type qualifier] may be (dis)allowed.
230 """
231
232 ASSIGNMENT_OR_VARIABLE = auto()
233 """An annotation used in an assignment or variable annotation:
234
235 ```python
236 x: Final[int] = 1
237 y: Final[str]
238 ```
239
240 **Allowed type qualifiers:** [`Final`][typing.Final].
241 """
242
243 CLASS = auto()
244 """An annotation used in the body of a class:
245
246 ```python
247 class Test:
248 x: Final[int] = 1
249 y: ClassVar[str]
250 ```
251
252 **Allowed type qualifiers:** [`ClassVar`][typing.ClassVar], [`Final`][typing.Final].
253 """
254
255 DATACLASS = auto()
256 """An annotation used in the body of a dataclass:
257
258 ```python
259 @dataclass
260 class Test:
261 x: Final[int] = 1
262 y: InitVar[str] = 'test'
263 ```
264
265 **Allowed type qualifiers:** [`ClassVar`][typing.ClassVar], [`Final`][typing.Final], [`InitVar`][dataclasses.InitVar].
266 """ # noqa: E501
267
268 TYPED_DICT = auto()
269 """An annotation used in the body of a [`TypedDict`][typing.TypedDict]:
270
271 ```python
272 class TD(TypedDict):
273 x: Required[ReadOnly[int]]
274 y: ReadOnly[NotRequired[str]]
275 ```
276
277 **Allowed type qualifiers:** [`ReadOnly`][typing.ReadOnly], [`Required`][typing.Required],
278 [`NotRequired`][typing.NotRequired].
279 """
280
281 NAMED_TUPLE = auto()
282 """An annotation used in the body of a [`NamedTuple`][typing.NamedTuple].
283
284 ```python
285 class NT(NamedTuple):
286 x: int
287 y: str
288 ```
289
290 **Allowed type qualifiers:** none.
291 """
292
293 FUNCTION = auto()
294 """An annotation used in a function, either for a parameter or the return value.
295
296 ```python
297 def func(a: int) -> str:
298 ...
299 ```
300
301 **Allowed type qualifiers:** none.
302 """
303
304 ANY = auto()
305 """An annotation that might come from any source.
306
307 **Allowed type qualifiers:** all.
308 """
309
310 BARE = auto()
311 """An annotation that is inspected as is.
312
313 **Allowed type qualifiers:** none.
314 """
315
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)
334
335
336class ForbiddenQualifier(Exception):
337 """The provided [type qualifier][] is forbidden."""
338
339 qualifier: Qualifier
340 """The forbidden qualifier."""
341
342 def __init__(self, qualifier: Qualifier, /) -> None:
343 self.qualifier = qualifier
344
345
346class _UnknownTypeEnum(Enum):
347 UNKNOWN = auto()
348
349 def __str__(self) -> str:
350 return 'UNKNOWN'
351
352 def __repr__(self) -> str:
353 return '<UNKNOWN>'
354
355
356UNKNOWN = _UnknownTypeEnum.UNKNOWN
357"""A sentinel value used when no [type expression][] is present."""
358
359_UnkownType: TypeAlias = Literal[_UnknownTypeEnum.UNKNOWN]
360"""The type of the [`UNKNOWN`][typing_inspection.introspection.UNKNOWN] sentinel value."""
361
362
363class InspectedAnnotation(NamedTuple):
364 """The result of the inspected annotation."""
365
366 type: Any | _UnkownType
367 """The final [type expression][], with [type qualifiers][type qualifier] and annotated metadata stripped.
368
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:
371
372 ```python
373 ID: Final = 1
374
375 class C:
376 x: ClassVar = 'test'
377 ```
378 """
379
380 qualifiers: set[Qualifier]
381 """The [type qualifiers][type qualifier] present on the annotation."""
382
383 metadata: list[Any]
384 """The annotated metadata."""
385
386
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.
395
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:
398
399 - Unwrap the type expression, keeping track of the type qualifiers.
400 - Unwrap [`Annotated`][typing.Annotated] forms, keeping track of the annotated metadata.
401
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:
409
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 ```
416
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 ```
427
428 - `'eager'`: Parse type aliases and raise any encountered [`NameError`][] exceptions.
429
430 Returns:
431 The result of the inspected annotation, where the type expression, used qualifiers and metadata is stored.
432
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] = []
446
447 while True:
448 annotation, _meta = _unpack_annotated(annotation, unpack_type_aliases=unpack_type_aliases)
449 if _meta:
450 metadata = _meta + metadata
451 continue
452
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
490
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
507
508 return InspectedAnnotation(annotation, qualifiers, metadata)
509
510
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__)
518
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], ...]`.
557
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, []
573
574 return annotation, []
575
576
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, []
586
587 return _unpack_annotated_inner(annotation, unpack_type_aliases=unpack_type_aliases, check_annotated=True)