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/_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
+ """