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/_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]
|