python-introspect 0.1.5__tar.gz → 0.1.6__tar.gz

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.
Files changed (27) hide show
  1. {python_introspect-0.1.5/src/python_introspect.egg-info → python_introspect-0.1.6}/PKG-INFO +2 -1
  2. {python_introspect-0.1.5 → python_introspect-0.1.6}/pyproject.toml +2 -1
  3. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/__init__.py +47 -1
  4. python_introspect-0.1.6/src/python_introspect/annotation_types.py +87 -0
  5. python_introspect-0.1.6/src/python_introspect/dataclass_projection.py +269 -0
  6. python_introspect-0.1.6/src/python_introspect/environment_projection.py +130 -0
  7. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/signature_analyzer.py +16 -62
  8. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/unified_parameter_analyzer.py +0 -6
  9. python_introspect-0.1.6/src/python_introspect/validation.py +276 -0
  10. {python_introspect-0.1.5 → python_introspect-0.1.6/src/python_introspect.egg-info}/PKG-INFO +2 -1
  11. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/SOURCES.txt +9 -1
  12. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/requires.txt +1 -0
  13. python_introspect-0.1.6/tests/test_annotation_types.py +46 -0
  14. python_introspect-0.1.6/tests/test_dataclass_projection.py +126 -0
  15. python_introspect-0.1.6/tests/test_environment_projection.py +103 -0
  16. {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_init.py +1 -1
  17. {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_signature_analyzer.py +31 -0
  18. {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_unified_parameter_analyzer.py +0 -20
  19. python_introspect-0.1.6/tests/test_validation.py +127 -0
  20. {python_introspect-0.1.5 → python_introspect-0.1.6}/LICENSE +0 -0
  21. {python_introspect-0.1.5 → python_introspect-0.1.6}/README.md +0 -0
  22. {python_introspect-0.1.5 → python_introspect-0.1.6}/setup.cfg +0 -0
  23. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/enableable.py +0 -0
  24. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/exceptions.py +0 -0
  25. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/dependency_links.txt +0 -0
  26. {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/top_level.txt +0 -0
  27. {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
+ Requires-Dist: annotated-types>=0.7.0
23
24
  Requires-Dist: metaclass-registry>=0.1.0
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-introspect"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,7 @@ classifiers = [
26
26
  ]
27
27
 
28
28
  dependencies = [
29
+ "annotated-types>=0.7.0",
29
30
  "metaclass-registry>=0.1.0",
30
31
  ]
31
32
 
@@ -9,7 +9,7 @@ Extensibility:
9
9
  type resolution for framework-specific types (lazy configs, proxies, etc.)
10
10
  """
11
11
 
12
- __version__ = "0.1.5"
12
+ __version__ = "0.1.6"
13
13
 
14
14
  from .signature_analyzer import (
15
15
  SignatureAnalyzer,
@@ -40,6 +40,31 @@ from .enableable import (
40
40
  is_enableable,
41
41
  mark_enableable,
42
42
  )
43
+ from .validation import (
44
+ AnnotationValidationError,
45
+ overlay_non_none_dataclass,
46
+ validate_annotated_dataclass,
47
+ validate_annotation_value,
48
+ )
49
+ from .dataclass_projection import (
50
+ dataclass_from_mapping,
51
+ project_dataclass,
52
+ )
53
+ from .environment_projection import (
54
+ EnvironmentVariable,
55
+ overlay_dataclass_from_environment,
56
+ )
57
+ from .annotation_types import (
58
+ enum_member_type,
59
+ get_enum_from_list,
60
+ is_enum_type,
61
+ is_list_of_enums,
62
+ is_union_type,
63
+ make_optional,
64
+ optional_member_type,
65
+ resolve_annotated,
66
+ resolve_optional,
67
+ )
43
68
 
44
69
  __all__ = [
45
70
  # Version
@@ -69,4 +94,25 @@ __all__ = [
69
94
  "Enableable",
70
95
  "is_enableable",
71
96
  "mark_enableable",
97
+ # Runtime annotation validation
98
+ "AnnotationValidationError",
99
+ "overlay_non_none_dataclass",
100
+ "validate_annotated_dataclass",
101
+ "validate_annotation_value",
102
+ # Dataclass projection
103
+ "dataclass_from_mapping",
104
+ "project_dataclass",
105
+ # Environment projection
106
+ "EnvironmentVariable",
107
+ "overlay_dataclass_from_environment",
108
+ # Annotation type operations
109
+ "enum_member_type",
110
+ "get_enum_from_list",
111
+ "is_enum_type",
112
+ "is_list_of_enums",
113
+ "is_union_type",
114
+ "make_optional",
115
+ "optional_member_type",
116
+ "resolve_annotated",
117
+ "resolve_optional",
72
118
  ]
@@ -0,0 +1,87 @@
1
+ """Generic operations derived directly from Python type annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ from enum import Enum
7
+ from typing import Annotated, Union, get_args, get_origin
8
+
9
+
10
+ def is_union_type(annotation: object) -> bool:
11
+ """Return whether ``annotation`` is a typing or PEP 604 union."""
12
+
13
+ return get_origin(annotation) in {Union, types.UnionType}
14
+
15
+
16
+ def optional_member_type(annotation: object) -> object | None:
17
+ """Return ``T`` only for the simple optional declaration ``T | None``."""
18
+
19
+ if not is_union_type(annotation):
20
+ return None
21
+ members = get_args(annotation)
22
+ if len(members) != 2 or type(None) not in members:
23
+ return None
24
+ return next(member for member in members if member is not type(None))
25
+
26
+
27
+ def make_optional(annotation: object) -> object:
28
+ """Return the same declaration with ``None`` admitted exactly once."""
29
+
30
+ if is_union_type(annotation) and type(None) in get_args(annotation):
31
+ return annotation
32
+ return Union[annotation, type(None)]
33
+
34
+
35
+ def resolve_optional(annotation: object) -> object:
36
+ """Resolve an optional declaration to its non-None member declaration."""
37
+
38
+ member_type = optional_member_type(annotation)
39
+ return annotation if member_type is None else member_type
40
+
41
+
42
+ def resolve_annotated(annotation: object) -> object:
43
+ """Resolve an ``Annotated[T, ...]`` declaration to its owned type ``T``."""
44
+
45
+ if get_origin(annotation) is Annotated:
46
+ return get_args(annotation)[0]
47
+ return annotation
48
+
49
+
50
+ def is_enum_type(annotation: object) -> bool:
51
+ """Return whether the declaration is an enum type."""
52
+
53
+ return isinstance(annotation, type) and issubclass(annotation, Enum)
54
+
55
+
56
+ def enum_member_type(annotation: object) -> type[Enum] | None:
57
+ """Return the enum type declared directly or as a simple optional."""
58
+
59
+ if is_enum_type(annotation):
60
+ return annotation
61
+ if not is_union_type(annotation):
62
+ return None
63
+ members = tuple(
64
+ member for member in get_args(annotation) if member is not type(None)
65
+ )
66
+ if len(members) != 1 or not is_enum_type(members[0]):
67
+ return None
68
+ return members[0]
69
+
70
+
71
+ def is_list_of_enums(annotation: object) -> bool:
72
+ """Return whether the declaration is ``list[SomeEnum]``."""
73
+
74
+ members = get_args(annotation)
75
+ return (
76
+ get_origin(annotation) is list
77
+ and len(members) == 1
78
+ and is_enum_type(members[0])
79
+ )
80
+
81
+
82
+ def get_enum_from_list(annotation: object) -> type[Enum] | None:
83
+ """Return the enum type from ``list[SomeEnum]`` when declared."""
84
+
85
+ if not is_list_of_enums(annotation):
86
+ return None
87
+ return get_args(annotation)[0]
@@ -0,0 +1,269 @@
1
+ """Dataclass construction and projection derived from declared annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import MISSING, fields, is_dataclass
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import (
10
+ Annotated,
11
+ Any,
12
+ Literal,
13
+ TypeVar,
14
+ get_args,
15
+ get_origin,
16
+ get_type_hints,
17
+ )
18
+
19
+ from .annotation_types import is_union_type
20
+ from .validation import validate_annotated_dataclass, validate_annotation_value
21
+
22
+
23
+ DataclassT = TypeVar("DataclassT")
24
+
25
+
26
+ def dataclass_from_mapping(
27
+ target_type: type[DataclassT],
28
+ values: Mapping[str, object],
29
+ ) -> DataclassT:
30
+ """Construct one dataclass from the fields declared by its class."""
31
+
32
+ if not isinstance(target_type, type) or not is_dataclass(target_type):
33
+ raise TypeError(
34
+ "dataclass_from_mapping requires a dataclass type; "
35
+ f"got {target_type!r}."
36
+ )
37
+ if not isinstance(values, Mapping):
38
+ raise TypeError("dataclass_from_mapping requires a mapping.")
39
+ non_text_keys = tuple(key for key in values if not isinstance(key, str))
40
+ if non_text_keys:
41
+ raise TypeError(
42
+ f"{target_type.__name__} field names must be strings; "
43
+ f"got {non_text_keys!r}."
44
+ )
45
+
46
+ declared_fields = tuple(
47
+ declared_field for declared_field in fields(target_type) if declared_field.init
48
+ )
49
+ declared_names = {declared_field.name for declared_field in declared_fields}
50
+ extras = tuple(sorted(set(values) - declared_names))
51
+ if extras:
52
+ raise ValueError(
53
+ f"{target_type.__name__} received undeclared field(s): {', '.join(extras)}."
54
+ )
55
+
56
+ annotations = get_type_hints(target_type, include_extras=True)
57
+ constructor_values: dict[str, object] = {}
58
+ missing: list[str] = []
59
+ for declared_field in declared_fields:
60
+ if declared_field.name not in values:
61
+ if declared_field.default is MISSING and declared_field.default_factory is MISSING:
62
+ missing.append(declared_field.name)
63
+ continue
64
+ annotation = annotations.get(declared_field.name, declared_field.type)
65
+ constructor_values[declared_field.name] = _mapping_value_for_annotation(
66
+ annotation,
67
+ values[declared_field.name],
68
+ path=f"{target_type.__name__}.{declared_field.name}",
69
+ )
70
+ if missing:
71
+ raise ValueError(
72
+ f"{target_type.__name__} is missing required field(s): {', '.join(missing)}."
73
+ )
74
+
75
+ result = target_type(**constructor_values)
76
+ validate_annotated_dataclass(result)
77
+ return result
78
+
79
+
80
+ def project_dataclass(
81
+ target_type: type[DataclassT],
82
+ source: object,
83
+ **overrides: object,
84
+ ) -> DataclassT:
85
+ """Project shared declared fields from one dataclass into another."""
86
+
87
+ if not isinstance(target_type, type) or not is_dataclass(target_type):
88
+ raise TypeError(
89
+ "project_dataclass requires a dataclass target type; "
90
+ f"got {target_type!r}."
91
+ )
92
+ if not is_dataclass(source) or isinstance(source, type):
93
+ raise TypeError("project_dataclass requires a dataclass source instance.")
94
+
95
+ target_fields = tuple(
96
+ declared_field for declared_field in fields(target_type) if declared_field.init
97
+ )
98
+ target_names = {declared_field.name for declared_field in target_fields}
99
+ invalid_overrides = tuple(sorted(set(overrides) - target_names))
100
+ if invalid_overrides:
101
+ raise ValueError(
102
+ f"{target_type.__name__} received undeclared override(s): "
103
+ f"{', '.join(invalid_overrides)}."
104
+ )
105
+
106
+ source_values = {
107
+ declared_field.name: object.__getattribute__(source, declared_field.name)
108
+ for declared_field in fields(source)
109
+ }
110
+ constructor_values = {
111
+ declared_field.name: source_values[declared_field.name]
112
+ for declared_field in target_fields
113
+ if declared_field.name in source_values
114
+ }
115
+ constructor_values.update(overrides)
116
+ result = target_type(**constructor_values)
117
+ validate_annotated_dataclass(result)
118
+ return result
119
+
120
+
121
+ def _mapping_value_for_annotation(
122
+ annotation: object,
123
+ value: object,
124
+ *,
125
+ path: str,
126
+ ) -> object:
127
+ origin = get_origin(annotation)
128
+ if origin is Annotated:
129
+ base_type = get_args(annotation)[0]
130
+ converted = _mapping_value_for_annotation(base_type, value, path=path)
131
+ validate_annotation_value(annotation, converted, path=path)
132
+ return converted
133
+ if annotation is Any:
134
+ return value
135
+ if annotation is tuple:
136
+ if not isinstance(value, (list, tuple)):
137
+ raise TypeError(f"{path} must be an array.")
138
+ return tuple(value)
139
+ if is_union_type(annotation):
140
+ successes: list[object] = []
141
+ errors: list[Exception] = []
142
+ for member_type in get_args(annotation):
143
+ try:
144
+ converted = _mapping_value_for_annotation(
145
+ member_type,
146
+ value,
147
+ path=path,
148
+ )
149
+ validate_annotation_value(member_type, converted, path=path)
150
+ except (TypeError, ValueError) as error:
151
+ errors.append(error)
152
+ continue
153
+ successes.append(converted)
154
+ if len(successes) == 1:
155
+ return successes[0]
156
+ exact_matches = tuple(
157
+ converted
158
+ for converted in successes
159
+ if type(converted) is type(value)
160
+ )
161
+ if len(exact_matches) == 1:
162
+ return exact_matches[0]
163
+ if successes:
164
+ raise TypeError(
165
+ f"{path} ambiguously matches multiple members of {annotation!r}."
166
+ )
167
+ value_errors = tuple(
168
+ error for error in errors if isinstance(error, ValueError)
169
+ )
170
+ if len(value_errors) == 1:
171
+ raise value_errors[0]
172
+ if value_errors:
173
+ details = "; ".join(str(error) for error in value_errors)
174
+ raise ValueError(
175
+ f"{path} does not match any constrained union member: {details}"
176
+ )
177
+ detail = f" Last error: {errors[-1]}" if errors else ""
178
+ raise TypeError(f"{path} does not match its declared union.{detail}") from (
179
+ errors[-1] if errors else None
180
+ )
181
+ if origin is Literal:
182
+ validate_annotation_value(annotation, value, path=path)
183
+ return value
184
+ if origin is tuple:
185
+ if not isinstance(value, (list, tuple)):
186
+ raise TypeError(f"{path} must be an array.")
187
+ member_types = get_args(annotation)
188
+ if not member_types:
189
+ return tuple(value)
190
+ if len(member_types) == 2 and member_types[1] is Ellipsis:
191
+ return tuple(
192
+ _mapping_value_for_annotation(
193
+ member_types[0],
194
+ item,
195
+ path=f"{path}[{index}]",
196
+ )
197
+ for index, item in enumerate(value)
198
+ )
199
+ if member_types and len(value) != len(member_types):
200
+ raise ValueError(f"{path} must contain {len(member_types)} item(s); got {len(value)}.")
201
+ return tuple(
202
+ _mapping_value_for_annotation(
203
+ member_type,
204
+ item,
205
+ path=f"{path}[{index}]",
206
+ )
207
+ for index, (member_type, item) in enumerate(zip(member_types, value))
208
+ )
209
+ if origin is list:
210
+ if not isinstance(value, list):
211
+ raise TypeError(f"{path} must be an array.")
212
+ member_types = get_args(annotation)
213
+ if not member_types:
214
+ return list(value)
215
+ return [
216
+ _mapping_value_for_annotation(
217
+ member_types[0],
218
+ item,
219
+ path=f"{path}[{index}]",
220
+ )
221
+ for index, item in enumerate(value)
222
+ ]
223
+ if origin in {dict, Mapping}:
224
+ if not isinstance(value, Mapping):
225
+ raise TypeError(f"{path} must be an object.")
226
+ member_types = get_args(annotation)
227
+ if len(member_types) != 2:
228
+ return dict(value)
229
+ key_type, item_type = member_types
230
+ return {
231
+ _mapping_value_for_annotation(
232
+ key_type,
233
+ key,
234
+ path=f"{path}.key",
235
+ ): _mapping_value_for_annotation(
236
+ item_type,
237
+ item,
238
+ path=f"{path}[{key!r}]",
239
+ )
240
+ for key, item in value.items()
241
+ }
242
+ if annotation is type(None):
243
+ if value is not None:
244
+ raise TypeError(f"{path} must be null.")
245
+ return None
246
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
247
+ if isinstance(value, annotation):
248
+ return value
249
+ try:
250
+ return annotation(value)
251
+ except ValueError as error:
252
+ choices = tuple(member.value for member in annotation)
253
+ raise ValueError(f"{path} must be one of {choices!r}; got {value!r}.") from error
254
+ if isinstance(annotation, type) and is_dataclass(annotation):
255
+ if isinstance(value, annotation):
256
+ return value
257
+ if not isinstance(value, Mapping):
258
+ raise TypeError(f"{path} must be an object.")
259
+ return dataclass_from_mapping(annotation, value)
260
+ if annotation is Path:
261
+ if isinstance(value, Path):
262
+ return value
263
+ if not isinstance(value, str):
264
+ raise TypeError(f"{path} must be a path string.")
265
+ return Path(value)
266
+ if annotation is float and type(value) is int:
267
+ return float(value)
268
+ validate_annotation_value(annotation, value, path=path)
269
+ return value
@@ -0,0 +1,130 @@
1
+ """Environment overlays derived from dataclass annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, fields, is_dataclass, replace
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Annotated, TypeVar, get_args, get_origin, get_type_hints
11
+
12
+ from .annotation_types import is_union_type, optional_member_type
13
+ from .validation import validate_annotated_dataclass, validate_annotation_value
14
+
15
+
16
+ DataclassT = TypeVar("DataclassT")
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class EnvironmentVariable:
21
+ """Bind one declared dataclass field to an environment variable."""
22
+
23
+ name: str
24
+ clear_on_empty: bool = False
25
+
26
+ def __post_init__(self) -> None:
27
+ validate_annotated_dataclass(self)
28
+ if not self.name:
29
+ raise ValueError("Environment variable names must not be empty.")
30
+
31
+
32
+ def overlay_dataclass_from_environment(
33
+ base: DataclassT,
34
+ environment: Mapping[str, str] | None = None,
35
+ ) -> DataclassT:
36
+ """Overlay environment-bound fields while preserving the dataclass type."""
37
+
38
+ if not is_dataclass(base) or isinstance(base, type):
39
+ raise TypeError("overlay_dataclass_from_environment requires a dataclass instance.")
40
+ source = os.environ if environment is None else environment
41
+ owner_type = type(base)
42
+ annotations = get_type_hints(owner_type, include_extras=True)
43
+ replacements: dict[str, object] = {}
44
+ for declared_field in fields(base):
45
+ if not declared_field.init:
46
+ continue
47
+ annotation = annotations.get(declared_field.name, declared_field.type)
48
+ binding = _environment_binding(annotation)
49
+ if binding is None or binding.name not in source:
50
+ continue
51
+ raw_value = source[binding.name]
52
+ if not isinstance(raw_value, str):
53
+ raise TypeError(
54
+ f"{binding.name} must be text; got {type(raw_value).__name__}."
55
+ )
56
+ if raw_value == "":
57
+ if binding.clear_on_empty:
58
+ replacements[declared_field.name] = None
59
+ continue
60
+ replacements[declared_field.name] = _text_value_for_annotation(
61
+ annotation,
62
+ raw_value,
63
+ path=binding.name,
64
+ )
65
+
66
+ result = replace(base, **replacements)
67
+ validate_annotated_dataclass(result)
68
+ return result
69
+
70
+
71
+ def _environment_binding(annotation: object) -> EnvironmentVariable | None:
72
+ if get_origin(annotation) is not Annotated:
73
+ return None
74
+ bindings = tuple(
75
+ metadata
76
+ for metadata in get_args(annotation)[1:]
77
+ if isinstance(metadata, EnvironmentVariable)
78
+ )
79
+ if len(bindings) > 1:
80
+ raise TypeError("A field cannot declare multiple environment variables.")
81
+ return bindings[0] if bindings else None
82
+
83
+
84
+ def _text_value_for_annotation(
85
+ annotation: object,
86
+ value: str,
87
+ *,
88
+ path: str,
89
+ ) -> object:
90
+ origin = get_origin(annotation)
91
+ if origin is Annotated:
92
+ base_type = get_args(annotation)[0]
93
+ converted = _text_value_for_annotation(base_type, value, path=path)
94
+ validate_annotation_value(annotation, converted, path=path)
95
+ return converted
96
+ if is_union_type(annotation):
97
+ member_type = optional_member_type(annotation)
98
+ if member_type is None:
99
+ raise TypeError(
100
+ f"{path} cannot populate ambiguous union {annotation!r} from text."
101
+ )
102
+ return _text_value_for_annotation(member_type, value, path=path)
103
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
104
+ try:
105
+ return annotation(value)
106
+ except ValueError as error:
107
+ choices = tuple(member.value for member in annotation)
108
+ raise ValueError(f"{path} must be one of {choices!r}.") from error
109
+ if annotation is Path:
110
+ return Path(value)
111
+ if annotation is str:
112
+ return value
113
+ if annotation is bool:
114
+ normalized = value.strip().lower()
115
+ if normalized in {"1", "true", "yes", "on"}:
116
+ return True
117
+ if normalized in {"0", "false", "no", "off"}:
118
+ return False
119
+ raise ValueError(f"{path} must be a boolean value.")
120
+ if annotation is int:
121
+ try:
122
+ return int(value)
123
+ except ValueError as error:
124
+ raise ValueError(f"{path} must be an integer.") from error
125
+ if annotation is float:
126
+ try:
127
+ return float(value)
128
+ except ValueError as error:
129
+ raise ValueError(f"{path} must be a number.") from error
130
+ raise TypeError(f"{path} cannot populate declared type {annotation!r} from text.")