python-introspect 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ Extensibility:
9
9
  type resolution for framework-specific types (lazy configs, proxies, etc.)
10
10
  """
11
11
 
12
- __version__ = "0.2.2"
12
+ __version__ = "0.2.4"
13
13
 
14
14
  from .signature_analyzer import (
15
15
  SignatureAnalyzer,
@@ -39,6 +39,7 @@ from .enableable import (
39
39
  is_enableable,
40
40
  mark_enableable,
41
41
  )
42
+ from .choices import AnnotationChoices, declared_annotation_choices
42
43
  from .validation import (
43
44
  AnnotatedDataclassValidationMixin,
44
45
  AnnotationValidationError,
@@ -119,6 +120,8 @@ __all__ = [
119
120
  "overlay_non_none_dataclass",
120
121
  "validate_annotated_dataclass",
121
122
  "validate_annotation_value",
123
+ "AnnotationChoices",
124
+ "declared_annotation_choices",
122
125
  # Dataclass projection
123
126
  "dataclass_from_mapping",
124
127
  "project_dataclass",
@@ -5,7 +5,15 @@ from __future__ import annotations
5
5
  import sys
6
6
  import types
7
7
  from enum import Enum
8
- from typing import Annotated, Literal, Union, get_args, get_origin, get_type_hints
8
+ from typing import (
9
+ Annotated,
10
+ ForwardRef,
11
+ Literal,
12
+ Union,
13
+ get_args,
14
+ get_origin,
15
+ get_type_hints,
16
+ )
9
17
 
10
18
 
11
19
  def resolved_class_annotations(owner: type) -> dict[str, object]:
@@ -13,6 +21,9 @@ def resolved_class_annotations(owner: type) -> dict[str, object]:
13
21
 
14
22
  Python 3.10 leaves strings inside PEP 585 aliases unresolved. Complete
15
23
  those declarations without interpreting Literal values or Annotated metadata.
24
+ A recursive alias such as ``V = int | list["V"]`` names itself; the inner
25
+ reference is left as a ``ForwardRef``, the form ``get_type_hints`` produces
26
+ for it on Python 3.11 and later.
16
27
  """
17
28
  annotations = get_type_hints(owner, include_extras=True)
18
29
  unresolved = set(annotations)
@@ -29,20 +40,34 @@ def resolved_class_annotations(owner: type) -> dict[str, object]:
29
40
  return annotations
30
41
 
31
42
 
32
- def _resolve_nested_type_strings(annotation: object, globalns: dict, localns: dict) -> object:
43
+ def _resolve_nested_type_strings(
44
+ annotation: object,
45
+ globalns: dict,
46
+ localns: dict,
47
+ expanding: frozenset[str] = frozenset(),
48
+ ) -> object:
33
49
  if isinstance(annotation, str):
34
- return _resolve_nested_type_strings(eval(annotation, globalns, localns), globalns, localns)
50
+ if annotation in expanding:
51
+ return ForwardRef(annotation)
52
+ return _resolve_nested_type_strings(
53
+ eval(annotation, globalns, localns), globalns, localns, expanding | {annotation}
54
+ )
35
55
  if isinstance(annotation, list):
36
- resolved = [_resolve_nested_type_strings(member, globalns, localns) for member in annotation]
56
+ resolved = [
57
+ _resolve_nested_type_strings(member, globalns, localns, expanding)
58
+ for member in annotation
59
+ ]
37
60
  return annotation if all(a is b for a, b in zip(resolved, annotation)) else resolved
38
61
  origin = get_origin(annotation)
39
62
  members = get_args(annotation)
40
63
  if not members or origin is Literal:
41
64
  return annotation
42
65
  if origin is Annotated:
43
- base = _resolve_nested_type_strings(members[0], globalns, localns)
66
+ base = _resolve_nested_type_strings(members[0], globalns, localns, expanding)
44
67
  return annotation if base is members[0] else Annotated[(base, *members[1:])]
45
- resolved = tuple(_resolve_nested_type_strings(member, globalns, localns) for member in members)
68
+ resolved = tuple(
69
+ _resolve_nested_type_strings(member, globalns, localns, expanding) for member in members
70
+ )
46
71
  if all(member is original for member, original in zip(resolved, members)):
47
72
  return annotation
48
73
  if isinstance(annotation, types.GenericAlias):
@@ -143,9 +168,16 @@ def enum_input_values(annotation: object) -> tuple[str, ...]:
143
168
 
144
169
  String-valued members expose their value. Members whose values are not
145
170
  strings expose their name, which keeps JSON and command-line projections
