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
|
@@ -0,0 +1,685 @@
|
|
|
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
|
+
"""Value construction and union disambiguation for confarg."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from confarg import _defaults
|
|
13
|
+
from confarg._errors import (
|
|
14
|
+
AmbiguousUnionError,
|
|
15
|
+
ConfargError,
|
|
16
|
+
MissingFieldError,
|
|
17
|
+
TypeCoercionError,
|
|
18
|
+
)
|
|
19
|
+
from confarg._types import (
|
|
20
|
+
_all_have_defaults,
|
|
21
|
+
_allows_none,
|
|
22
|
+
_dict_kv,
|
|
23
|
+
_elem_type,
|
|
24
|
+
_is_callable,
|
|
25
|
+
_is_dict,
|
|
26
|
+
_is_frozenset,
|
|
27
|
+
_is_list,
|
|
28
|
+
_is_literal,
|
|
29
|
+
_is_set,
|
|
30
|
+
_is_struct,
|
|
31
|
+
_is_tuple,
|
|
32
|
+
_is_type_ref,
|
|
33
|
+
_is_union,
|
|
34
|
+
_literal_values,
|
|
35
|
+
_resolve_type,
|
|
36
|
+
_StrToken,
|
|
37
|
+
_struct_defaults,
|
|
38
|
+
_struct_fields,
|
|
39
|
+
_tuple_types,
|
|
40
|
+
_union_args,
|
|
41
|
+
_union_args_no_none,
|
|
42
|
+
_var_keyword_name,
|
|
43
|
+
_var_positional_name,
|
|
44
|
+
)
|
|
45
|
+
from confarg.typedload._coerce import _FALSY, _TRUTHY, _coerce_bool, _coerce_leaf, _coerce_type_ref, _src_type
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def construct(tp: Any, data: Any, *, path: str = "", union_tag: str = _defaults.UNION_TAG) -> Any:
|
|
49
|
+
"""Construct a typed value from raw data.
|
|
50
|
+
|
|
51
|
+
Dispatches to specialized constructors based on the target type.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
tp: The target type to construct.
|
|
55
|
+
data: The raw data to construct from.
|
|
56
|
+
path: Dot-separated field path for error messages.
|
|
57
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The constructed value matching the target type.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
MissingFieldError: If a required dataclass field is missing.
|
|
64
|
+
TypeCoercionError: If a value cannot be coerced to the target type.
|
|
65
|
+
"""
|
|
66
|
+
tp = _resolve_type(tp)
|
|
67
|
+
|
|
68
|
+
if data is None and _allows_none(tp):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
if _is_callable(tp):
|
|
72
|
+
from confarg._callable import _resolve_callable_spec
|
|
73
|
+
|
|
74
|
+
return _resolve_callable_spec(data, tp, path=path, union_tag=union_tag)
|
|
75
|
+
|
|
76
|
+
if _is_union(tp):
|
|
77
|
+
return _construct_union(tp, data, path, union_tag)
|
|
78
|
+
|
|
79
|
+
if _is_struct(tp):
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
raise TypeCoercionError(
|
|
82
|
+
f"Cannot construct {tp.__name__} at '{path}': expected dict, got {_src_type(data)} {data!r}"
|
|
83
|
+
)
|
|
84
|
+
if union_tag in data:
|
|
85
|
+
return _construct_by_class_path(tp, data, path, union_tag)
|
|
86
|
+
return _construct_struct(tp, data, path, union_tag)
|
|
87
|
+
|
|
88
|
+
if _is_list(tp):
|
|
89
|
+
return _construct_list(tp, data, path, union_tag)
|
|
90
|
+
|
|
91
|
+
if _is_set(tp) or _is_frozenset(tp):
|
|
92
|
+
return _construct_set(tp, data, path, union_tag)
|
|
93
|
+
|
|
94
|
+
if _is_tuple(tp):
|
|
95
|
+
return _construct_tuple(tp, data, path, union_tag)
|
|
96
|
+
|
|
97
|
+
if _is_dict(tp):
|
|
98
|
+
return _construct_dict(tp, data, path, union_tag)
|
|
99
|
+
|
|
100
|
+
if _is_type_ref(tp):
|
|
101
|
+
return _coerce_type_ref(tp, data, path)
|
|
102
|
+
|
|
103
|
+
return _coerce_leaf(tp, data, path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _construct_struct(tp: Any, data: dict[str, Any], path: str, union_tag: str) -> Any:
|
|
107
|
+
"""Construct a dataclass or plain-class instance from a dict of raw data."""
|
|
108
|
+
flds = _struct_fields(tp)
|
|
109
|
+
defs = _struct_defaults(tp)
|
|
110
|
+
kwargs: dict[str, Any] = {}
|
|
111
|
+
|
|
112
|
+
extra = {k for k in data if k not in flds and k != union_tag}
|
|
113
|
+
if extra:
|
|
114
|
+
raise TypeCoercionError(
|
|
115
|
+
f"Unknown field(s) {sorted(extra)} for {tp.__name__} at '{path}'. Valid fields: {sorted(flds.keys())}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
for name, ft in flds.items():
|
|
119
|
+
fp = f"{path}.{name}" if path else name
|
|
120
|
+
if name in data:
|
|
121
|
+
field_data = data[name]
|
|
122
|
+
# Partial .idx dict for a tuple field: merge into default so only named
|
|
123
|
+
# indices are overridden and the rest keep their default values.
|
|
124
|
+
if isinstance(field_data, dict) and name in defs:
|
|
125
|
+
tup_tp: Any = ft if _is_tuple(ft) else None
|
|
126
|
+
if tup_tp is None and _is_union(ft):
|
|
127
|
+
tup_vars = [_resolve_type(v) for v in _union_args_no_none(ft) if _is_tuple(_resolve_type(v))]
|
|
128
|
+
if len(tup_vars) == 1:
|
|
129
|
+
tup_tp = tup_vars[0]
|
|
130
|
+
if tup_tp is not None and defs[name] is not None:
|
|
131
|
+
base = list(defs[name])
|
|
132
|
+
for ik, iv in field_data.items():
|
|
133
|
+
try:
|
|
134
|
+
idx = int(ik)
|
|
135
|
+
if idx >= 0:
|
|
136
|
+
while len(base) <= idx:
|
|
137
|
+
base.append(None)
|
|
138
|
+
base[idx] = iv
|
|
139
|
+
except ValueError:
|
|
140
|
+
pass
|
|
141
|
+
field_data = base
|
|
142
|
+
kwargs[name] = construct(ft, field_data, path=fp, union_tag=union_tag)
|
|
143
|
+
elif name in defs:
|
|
144
|
+
kwargs[name] = defs[name]
|
|
145
|
+
elif _is_struct(ft) and _all_have_defaults(ft):
|
|
146
|
+
kwargs[name] = _construct_struct(ft, {}, fp, union_tag)
|
|
147
|
+
else:
|
|
148
|
+
raise MissingFieldError(
|
|
149
|
+
f"Missing required field '{fp}' of type {ft!r}."
|
|
150
|
+
f" Set it via CLI (--{fp}), environment variable, or config file."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
var_pos_name = _var_positional_name(tp)
|
|
154
|
+
var_kw_name = _var_keyword_name(tp)
|
|
155
|
+
var_kw = dict(kwargs.pop(var_kw_name, {})) if var_kw_name else {}
|
|
156
|
+
|
|
157
|
+
if var_pos_name is None:
|
|
158
|
+
return tp(**kwargs, **var_kw)
|
|
159
|
+
|
|
160
|
+
# *args present: pass pre-*args params positionally to avoid conflict.
|
|
161
|
+
var_pos = list(kwargs.pop(var_pos_name, []))
|
|
162
|
+
sig = inspect.signature(tp.__init__)
|
|
163
|
+
pos_args: list[Any] = []
|
|
164
|
+
for pname, param in sig.parameters.items():
|
|
165
|
+
if pname == "self":
|
|
166
|
+
continue
|
|
167
|
+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
168
|
+
break
|
|
169
|
+
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY):
|
|
170
|
+
if pname in kwargs:
|
|
171
|
+
pos_args.append(kwargs.pop(pname))
|
|
172
|
+
return tp(*pos_args, *var_pos, **kwargs, **var_kw)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _construct_list(tp: Any, data: Any, path: str, union_tag: str) -> list[Any]:
|
|
176
|
+
"""Construct a list from raw data.
|
|
177
|
+
|
|
178
|
+
Handles both list and dict (with integer keys) input data.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
tp: The list type (e.g. list[int]).
|
|
182
|
+
data: The raw data (list or dict with integer keys).
|
|
183
|
+
path: Dot-separated field path for error messages.
|
|
184
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The constructed list.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
TypeCoercionError: If data is not a list or dict with integer keys.
|
|
191
|
+
"""
|
|
192
|
+
if not isinstance(data, list | dict):
|
|
193
|
+
raise TypeCoercionError(
|
|
194
|
+
f"Cannot construct list at '{path}': expected list or dict with integer keys,"
|
|
195
|
+
f" got {type(data).__name__} {data!r}"
|
|
196
|
+
)
|
|
197
|
+
return _build_items(_elem_type(tp), data, path, union_tag)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _construct_set(tp: Any, data: Any, path: str, union_tag: str) -> set[Any] | frozenset[Any]:
|
|
201
|
+
"""Construct a set or frozenset from raw data.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
tp: The set or frozenset type.
|
|
205
|
+
data: The raw data (list, set, tuple, or dict with integer keys).
|
|
206
|
+
path: Dot-separated field path for error messages.
|
|
207
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The constructed set or frozenset.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
TypeCoercionError: If data cannot be interpreted as a sequence.
|
|
214
|
+
"""
|
|
215
|
+
et = _elem_type(tp)
|
|
216
|
+
items = _build_items(et, data, path, union_tag)
|
|
217
|
+
return frozenset(items) if _is_frozenset(tp) else set(items)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_items(et: Any, data: Any, path: str, union_tag: str) -> list[Any]:
|
|
221
|
+
"""Build a list of constructed items from sequence-like raw data.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
et: The element type to construct each item as.
|
|
225
|
+
data: The raw data (list, set, frozenset, tuple, or dict with integer keys).
|
|
226
|
+
path: Dot-separated field path for error messages.
|
|
227
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
A list of constructed items.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
TypeCoercionError: If data is not a sequence or dict with integer keys.
|
|
234
|
+
"""
|
|
235
|
+
if isinstance(data, list | set | frozenset | tuple):
|
|
236
|
+
return [construct(et, v, path=f"{path}[{i}]", union_tag=union_tag) for i, v in enumerate(data)]
|
|
237
|
+
if isinstance(data, dict):
|
|
238
|
+
from confarg._merge import LIST_APPEND_KEY, LIST_DELETE_KEY, _to_append_list
|
|
239
|
+
|
|
240
|
+
if LIST_APPEND_KEY in data:
|
|
241
|
+
# Append-only dict produced when there is no base list (e.g. --foo+ with no config).
|
|
242
|
+
items_data = _to_append_list(data[LIST_APPEND_KEY])
|
|
243
|
+
return [construct(et, v, path=f"{path}[{i}]", union_tag=union_tag) for i, v in enumerate(items_data)]
|
|
244
|
+
if LIST_DELETE_KEY in data:
|
|
245
|
+
raise TypeCoercionError(
|
|
246
|
+
f"List deletion (the '-' operator) at '{path}' requires a base list to delete from,"
|
|
247
|
+
" but no base list was provided. Supply the full list via a config file or other source."
|
|
248
|
+
)
|
|
249
|
+
if not data:
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
max_idx = max(int(k) for k in data)
|
|
253
|
+
except ValueError:
|
|
254
|
+
raise TypeCoercionError(
|
|
255
|
+
f"Cannot construct collection at '{path}': dict keys must be integer indices"
|
|
256
|
+
) from None
|
|
257
|
+
gaps = [i for i in range(max_idx + 1) if str(i) not in data]
|
|
258
|
+
if gaps and not _allows_none(et):
|
|
259
|
+
raise TypeCoercionError(
|
|
260
|
+
f"List at '{path}' has gap(s) at index/indices {gaps}:"
|
|
261
|
+
f" when using index-keyed form, all indices 0-{max_idx} must be provided,"
|
|
262
|
+
f" or the element type must be Optional."
|
|
263
|
+
)
|
|
264
|
+
return [
|
|
265
|
+
construct(et, data[str(i)] if str(i) in data else None, path=f"{path}[{i}]", union_tag=union_tag)
|
|
266
|
+
for i in range(max_idx + 1)
|
|
267
|
+
]
|
|
268
|
+
raise TypeCoercionError(
|
|
269
|
+
f"Cannot construct collection at '{path}': expected sequence or dict with integer keys,"
|
|
270
|
+
f" got {type(data).__name__} {data!r}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _construct_tuple(tp: Any, data: Any, path: str, union_tag: str) -> tuple[Any, ...]:
|
|
275
|
+
"""Construct a tuple from raw data.
|
|
276
|
+
|
|
277
|
+
Handles both fixed-length and variable-length (tuple[X, ...]) tuples.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
tp: The tuple type.
|
|
281
|
+
data: The raw data (list, tuple, or dict with integer keys).
|
|
282
|
+
path: Dot-separated field path for error messages.
|
|
283
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The constructed tuple.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
TypeCoercionError: If data cannot be interpreted as a sequence.
|
|
290
|
+
"""
|
|
291
|
+
tt = _tuple_types(tp)
|
|
292
|
+
if tt is None:
|
|
293
|
+
# variable length
|
|
294
|
+
et = _elem_type(tp)
|
|
295
|
+
return tuple(_build_items(et, data, path, union_tag))
|
|
296
|
+
|
|
297
|
+
if not isinstance(data, list | tuple | dict):
|
|
298
|
+
raise TypeCoercionError(
|
|
299
|
+
f"Cannot construct tuple at '{path}': expected list, tuple, or dict with integer keys,"
|
|
300
|
+
f" got {type(data).__name__} {data!r}"
|
|
301
|
+
)
|
|
302
|
+
if isinstance(data, list | tuple):
|
|
303
|
+
if len(data) > len(tt):
|
|
304
|
+
raise TypeCoercionError(f"Cannot construct {tp} at '{path}': expected {len(tt)} elements, got {len(data)}")
|
|
305
|
+
if len(data) < len(tt):
|
|
306
|
+
missing = [i for i in range(len(data), len(tt)) if not _allows_none(tt[i])]
|
|
307
|
+
if missing:
|
|
308
|
+
raise TypeCoercionError(
|
|
309
|
+
f"Cannot construct {tp} at '{path}': expected {len(tt)} elements, got {len(data)}"
|
|
310
|
+
)
|
|
311
|
+
seq = list(data)
|
|
312
|
+
else:
|
|
313
|
+
try:
|
|
314
|
+
mx = max(int(k) for k in data) if data else -1
|
|
315
|
+
except ValueError:
|
|
316
|
+
raise TypeCoercionError(f"Cannot construct tuple at '{path}': dict keys must be integer indices") from None
|
|
317
|
+
if mx >= len(tt):
|
|
318
|
+
raise TypeCoercionError(
|
|
319
|
+
f"Cannot construct {tp} at '{path}': index {mx} out of range for tuple of length {len(tt)}"
|
|
320
|
+
)
|
|
321
|
+
seq = [data.get(str(i)) for i in range(len(tt))]
|
|
322
|
+
return tuple(
|
|
323
|
+
construct(et, seq[i] if i < len(seq) else None, path=f"{path}[{i}]", union_tag=union_tag)
|
|
324
|
+
for i, et in enumerate(tt)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _construct_dict(tp: Any, data: Any, path: str, union_tag: str) -> dict[Any, Any]:
|
|
329
|
+
"""Construct a typed dict from raw data.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
tp: The dict type (e.g. dict[str, int]).
|
|
333
|
+
data: The raw data dict.
|
|
334
|
+
path: Dot-separated field path for error messages.
|
|
335
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
The constructed dict with coerced keys and constructed values.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
TypeCoercionError: If data is not a dict.
|
|
342
|
+
"""
|
|
343
|
+
kt, vt = _dict_kv(tp)
|
|
344
|
+
if not isinstance(data, dict):
|
|
345
|
+
raise TypeCoercionError(f"Cannot construct dict at '{path}': expected dict, got {_src_type(data)} {data!r}")
|
|
346
|
+
return {
|
|
347
|
+
_coerce_leaf(kt, _StrToken(k) if isinstance(k, str) else k, path): construct(
|
|
348
|
+
vt, v, path=f"{path}.{k}", union_tag=union_tag
|
|
349
|
+
)
|
|
350
|
+
for k, v in data.items()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _construct_union(tp: Any, data: Any, path: str, union_tag: str) -> Any:
|
|
355
|
+
"""Construct a value for a Union type.
|
|
356
|
+
|
|
357
|
+
Tries tag-based disambiguation, structural disambiguation for struct
|
|
358
|
+
(dataclass or plain-class) variants, and finally leaf coercion in order.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
tp: The Union type.
|
|
362
|
+
data: The raw data.
|
|
363
|
+
path: Dot-separated field path for error messages.
|
|
364
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
The constructed value matching one of the Union variants.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
AmbiguousUnionError: If multiple dataclass variants match structurally.
|
|
371
|
+
TypeCoercionError: If no variant can accept the data.
|
|
372
|
+
"""
|
|
373
|
+
all_args = _union_args(tp)
|
|
374
|
+
non_none = _union_args_no_none(tp)
|
|
375
|
+
|
|
376
|
+
if data is None and type(None) in all_args: # pragma: no cover
|
|
377
|
+
return None # construct() short-circuits at line 70-71 before reaching here
|
|
378
|
+
|
|
379
|
+
# Single non-None variant
|
|
380
|
+
if len(non_none) == 1:
|
|
381
|
+
if type(None) in all_args and isinstance(data, _StrToken) and data.lower() in ("none", "null"):
|
|
382
|
+
return None
|
|
383
|
+
try:
|
|
384
|
+
return construct(non_none[0], data, path=path, union_tag=union_tag)
|
|
385
|
+
except (TypeCoercionError, MissingFieldError):
|
|
386
|
+
if type(None) in all_args:
|
|
387
|
+
raise TypeCoercionError(
|
|
388
|
+
f"Cannot coerce {_src_type(data)} {data!r} to"
|
|
389
|
+
f" {getattr(_resolve_type(non_none[0]), '__name__', repr(non_none[0]))} at '{path}'."
|
|
390
|
+
f" To set this field to None, pass 'none' or 'null'."
|
|
391
|
+
) from None
|
|
392
|
+
raise # pragma: no cover # len(non_none)==1 without NoneType is impossible via normal Union typing
|
|
393
|
+
|
|
394
|
+
# Class tag in data — import by full dotted path at runtime
|
|
395
|
+
if isinstance(data, dict) and union_tag in data:
|
|
396
|
+
tag = data[union_tag]
|
|
397
|
+
cls = _import_class_by_path(tag, path, union_tag)
|
|
398
|
+
matching = [v for v in non_none if _is_struct(_resolve_type(v)) and issubclass(cls, _resolve_type(v))]
|
|
399
|
+
if len(matching) > 1:
|
|
400
|
+
raise AmbiguousUnionError(
|
|
401
|
+
f"Class {tag!r} at '{path}' matches multiple union variants: "
|
|
402
|
+
+ ", ".join(f"{_resolve_type(v).__module__}.{_resolve_type(v).__name__}" for v in matching)
|
|
403
|
+
)
|
|
404
|
+
if matching:
|
|
405
|
+
cleaned = {k: v2 for k, v2 in data.items() if k != union_tag}
|
|
406
|
+
return _construct_struct(cls, cleaned, path, union_tag)
|
|
407
|
+
valid_variants = sorted(
|
|
408
|
+
f"{_resolve_type(v).__module__}.{_resolve_type(v).__name__}"
|
|
409
|
+
for v in non_none
|
|
410
|
+
if _is_struct(_resolve_type(v))
|
|
411
|
+
)
|
|
412
|
+
raise TypeCoercionError(
|
|
413
|
+
f"Class {tag!r} at '{path}' is not compatible with any union variant."
|
|
414
|
+
f" Expected a subclass of one of: {valid_variants}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Structural disambiguation for struct (dataclass or plain class) variants
|
|
418
|
+
dc_vars = [v for v in non_none if _is_struct(_resolve_type(v))]
|
|
419
|
+
if isinstance(data, dict) and dc_vars:
|
|
420
|
+
matches = _disambiguate_struct(dc_vars, data, union_tag)
|
|
421
|
+
if len(matches) == 1:
|
|
422
|
+
return construct(matches[0], data, path=path, union_tag=union_tag)
|
|
423
|
+
if len(matches) > 1:
|
|
424
|
+
raise AmbiguousUnionError(_ambiguous_union_msg(matches, data, path, union_tag))
|
|
425
|
+
|
|
426
|
+
# Struct vs leaf: if data is a dict, try struct variants
|
|
427
|
+
if isinstance(data, dict) and dc_vars:
|
|
428
|
+
for var in dc_vars:
|
|
429
|
+
try:
|
|
430
|
+
return construct(var, data, path=path, union_tag=union_tag)
|
|
431
|
+
except (ConfargError, TypeError):
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# Leaf union coercion
|
|
435
|
+
leaf_vars = [v for v in non_none if not _is_struct(_resolve_type(v))]
|
|
436
|
+
tuple_vars = [v for v in leaf_vars if _is_tuple(_resolve_type(v))]
|
|
437
|
+
coll_vars = [
|
|
438
|
+
v
|
|
439
|
+
for v in leaf_vars
|
|
440
|
+
if not _is_tuple(_resolve_type(v))
|
|
441
|
+
and (
|
|
442
|
+
_is_dict(_resolve_type(v))
|
|
443
|
+
or _is_list(_resolve_type(v))
|
|
444
|
+
or _is_set(_resolve_type(v))
|
|
445
|
+
or _is_frozenset(_resolve_type(v))
|
|
446
|
+
)
|
|
447
|
+
]
|
|
448
|
+
scalar_leaf_vars = [v for v in leaf_vars if not _is_tuple(_resolve_type(v)) and v not in coll_vars]
|
|
449
|
+
|
|
450
|
+
# Tuple variants — disambiguate by data length, then try each candidate
|
|
451
|
+
if tuple_vars and isinstance(data, list | dict):
|
|
452
|
+
if isinstance(data, list):
|
|
453
|
+
data_len = len(data)
|
|
454
|
+
else:
|
|
455
|
+
try:
|
|
456
|
+
data_len = (max(int(k) for k in data) + 1) if data else 0
|
|
457
|
+
except ValueError:
|
|
458
|
+
data_len = -1
|
|
459
|
+
candidates = (
|
|
460
|
+
[v for v in tuple_vars if (lambda tt: tt is None or len(tt) == data_len)(_tuple_types(_resolve_type(v)))]
|
|
461
|
+
if data_len >= 0
|
|
462
|
+
else []
|
|
463
|
+
)
|
|
464
|
+
if not candidates:
|
|
465
|
+
candidates = tuple_vars
|
|
466
|
+
for var in candidates:
|
|
467
|
+
try:
|
|
468
|
+
return construct(var, data, path=path, union_tag=union_tag)
|
|
469
|
+
except (ConfargError, TypeError):
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
# Collection leaf variants (dict, list, set, frozenset)
|
|
473
|
+
if coll_vars and isinstance(data, dict | list | set | frozenset):
|
|
474
|
+
for var in coll_vars:
|
|
475
|
+
try:
|
|
476
|
+
return construct(var, data, path=path, union_tag=union_tag)
|
|
477
|
+
except (ConfargError, TypeError):
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
# Scalar leaf variants — "none"/"null" tokens → None in any Optional union
|
|
481
|
+
if type(None) in all_args and isinstance(data, _StrToken) and data.lower() in ("none", "null"):
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
# Bool before int (bool is a subclass of int; must be checked first)
|
|
485
|
+
if bool in scalar_leaf_vars and int in scalar_leaf_vars:
|
|
486
|
+
if isinstance(data, bool):
|
|
487
|
+
return data
|
|
488
|
+
if isinstance(data, _StrToken) and data.lower() in (_TRUTHY | _FALSY):
|
|
489
|
+
return _coerce_bool(data)
|
|
490
|
+
|
|
491
|
+
# Steal rule: when str is present and value is a _StrToken, try non-str types first
|
|
492
|
+
_has_str = any(_resolve_type(v) is str for v in scalar_leaf_vars)
|
|
493
|
+
if _has_str and isinstance(data, _StrToken):
|
|
494
|
+
_ordered = [v for v in scalar_leaf_vars if _resolve_type(v) is not str] + [
|
|
495
|
+
v for v in scalar_leaf_vars if _resolve_type(v) is str
|
|
496
|
+
]
|
|
497
|
+
else:
|
|
498
|
+
_ordered = scalar_leaf_vars
|
|
499
|
+
|
|
500
|
+
for var in _ordered:
|
|
501
|
+
vr = _resolve_type(var)
|
|
502
|
+
if vr is type(None): # pragma: no cover # NoneType is excluded from non_none by _union_args_no_none
|
|
503
|
+
continue
|
|
504
|
+
if vr is bool and int in scalar_leaf_vars:
|
|
505
|
+
continue # handled above
|
|
506
|
+
try:
|
|
507
|
+
return _coerce_leaf(vr, data, path)
|
|
508
|
+
except (TypeCoercionError, ValueError, TypeError):
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
variant_names = " | ".join(
|
|
512
|
+
"None" if v is type(None) else getattr(_resolve_type(v), "__name__", repr(v)) for v in all_args
|
|
513
|
+
)
|
|
514
|
+
msg = f"Cannot coerce {_src_type(data)} {data!r} to {variant_names} at '{path}'"
|
|
515
|
+
if type(None) in all_args:
|
|
516
|
+
msg += ". To set this field to None, pass 'none' or 'null'."
|
|
517
|
+
raise TypeCoercionError(msg)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _value_matches_type(value: Any, tp: Any, union_tag: str) -> bool:
|
|
521
|
+
"""Check if a raw value could match the target type structurally.
|
|
522
|
+
|
|
523
|
+
Used during union disambiguation to test whether a value is compatible
|
|
524
|
+
with a candidate type.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
value: The raw value to check.
|
|
528
|
+
tp: The candidate type.
|
|
529
|
+
union_tag: The field name used as a discriminator tag in unions.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
True if the value could plausibly be coerced to the target type.
|
|
533
|
+
"""
|
|
534
|
+
tp = _resolve_type(tp)
|
|
535
|
+
|
|
536
|
+
if value is None:
|
|
537
|
+
return _allows_none(tp)
|
|
538
|
+
|
|
539
|
+
if _is_struct(tp):
|
|
540
|
+
if not isinstance(value, dict):
|
|
541
|
+
return False
|
|
542
|
+
flds = _struct_fields(tp)
|
|
543
|
+
defs = _struct_defaults(tp)
|
|
544
|
+
keys = {k for k in value if k != union_tag}
|
|
545
|
+
required = {n for n in flds if n not in defs}
|
|
546
|
+
return required.issubset(keys) and keys.issubset(set(flds))
|
|
547
|
+
|
|
548
|
+
if tp is bool:
|
|
549
|
+
if isinstance(value, bool):
|
|
550
|
+
return True
|
|
551
|
+
if isinstance(value, _StrToken):
|
|
552
|
+
return value.lower() in (_TRUTHY | _FALSY)
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
if tp is int:
|
|
556
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
557
|
+
return True
|
|
558
|
+
if isinstance(value, _StrToken):
|
|
559
|
+
try:
|
|
560
|
+
int(value)
|
|
561
|
+
return True
|
|
562
|
+
except ValueError:
|
|
563
|
+
return False
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
if tp is float:
|
|
567
|
+
if isinstance(value, float) and not isinstance(value, bool):
|
|
568
|
+
return True
|
|
569
|
+
if isinstance(value, _StrToken):
|
|
570
|
+
try:
|
|
571
|
+
float(value)
|
|
572
|
+
return True
|
|
573
|
+
except ValueError:
|
|
574
|
+
return False
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
if tp is str:
|
|
578
|
+
return isinstance(value, str)
|
|
579
|
+
|
|
580
|
+
if _is_literal(tp):
|
|
581
|
+
vals = _literal_values(tp)
|
|
582
|
+
if isinstance(value, _StrToken):
|
|
583
|
+
s = str(value)
|
|
584
|
+
return any(str(v) == s for v in vals)
|
|
585
|
+
return any(type(v) is type(value) and v == value for v in vals)
|
|
586
|
+
|
|
587
|
+
return True
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _ambiguous_union_msg(matches: list[Any], data: dict[str, Any], path: str, union_tag: str) -> str:
|
|
591
|
+
"""Build a diagnostic AmbiguousUnionError message with per-variant field breakdowns."""
|
|
592
|
+
lines = [f"Ambiguous union at '{path}': cannot distinguish between " + ", ".join(m.__name__ for m in matches) + "."]
|
|
593
|
+
provided = {k for k in data if k != union_tag}
|
|
594
|
+
for var in matches:
|
|
595
|
+
flds = _struct_fields(var)
|
|
596
|
+
defs = _struct_defaults(var)
|
|
597
|
+
required = sorted(n for n in flds if n not in defs)
|
|
598
|
+
optional = sorted(n for n in flds if n in defs)
|
|
599
|
+
parts = []
|
|
600
|
+
if required:
|
|
601
|
+
parts.append("required: " + ", ".join(required))
|
|
602
|
+
if optional:
|
|
603
|
+
parts.append("optional: " + ", ".join(optional))
|
|
604
|
+
lines.append(f" {var.__name__}: {'; '.join(parts) if parts else '(no fields)'}")
|
|
605
|
+
lines.append(f"Provided fields: {sorted(provided) if provided else '(none)'}")
|
|
606
|
+
lines.append(
|
|
607
|
+
f"To select a variant add a {union_tag!r} field, e.g. {union_tag!r}: {matches[0].__name__!r}."
|
|
608
|
+
f" The field name can be changed via the union_tag= parameter."
|
|
609
|
+
)
|
|
610
|
+
return "\n".join(lines)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _disambiguate_struct(variants: list[Any], data: dict[str, Any], union_tag: str) -> list[Any]:
|
|
614
|
+
"""Filter struct union variants to those matching data structurally."""
|
|
615
|
+
keys = {k for k in data if k != union_tag}
|
|
616
|
+
candidates = []
|
|
617
|
+
|
|
618
|
+
for var in variants:
|
|
619
|
+
var = _resolve_type(var)
|
|
620
|
+
flds = _struct_fields(var)
|
|
621
|
+
defs = _struct_defaults(var)
|
|
622
|
+
required = {n for n in flds if n not in defs}
|
|
623
|
+
all_names = set(flds)
|
|
624
|
+
|
|
625
|
+
if not required.issubset(keys):
|
|
626
|
+
continue
|
|
627
|
+
if not keys.issubset(all_names):
|
|
628
|
+
continue
|
|
629
|
+
candidates.append(var)
|
|
630
|
+
|
|
631
|
+
if len(candidates) <= 1:
|
|
632
|
+
return candidates
|
|
633
|
+
|
|
634
|
+
# Refine by value-type compatibility
|
|
635
|
+
refined = []
|
|
636
|
+
for var in candidates:
|
|
637
|
+
flds = _struct_fields(var)
|
|
638
|
+
ok = True
|
|
639
|
+
for k in keys:
|
|
640
|
+
if k in flds and not _value_matches_type(data[k], flds[k], union_tag):
|
|
641
|
+
ok = False
|
|
642
|
+
break
|
|
643
|
+
if ok:
|
|
644
|
+
refined.append(var)
|
|
645
|
+
|
|
646
|
+
if not refined:
|
|
647
|
+
return candidates
|
|
648
|
+
if len(refined) <= 1:
|
|
649
|
+
return refined
|
|
650
|
+
|
|
651
|
+
return refined
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _import_class_by_path(tag: str, path: str, union_tag: str) -> type:
|
|
655
|
+
"""Import and validate a class by its full dotted module path.
|
|
656
|
+
|
|
657
|
+
Raises TypeCoercionError if the path cannot be imported or does not resolve
|
|
658
|
+
to a class. The tag must be a fully-qualified dotted path such as
|
|
659
|
+
``'mypackage.mymodule.MyClass'``.
|
|
660
|
+
"""
|
|
661
|
+
from confarg._callable import _import_dotted
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
obj = _import_dotted(tag)
|
|
665
|
+
except TypeCoercionError as e:
|
|
666
|
+
raise TypeCoercionError(
|
|
667
|
+
f"Cannot import class {tag!r} from {union_tag!r} tag at '{path}': {e}."
|
|
668
|
+
f" The value must be a full dotted path, e.g. 'mypackage.mymodule.MyClass'."
|
|
669
|
+
) from e
|
|
670
|
+
if not isinstance(obj, type):
|
|
671
|
+
raise TypeCoercionError(
|
|
672
|
+
f"Value of {union_tag!r} tag at '{path}' must be a class path,"
|
|
673
|
+
f" but {tag!r} resolved to {type(obj).__name__!r}, not a class."
|
|
674
|
+
)
|
|
675
|
+
return obj
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _construct_by_class_path(tp: type, data: dict[str, Any], path: str, union_tag: str) -> Any:
|
|
679
|
+
"""Import the class named by union_tag, validate it is a subclass of tp, and construct it."""
|
|
680
|
+
tag = data[union_tag]
|
|
681
|
+
cls = _import_class_by_path(tag, path, union_tag)
|
|
682
|
+
if not issubclass(cls, tp):
|
|
683
|
+
raise TypeCoercionError(f"Class {tag!r} at '{path}' is not a subclass of {tp.__module__}.{tp.__name__}.")
|
|
684
|
+
cleaned = {k: v for k, v in data.items() if k != union_tag}
|
|
685
|
+
return _construct_struct(cls, cleaned, path, union_tag)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: confarg
|
|
3
|
+
Version: 0.0.1.dev2
|
|
4
|
+
Summary: A tool to manage complex, dynamic configurations.
|
|
5
|
+
Author: confarg
|
|
6
|
+
Author-email: confarg <280620574+confarg@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: argcomplete>=3.0 ; extra == 'completion'
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Provides-Extra: completion
|