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/_completion.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
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
|
+
"""Dynamic tab-completion support for confarg's argparse integration."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from confarg import _defaults
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _collect_partial_config(argv: list[str], config_flag: str) -> dict[str, Any]:
|
|
18
|
+
"""Scan argv for --<config_flag> FILE tokens and return a merged dict.
|
|
19
|
+
|
|
20
|
+
Errors (missing files, parse failures) are silently ignored — this runs
|
|
21
|
+
at shell-completion time and must never crash.
|
|
22
|
+
"""
|
|
23
|
+
from confarg._files import _load_file
|
|
24
|
+
from confarg._merge import _deep_merge
|
|
25
|
+
|
|
26
|
+
merged: dict[str, Any] = {}
|
|
27
|
+
flag_prefix = f"--{config_flag}"
|
|
28
|
+
i = 0
|
|
29
|
+
while i < len(argv):
|
|
30
|
+
tok = argv[i]
|
|
31
|
+
# Only handle root --config / --config=FILE, not --config.subpath variants
|
|
32
|
+
if tok == flag_prefix:
|
|
33
|
+
# Space-separated form: --config file1 file2 ...
|
|
34
|
+
i += 1
|
|
35
|
+
while i < len(argv) and not argv[i].startswith("--"):
|
|
36
|
+
try:
|
|
37
|
+
merged = _deep_merge(merged, _load_file(Path(argv[i])))
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
i += 1
|
|
41
|
+
elif tok.startswith(f"{flag_prefix}="):
|
|
42
|
+
# Equals form: --config=file
|
|
43
|
+
path_str = tok[len(flag_prefix) + 1 :]
|
|
44
|
+
if path_str:
|
|
45
|
+
try:
|
|
46
|
+
merged = _deep_merge(merged, _load_file(Path(path_str)))
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
i += 1
|
|
50
|
+
else:
|
|
51
|
+
i += 1
|
|
52
|
+
return merged
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _collect_partial_cli_tags(argv: list[str], union_tag: str) -> dict[str, str]:
|
|
56
|
+
"""Scan argv for --<prefix>.<union_tag> VALUE tokens.
|
|
57
|
+
|
|
58
|
+
Returns {field_prefix: class_path_string}.
|
|
59
|
+
"""
|
|
60
|
+
tags: dict[str, str] = {}
|
|
61
|
+
suffix = f".{union_tag}"
|
|
62
|
+
i = 0
|
|
63
|
+
while i < len(argv):
|
|
64
|
+
tok = argv[i]
|
|
65
|
+
if not tok.startswith("--"):
|
|
66
|
+
i += 1
|
|
67
|
+
continue
|
|
68
|
+
if "=" in tok:
|
|
69
|
+
flag, _, val = tok.partition("=")
|
|
70
|
+
flag = flag[2:] # strip leading --
|
|
71
|
+
if flag.endswith(suffix):
|
|
72
|
+
prefix = flag[: -len(suffix)]
|
|
73
|
+
tags[prefix] = val
|
|
74
|
+
elif tok[2:].endswith(suffix):
|
|
75
|
+
flag = tok[2:]
|
|
76
|
+
prefix = flag[: -len(suffix)]
|
|
77
|
+
if i + 1 < len(argv) and not argv[i + 1].startswith("--"):
|
|
78
|
+
tags[prefix] = argv[i + 1]
|
|
79
|
+
i += 2
|
|
80
|
+
continue
|
|
81
|
+
i += 1
|
|
82
|
+
return tags
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_tags_from_config(
|
|
86
|
+
merged: dict[str, Any],
|
|
87
|
+
dc_type: Any,
|
|
88
|
+
prefix: str,
|
|
89
|
+
union_tag: str,
|
|
90
|
+
) -> dict[str, str]:
|
|
91
|
+
"""Walk merged config in parallel with dc_type; return {prefix: class_path} for resolved unions."""
|
|
92
|
+
from confarg._types import _is_struct, _is_union, _resolve_type, _struct_fields, _union_args_no_none
|
|
93
|
+
|
|
94
|
+
tags: dict[str, str] = {}
|
|
95
|
+
tp = _resolve_type(dc_type)
|
|
96
|
+
if not _is_struct(tp):
|
|
97
|
+
return tags
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
flds = _struct_fields(tp)
|
|
101
|
+
except Exception:
|
|
102
|
+
return tags
|
|
103
|
+
|
|
104
|
+
for name, ft in flds.items():
|
|
105
|
+
flag = f"{prefix}.{name}" if prefix else name
|
|
106
|
+
resolved = _resolve_type(ft)
|
|
107
|
+
|
|
108
|
+
if _is_union(resolved):
|
|
109
|
+
non_none = _union_args_no_none(resolved)
|
|
110
|
+
if len(non_none) > 1:
|
|
111
|
+
sub = merged.get(name)
|
|
112
|
+
if isinstance(sub, dict) and union_tag in sub:
|
|
113
|
+
val = sub[union_tag]
|
|
114
|
+
if isinstance(val, str):
|
|
115
|
+
tags[flag] = val
|
|
116
|
+
elif len(non_none) == 1:
|
|
117
|
+
sub = merged.get(name, {})
|
|
118
|
+
if isinstance(sub, dict):
|
|
119
|
+
tags.update(_resolve_tags_from_config(sub, _resolve_type(non_none[0]), flag, union_tag))
|
|
120
|
+
elif _is_struct(resolved):
|
|
121
|
+
sub = merged.get(name, {})
|
|
122
|
+
if isinstance(sub, dict):
|
|
123
|
+
tags.update(_resolve_tags_from_config(sub, resolved, flag, union_tag))
|
|
124
|
+
|
|
125
|
+
return tags
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extend_walk(
|
|
129
|
+
dc_type: Any,
|
|
130
|
+
parser: argparse.ArgumentParser,
|
|
131
|
+
group_target: argparse.ArgumentParser | argparse._ArgumentGroup,
|
|
132
|
+
prefix: str,
|
|
133
|
+
union_tag: str,
|
|
134
|
+
existing_dests: set[str],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Register fields of dc_type under prefix, skipping already-registered dests."""
|
|
137
|
+
from confarg._argparse import (
|
|
138
|
+
_add_leaf_argument,
|
|
139
|
+
_add_union_tag_argument,
|
|
140
|
+
_build_help,
|
|
141
|
+
_get_field_docstrings,
|
|
142
|
+
_resolve_struct,
|
|
143
|
+
)
|
|
144
|
+
from confarg._types import (
|
|
145
|
+
_is_callable,
|
|
146
|
+
_is_dict,
|
|
147
|
+
_is_struct,
|
|
148
|
+
_resolve_type,
|
|
149
|
+
_struct_defaults,
|
|
150
|
+
_union_args_no_none,
|
|
151
|
+
_unwrap_optional,
|
|
152
|
+
_var_param_names,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
setup = _resolve_struct(dc_type)
|
|
156
|
+
if setup is None:
|
|
157
|
+
return
|
|
158
|
+
tp, flds, hints = setup
|
|
159
|
+
|
|
160
|
+
var_params = _var_param_names(tp)
|
|
161
|
+
docstrings = _get_field_docstrings(tp)
|
|
162
|
+
defaults = _struct_defaults(tp)
|
|
163
|
+
|
|
164
|
+
for name in flds:
|
|
165
|
+
if name == union_tag or name in var_params:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
raw_type = hints.get(name, Any)
|
|
169
|
+
resolved = _resolve_type(raw_type)
|
|
170
|
+
flag = f"{prefix}.{name}" if prefix else name
|
|
171
|
+
|
|
172
|
+
core = _unwrap_optional(resolved)
|
|
173
|
+
if core is None:
|
|
174
|
+
# Multi-variant union: register its class-tag flag if not already present
|
|
175
|
+
non_none = _union_args_no_none(resolved)
|
|
176
|
+
dest = f"{flag}.{union_tag}"
|
|
177
|
+
if dest not in existing_dests:
|
|
178
|
+
concrete = [_resolve_type(v) for v in non_none if _is_struct(_resolve_type(v))]
|
|
179
|
+
_add_union_tag_argument(group_target, flag, union_tag, concrete)
|
|
180
|
+
existing_dests.add(dest)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if _is_callable(core):
|
|
184
|
+
if flag not in existing_dests:
|
|
185
|
+
help_text = _build_help(name, raw_type, docstrings, defaults, flag=flag)
|
|
186
|
+
_add_leaf_argument(group_target, flag, raw_type, core, help_text)
|
|
187
|
+
existing_dests.add(flag)
|
|
188
|
+
from confarg._argparse import _add_callable_fn_flags
|
|
189
|
+
|
|
190
|
+
_add_callable_fn_flags(group_target, flag)
|
|
191
|
+
existing_dests.update({f"{flag}.fn", f"{flag}.class"})
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if _is_struct(core):
|
|
195
|
+
# Find or create argument group
|
|
196
|
+
existing_titles = {g.title for g in parser._action_groups}
|
|
197
|
+
if flag not in existing_titles:
|
|
198
|
+
import inspect as _inspect
|
|
199
|
+
|
|
200
|
+
new_group = parser.add_argument_group(flag, _inspect.getdoc(core) or "")
|
|
201
|
+
else:
|
|
202
|
+
new_group = next(g for g in parser._action_groups if g.title == flag)
|
|
203
|
+
_extend_walk(core, parser, new_group, flag, union_tag, existing_dests)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if _is_dict(core):
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if flag not in existing_dests:
|
|
210
|
+
help_text = _build_help(name, raw_type, docstrings, defaults, flag=flag)
|
|
211
|
+
_add_leaf_argument(group_target, flag, raw_type, core, help_text)
|
|
212
|
+
existing_dests.add(flag)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _pre_extend_parser_for_completion(
|
|
216
|
+
parser: argparse.ArgumentParser,
|
|
217
|
+
dc_type: Any,
|
|
218
|
+
union_tag: str,
|
|
219
|
+
config_flag: str,
|
|
220
|
+
argv: list[str],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Extend parser with variant-specific flags for any union fields whose class tag is known.
|
|
223
|
+
|
|
224
|
+
Reads class tags from config files listed in argv and from explicit --<field>.class argv
|
|
225
|
+
tokens, then imports each resolved class and registers its fields onto the parser.
|
|
226
|
+
All errors are silently swallowed — this must never crash a completion invocation.
|
|
227
|
+
"""
|
|
228
|
+
from confarg._callable import _import_dotted
|
|
229
|
+
from confarg._types import _is_struct, _resolve_type
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
config_dict = _collect_partial_config(argv, config_flag)
|
|
233
|
+
cli_tags = _collect_partial_cli_tags(argv, union_tag)
|
|
234
|
+
config_tags = _resolve_tags_from_config(config_dict, dc_type, prefix="", union_tag=union_tag)
|
|
235
|
+
|
|
236
|
+
# CLI wins over config
|
|
237
|
+
all_tags = {**config_tags, **cli_tags}
|
|
238
|
+
|
|
239
|
+
existing_dests = {a.dest for a in parser._actions}
|
|
240
|
+
|
|
241
|
+
for field_prefix, class_path in all_tags.items():
|
|
242
|
+
try:
|
|
243
|
+
cls = _import_dotted(class_path)
|
|
244
|
+
if not isinstance(cls, type) or not _is_struct(_resolve_type(cls)):
|
|
245
|
+
continue
|
|
246
|
+
_extend_walk(cls, parser, parser, field_prefix, union_tag, existing_dests)
|
|
247
|
+
except Exception:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
from confarg._argparse import (
|
|
251
|
+
_add_callable_bind_flags,
|
|
252
|
+
_collect_fn_paths_from_argv,
|
|
253
|
+
_collect_fn_paths_from_config,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
config_fns = _collect_fn_paths_from_config(config_dict, dc_type, "", union_tag)
|
|
257
|
+
argv_fns = _collect_fn_paths_from_argv(argv)
|
|
258
|
+
for field_flag, fn_path in {**config_fns, **argv_fns}.items():
|
|
259
|
+
try:
|
|
260
|
+
_add_callable_bind_flags(parser, field_flag, fn_path, existing_dests)
|
|
261
|
+
except Exception:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def setup_completion(
|
|
269
|
+
parser: argparse.ArgumentParser,
|
|
270
|
+
dc_type: Any,
|
|
271
|
+
*,
|
|
272
|
+
union_tag: str = _defaults.UNION_TAG,
|
|
273
|
+
config_flag: str = "config",
|
|
274
|
+
argv: list[str] | None = None,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Enable tab-completion for the parser.
|
|
277
|
+
|
|
278
|
+
Must be called after :func:`~confarg.populate_parser` and before
|
|
279
|
+
``parser.parse_args()``. Requires the ``argcomplete`` package::
|
|
280
|
+
|
|
281
|
+
pip install confarg[completion]
|
|
282
|
+
|
|
283
|
+
Also requires one-time shell setup::
|
|
284
|
+
|
|
285
|
+
eval "$(register-python-argcomplete <your-script>)"
|
|
286
|
+
|
|
287
|
+
When a union field's concrete class is determinable — either from a
|
|
288
|
+
``--config`` file listed in the current command line or from a
|
|
289
|
+
``--<field>.class`` flag — this function extends the parser with that
|
|
290
|
+
class's fields so the shell can offer them as completions.
|
|
291
|
+
|
|
292
|
+
In a normal (non-completion) run ``argcomplete.autocomplete`` returns
|
|
293
|
+
immediately, making this call a no-op with negligible overhead.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
parser: The :class:`~argparse.ArgumentParser` previously populated by
|
|
297
|
+
:func:`~confarg.populate_parser`.
|
|
298
|
+
dc_type: The top-level dataclass type (same as passed to
|
|
299
|
+
:func:`~confarg.populate_parser`).
|
|
300
|
+
union_tag: Discriminator field name (default ``"class"``).
|
|
301
|
+
config_flag: Config file flag name (default ``"config"``).
|
|
302
|
+
argv: CLI argument list. Defaults to ``sys.argv[1:]``.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
ImportError: If ``argcomplete`` is not installed.
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
import argcomplete
|
|
309
|
+
except ImportError:
|
|
310
|
+
raise ImportError(
|
|
311
|
+
"Tab-completion requires 'argcomplete'. Install with: pip install confarg[completion]"
|
|
312
|
+
) from None
|
|
313
|
+
|
|
314
|
+
if argv is None:
|
|
315
|
+
argv = sys.argv[1:]
|
|
316
|
+
|
|
317
|
+
_pre_extend_parser_for_completion(parser, dc_type, union_tag, config_flag, argv)
|
|
318
|
+
argcomplete.autocomplete(parser)
|
confarg/_defaults.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
UNION_TAG: Final[str] = "class"
|
|
8
|
+
"""The default field name used as a discriminator tag in unions."""
|
|
9
|
+
|
|
10
|
+
ENV_PREFIX: Final[str | None] = None
|
|
11
|
+
"""The default environment variable prefix.
|
|
12
|
+
|
|
13
|
+
``None`` means environment variable parsing is disabled by default.
|
|
14
|
+
Set an explicit prefix (e.g. ``"MYAPP_"``) to enable env var reading.
|
|
15
|
+
"""
|
confarg/_errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
"""Exception classes for confarg."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfargError(Exception):
|
|
13
|
+
"""Base exception for all confarg errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MissingFieldError(ConfargError):
|
|
17
|
+
"""Raised when a required field is not provided by any source."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TypeCoercionError(ConfargError):
|
|
21
|
+
"""Raised when a value cannot be coerced to the target type."""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def cannot_coerce(cls, src: str, value: Any, tp: str, path: str) -> TypeCoercionError:
|
|
25
|
+
return cls(f"Cannot coerce {src} {value!r} to {tp} at '{path}'")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidConfigFileError(ConfargError):
|
|
29
|
+
"""Raised for config file issues: not found, malformed, or unsupported format."""
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def not_found(cls, path: Any) -> InvalidConfigFileError:
|
|
33
|
+
return cls(f"Config file not found: {path}")
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def malformed(cls, fmt: str, path: Any, exc: Any) -> InvalidConfigFileError:
|
|
37
|
+
return cls(f"Malformed {fmt}: {path}: {exc}")
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def missing_library(cls, lib: str, pkg: str, action: str) -> InvalidConfigFileError:
|
|
41
|
+
return cls(f"{lib} is required for {action}. Install it with: pip install {pkg}")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def unsupported_format(cls, ext: str) -> InvalidConfigFileError:
|
|
45
|
+
return cls(f"Unsupported config file format: {ext!r}. Supported formats: .yaml/.yml, .toml, .json")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnknownArgumentError(ConfargError):
|
|
49
|
+
"""Raised when an unrecognized CLI argument is encountered."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AmbiguousUnionError(ConfargError):
|
|
53
|
+
"""Raised when a Union cannot be disambiguated by structure and no tag is provided."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CircularReferenceError(ConfargError):
|
|
57
|
+
"""Raised when expression references form a cycle in the dependency graph."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MissingReferenceError(ConfargError):
|
|
61
|
+
"""Raised when an expression references a field path that does not exist."""
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def field_not_found(cls, path: str, detail: str | None = None) -> MissingReferenceError:
|
|
65
|
+
base = f"Field '{path}' not found"
|
|
66
|
+
return cls(f"{base}: {detail}") if detail is not None else cls(f"{base} in configuration")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class UnsafeExpressionError(ConfargError):
|
|
70
|
+
"""Raised when an expression contains disallowed AST nodes or function calls."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ExpressionEvalError(ConfargError):
|
|
74
|
+
"""Raised for runtime errors during expression evaluation."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfargWarning(UserWarning):
|
|
78
|
+
"""Emitted for non-fatal configuration issues.
|
|
79
|
+
|
|
80
|
+
Currently raised when an environment variable matches the configured prefix
|
|
81
|
+
but does not correspond to any known field on the target type. Convert to
|
|
82
|
+
errors in your test-suite via::
|
|
83
|
+
|
|
84
|
+
warnings.filterwarnings("error", category=confarg.ConfargWarning)
|
|
85
|
+
"""
|