146
- unambiguous, including enums whose concrete value is ``None``.
171
+ unambiguous, including enums whose concrete value is ``None``. A field
172
+ declaring :class:`~python_introspect.choices.AnnotationChoices` exposes its
173
+ choice labels.
147
174
  """
148
175
 
176
+ from .choices import declared_annotation_choices
177
+
178
+ choices = declared_annotation_choices(annotation)
179
+ if choices is not None:
180
+ return tuple(choices.label(choice) for choice in choices.choices())
149
181
  enum_type = declared_enum_type(annotation)
150
182
  if enum_type is None:
151
183
  return ()
@@ -0,0 +1,65 @@
1
+ """Finite value sets declared as ``Annotated`` metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Annotated, get_args, get_origin
7
+
8
+ from .annotation_types import is_union_type
9
+
10
+
11
+ class AnnotationChoices(ABC):
12
+ """``Annotated`` metadata declaring the finite values a field may hold.
13
+
14
+ The declaring package owns the choice set, which may be computed when it is
15
+ asked for. Validation checks each value (or each item of a sequence value)
16
+ against it, and form builders render the choices with their labels.
17
+ ``None`` is never a choice: an ``Optional`` annotation admits it separately.
18
+ """
19
+
20
+ @abstractmethod
21
+ def choices(self) -> tuple[object, ...]:
22
+ """Return the admissible values in display order."""
23
+
24
+ def label(self, choice: object) -> str:
25
+ """Return the display label for one admissible value."""
26
+
27
+ return str(choice)
28
+
29
+ def choice_for_label(self, label: str) -> object:
30
+ """Decode one display label back to its admissible value."""
31
+
32
+ for choice in self.choices():
33
+ if self.label(choice) == label:
34
+ return choice
35
+ raise ValueError(f"{label!r} is not one of {[self.label(c) for c in self.choices()]}.")
36
+
37
+
38
+ def declared_annotation_choices(annotation: object) -> AnnotationChoices | None:
39
+ """Return the choice set declared on an annotation.
40
+
41
+ The metadata is found through ``Annotated``, ``Optional`` and homogeneous
42
+ containers, mirroring how enum declarations are recognised.
43
+ """
44
+
45
+ if get_origin(annotation) is Annotated:
46
+ base, *metadata = get_args(annotation)
47
+ for item in metadata:
48
+ if isinstance(item, AnnotationChoices):
49
+ return item
50
+ return declared_annotation_choices(base)
51
+ members = get_args(annotation)
52
+ if is_union_type(annotation):
53
+ found = [
54
+ choices
55
+ for member in members
56
+ if member is not type(None)
57
+ if (choices := declared_annotation_choices(member)) is not None
58
+ ]
59
+ return found[0] if len(found) == 1 else None
60
+ origin = get_origin(annotation)
61
+ if origin in {list, set, frozenset} and len(members) == 1:
62
+ return declared_annotation_choices(members[0])
63
+ if origin is tuple and len(members) == 2 and members[1] is Ellipsis:
64
+ return declared_annotation_choices(members[0])
65
+ return None
@@ -19,6 +19,7 @@ from typing import (
19
19
  from annotated_types import Ge, Gt, Interval, Le, Len, Lt, MaxLen, MinLen, Predicate
20
20
 
21
21
  from .annotation_types import is_union_type, resolved_class_annotations
22
+ from .choices import AnnotationChoices
22
23
 
23
24
 
24
25
  class AnnotationValidationError(ValueError):
@@ -146,6 +147,9 @@ def validate_annotation_value(
146
147
  if origin in {dict, Mapping}:
147
148
  _validate_mapping(annotation, value, path)
148
149
  return
150
+ if origin is type:
151
+ _validate_class_value(annotation, value, path)
152
+ return
149
153
  if origin is Callable:
150
154
  if not callable(value):
151
155
  raise TypeError(f"{path} must be callable; got {type(value).__name__}.")
@@ -205,6 +209,21 @@ def _validate_sequence(annotation: object, value: object, path: str) -> None:
205
209
  validate_annotation_value(members[0], item, path=f"{path}[{index}]")
206
210
 
207
211
 
212
+ def _validate_class_value(annotation: object, value: object, path: str) -> None:
213
+ if not isinstance(value, type):
214
+ raise TypeError(f"{path} must be a class; got {type(value).__name__}.")
215
+ members = get_args(annotation)
216
+ if not members or members[0] is Any:
217
+ return
218
+ bounds = get_args(members[0]) if is_union_type(members[0]) else (members[0],)
219
+ if not any(isinstance(bound, type) and issubclass(value, bound) for bound in bounds):
220
+ raise TypeError(
221
+ f"{path} must be a subclass of "
222
+ f"{' | '.join(_annotation_label(bound) for bound in bounds)}; "
223
+ f"got {value.__qualname__}."
224
+ )
225
+
226
+
208
227
  def _validate_mapping(annotation: object, value: object, path: str) -> None:
209
228
  if not isinstance(value, Mapping):
210
229
  raise TypeError(f"{path} must be a mapping; got {type(value).__name__}.")
@@ -226,6 +245,20 @@ def _validate_constraint(metadata: object, value: object, path: str) -> None:
226
245
  """Ignore annotation metadata that does not declare a runtime constraint."""
227
246
 
228
247
 
248
+ @_validate_constraint.register
249
+ def _(metadata: AnnotationChoices, value: object, path: str) -> None:
250
+ if value is None:
251
+ return
252
+ choices = metadata.choices()
253
+ items = value if isinstance(value, (list, tuple, frozenset, set)) else (value,)
254
+ for item in items:
255
+ if item not in choices:
256
+ raise AnnotationValidationError(
257
+ f"{path} must hold values from "
258
+ f"{[metadata.label(choice) for choice in choices]}; got {item!r}."
259
+ )
260
+
261
+
229
262
  @_validate_constraint.register
230
263
  def _(metadata: Ge, value: object, path: str) -> None:
231
264
  if value < metadata.ge:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Pure Python introspection toolkit for function signatures, dataclasses, and type hints
5
5
  Author-email: Tristan Simas <tristan.simas@mail.mcgill.ca>
6
6
  License: MIT
@@ -1,6 +1,7 @@
1
- python_introspect/__init__.py,sha256=IHmyKt23hfkRVeRaVD9mDuoWAG7Q7BorK-cP4B9o0Eg,3985
2
- python_introspect/annotation_types.py,sha256=KZDv0fUAuvVQDEj0U_cozA_31vl3MyNsOfSOcdo3aJk,7557
1
+ python_introspect/__init__.py,sha256=gez9bqvqRza1xmWmaoT9E_s75-LgOukZzcOD-wAkTVI,4113
2
+ python_introspect/annotation_types.py,sha256=AEo84uIUoYSqoyMM6qicyqXxiMHD2IWqytUiJybDS3o,8384
3
3
  python_introspect/callable_declaration.py,sha256=O7i0dGJP5rKWSlwfmmqlQLJwNQC_oSTngImVy3idaVE,1409
4
+ python_introspect/choices.py,sha256=kpaFx63SYDvJkCKpXFDvkORyVtHEUnujVcvNQ6jQVN8,2415
4
5
  python_introspect/dataclass_projection.py,sha256=531PIrAunWNuQBwnZDBRpDa0Gy8iK_F6B4ThwYqOhCU,10869
5
6
  python_introspect/docstring_annotations.py,sha256=RE6AZPgz8aBEIIvUoVEHpraItZfjareKxYvuaZa1H8k,6129
6
7
  python_introspect/enableable.py,sha256=Al6t1kpqbxZIvDb70XJPYEk1WgubGiWUQySNcBcLjrY,5253
@@ -11,9 +12,9 @@ python_introspect/public_api.py,sha256=8wkxQKmnxl_46U49Dzq4XXIOqmrYQOD0S0cl9bc-Y
11
12
  python_introspect/runtime_parameter.py,sha256=Wf7S1GfnUWhkRzIvjhmoYz2WR0HT2K_MuFDcUEphmJc,3067
12
13
  python_introspect/signature_analyzer.py,sha256=DlFaxudB-dANV0FD9MmLJWdvxBKobJbtEn5n6tda2JU,60926
13
14
  python_introspect/unified_parameter_analyzer.py,sha256=Qw39mQsYDYq5zEiB6ALRoZqwQeZ4PJmlkFxpTaKkccg,12231
14
- python_introspect/validation.py,sha256=vj3OwJFShA2n4Zg45rXj8qghsJxDe1JhND8gkuNbfLQ,10447
15
- python_introspect-0.2.2.dist-info/licenses/LICENSE,sha256=xagEoeTAj1WT64RmyR3E6HH-eTGdgXN6gqPMUUt7L_Y,1070
16
- python_introspect-0.2.2.dist-info/METADATA,sha256=rwnVJA2UW6z5V1pznJtSjYqb0-H9_Y55ynK00qji8SE,4677
17
- python_introspect-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
- python_introspect-0.2.2.dist-info/top_level.txt,sha256=TZq9Yj1LeXI7A96PDqf7ZbWGSB1zLlQDww0esC91bwY,18
19
- python_introspect-0.2.2.dist-info/RECORD,,
15
+ python_introspect/validation.py,sha256=MpyvHhHO39SzQqWxVvQHTiAepTcjc79o8A54-YcMe94,11741
16
+ python_introspect-0.2.4.dist-info/licenses/LICENSE,sha256=xagEoeTAj1WT64RmyR3E6HH-eTGdgXN6gqPMUUt7L_Y,1070
17
+ python_introspect-0.2.4.dist-info/METADATA,sha256=3M7pFCMcmRPZald2Yh9ctn4VZ0RzIHmn4ZPHOIGjvb0,4677
18
+ python_introspect-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ python_introspect-0.2.4.dist-info/top_level.txt,sha256=TZq9Yj1LeXI7A96PDqf7ZbWGSB1zLlQDww0esC91bwY,18
20
+ python_introspect-0.2.4.dist-info/RECORD,,