python-introspect 0.2.2__py3-none-any.whl → 0.2.3__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.3"
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",
@@ -143,9 +143,16 @@ def enum_input_values(annotation: object) -> tuple[str, ...]:
143
143
 
144
144
  String-valued members expose their value. Members whose values are not
145
145
  strings expose their name, which keeps JSON and command-line projections
146
- unambiguous, including enums whose concrete value is ``None``.
146
+ unambiguous, including enums whose concrete value is ``None``. A field
147
+ declaring :class:`~python_introspect.choices.AnnotationChoices` exposes its
148
+ choice labels.
147
149
  """
148
150
 
151
+ from .choices import declared_annotation_choices
152
+
153
+ choices = declared_annotation_choices(annotation)
154
+ if choices is not None:
155
+ return tuple(choices.label(choice) for choice in choices.choices())
149
156
  enum_type = declared_enum_type(annotation)
150
157
  if enum_type is None:
151
158
  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.3
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=12UGFxz8sFASpsYSjoBXgcQeQj_6W-qsx1zz-1O--ac,4113
2
+ python_introspect/annotation_types.py,sha256=xE99AQ8PgUKkoNeONLJM2kH1nbqtvXLdb4mR6in7QOU,7877
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.3.dist-info/licenses/LICENSE,sha256=xagEoeTAj1WT64RmyR3E6HH-eTGdgXN6gqPMUUt7L_Y,1070
17
+ python_introspect-0.2.3.dist-info/METADATA,sha256=9jzoO0sZwPgUZ4DiHrU1xsqXsEO5mGoSTiQsSf-F5K4,4677
18
+ python_introspect-0.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ python_introspect-0.2.3.dist-info/top_level.txt,sha256=TZq9Yj1LeXI7A96PDqf7ZbWGSB1zLlQDww0esC91bwY,18
20
+ python_introspect-0.2.3.dist-info/RECORD,,