confarg 0.0.1.dev2__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.
- confarg/__init__.py +440 -0
- confarg/_argparse.py +958 -0
- confarg/_callable.py +593 -0
- confarg/_completion.py +318 -0
- confarg/_defaults.py +15 -0
- confarg/_errors.py +85 -0
- confarg/_files.py +426 -0
- confarg/_merge.py +284 -0
- confarg/_parse_cli.py +507 -0
- confarg/_parse_env.py +279 -0
- confarg/_serialize.py +206 -0
- confarg/_types.py +614 -0
- confarg/dictexpr/__init__.py +34 -0
- confarg/dictexpr/_expressions.py +566 -0
- confarg/typedload/__init__.py +44 -0
- confarg/typedload/_coerce.py +178 -0
- confarg/typedload/_construct.py +685 -0
- confarg-0.0.1.dev2.dist-info/METADATA +9 -0
- confarg-0.0.1.dev2.dist-info/RECORD +20 -0
- confarg-0.0.1.dev2.dist-info/WHEEL +4 -0
confarg/_parse_env.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
|
|
5
|
+
"""Environment variable parsing for confarg."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from confarg._errors import ConfargError, ConfargWarning
|
|
16
|
+
from confarg._merge import DICT_DELETE, _accumulate_list_delete, _set_nested
|
|
17
|
+
from confarg._types import (
|
|
18
|
+
_dict_kv,
|
|
19
|
+
_elem_type,
|
|
20
|
+
_is_callable,
|
|
21
|
+
_is_dict,
|
|
22
|
+
_is_frozenset,
|
|
23
|
+
_is_list,
|
|
24
|
+
_is_set,
|
|
25
|
+
_is_struct,
|
|
26
|
+
_is_struct_like,
|
|
27
|
+
_is_tuple,
|
|
28
|
+
_is_union,
|
|
29
|
+
_is_varlen_collection,
|
|
30
|
+
_resolve_type,
|
|
31
|
+
_StrToken,
|
|
32
|
+
_struct_fields,
|
|
33
|
+
_try_coerce,
|
|
34
|
+
_tuple_types,
|
|
35
|
+
_union_args_no_none,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_env_parts(target: Any, parts: list[str]) -> tuple[list[str], Any]:
|
|
40
|
+
"""Map env var parts to actual field names using case-insensitive matching.
|
|
41
|
+
|
|
42
|
+
Walks the type tree to find the correct casing for each path segment.
|
|
43
|
+
Falls back to lowercase when the type is not a dataclass (e.g. dict keys,
|
|
44
|
+
list indices).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
target: The root target type.
|
|
48
|
+
parts: A list of env var path segments.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A tuple of (resolved_parts, leaf_type) where resolved_parts has correct
|
|
52
|
+
casing and leaf_type is the resolved type of the final field (or None).
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ConfargError: If a part matches multiple field names at the same level.
|
|
56
|
+
"""
|
|
57
|
+
resolved: list[str] = []
|
|
58
|
+
tp: Any = _resolve_type(target)
|
|
59
|
+
|
|
60
|
+
for part in parts:
|
|
61
|
+
name, tp = _match_env_part(tp, part)
|
|
62
|
+
resolved.append(name)
|
|
63
|
+
|
|
64
|
+
return resolved, tp
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _match_env_part(tp: Any, part: str) -> tuple[str, Any]:
|
|
68
|
+
"""Match a single env var part against the current type level.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tp: The current type being walked.
|
|
72
|
+
part: The env var path segment to match.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A tuple of (resolved_name, next_type) where next_type may be None.
|
|
76
|
+
"""
|
|
77
|
+
if tp is None:
|
|
78
|
+
return part.lower(), None
|
|
79
|
+
|
|
80
|
+
tp = _resolve_type(tp)
|
|
81
|
+
|
|
82
|
+
if _is_struct(tp):
|
|
83
|
+
flds = _struct_fields(tp)
|
|
84
|
+
matches = [name for name in flds if name.lower() == part.lower()]
|
|
85
|
+
if len(matches) > 1:
|
|
86
|
+
raise ConfargError(
|
|
87
|
+
f"Ambiguous env var segment {part!r}: matches multiple fields {matches} in {tp.__name__}."
|
|
88
|
+
" Check your dataclass definition for duplicate case-insensitive field names."
|
|
89
|
+
)
|
|
90
|
+
if len(matches) == 1:
|
|
91
|
+
return matches[0], flds[matches[0]]
|
|
92
|
+
return part.lower(), None
|
|
93
|
+
|
|
94
|
+
if _is_union(tp):
|
|
95
|
+
name_to_types: dict[str, list[Any]] = {}
|
|
96
|
+
for variant in _union_args_no_none(tp):
|
|
97
|
+
v = _resolve_type(variant)
|
|
98
|
+
if _is_struct(v):
|
|
99
|
+
flds = _struct_fields(v)
|
|
100
|
+
for fname in flds:
|
|
101
|
+
if fname.lower() == part.lower():
|
|
102
|
+
name_to_types.setdefault(fname, []).append(flds[fname])
|
|
103
|
+
if len(name_to_types) > 1:
|
|
104
|
+
raise ConfargError(
|
|
105
|
+
f"Ambiguous env var segment {part!r}: matches fields"
|
|
106
|
+
f" {sorted(name_to_types.keys())} across union variants."
|
|
107
|
+
" Use a more specific environment variable name or add a discriminator field."
|
|
108
|
+
)
|
|
109
|
+
if len(name_to_types) == 1:
|
|
110
|
+
name = next(iter(name_to_types))
|
|
111
|
+
types = name_to_types[name]
|
|
112
|
+
# Only return a concrete type when all variants agree; otherwise None (defer to construct)
|
|
113
|
+
ft = types[0] if all(t == types[0] for t in types[1:]) else None
|
|
114
|
+
return name, ft
|
|
115
|
+
return part.lower(), None
|
|
116
|
+
|
|
117
|
+
if _is_list(tp) or _is_set(tp) or _is_frozenset(tp):
|
|
118
|
+
return part.lower(), _elem_type(tp)
|
|
119
|
+
|
|
120
|
+
if _is_tuple(tp):
|
|
121
|
+
tt = _tuple_types(tp)
|
|
122
|
+
if tt is None:
|
|
123
|
+
return part.lower(), _elem_type(tp)
|
|
124
|
+
try:
|
|
125
|
+
idx = int(part)
|
|
126
|
+
if 0 <= idx < len(tt):
|
|
127
|
+
return part.lower(), tt[idx]
|
|
128
|
+
except ValueError:
|
|
129
|
+
pass
|
|
130
|
+
return part.lower(), None
|
|
131
|
+
|
|
132
|
+
if _is_dict(tp):
|
|
133
|
+
_, vt = _dict_kv(tp)
|
|
134
|
+
return part.lower(), vt
|
|
135
|
+
|
|
136
|
+
return part.lower(), None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_env(
|
|
140
|
+
env: Mapping[str, str],
|
|
141
|
+
prefix: str,
|
|
142
|
+
separator: str,
|
|
143
|
+
target: Any,
|
|
144
|
+
config_flag: str = "config",
|
|
145
|
+
) -> tuple[dict[str, Any], list[tuple[str, Path]]]:
|
|
146
|
+
"""Parse environment variables into a nested dict matching the target type.
|
|
147
|
+
|
|
148
|
+
Variables are matched by prefix and split by separator into nested keys.
|
|
149
|
+
Field names are matched case-insensitively against the target type tree.
|
|
150
|
+
For non-dataclass targets, the value is stored under a ``__root__`` key.
|
|
151
|
+
|
|
152
|
+
A variable whose first segment (after stripping the prefix) matches
|
|
153
|
+
``config_flag`` (case-insensitive) is treated as a sub-config file pointer:
|
|
154
|
+
the value is a file path, and the remaining segments form the subpath at
|
|
155
|
+
which the file's contents will be merged (e.g. ``CONFARG_CONFIG__DB=db.yaml``
|
|
156
|
+
merges ``db.yaml`` under the ``db`` key).
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
env: The environment variable mapping to scan.
|
|
160
|
+
prefix: Required prefix for relevant variables (empty string to match all).
|
|
161
|
+
separator: Separator used to split variable names into nested keys.
|
|
162
|
+
target: The target type, used to determine dataclass vs scalar handling.
|
|
163
|
+
config_flag: The magic segment name that marks a sub-config file pointer.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
A tuple of (data_dict, env_configs) where data_dict contains inline values
|
|
167
|
+
and env_configs is a list of (subpath, Path) pairs for deferred file loading.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ConfargError: If an env var segment matches multiple field names.
|
|
171
|
+
"""
|
|
172
|
+
data: dict[str, Any] = {}
|
|
173
|
+
env_configs: list[tuple[str, Path]] = []
|
|
174
|
+
is_struct = _is_struct_like(_resolve_type(target))
|
|
175
|
+
|
|
176
|
+
for orig_key, value in env.items():
|
|
177
|
+
key = orig_key
|
|
178
|
+
if prefix:
|
|
179
|
+
if not key.startswith(prefix):
|
|
180
|
+
continue
|
|
181
|
+
key = key[len(prefix) :]
|
|
182
|
+
key = key.removeprefix(separator)
|
|
183
|
+
|
|
184
|
+
parts = key.split(separator) if separator in key else [key]
|
|
185
|
+
|
|
186
|
+
# Config flag: CONFARG_CONFIG[__subpath]=file.yaml → defer to file loading
|
|
187
|
+
if parts[0].lower() == config_flag.lower():
|
|
188
|
+
subpath_parts = parts[1:]
|
|
189
|
+
if subpath_parts:
|
|
190
|
+
resolved_parts, _ = _resolve_env_parts(target, subpath_parts)
|
|
191
|
+
subpath = ".".join(resolved_parts)
|
|
192
|
+
else:
|
|
193
|
+
subpath = ""
|
|
194
|
+
env_configs.append((subpath, Path(value)))
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Delete sentinel: FOO__BAR- deletes key BAR from FOO (dict-key deletion).
|
|
198
|
+
# FOO__ITEMS__1- deletes index 1 from FOO__ITEMS (list-index deletion).
|
|
199
|
+
if parts[-1].endswith("-") and len(parts[-1]) > 1:
|
|
200
|
+
raw_last = parts[-1][:-1]
|
|
201
|
+
try:
|
|
202
|
+
delete_idx = int(raw_last)
|
|
203
|
+
is_list_delete = True
|
|
204
|
+
except ValueError:
|
|
205
|
+
is_list_delete = False
|
|
206
|
+
delete_idx = -1
|
|
207
|
+
|
|
208
|
+
if is_list_delete:
|
|
209
|
+
parent_raw = parts[:-1]
|
|
210
|
+
if parent_raw:
|
|
211
|
+
parent_parts, _ = _resolve_env_parts(target, parent_raw)
|
|
212
|
+
else:
|
|
213
|
+
parent_parts = []
|
|
214
|
+
_accumulate_list_delete(data, parent_parts, delete_idx, orig_key)
|
|
215
|
+
else:
|
|
216
|
+
del_parts_raw = parts[:-1] + [raw_last]
|
|
217
|
+
del_parts, _ = _resolve_env_parts(target, del_parts_raw)
|
|
218
|
+
_set_nested(data, del_parts, DICT_DELETE)
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if not is_struct:
|
|
222
|
+
# For scalar targets, store under sentinel key
|
|
223
|
+
data["__root__"] = _try_coerce(_resolve_type(target), _StrToken(value))
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
parts, ft = _resolve_env_parts(target, parts)
|
|
227
|
+
# Skip env vars whose first segment doesn't match a known field
|
|
228
|
+
root_tp = _resolve_type(target)
|
|
229
|
+
if _is_struct(root_tp) and parts[0] not in _struct_fields(root_tp):
|
|
230
|
+
known = sorted(_struct_fields(root_tp).keys())
|
|
231
|
+
warnings.warn(
|
|
232
|
+
f"Environment variable {orig_key!r} has no matching field"
|
|
233
|
+
f" (segment {parts[0]!r} not found in {root_tp.__name__})."
|
|
234
|
+
f" Known fields: {known}. The variable will be ignored.",
|
|
235
|
+
ConfargWarning,
|
|
236
|
+
stacklevel=3,
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
if _is_union(root_tp):
|
|
240
|
+
struct_variants = [_resolve_type(v) for v in _union_args_no_none(root_tp) if _is_struct(_resolve_type(v))]
|
|
241
|
+
if struct_variants and not any(parts[0] in _struct_fields(v) for v in struct_variants):
|
|
242
|
+
all_fields = sorted({f for v in struct_variants for f in _struct_fields(v)})
|
|
243
|
+
warnings.warn(
|
|
244
|
+
f"Environment variable {orig_key!r} has no matching field"
|
|
245
|
+
f" (segment {parts[0]!r} not found in any union variant)."
|
|
246
|
+
f" Known fields across variants: {all_fields}. The variable will be ignored.",
|
|
247
|
+
ConfargWarning,
|
|
248
|
+
stacklevel=3,
|
|
249
|
+
)
|
|
250
|
+
continue
|
|
251
|
+
if value.startswith(("[", "{")):
|
|
252
|
+
accepts_obj = value.startswith("{") and (
|
|
253
|
+
_is_struct(ft)
|
|
254
|
+
or _is_dict(ft)
|
|
255
|
+
or _is_callable(ft)
|
|
256
|
+
or (_is_union(ft) and any(_is_struct(_resolve_type(v)) for v in _union_args_no_none(ft)))
|
|
257
|
+
)
|
|
258
|
+
accepts_arr = value.startswith("[") and (
|
|
259
|
+
_is_varlen_collection(ft)
|
|
260
|
+
or _is_tuple(ft)
|
|
261
|
+
or (
|
|
262
|
+
_is_union(ft)
|
|
263
|
+
and any(
|
|
264
|
+
_is_varlen_collection(_resolve_type(v)) or _is_tuple(_resolve_type(v))
|
|
265
|
+
for v in _union_args_no_none(ft)
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
if accepts_obj or accepts_arr:
|
|
270
|
+
try:
|
|
271
|
+
parsed = json.loads(value)
|
|
272
|
+
if isinstance(parsed, list | dict):
|
|
273
|
+
_set_nested(data, parts, parsed)
|
|
274
|
+
continue
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
pass
|
|
277
|
+
_set_nested(data, parts, _try_coerce(ft, _StrToken(value)))
|
|
278
|
+
|
|
279
|
+
return data, env_configs
|
confarg/_serialize.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
|
|
5
|
+
"""Serialization of dataclass instances to plain dicts."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from confarg._errors import ConfargError
|
|
14
|
+
from confarg._types import (
|
|
15
|
+
TagPolicy,
|
|
16
|
+
_dict_kv,
|
|
17
|
+
_elem_type,
|
|
18
|
+
_is_callable,
|
|
19
|
+
_is_dict,
|
|
20
|
+
_is_frozenset,
|
|
21
|
+
_is_list,
|
|
22
|
+
_is_set,
|
|
23
|
+
_is_struct,
|
|
24
|
+
_is_tuple,
|
|
25
|
+
_is_union,
|
|
26
|
+
_resolve_type,
|
|
27
|
+
_StrToken,
|
|
28
|
+
_struct_fields,
|
|
29
|
+
_tuple_types,
|
|
30
|
+
_union_args_no_none,
|
|
31
|
+
)
|
|
32
|
+
from confarg.typedload._construct import _disambiguate_struct
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _serialize(
|
|
36
|
+
tp: Any,
|
|
37
|
+
instance: Any,
|
|
38
|
+
path: str,
|
|
39
|
+
union_tag: str,
|
|
40
|
+
tag_policy: TagPolicy,
|
|
41
|
+
) -> Any:
|
|
42
|
+
"""Serialize a typed value to a plain dict/list/leaf structure.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
tp: The declared type of the value.
|
|
46
|
+
instance: The value to serialize.
|
|
47
|
+
path: Dot-separated field path for diagnostics.
|
|
48
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
49
|
+
tag_policy: "auto" or "always".
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A JSON-compatible structure (dict, list, or leaf value).
|
|
53
|
+
"""
|
|
54
|
+
tp = _resolve_type(tp)
|
|
55
|
+
|
|
56
|
+
if instance is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
if _is_callable(tp):
|
|
60
|
+
from confarg._callable import _serialize_callable
|
|
61
|
+
|
|
62
|
+
return _serialize_callable(instance)
|
|
63
|
+
|
|
64
|
+
if _is_union(tp):
|
|
65
|
+
return _serialize_union(tp, instance, path, union_tag, tag_policy)
|
|
66
|
+
|
|
67
|
+
if _is_struct(tp):
|
|
68
|
+
return _serialize_struct(tp, instance, path, union_tag, tag_policy)
|
|
69
|
+
|
|
70
|
+
if _is_list(tp):
|
|
71
|
+
et = _elem_type(tp)
|
|
72
|
+
return [_serialize(et, v, f"{path}[{i}]", union_tag, tag_policy) for i, v in enumerate(instance)]
|
|
73
|
+
|
|
74
|
+
if _is_set(tp) or _is_frozenset(tp):
|
|
75
|
+
et = _elem_type(tp)
|
|
76
|
+
items = [_serialize(et, v, path, union_tag, tag_policy) for v in instance]
|
|
77
|
+
return sorted(items, key=_sort_key)
|
|
78
|
+
|
|
79
|
+
if _is_tuple(tp):
|
|
80
|
+
return _serialize_tuple(tp, instance, path, union_tag, tag_policy)
|
|
81
|
+
|
|
82
|
+
if _is_dict(tp):
|
|
83
|
+
return _serialize_dict(tp, instance, path, union_tag, tag_policy)
|
|
84
|
+
|
|
85
|
+
return _serialize_leaf(tp, instance)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _serialize_struct(
|
|
89
|
+
tp: Any,
|
|
90
|
+
instance: Any,
|
|
91
|
+
path: str,
|
|
92
|
+
union_tag: str,
|
|
93
|
+
tag_policy: TagPolicy,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Serialize a dataclass or plain-class instance to a dict."""
|
|
96
|
+
actual_tp = type(instance)
|
|
97
|
+
if actual_tp is not tp and _is_struct(actual_tp) and issubclass(actual_tp, tp):
|
|
98
|
+
out = _serialize_struct(actual_tp, instance, path, union_tag, tag_policy)
|
|
99
|
+
out[union_tag] = f"{actual_tp.__module__}.{actual_tp.__name__}"
|
|
100
|
+
return out
|
|
101
|
+
flds = _struct_fields(tp)
|
|
102
|
+
out: dict[str, Any] = {}
|
|
103
|
+
for name, ft in flds.items():
|
|
104
|
+
try:
|
|
105
|
+
value = getattr(instance, name)
|
|
106
|
+
except AttributeError:
|
|
107
|
+
raise ConfargError(
|
|
108
|
+
f"Field '{name}' is declared in {type(instance).__name__}.__init__"
|
|
109
|
+
f" but not accessible on the instance as self.{name}."
|
|
110
|
+
f" Plain classes must store every __init__ parameter as a same-named instance attribute."
|
|
111
|
+
) from None
|
|
112
|
+
fp = f"{path}.{name}" if path else name
|
|
113
|
+
out[name] = _serialize(ft, value, fp, union_tag, tag_policy)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _serialize_union(
|
|
118
|
+
tp: Any,
|
|
119
|
+
instance: Any,
|
|
120
|
+
path: str,
|
|
121
|
+
union_tag: str,
|
|
122
|
+
tag_policy: TagPolicy,
|
|
123
|
+
) -> Any:
|
|
124
|
+
"""Serialize a Union value, adding a class tag when needed."""
|
|
125
|
+
variant_tp = _find_variant_type(tp, instance)
|
|
126
|
+
if variant_tp is None:
|
|
127
|
+
return instance
|
|
128
|
+
|
|
129
|
+
serialized = _serialize(variant_tp, instance, path, union_tag, tag_policy)
|
|
130
|
+
|
|
131
|
+
if _is_struct(variant_tp) and isinstance(serialized, dict):
|
|
132
|
+
if tag_policy == "always" or _needs_tag(tp, serialized, union_tag):
|
|
133
|
+
serialized[union_tag] = f"{variant_tp.__module__}.{variant_tp.__name__}"
|
|
134
|
+
|
|
135
|
+
return serialized
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _serialize_tuple(
|
|
139
|
+
tp: Any,
|
|
140
|
+
instance: Any,
|
|
141
|
+
path: str,
|
|
142
|
+
union_tag: str,
|
|
143
|
+
tag_policy: TagPolicy,
|
|
144
|
+
) -> list[Any]:
|
|
145
|
+
"""Serialize a tuple to a list."""
|
|
146
|
+
tt = _tuple_types(tp)
|
|
147
|
+
if tt is None:
|
|
148
|
+
et = _elem_type(tp)
|
|
149
|
+
return [_serialize(et, v, f"{path}[{i}]", union_tag, tag_policy) for i, v in enumerate(instance)]
|
|
150
|
+
return [
|
|
151
|
+
_serialize(et, v, f"{path}[{i}]", union_tag, tag_policy)
|
|
152
|
+
for i, (et, v) in enumerate(zip(tt, instance, strict=False))
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _serialize_dict(
|
|
157
|
+
tp: Any,
|
|
158
|
+
instance: Any,
|
|
159
|
+
path: str,
|
|
160
|
+
union_tag: str,
|
|
161
|
+
tag_policy: TagPolicy,
|
|
162
|
+
) -> dict[Any, Any]:
|
|
163
|
+
"""Serialize a typed dict."""
|
|
164
|
+
kt, vt = _dict_kv(tp)
|
|
165
|
+
return {
|
|
166
|
+
_serialize_leaf(kt, k): _serialize(vt, v, f"{path}.{k}", union_tag, tag_policy) for k, v in instance.items()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _serialize_leaf(tp: Any, value: Any) -> Any:
|
|
171
|
+
"""Serialize a leaf value: Enum → .value, Path → str, float → float, else passthrough."""
|
|
172
|
+
if isinstance(value, enum.Enum):
|
|
173
|
+
return value.value
|
|
174
|
+
if isinstance(value, Path):
|
|
175
|
+
return str(value)
|
|
176
|
+
if tp is float and isinstance(value, int) and not isinstance(value, bool):
|
|
177
|
+
return float(value)
|
|
178
|
+
if type(value) is _StrToken:
|
|
179
|
+
return str(value)
|
|
180
|
+
if isinstance(value, type):
|
|
181
|
+
return f"{value.__module__}.{value.__qualname__}"
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _find_variant_type(tp: Any, instance: Any) -> Any | None:
|
|
186
|
+
"""Find which Union variant matches the instance's type."""
|
|
187
|
+
args = _union_args_no_none(tp)
|
|
188
|
+
for arg in args:
|
|
189
|
+
arg_r = _resolve_type(arg)
|
|
190
|
+
if isinstance(instance, arg_r):
|
|
191
|
+
return arg_r
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _needs_tag(tp: Any, serialized_data: dict[str, Any], union_tag: str) -> bool:
|
|
196
|
+
"""Check if a class tag is needed by running disambiguation on the serialized data."""
|
|
197
|
+
struct_vars = [v for v in _union_args_no_none(tp) if _is_struct(_resolve_type(v))]
|
|
198
|
+
if len(struct_vars) <= 1:
|
|
199
|
+
return False
|
|
200
|
+
matches = _disambiguate_struct(struct_vars, serialized_data, union_tag)
|
|
201
|
+
return len(matches) != 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _sort_key(value: Any) -> tuple[str, str]:
|
|
205
|
+
"""Sort key for heterogeneous set/frozenset serialization."""
|
|
206
|
+
return (type(value).__name__, str(value))
|