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

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

207 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, 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)