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.
@@ -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