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 ADDED
@@ -0,0 +1,440 @@
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
+ """confarg — read configuration from CLI args, env vars, and config files into dataclasses."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from collections.abc import Mapping, Sequence
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from confarg import _defaults
16
+ from confarg._argparse import FieldMeta, from_namespace, populate_parser
17
+ from confarg._completion import setup_completion
18
+ from confarg._errors import (
19
+ AmbiguousUnionError,
20
+ CircularReferenceError,
21
+ ConfargError,
22
+ ConfargWarning,
23
+ ExpressionEvalError,
24
+ InvalidConfigFileError,
25
+ MissingFieldError,
26
+ MissingReferenceError,
27
+ TypeCoercionError,
28
+ UnknownArgumentError,
29
+ UnsafeExpressionError,
30
+ )
31
+ from confarg._files import INCLUDE_KEY, _dump_file, _load_file, _load_file_item
32
+ from confarg._merge import LIST_APPEND_KEY, _deep_merge
33
+ from confarg._parse_cli import _parse_cli
34
+ from confarg._parse_env import _parse_env
35
+ from confarg._serialize import _serialize
36
+ from confarg._types import _MISSING, TagPolicy, _is_dc, _is_struct, _is_struct_like, _resolve_type
37
+ from confarg.dictexpr import resolve_expressions
38
+ from confarg.typedload import construct as _tc
39
+
40
+
41
+ def merge(
42
+ target: type,
43
+ *,
44
+ args: Sequence[str] | None = None,
45
+ env: Mapping[str, str] | None = None,
46
+ env_prefix: str | None = _defaults.ENV_PREFIX,
47
+ env_separator: str = "__",
48
+ cli_prefix: str = "",
49
+ config_flag: str = "config",
50
+ files: Sequence[str | Path] = (),
51
+ env_config: str | None = None,
52
+ union_tag: str = "class",
53
+ ) -> dict[str, Any]:
54
+ """Collect and merge configuration from all sources into a raw dict.
55
+
56
+ Sources are merged in priority order: config files (lowest), then
57
+ environment variables, then CLI arguments (highest). No expression
58
+ resolution and no dataclass construction are performed — the returned
59
+ dict reflects the config input exactly as written, with ${...}
60
+ expression strings preserved.
61
+
62
+ Args:
63
+ target: The dataclass type (or scalar type) used to guide CLI parsing.
64
+ args: CLI arguments to parse. Defaults to sys.argv[1:].
65
+ env: Environment variable mapping to scan. Defaults to os.environ.
66
+ env_prefix: Prefix that env vars must start with. Defaults to ``None``,
67
+ which disables environment variable parsing entirely. Set to ``""``
68
+ to read all env vars without filtering, or to e.g. ``"MYAPP_"`` to
69
+ read only vars with that prefix.
70
+ env_separator: Separator used to split env var names into nested keys.
71
+ cli_prefix: Required prefix for CLI flags.
72
+ config_flag: The flag name used to specify config files on the CLI.
73
+ files: Paths to config files to load.
74
+ env_config: Name of an env var whose value is a config file path to load.
75
+ Loaded after ``files`` but before CLI ``--config`` files.
76
+ union_tag: The field name used as a discriminator tag in unions.
77
+
78
+ Returns:
79
+ A plain dict of the merged configuration, with expression strings intact.
80
+
81
+ Raises:
82
+ InvalidConfigFileError: If a config file cannot be loaded.
83
+ UnknownArgumentError: If an unrecognized CLI argument is encountered.
84
+ """
85
+ if args is None:
86
+ args = sys.argv[1:]
87
+ if env is None:
88
+ env = os.environ
89
+
90
+ # 1. Parse CLI
91
+ cli_data, cli_configs = _parse_cli(args, target, cli_prefix, config_flag, union_tag)
92
+
93
+ # 2. Parse env vars (done here so env-specified config files are loaded in order)
94
+ if env_prefix is None:
95
+ env_data: dict[str, Any] = {}
96
+ env_configs: list[tuple[str, Path]] = []
97
+ else:
98
+ # Exclude the env_config key so it is not mistakenly treated as a field.
99
+ env_for_fields = {k: v for k, v in env.items() if k != env_config} if env_config else env
100
+ env_data, env_configs = _parse_env(env_for_fields, env_prefix, env_separator, target, config_flag)
101
+
102
+ # 3. Load config files in priority order (all become config-level, below inline env/CLI)
103
+ config_data: dict[str, Any] = {}
104
+ for f in files:
105
+ config_data = _deep_merge(config_data, _load_file(Path(f)), union_tag=union_tag)
106
+ if env_config is not None:
107
+ env_config_path = env.get(env_config)
108
+ if env_config_path:
109
+ config_data = _deep_merge(config_data, _load_file(Path(env_config_path)), union_tag=union_tag)
110
+ for subpath, fpath in env_configs:
111
+ fdata: dict[str, Any] = _load_file(fpath)
112
+ if subpath:
113
+ for part in reversed(subpath.split(".")):
114
+ fdata = {part: fdata}
115
+ config_data = _deep_merge(config_data, fdata, union_tag=union_tag)
116
+ for subpath, fpath in cli_configs:
117
+ if subpath.endswith("+"):
118
+ # Append mode: --config.foo.bar+ file → subpath = "foo.bar+"
119
+ real_subpath = subpath[:-1].rstrip(".")
120
+ if not real_subpath:
121
+ raise ConfargError(
122
+ f"--{config_flag}+ requires a field path. Use --{config_flag}.fieldname+ /path/to/file."
123
+ )
124
+ last_key = real_subpath.rsplit(".", 1)[-1]
125
+ fitem = _load_file_item(fpath)
126
+ # Disambiguation (see convention):
127
+ # - top-level list → the element to append IS the list
128
+ # - dict with exactly one key == last_key whose value is a list
129
+ # → those list items are appended individually
130
+ # - anything else → appended as a single element
131
+ if isinstance(fitem, list):
132
+ append_items: list[Any] = [fitem]
133
+ elif (
134
+ isinstance(fitem, dict) and len(fitem) == 1 and last_key in fitem and isinstance(fitem[last_key], list)
135
+ ):
136
+ append_items = fitem[last_key]
137
+ else:
138
+ append_items = [fitem]
139
+ fdata: dict[str, Any] = {LIST_APPEND_KEY: append_items}
140
+ for part in reversed(real_subpath.split(".")):
141
+ fdata = {part: fdata}
142
+ else:
143
+ fdata = _load_file(fpath)
144
+ if subpath:
145
+ for part in reversed(subpath.split(".")):
146
+ fdata = {part: fdata}
147
+ config_data = _deep_merge(config_data, fdata, union_tag=union_tag)
148
+
149
+ # 4. Merge: config (lowest) → env → CLI (highest)
150
+ merged = _deep_merge(config_data, env_data, union_tag=union_tag)
151
+ merged = _deep_merge(merged, cli_data, union_tag=union_tag)
152
+
153
+ return merged
154
+
155
+
156
+ def from_dict[T](
157
+ target: type[T],
158
+ data: dict[str, Any],
159
+ *,
160
+ union_tag: str = "class",
161
+ ) -> T:
162
+ """Construct a dataclass instance from a plain config dict.
163
+
164
+ Resolves ${...} expressions then constructs the target type. Use this as
165
+ the second step after merge(), or to load configuration from a dict you
166
+ have assembled yourself.
167
+
168
+ Args:
169
+ target: The dataclass type (or scalar type) to construct.
170
+ data: The raw config dict (e.g. the output of merge()).
171
+ union_tag: The field name used as a discriminator tag in unions.
172
+
173
+ Returns:
174
+ An instance of the target type.
175
+
176
+ Raises:
177
+ MissingFieldError: If a required field is not provided.
178
+ TypeCoercionError: If a value cannot be coerced to the target type.
179
+ AmbiguousUnionError: If a Union cannot be disambiguated.
180
+ CircularReferenceError: If expression references form a cycle.
181
+ UnsafeExpressionError: If an expression contains disallowed constructs.
182
+ MissingReferenceError: If an expression references a field that does not exist.
183
+ ExpressionEvalError: If an expression fails at runtime.
184
+ """
185
+ target_r = _resolve_type(target)
186
+ is_dataclass = _is_struct_like(target_r)
187
+
188
+ resolved = resolve_expressions(data)
189
+
190
+ if not is_dataclass:
191
+ raw = resolved.get("__root__", _MISSING)
192
+ if raw is _MISSING:
193
+ raise MissingFieldError(
194
+ f"No value provided for target type {target_r!r}."
195
+ " Provide a value via CLI flag (--<prefix> <value>), environment variable, or config file."
196
+ )
197
+ return _tc(target_r, raw, union_tag=union_tag) # type: ignore[return-value]
198
+
199
+ return _tc(target_r, resolved, union_tag=union_tag) # type: ignore[return-value]
200
+
201
+
202
+ def interpolate(data: dict[str, Any]) -> dict[str, Any]:
203
+ """Resolve ${...} expressions in a merged config dict.
204
+
205
+ This is the first half of from_dict(). Call it to get the fully-resolved
206
+ dict before passing it to construct() or inspecting values.
207
+
208
+ Args:
209
+ data: A plain config dict, e.g. the output of merge().
210
+
211
+ Returns:
212
+ A new dict with all ${...} expression strings replaced by their values.
213
+
214
+ Raises:
215
+ CircularReferenceError: If expression references form a cycle.
216
+ UnsafeExpressionError: If an expression contains disallowed constructs.
217
+ MissingReferenceError: If an expression references a field that does not exist.
218
+ ExpressionEvalError: If an expression fails at runtime.
219
+ """
220
+ return resolve_expressions(data)
221
+
222
+
223
+ def construct[T](
224
+ target: type[T],
225
+ data: dict[str, Any],
226
+ *,
227
+ union_tag: str = "class",
228
+ ) -> T:
229
+ """Construct a typed object from an already-interpolated config dict.
230
+
231
+ This is the second half of from_dict(). Unlike from_dict(), it does NOT
232
+ resolve ${...} expressions — call interpolate() first if needed.
233
+
234
+ Use this together with interpolate() when you want to keep the interpolated
235
+ dict around (e.g. to dump it with dump_file()):
236
+
237
+ raw = confarg.merge(MyConfig, ...)
238
+ resolved = confarg.interpolate(raw)
239
+ confarg.dump_file(resolved, "out.yaml") # serialize the dict
240
+ cfg = confarg.construct(MyConfig, resolved) # build the typed object
241
+
242
+ Args:
243
+ target: The dataclass or plain-class type to construct.
244
+ data: An interpolated config dict (output of interpolate() or merge()).
245
+ union_tag: The field name used as a discriminator tag in unions.
246
+
247
+ Returns:
248
+ An instance of the target type.
249
+
250
+ Raises:
251
+ MissingFieldError: If a required field is not provided.
252
+ TypeCoercionError: If a value cannot be coerced to the target type.
253
+ AmbiguousUnionError: If a Union cannot be disambiguated.
254
+ """
255
+ target_r = _resolve_type(target)
256
+ return _tc(target_r, data, union_tag=union_tag) # type: ignore[return-value]
257
+
258
+
259
+ def load[T](
260
+ target: type[T],
261
+ *,
262
+ args: Sequence[str] | None = None,
263
+ env: Mapping[str, str] | None = None,
264
+ env_prefix: str | None = _defaults.ENV_PREFIX,
265
+ env_separator: str = "__",
266
+ cli_prefix: str = "",
267
+ config_flag: str = "config",
268
+ files: Sequence[str | Path] = (),
269
+ env_config: str | None = None,
270
+ union_tag: str = "class",
271
+ ) -> T:
272
+ """Load configuration into the target type from CLI args, env vars, and config files.
273
+
274
+ Sources are merged in priority order: config files (lowest), then
275
+ environment variables, then CLI arguments (highest).
276
+
277
+ This is a convenience wrapper around merge() + from_dict(). For more
278
+ control — e.g. to inspect or save the raw merged dict before construction —
279
+ call those two functions directly.
280
+
281
+ Args:
282
+ target: The dataclass type (or scalar type) to load configuration into.
283
+ args: CLI arguments to parse. Defaults to sys.argv[1:].
284
+ env: Environment variable mapping to scan. Defaults to os.environ.
285
+ env_prefix: Prefix that env vars must start with. Defaults to ``None``,
286
+ which disables environment variable parsing entirely. Set to ``""``
287
+ to read all env vars without filtering, or to e.g. ``"MYAPP_"`` to
288
+ read only vars with that prefix.
289
+ env_separator: Separator used to split env var names into nested keys.
290
+ cli_prefix: Required prefix for CLI flags.
291
+ config_flag: The flag name used to specify config files on the CLI.
292
+ files: Paths to config files to load.
293
+ env_config: Name of an env var whose value is a config file path to load.
294
+ Loaded after ``files`` but before CLI ``--config`` files.
295
+ union_tag: The field name used as a discriminator tag in unions.
296
+
297
+ Returns:
298
+ An instance of the target type populated with the merged configuration.
299
+
300
+ Raises:
301
+ MissingFieldError: If a required field is not provided by any source.
302
+ TypeCoercionError: If a value cannot be coerced to the target type.
303
+ InvalidConfigFileError: If a config file cannot be loaded.
304
+ UnknownArgumentError: If an unrecognized CLI argument is encountered.
305
+ AmbiguousUnionError: If a Union cannot be disambiguated.
306
+ CircularReferenceError: If expression references form a cycle.
307
+ UnsafeExpressionError: If an expression contains disallowed constructs.
308
+ MissingReferenceError: If an expression references a field that does not exist.
309
+ ExpressionEvalError: If an expression fails at runtime.
310
+ """
311
+ data = merge(
312
+ target,
313
+ args=args,
314
+ env=env,
315
+ env_prefix=env_prefix,
316
+ env_separator=env_separator,
317
+ cli_prefix=cli_prefix,
318
+ config_flag=config_flag,
319
+ files=files,
320
+ env_config=env_config,
321
+ union_tag=union_tag,
322
+ )
323
+ return from_dict(target, data, union_tag=union_tag)
324
+
325
+
326
+ def _strip_str_tokens(value: Any) -> Any:
327
+ """Recursively convert _StrToken instances to plain str for serialization."""
328
+ from confarg._types import _StrToken as _ST
329
+
330
+ if type(value) is _ST:
331
+ return str(value)
332
+ if isinstance(value, dict):
333
+ return {k: _strip_str_tokens(v) for k, v in value.items()}
334
+ if isinstance(value, list):
335
+ return [_strip_str_tokens(v) for v in value]
336
+ return value
337
+
338
+
339
+ def dump(
340
+ value: Any,
341
+ *,
342
+ union_tag: str = "class",
343
+ tag_policy: TagPolicy = "auto",
344
+ ) -> dict[str, Any]:
345
+ """Serialize to a plain dict.
346
+
347
+ Dispatches on the value type:
348
+
349
+ - **Dataclass instance**: serializes to a config-compatible dict.
350
+ ``union_tag`` and ``tag_policy`` apply.
351
+ - **Raw dict** (e.g. from ``merge()``): normalizes internal tokens to plain
352
+ ``str``. ``union_tag`` and ``tag_policy`` are ignored.
353
+
354
+ Args:
355
+ value: A dataclass instance or a raw config dict.
356
+ union_tag: The field name used as a discriminator tag in unions.
357
+ tag_policy: "auto" (tag only when needed) or "always" (tag every union DC).
358
+
359
+ Returns:
360
+ A plain dict representation.
361
+
362
+ Raises:
363
+ TypeError: If value is not a dataclass instance or a dict.
364
+ """
365
+ if isinstance(value, dict):
366
+ return _strip_str_tokens(value)
367
+ if isinstance(value, type) or not _is_dc(type(value)):
368
+ tp_name = type(value).__name__
369
+ if _is_struct(type(value)):
370
+ raise TypeError(
371
+ f"dump() only supports dataclass instances, not plain classes.\n"
372
+ f"{tp_name} is a plain class — keep the merged dict and dump that instead:\n"
373
+ f" raw = confarg.merge(...)\n"
374
+ f" confarg.dump_file(raw, path)"
375
+ )
376
+ raise TypeError(f"Expected a dataclass instance or dict, got {tp_name}")
377
+ tp = type(value)
378
+ return _serialize(tp, value, "", union_tag, tag_policy)
379
+
380
+
381
+ def dump_file(
382
+ value: Any,
383
+ path: str | Path,
384
+ *,
385
+ union_tag: str = "class",
386
+ tag_policy: TagPolicy = "auto",
387
+ ) -> None:
388
+ """Write to a config file.
389
+
390
+ Accepts dataclass instances or raw config dicts — see ``dump()`` for
391
+ dispatch behaviour. The output format is determined by the file extension
392
+ (.toml, .yaml, .yml, .json).
393
+
394
+ Args:
395
+ value: A dataclass instance or a raw config dict.
396
+ path: Path to the output file.
397
+ union_tag: The field name used as a discriminator tag in unions.
398
+ tag_policy: "auto" or "always".
399
+
400
+ Raises:
401
+ TypeError: If value is not a dataclass instance or a dict.
402
+ InvalidConfigFileError: If the format is unsupported or the required library is not installed.
403
+ """
404
+ _dump_file(dump(value, union_tag=union_tag, tag_policy=tag_policy), Path(path))
405
+
406
+
407
+ __all__ = [
408
+ # Two-step API
409
+ "merge",
410
+ "from_dict",
411
+ # Three-step API (dict-centric)
412
+ "interpolate",
413
+ "construct",
414
+ # One-step convenience
415
+ "load",
416
+ # Dump
417
+ "dump",
418
+ "dump_file",
419
+ # Types
420
+ "TagPolicy",
421
+ # argparse helpers
422
+ "populate_parser",
423
+ "from_namespace",
424
+ "FieldMeta",
425
+ "setup_completion",
426
+ # Errors / warnings
427
+ "ConfargError",
428
+ "ConfargWarning",
429
+ "MissingFieldError",
430
+ "TypeCoercionError",
431
+ "InvalidConfigFileError",
432
+ "UnknownArgumentError",
433
+ "AmbiguousUnionError",
434
+ "CircularReferenceError",
435
+ "MissingReferenceError",
436
+ "UnsafeExpressionError",
437
+ "ExpressionEvalError",
438
+ # Constants
439
+ "INCLUDE_KEY",
440
+ ]