Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/typing_inspection/introspection.py: 44%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

219 statements  

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)