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.
- {python_introspect-0.1.5/src/python_introspect.egg-info → python_introspect-0.1.6}/PKG-INFO +2 -1
- {python_introspect-0.1.5 → python_introspect-0.1.6}/pyproject.toml +2 -1
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/__init__.py +47 -1
- python_introspect-0.1.6/src/python_introspect/annotation_types.py +87 -0
- python_introspect-0.1.6/src/python_introspect/dataclass_projection.py +269 -0
- python_introspect-0.1.6/src/python_introspect/environment_projection.py +130 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/signature_analyzer.py +16 -62
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/unified_parameter_analyzer.py +0 -6
- python_introspect-0.1.6/src/python_introspect/validation.py +276 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6/src/python_introspect.egg-info}/PKG-INFO +2 -1
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/SOURCES.txt +9 -1
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/requires.txt +1 -0
- python_introspect-0.1.6/tests/test_annotation_types.py +46 -0
- python_introspect-0.1.6/tests/test_dataclass_projection.py +126 -0
- python_introspect-0.1.6/tests/test_environment_projection.py +103 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_init.py +1 -1
- {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_signature_analyzer.py +31 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/tests/test_unified_parameter_analyzer.py +0 -20
- python_introspect-0.1.6/tests/test_validation.py +127 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/LICENSE +0 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/README.md +0 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/setup.cfg +0 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/enableable.py +0 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect/exceptions.py +0 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/dependency_links.txt +0 -0
- {python_introspect-0.1.5 → python_introspect-0.1.6}/src/python_introspect.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.")
|