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/_argparse.py ADDED
@@ -0,0 +1,958 @@
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
+ """Argparse integration for confarg."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import ast
11
+ import dataclasses
12
+ import inspect
13
+ import os
14
+ import textwrap
15
+ from collections.abc import Mapping, Sequence
16
+ from pathlib import Path
17
+ from typing import Any, get_type_hints
18
+
19
+ from confarg import _defaults
20
+ from confarg._files import _load_file
21
+ from confarg._merge import _deep_merge, _set_nested
22
+ from confarg._parse_env import _parse_env
23
+ from confarg._types import (
24
+ _allows_none,
25
+ _callable_return_type,
26
+ _elem_type,
27
+ _is_bool,
28
+ _is_callable,
29
+ _is_dict,
30
+ _is_enum,
31
+ _is_literal,
32
+ _is_struct,
33
+ _is_tuple,
34
+ _is_type_ref,
35
+ _is_varlen_collection,
36
+ _literal_values,
37
+ _resolve_type,
38
+ _StrToken,
39
+ _struct_defaults,
40
+ _struct_fields,
41
+ _tuple_types,
42
+ _union_args_no_none,
43
+ _unwrap_optional,
44
+ _var_param_names,
45
+ )
46
+ from confarg.dictexpr import resolve_expressions
47
+ from confarg.typedload import construct
48
+
49
+
50
+ @dataclasses.dataclass
51
+ class FieldMeta:
52
+ """Optional per-field metadata for argparse integration.
53
+
54
+ Attach via ``Annotated``::
55
+
56
+ from typing import Annotated
57
+ from confarg import FieldMeta
58
+
59
+ @dataclass
60
+ class Config:
61
+ port: Annotated[int, FieldMeta(help="TCP port.", metavar="PORT")]
62
+ \"\"\"Fallback docstring (FieldMeta.help takes precedence).\"\"\"
63
+ """
64
+
65
+ help: str | None = None
66
+ metavar: str | None = None
67
+
68
+
69
+ def _get_field_meta(raw_type: Any) -> FieldMeta | None:
70
+ """Return FieldMeta from Annotated[T, FieldMeta(...)] annotation, or None."""
71
+ from typing import Annotated, get_args, get_origin
72
+
73
+ if get_origin(raw_type) is Annotated:
74
+ for arg in get_args(raw_type)[1:]:
75
+ if isinstance(arg, FieldMeta):
76
+ return arg
77
+ return None
78
+
79
+
80
+ def _get_field_docstrings(dc_type: type) -> dict[str, str]:
81
+ """Extract attribute docstrings (string literals after field defs) via AST.
82
+
83
+ For each annotated field in the class body, if the immediately following
84
+ statement is a string constant, that string is treated as the field's
85
+ docstring. Returns an empty dict when source is unavailable (e.g.
86
+ dynamically created classes).
87
+ """
88
+ try:
89
+ source = inspect.getsource(dc_type)
90
+ source = textwrap.dedent(source)
91
+ tree = ast.parse(source)
92
+ except (OSError, TypeError, IndentationError, SyntaxError):
93
+ return {}
94
+
95
+ for node in ast.walk(tree):
96
+ if not (isinstance(node, ast.ClassDef) and node.name == dc_type.__name__):
97
+ continue
98
+ result: dict[str, str] = {}
99
+ stmts = node.body
100
+ for i, stmt in enumerate(stmts):
101
+ if not (isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name)):
102
+ continue
103
+ if i + 1 >= len(stmts):
104
+ continue
105
+ next_stmt = stmts[i + 1]
106
+ if (
107
+ isinstance(next_stmt, ast.Expr)
108
+ and isinstance(next_stmt.value, ast.Constant)
109
+ and isinstance(next_stmt.value.value, str)
110
+ ):
111
+ result[stmt.target.id] = inspect.cleandoc(next_stmt.value.value)
112
+ return result
113
+ return {}
114
+
115
+
116
+ def _build_help(
117
+ field_name: str,
118
+ raw_type: Any,
119
+ docstrings: dict[str, str],
120
+ defaults: dict[str, Any],
121
+ flag: str = "",
122
+ ) -> str:
123
+ """Compose the argparse help string for one field.
124
+
125
+ Priority: FieldMeta.help > attribute docstring > empty string.
126
+ If the field has a dataclass default, appends ``(default: <repr>)``.
127
+ If the field is Optional, appends a hint about the None sentinel.
128
+ """
129
+ meta = _get_field_meta(raw_type)
130
+ if meta is not None and meta.help is not None:
131
+ base = meta.help
132
+ else:
133
+ base = docstrings.get(field_name, "")
134
+
135
+ if field_name in defaults:
136
+ suffix = f"(default: {defaults[field_name]!r})"
137
+ base = f"{base} {suffix}".strip() if base else suffix
138
+
139
+ if flag and _allows_none(_resolve_type(raw_type)) and _union_args_no_none(_resolve_type(raw_type)):
140
+ none_hint = "(pass 'none' or 'null' to set to None)"
141
+ base = f"{base} {none_hint}".strip() if base else none_hint
142
+
143
+ return base
144
+
145
+
146
+ def _add_leaf_argument(
147
+ target: argparse.ArgumentParser | argparse._ArgumentGroup,
148
+ flag: str,
149
+ raw_type: Any,
150
+ core: Any,
151
+ help_text: str,
152
+ ) -> None:
153
+ """Register a single leaf field as an argparse argument."""
154
+ meta = _get_field_meta(raw_type)
155
+ metavar: str | None = meta.metavar if meta is not None else None
156
+
157
+ common: dict[str, Any] = {
158
+ "dest": flag,
159
+ "default": argparse.SUPPRESS,
160
+ "help": help_text,
161
+ }
162
+
163
+ if _is_bool(core):
164
+ target.add_argument(f"--{flag}", type=str, metavar="true|false", **common)
165
+ return
166
+
167
+ if _is_varlen_collection(core):
168
+ et = _resolve_type(_elem_type(core))
169
+ target.add_argument(
170
+ f"--{flag}",
171
+ nargs="*",
172
+ type=str,
173
+ metavar=metavar or getattr(et, "__name__", "ITEM").upper(),
174
+ **common,
175
+ )
176
+ return
177
+
178
+ if _is_tuple(core):
179
+ tt = _tuple_types(core)
180
+ if tt is not None:
181
+ target.add_argument(
182
+ f"--{flag}",
183
+ nargs=len(tt),
184
+ type=str,
185
+ metavar=metavar or "VALUE",
186
+ **common,
187
+ )
188
+ else: # pragma: no cover
189
+ # tuple[X, ...] — variable length (unreachable: caught by _is_varlen_collection above)
190
+ et = _resolve_type(_elem_type(core))
191
+ target.add_argument(
192
+ f"--{flag}",
193
+ nargs="*",
194
+ type=str,
195
+ metavar=metavar or getattr(et, "__name__", "ITEM").upper(),
196
+ **common,
197
+ )
198
+ return
199
+
200
+ if _is_literal(core):
201
+ target.add_argument(
202
+ f"--{flag}",
203
+ choices=[str(v) for v in _literal_values(core)],
204
+ type=str,
205
+ **common,
206
+ )
207
+ return
208
+
209
+ if _is_enum(core):
210
+ target.add_argument(
211
+ f"--{flag}",
212
+ choices=[e.name for e in core],
213
+ type=str,
214
+ metavar=metavar or flag.rsplit(".", 1)[-1].upper(),
215
+ **common,
216
+ )
217
+ return
218
+
219
+ if _is_type_ref(core):
220
+ target.add_argument(
221
+ f"--{flag}",
222
+ type=str,
223
+ metavar=metavar or "DOTTED.CLASS.PATH",
224
+ **common,
225
+ )
226
+ return
227
+
228
+ # Generic scalar (str, int, float, Path, …)
229
+ type_name = getattr(core, "__name__", "VALUE").upper()
230
+ target.add_argument(
231
+ f"--{flag}",
232
+ type=str,
233
+ metavar=metavar or type_name,
234
+ **common,
235
+ )
236
+
237
+
238
+ def _add_callable_fn_flags(
239
+ target: argparse.ArgumentParser | argparse._ArgumentGroup,
240
+ flag: str,
241
+ ) -> None:
242
+ """Register --<flag>.fn, --<flag>.class, and --<flag>.call as discrete string flags."""
243
+ for sub, desc in (
244
+ ("fn", "function or class; classes get functools.partial with bind kwargs"),
245
+ ("class", "class to instantiate; the resulting instance is the callable"),
246
+ ("call", "factory function to call; result is used as the callable field value"),
247
+ ):
248
+ target.add_argument(
249
+ f"--{flag}.{sub}",
250
+ dest=f"{flag}.{sub}",
251
+ type=str,
252
+ default=argparse.SUPPRESS,
253
+ metavar="DOTTED.PATH",
254
+ help=f"Dotted import path for the '{flag}' callable ({desc}).",
255
+ )
256
+
257
+
258
+ def _add_callable_bind_flags(
259
+ parser: argparse.ArgumentParser,
260
+ flag: str,
261
+ fn_path: str,
262
+ existing_dests: set[str] | None = None,
263
+ ) -> None:
264
+ """Register --<flag>.bind.<param> flags by inspecting the target's signature.
265
+
266
+ Silently does nothing when the signature is uninspectable (C extensions).
267
+ """
268
+ from confarg._callable import _import_dotted
269
+
270
+ try:
271
+ obj = _import_dotted(fn_path)
272
+ except Exception:
273
+ return
274
+
275
+ try:
276
+ target_obj = obj.__init__ if isinstance(obj, type) else obj
277
+ sig = inspect.signature(target_obj)
278
+ except (ValueError, TypeError):
279
+ return
280
+
281
+ if existing_dests is None:
282
+ existing_dests = {a.dest for a in parser._actions}
283
+
284
+ existing_titles = {g.title for g in parser._action_groups}
285
+ if flag in existing_titles:
286
+ group: argparse.ArgumentParser | argparse._ArgumentGroup = next(
287
+ g for g in parser._action_groups if g.title == flag
288
+ )
289
+ else:
290
+ group = parser.add_argument_group(flag, f"Bind arguments for callable '{flag}'")
291
+
292
+ for param_name, param in sig.parameters.items():
293
+ if param_name == "self":
294
+ continue
295
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
296
+ continue
297
+ dest = f"{flag}.bind.{param_name}"
298
+ if dest in existing_dests:
299
+ continue
300
+ help_parts = []
301
+ ann = param.annotation
302
+ if ann is not inspect.Parameter.empty:
303
+ help_parts.append(getattr(ann, "__name__", repr(ann)))
304
+ if param.default is not inspect.Parameter.empty:
305
+ help_parts.append(f"default: {param.default!r}")
306
+ try:
307
+ group.add_argument(
308
+ f"--{dest}",
309
+ dest=dest,
310
+ type=str,
311
+ default=argparse.SUPPRESS,
312
+ metavar=param_name.upper(),
313
+ help=", ".join(help_parts),
314
+ )
315
+ except Exception:
316
+ pass
317
+ existing_dests.add(dest)
318
+
319
+
320
+ def _add_callable_factory_flags(
321
+ target: argparse.ArgumentParser | argparse._ArgumentGroup,
322
+ flag: str,
323
+ cls: type,
324
+ existing_dests: set[str] | None = None,
325
+ ) -> None:
326
+ """Register --<flag>.<param> flags for factory-mode constructor kwargs."""
327
+ from confarg._types import _init_defaults, _init_fields
328
+
329
+ try:
330
+ fields = _init_fields(cls)
331
+ defaults = _init_defaults(cls)
332
+ except Exception:
333
+ return
334
+
335
+ if existing_dests is None:
336
+ existing_dests = set()
337
+
338
+ for param_name, ft in fields.items():
339
+ dest = f"{flag}.{param_name}"
340
+ if dest in existing_dests:
341
+ continue
342
+ core_ft = _resolve_type(ft)
343
+ help_parts: list[str] = []
344
+ type_name = getattr(core_ft, "__name__", repr(core_ft))
345
+ if type_name and type_name != "Any":
346
+ help_parts.append(type_name)
347
+ if param_name in defaults:
348
+ help_parts.append(f"default: {defaults[param_name]!r}")
349
+ try:
350
+ _add_leaf_argument(target, dest, ft, core_ft, ", ".join(help_parts))
351
+ except Exception:
352
+ pass
353
+ existing_dests.add(dest)
354
+
355
+
356
+ def _get_callable_field_return_type(dc_type: Any, flag: str) -> Any | None:
357
+ """Return the Callable return type for the field at the given dot-separated flag path."""
358
+ parts = flag.split(".")
359
+ tp = _resolve_type(dc_type)
360
+ for part in parts:
361
+ if not _is_struct(tp):
362
+ return None
363
+ flds = _struct_fields(tp)
364
+ if part not in flds:
365
+ return None
366
+ tp = _resolve_type(flds[part])
367
+ unwrapped = _unwrap_optional(tp)
368
+ if unwrapped is None:
369
+ return None
370
+ tp = unwrapped
371
+ if not _is_callable(tp):
372
+ return None
373
+ return _callable_return_type(tp)
374
+
375
+
376
+ def _collect_fn_paths_from_argv(argv: Sequence[str]) -> dict[str, tuple[str, str]]:
377
+ """Scan argv for --<field>.fn or --<field>.class tokens.
378
+
379
+ Returns {field_flag: (fn_path, mode)} where mode is "fn" or "class".
380
+ CLI wins for duplicate keys.
381
+ """
382
+ result: dict[str, tuple[str, str]] = {}
383
+ i = 0
384
+ while i < len(argv):
385
+ tok = argv[i]
386
+ if not tok.startswith("--"):
387
+ i += 1
388
+ continue
389
+ if "=" in tok:
390
+ key, _, val = tok[2:].partition("=")
391
+ for suffix in (".fn", ".class", ".call"):
392
+ if key.endswith(suffix) and len(key) > len(suffix):
393
+ result[key[: -len(suffix)]] = (val, suffix[1:])
394
+ break
395
+ i += 1
396
+ else:
397
+ key = tok[2:]
398
+ for suffix in (".fn", ".class", ".call"):
399
+ if key.endswith(suffix) and len(key) > len(suffix):
400
+ field_flag = key[: -len(suffix)]
401
+ if i + 1 < len(argv) and not argv[i + 1].startswith("--"):
402
+ result[field_flag] = (argv[i + 1], suffix[1:])
403
+ i += 2
404
+ else:
405
+ i += 1
406
+ break
407
+ else:
408
+ i += 1
409
+ return result
410
+
411
+
412
+ def _collect_fn_paths_from_config(
413
+ config_dict: dict[str, Any],
414
+ dc_type: Any,
415
+ prefix: str,
416
+ union_tag: str,
417
+ ) -> dict[str, tuple[str, str]]:
418
+ """Walk dc_type + config_dict to find fn/class values for Callable fields.
419
+
420
+ Returns {field_flag: (fn_path, mode)} where mode is "fn" or "class".
421
+ """
422
+ result: dict[str, tuple[str, str]] = {}
423
+ tp = _resolve_type(dc_type)
424
+ if not _is_struct(tp):
425
+ return result
426
+ try:
427
+ flds = _struct_fields(tp)
428
+ except Exception:
429
+ return result
430
+
431
+ for name, ft in flds.items():
432
+ flag = f"{prefix}.{name}" if prefix else name
433
+ resolved = _unwrap_optional(_resolve_type(ft))
434
+ if resolved is None:
435
+ continue
436
+ if _is_callable(resolved):
437
+ sub = config_dict.get(name)
438
+ if isinstance(sub, dict):
439
+ fn_val = sub.get("fn")
440
+ cls_val = sub.get("class")
441
+ call_val = sub.get("call")
442
+ if isinstance(fn_val, str):
443
+ result[flag] = (fn_val, "fn")
444
+ elif isinstance(cls_val, str):
445
+ result[flag] = (cls_val, "class")
446
+ elif isinstance(call_val, str):
447
+ result[flag] = (call_val, "call")
448
+ elif isinstance(sub, str):
449
+ result[flag] = (sub, "fn")
450
+ elif _is_struct(resolved):
451
+ sub = config_dict.get(name, {})
452
+ if isinstance(sub, dict):
453
+ result.update(_collect_fn_paths_from_config(sub, resolved, flag, union_tag))
454
+ return result
455
+
456
+
457
+ def _extend_callable_flags(
458
+ parser: argparse.ArgumentParser,
459
+ dc_type: Any,
460
+ argv: Sequence[str],
461
+ config_flag: str,
462
+ union_tag: str,
463
+ ) -> None:
464
+ """Pre-parse argv/config to find Callable fn paths, then register bind flags.
465
+
466
+ Errors are silently ignored — this is a best-effort enhancement.
467
+ """
468
+ try:
469
+ config_dict: dict[str, Any] = {}
470
+ argv_list = list(argv)
471
+ flag_prefix = f"--{config_flag}"
472
+ i = 0
473
+ while i < len(argv_list):
474
+ tok = argv_list[i]
475
+ if tok == flag_prefix:
476
+ i += 1
477
+ while i < len(argv_list) and not argv_list[i].startswith("--"):
478
+ try:
479
+ config_dict = _deep_merge(config_dict, _load_file(Path(argv_list[i])))
480
+ except Exception:
481
+ pass
482
+ i += 1
483
+ elif tok.startswith(f"{flag_prefix}="):
484
+ path_str = tok[len(flag_prefix) + 1 :]
485
+ if path_str:
486
+ try:
487
+ config_dict = _deep_merge(config_dict, _load_file(Path(path_str)))
488
+ except Exception:
489
+ pass
490
+ i += 1
491
+ else:
492
+ i += 1
493
+
494
+ config_fns = _collect_fn_paths_from_config(config_dict, dc_type, "", union_tag)
495
+ argv_fns = _collect_fn_paths_from_argv(argv_list)
496
+ all_fns = {**config_fns, **argv_fns}
497
+
498
+ existing_dests = {a.dest for a in parser._actions}
499
+ for field_flag, (fn_path, mode) in all_fns.items():
500
+ if mode == "class":
501
+ try:
502
+ from confarg._callable import _import_dotted
503
+
504
+ cls = _import_dotted(fn_path)
505
+ if isinstance(cls, type):
506
+ _add_callable_factory_flags(parser, field_flag, cls, existing_dests)
507
+ continue
508
+ except Exception:
509
+ pass
510
+ elif mode == "call":
511
+ # Register the factory function's parameters as bind flags
512
+ _add_callable_bind_flags(parser, field_flag, fn_path, existing_dests)
513
+ continue
514
+ else: # mode == "fn"
515
+ try:
516
+ from confarg._callable import _detect_owning_class, _import_dotted
517
+
518
+ obj = _import_dotted(fn_path)
519
+ if isinstance(obj, type):
520
+ _add_callable_factory_flags(parser, field_flag, obj, existing_dests)
521
+ continue
522
+ owning_cls = _detect_owning_class(obj)
523
+ if owning_cls is not None:
524
+ _add_callable_factory_flags(parser, field_flag, owning_cls, existing_dests)
525
+ continue
526
+ except Exception:
527
+ pass
528
+ _add_callable_bind_flags(parser, field_flag, fn_path, existing_dests)
529
+ except Exception:
530
+ pass
531
+
532
+
533
+ def _add_union_tag_argument(
534
+ target: argparse.ArgumentParser | argparse._ArgumentGroup,
535
+ flag: str,
536
+ union_tag: str,
537
+ variant_types: list[Any],
538
+ ) -> None:
539
+ """Register --<flag>.<union_tag> for dynamic class dispatch.
540
+
541
+ Attaches a .completer attribute listing known variant class paths so that
542
+ argcomplete (if installed) can suggest them without requiring an import here.
543
+ """
544
+ dest = f"{flag}.{union_tag}"
545
+ action = target.add_argument(
546
+ f"--{dest}",
547
+ type=str,
548
+ dest=dest,
549
+ default=argparse.SUPPRESS,
550
+ metavar="DOTTED.CLASS.PATH",
551
+ help=(
552
+ f"Fully-qualified class path selecting the variant for '{flag}' "
553
+ f"(e.g. mypackage.MyClass). "
554
+ f"Once set, use --{flag}.<field> flags for that class's fields."
555
+ ),
556
+ )
557
+ if variant_types:
558
+ paths = [f"{v.__module__}.{v.__qualname__}" for v in variant_types]
559
+ action.completer = lambda prefix, parsed_args, **kw: [p for p in paths if p.startswith(prefix)]
560
+
561
+
562
+ def _resolve_struct(
563
+ dc_type: Any,
564
+ ) -> tuple[Any, dict[str, Any], dict[str, Any]] | None:
565
+ """Resolve a struct type to (tp, fields, hints_with_extras), or None if not a struct."""
566
+ tp = _resolve_type(dc_type)
567
+ if not _is_struct(tp):
568
+ return None
569
+ try:
570
+ flds = _struct_fields(tp)
571
+ except Exception:
572
+ return None
573
+ try:
574
+ hints = get_type_hints(tp, include_extras=True)
575
+ except Exception:
576
+ hints = {name: flds[name] for name in flds}
577
+ return tp, flds, hints
578
+
579
+
580
+ def _walk_struct(
581
+ dc_type: Any,
582
+ parser: argparse.ArgumentParser,
583
+ group_target: argparse.ArgumentParser | argparse._ArgumentGroup,
584
+ prefix: str,
585
+ union_tag: str,
586
+ ) -> None:
587
+ """Recursively register dataclass fields onto parser / argument groups.
588
+
589
+ Each nested dataclass creates its own named group (for --help grouping).
590
+ Leaf fields are registered on ``group_target``. Dict-typed fields and
591
+ multi-variant union fields are silently skipped.
592
+ """
593
+ setup = _resolve_struct(dc_type)
594
+ if setup is None:
595
+ return
596
+ tp, flds, hints = setup
597
+ var_params = _var_param_names(tp)
598
+ docstrings = _get_field_docstrings(tp)
599
+ defaults = _struct_defaults(tp)
600
+
601
+ for name in flds:
602
+ if name == union_tag or name in var_params:
603
+ continue
604
+
605
+ raw_type = hints.get(name, Any)
606
+ resolved = _resolve_type(raw_type)
607
+ flag = f"{prefix}.{name}" if prefix else name
608
+
609
+ core = _unwrap_optional(resolved)
610
+ if core is None:
611
+ # Multi-variant union: register class-tag flag only when struct variants exist
612
+ non_none = _union_args_no_none(resolved)
613
+ concrete = [_resolve_type(v) for v in non_none if _is_struct(_resolve_type(v))]
614
+ if concrete:
615
+ _add_union_tag_argument(group_target, flag, union_tag, concrete)
616
+ continue
617
+
618
+ if _is_callable(core):
619
+ help_text = _build_help(name, raw_type, docstrings, defaults, flag=flag)
620
+ _add_leaf_argument(group_target, flag, raw_type, core, help_text)
621
+ _add_callable_fn_flags(group_target, flag)
622
+ ret = _callable_return_type(core)
623
+ if ret is not None and isinstance(ret, type) and _is_struct(ret):
624
+ existing = (
625
+ {a.dest for a in group_target._group_actions} if hasattr(group_target, "_group_actions") else set()
626
+ )
627
+ _add_callable_factory_flags(group_target, flag, ret, existing)
628
+ continue
629
+
630
+ if _is_struct(core):
631
+ group_desc = inspect.getdoc(core) or ""
632
+ new_group = parser.add_argument_group(flag, group_desc)
633
+ _walk_struct(core, parser, new_group, flag, union_tag)
634
+ continue
635
+
636
+ if _is_dict(core):
637
+ # Keys unknown at registration time; skip
638
+ continue
639
+
640
+ help_text = _build_help(name, raw_type, docstrings, defaults, flag=flag)
641
+ _add_leaf_argument(group_target, flag, raw_type, core, help_text)
642
+
643
+
644
+ def _register_subconfig_flags(
645
+ dc_type: Any,
646
+ parser: argparse.ArgumentParser,
647
+ config_flag: str,
648
+ prefix: str,
649
+ union_tag: str,
650
+ ) -> None:
651
+ """Recursively register --<config_flag>.<subpath> flags for nested structs."""
652
+ setup = _resolve_struct(dc_type)
653
+ if setup is None:
654
+ return
655
+ _tp, flds, hints = setup
656
+
657
+ for name in flds:
658
+ if name == union_tag:
659
+ continue
660
+
661
+ resolved = _resolve_type(hints.get(name, Any))
662
+ subpath = f"{prefix}.{name}" if prefix else name
663
+
664
+ core = _unwrap_optional(resolved)
665
+ if core is None:
666
+ continue
667
+
668
+ if not _is_struct(core):
669
+ continue
670
+
671
+ parser.add_argument(
672
+ f"--{config_flag}.{subpath}",
673
+ nargs="*",
674
+ metavar="FILE",
675
+ dest=f"{config_flag}.{subpath}",
676
+ default=argparse.SUPPRESS,
677
+ help=(
678
+ f"Config file(s) whose contents are merged under the '{subpath}' field. "
679
+ f"Equivalent to a root config file with a top-level '{subpath}' key. "
680
+ "Supports TOML, YAML, and JSON."
681
+ ),
682
+ )
683
+ _register_subconfig_flags(core, parser, config_flag, subpath, union_tag)
684
+
685
+
686
+ def populate_parser(
687
+ dc_type: type,
688
+ parser: argparse.ArgumentParser,
689
+ *,
690
+ union_tag: str = _defaults.UNION_TAG,
691
+ config_flag: str = "config",
692
+ argv: Sequence[str] | None = None,
693
+ ) -> None:
694
+ """Register fields of a dataclass type as arguments on an ArgumentParser.
695
+
696
+ Field types, defaults, and attribute docstrings are read automatically.
697
+ For richer control, annotate individual fields with :class:`FieldMeta`::
698
+
699
+ port: Annotated[int, FieldMeta(help="TCP port.", metavar="PORT")]
700
+
701
+ All confarg arguments use ``default=argparse.SUPPRESS``, so fields absent
702
+ from the command line do not appear in the resulting Namespace. This makes
703
+ it straightforward to compose with :func:`from_namespace` for config-file
704
+ and env-var sources.
705
+
706
+ A ``--<config_flag>`` argument (default ``--config``) is also registered so
707
+ users can pass one or more TOML/YAML config files on the command line.
708
+ Pass ``config_flag=""`` to suppress it.
709
+
710
+ **Skipped fields:**
711
+
712
+ - ``dict``-typed fields (keys are unknown at registration time).
713
+ - Multi-variant union fields (ambiguous argparse type mapping).
714
+
715
+ Args:
716
+ dc_type: The dataclass type whose fields to register.
717
+ parser: The :class:`argparse.ArgumentParser` to populate.
718
+ union_tag: Name of the union discriminator field to skip (matches
719
+ the ``union_tag`` parameter of :func:`from_namespace`).
720
+ config_flag: Name of the config-file flag (default ``"config"``).
721
+ Set to ``""`` to disable config-file argument registration.
722
+ argv: CLI argument list used to pre-resolve ``--<field>.fn`` / ``--<field>.class``
723
+ values so that callable ``--<field>.bind.*`` flags can be registered
724
+ before :meth:`~argparse.ArgumentParser.parse_args` is called.
725
+ Has no effect on which config-source flags are registered.
726
+ """
727
+ _walk_struct(dc_type, parser, parser, prefix="", union_tag=union_tag)
728
+ if config_flag:
729
+ parser.add_argument(
730
+ f"--{config_flag}",
731
+ nargs="*",
732
+ metavar="FILE",
733
+ dest=config_flag,
734
+ default=argparse.SUPPRESS,
735
+ help=(
736
+ "Config file(s) to merge at lowest priority (below env vars and CLI flags). "
737
+ "Multiple files are merged left-to-right; later files override earlier ones. "
738
+ "Supports TOML, YAML, and JSON. "
739
+ f"Use --{config_flag}.<field> FILE to scope a file's contents under a specific field "
740
+ f"(e.g. --{config_flag}.db db.toml merges db.toml as if its keys were nested under 'db')."
741
+ ),
742
+ )
743
+ _register_subconfig_flags(dc_type, parser, config_flag, prefix="", union_tag=union_tag)
744
+ if argv is not None:
745
+ _extend_callable_flags(parser, dc_type, argv, config_flag, union_tag)
746
+
747
+
748
+ def _collect_ns_fields(
749
+ flat: dict[str, Any],
750
+ dc_type: Any,
751
+ prefix: str,
752
+ union_tag: str,
753
+ result: dict[str, Any],
754
+ ) -> None:
755
+ """Walk dc_type and copy matching flat-namespace entries into nested dict."""
756
+ setup = _resolve_struct(dc_type)
757
+ if setup is None:
758
+ return
759
+ _tp, flds, hints = setup
760
+
761
+ for name in flds:
762
+ if name == union_tag:
763
+ continue
764
+
765
+ raw_type = hints.get(name, Any)
766
+ resolved = _resolve_type(raw_type)
767
+ flag = f"{prefix}.{name}" if prefix else name
768
+
769
+ core = _unwrap_optional(resolved)
770
+ if core is None:
771
+ # Multi-variant union: pick up --<flag>.<union_tag> and recurse into resolved variant.
772
+ non_none = _union_args_no_none(resolved)
773
+ if any(_is_struct(_resolve_type(v)) for v in non_none):
774
+ tag_key = f"{flag}.{union_tag}"
775
+ if tag_key in flat:
776
+ class_tag = flat[tag_key]
777
+ tok = _StrToken(class_tag) if isinstance(class_tag, str) else class_tag
778
+ _set_nested(result, [*flag.split("."), union_tag], tok)
779
+ try:
780
+ from confarg._callable import _import_dotted
781
+
782
+ cls = _import_dotted(str(class_tag))
783
+ if isinstance(cls, type) and _is_struct(_resolve_type(cls)):
784
+ _collect_ns_fields(flat, cls, flag, union_tag, result)
785
+ except Exception:
786
+ pass
787
+ continue
788
+
789
+ if _is_struct(core):
790
+ _collect_ns_fields(flat, core, flag, union_tag, result)
791
+ continue
792
+
793
+ if _is_dict(core):
794
+ continue
795
+
796
+ if _is_callable(core):
797
+ spec: dict[str, Any] = {}
798
+ fn_key = f"{flag}.fn"
799
+ cls_key = f"{flag}.class"
800
+ call_key = f"{flag}.call"
801
+ bind_prefix = f"{flag}.bind."
802
+ flag_prefix = f"{flag}."
803
+ if fn_key in flat:
804
+ v = flat[fn_key]
805
+ spec["fn"] = _StrToken(v) if isinstance(v, str) else v
806
+ if cls_key in flat:
807
+ v = flat[cls_key]
808
+ spec["class"] = _StrToken(v) if isinstance(v, str) else v
809
+ if call_key in flat:
810
+ v = flat[call_key]
811
+ spec["call"] = _StrToken(v) if isinstance(v, str) else v
812
+ bind: dict[str, Any] = {}
813
+ for k, v in flat.items():
814
+ if k.startswith(bind_prefix):
815
+ param = k[len(bind_prefix) :]
816
+ bind[param] = _StrToken(v) if isinstance(v, str) else v
817
+ if bind:
818
+ spec["bind"] = bind
819
+ # Factory mode: collect flat constructor kwargs (no bind. prefix)
820
+ ret = _callable_return_type(core)
821
+ if (
822
+ (ret is not None and isinstance(ret, type) and ret is not type(None))
823
+ or cls_key in flat
824
+ or fn_key in flat
825
+ ):
826
+ for k, v in flat.items():
827
+ if (
828
+ k.startswith(flag_prefix)
829
+ and k != fn_key
830
+ and k != cls_key
831
+ and k != call_key
832
+ and not k.startswith(bind_prefix)
833
+ and "." not in k[len(flag_prefix) :]
834
+ ):
835
+ kwarg_name = k[len(flag_prefix) :]
836
+ spec[kwarg_name] = _StrToken(v) if isinstance(v, str) else v
837
+ if flag in flat:
838
+ blob = flat[flag]
839
+ if isinstance(blob, str) and not spec:
840
+ _set_nested(result, flag.split("."), _StrToken(blob))
841
+ continue
842
+ if isinstance(blob, dict):
843
+ blob_bind = blob.get("bind", {})
844
+ merged_spec = {**blob, **{k: v for k, v in spec.items() if k != "bind"}}
845
+ if isinstance(blob_bind, dict) and bind:
846
+ merged_spec["bind"] = {**blob_bind, **bind}
847
+ elif bind:
848
+ merged_spec["bind"] = bind
849
+ spec = merged_spec
850
+ if spec:
851
+ _set_nested(result, flag.split("."), spec)
852
+ continue
853
+
854
+ if flag in flat:
855
+ v = flat[flag]
856
+ if isinstance(v, str):
857
+ v = _StrToken(v)
858
+ elif isinstance(v, list):
859
+ v = [_StrToken(item) if isinstance(item, str) else item for item in v]
860
+ _set_nested(result, flag.split("."), v)
861
+
862
+
863
+ def from_namespace[T](
864
+ ns: argparse.Namespace,
865
+ dc_type: type[T],
866
+ *,
867
+ union_tag: str = _defaults.UNION_TAG,
868
+ config_flag: str = "config",
869
+ files: Sequence[str | Path] = (),
870
+ env: Mapping[str, str] | None = None,
871
+ env_prefix: str | None = _defaults.ENV_PREFIX,
872
+ env_separator: str = "__",
873
+ ) -> T:
874
+ """Construct a dataclass instance from an argparse :class:`~argparse.Namespace`.
875
+
876
+ Merges three sources in ascending priority order: config files, environment
877
+ variables, then CLI arguments from the Namespace. This mirrors the
878
+ behaviour of :func:`confarg.load`.
879
+
880
+ Only fields registered by :func:`populate_parser` are consumed from ``ns``.
881
+ Fields absent from the Namespace fall back to env vars, config files, or
882
+ dataclass defaults; missing required fields raise
883
+ :class:`~confarg.MissingFieldError`.
884
+
885
+ Args:
886
+ ns: The Namespace returned by ``ArgumentParser.parse_args()``.
887
+ dc_type: The dataclass type to construct.
888
+ union_tag: Discriminator field name (same as :func:`confarg.load`).
889
+ config_flag: Name of the config-file attribute on ``ns`` (default
890
+ ``"config"``). Must match the ``config_flag`` passed to
891
+ :func:`populate_parser`. Subkey flags ``--config.<subpath>``
892
+ (registered automatically by :func:`populate_parser`) are also
893
+ consumed. Set to ``""`` to ignore all config-file attributes.
894
+ files: Additional root-level config file paths to load (lowest priority).
895
+ env: Environment variable mapping. Defaults to ``os.environ``.
896
+ Pass ``{}`` to disable env-var reading.
897
+ env_prefix: Prefix that env vars must start with. Defaults to ``None``,
898
+ which disables environment variable parsing entirely. Set to ``""``
899
+ to read all env vars without filtering, or to e.g. ``"MYAPP_"`` to
900
+ read only vars with that prefix.
901
+ env_separator: Separator used to split env var names into nested keys.
902
+
903
+ Returns:
904
+ An instance of ``dc_type`` populated from all sources.
905
+ """
906
+ if env is None:
907
+ env = os.environ
908
+
909
+ # 1. Collect CLI field values from the namespace
910
+ cli_data: dict[str, Any] = {}
911
+ _collect_ns_fields(vars(ns), dc_type, prefix="", union_tag=union_tag, result=cli_data)
912
+
913
+ # 2. Collect (subpath, path) pairs for all config files
914
+ # - explicit files= param → root (subpath "")
915
+ # - --config file.toml → root
916
+ # - --config.server file.toml → subpath "server"
917
+ file_pairs: list[tuple[str, Path]] = [("", Path(f)) for f in files]
918
+ if config_flag:
919
+ for f in getattr(ns, config_flag, None) or []:
920
+ file_pairs.append(("", Path(f)))
921
+ cfg_prefix = f"{config_flag}."
922
+ for key, val in vars(ns).items():
923
+ if key.startswith(cfg_prefix):
924
+ subpath = key[len(cfg_prefix) :]
925
+ for f in val or []:
926
+ file_pairs.append((subpath, Path(f)))
927
+
928
+ # 3. Load config files, nesting subpath files under their key
929
+ config_data: dict[str, Any] = {}
930
+ for subpath, fpath in file_pairs:
931
+ fdata = _load_file(fpath)
932
+ if subpath:
933
+ for part in reversed(subpath.split(".")):
934
+ fdata = {part: fdata}
935
+ config_data = _deep_merge(config_data, fdata, union_tag=union_tag)
936
+
937
+ # 4. Parse env vars
938
+ if env_prefix is None:
939
+ env_data: dict[str, Any] = {}
940
+ env_configs: list[tuple[str, Path]] = []
941
+ else:
942
+ env_data, env_configs = _parse_env(env, env_prefix, env_separator, dc_type)
943
+ for subpath, fpath in env_configs:
944
+ fdata = _load_file(fpath)
945
+ if subpath:
946
+ for part in reversed(subpath.split(".")):
947
+ fdata = {part: fdata}
948
+ config_data = _deep_merge(config_data, fdata, union_tag=union_tag)
949
+
950
+ # 5. Merge: config < env < CLI
951
+ merged = _deep_merge(config_data, env_data, union_tag=union_tag)
952
+ merged = _deep_merge(merged, cli_data, union_tag=union_tag)
953
+
954
+ # 6. Resolve expressions
955
+ merged = resolve_expressions(merged)
956
+
957
+ # 7. Construct
958
+ return construct(_resolve_type(dc_type), merged, union_tag=union_tag) # type: ignore[return-value]