confarg 0.0.1__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,60 @@
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
+ """A tool to manage complex configurations.
6
+
7
+ > Load and resolve complex configurations from files, environment variables and command line arguments. Keep your data
8
+ > structures and favorite CLI library.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ from confarg import exceptions
19
+ from confarg._api import build, dump, dump_file, from_dict, load, merge, resolve
20
+ from confarg._types import TagPolicy
21
+ from confarg.typedload._coerce import _LEAF_COERCIONS
22
+
23
+
24
+ def register_leaf_type(tp: type, coerce: Callable[[Any], Any]) -> None:
25
+ """Register a custom leaf type.
26
+
27
+ After registration, ``tp`` is treated as a leaf: values of that type can
28
+ appear as scalars in config files, env vars, and CLI args. ``coerce`` is
29
+ called with the raw input value — either a ``_StrToken`` (a ``str``
30
+ subclass) from CLI/env sources, or the natively-parsed Python object from
31
+ config files — and must return an instance of ``tp``, raising
32
+ ``ValueError`` or ``TypeError`` on failure.
33
+
34
+ Example::
35
+
36
+ from uuid import UUID
37
+ confarg.register_leaf_type(UUID, UUID)
38
+ """
39
+ _LEAF_COERCIONS[tp] = coerce
40
+
41
+
42
+ __all__ = [ # noqa: RUF022
43
+ # Two-step API
44
+ "merge",
45
+ "build",
46
+ # Three-step API (dict-centric)
47
+ "resolve",
48
+ "from_dict",
49
+ # One-step convenience
50
+ "load",
51
+ # Dump
52
+ "dump",
53
+ "dump_file",
54
+ # Types
55
+ "TagPolicy",
56
+ # Leaf-type extension
57
+ "register_leaf_type",
58
+ # Exceptions / warnings
59
+ "exceptions",
60
+ ]
confarg/_api.py ADDED
@@ -0,0 +1,405 @@
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
+ """Public API implementation."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, overload
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping, Sequence
16
+ from types import UnionType
17
+ from typing import TypeAliasType
18
+
19
+ from confarg import _defaults
20
+ from confarg._files import _dump_file
21
+ from confarg._parse_cli import _parse_cli
22
+ from confarg._pipeline import _merge_sources
23
+ from confarg._serialize import _serialize
24
+ from confarg._types import _MISSING, TagPolicy, _is_dc, _is_struct, _is_struct_like, _resolve_type
25
+ from confarg._types import _StrToken as _ST
26
+ from confarg.dictexpr import resolve_expressions
27
+ from confarg.exceptions import MissingFieldError
28
+ from confarg.typedload import construct as _tc
29
+
30
+
31
+ def merge( # noqa: PLR0913
32
+ target: type | TypeAliasType | UnionType,
33
+ *,
34
+ argv: Sequence[str] | None = None,
35
+ env: Mapping[str, str] | None = None,
36
+ env_prefix: str | None = _defaults.ENV_PREFIX,
37
+ env_separator: str = _defaults.ENV_SEPARATOR,
38
+ cli_prefix: str = "",
39
+ config_flag: str = _defaults.CONFIG_FLAG,
40
+ files: Sequence[str | Path] = (),
41
+ env_config: str | None = None,
42
+ union_tag: str = _defaults.UNION_TAG,
43
+ ) -> dict[str, Any]:
44
+ """Collect and merge configuration from all sources into a raw dict.
45
+
46
+ Sources are merged in priority order: config files (lowest), then
47
+ environment variables, then CLI arguments (highest). No expression
48
+ resolution and no dataclass construction are performed — the returned
49
+ dict reflects the config input exactly as written, with ${...}
50
+ expression strings preserved.
51
+
52
+ The returned dict is **unvalidated**: it is not guaranteed to be
53
+ constructible into ``target``. Type validation and coercion happen in
54
+ ``build()`` (or the one-shot ``load()``), which raises ``TypeCoercionError``
55
+ / ``MissingFieldError`` for data that cannot be built. CLI parsing rejects
56
+ only the cases it can prove wrong at parse time (e.g. a missing value); a
57
+ config file or env var carrying ``"abc"`` for an ``int`` field merges
58
+ cleanly here and fails later, in ``build()``.
59
+
60
+ Args:
61
+ target: The dataclass type (or scalar type) used to guide CLI parsing.
62
+ argv: CLI arguments to parse. Defaults to sys.argv[1:].
63
+ env: Environment variable mapping to scan. Defaults to os.environ.
64
+ env_prefix: Prefix that env vars must start with. Defaults to ``None``,
65
+ which disables environment variable parsing entirely. Set to ``""``
66
+ to read all env vars without filtering, or to e.g. ``"MYAPP_"`` to
67
+ read only vars with that prefix.
68
+ env_separator: Separator used to split env var names into nested keys.
69
+ Defaults to ``"__"`` (double underscore).
70
+ cli_prefix: Required prefix for all CLI flags. Defaults to ``""``,
71
+ which means no prefix is required.
72
+ config_flag: Flag name used to specify config files on the CLI
73
+ (``--config path/to/file.yaml``). Set to ``""`` to disable.
74
+ Defaults to ``"config"``.
75
+ files: Paths to config files to load.
76
+ env_config: Name of an env var whose value is a config file path to load.
77
+ Loaded after ``files`` but before CLI ``--config`` files.
78
+ union_tag: Field name used as a discriminator tag in union types.
79
+ Defaults to ``"class"`` — a Python keyword that can never clash
80
+ with a dataclass field name.
81
+
82
+ Returns:
83
+ A plain dict of the merged configuration, with expression strings intact.
84
+
85
+ Config file loading order:
86
+ All config files share the same priority level (below inline env vars and
87
+ CLI args). Within that level they are loaded left-to-right so that later
88
+ sources win on conflict. The full sequence is:
89
+
90
+ 1. ``files`` — in the order given.
91
+ 2. ``env_config`` — the single global path named by that env var (if set).
92
+ 3. ``CONFIG__*`` env vars — sorted lexicographically by their env var name,
93
+ which is equivalent to sorting by subpath depth (shallower paths first).
94
+ A global ``CONFIG=file`` therefore loads before ``CONFIG__DB=db.yaml``,
95
+ which loads before ``CONFIG__DB__HOST=host.yaml``.
96
+ 4. CLI ``--config`` / ``--config.subpath`` flags — in left-to-right order.
97
+
98
+ Raises:
99
+ InvalidConfigFileError: If a config file cannot be loaded.
100
+ UnknownArgumentError: If an unrecognized CLI argument is encountered.
101
+ """
102
+ if argv is None:
103
+ argv = sys.argv[1:]
104
+ if env is None:
105
+ env = os.environ
106
+
107
+ cli_data, cli_configs = _parse_cli(argv, target, cli_prefix, config_flag, union_tag)
108
+ return _merge_sources(
109
+ target,
110
+ cli_data,
111
+ cli_configs,
112
+ env=env,
113
+ env_prefix=env_prefix,
114
+ env_separator=env_separator,
115
+ config_flag=config_flag,
116
+ files=files,
117
+ env_config=env_config,
118
+ union_tag=union_tag,
119
+ )
120
+
121
+
122
+ @overload
123
+ def build[T](target: type[T], data: dict[str, Any], *, union_tag: str = ...) -> T: ...
124
+ @overload
125
+ def build(target: object, data: dict[str, Any], *, union_tag: str = ...) -> Any: ...
126
+ def build[T](
127
+ target: type[T] | TypeAliasType | UnionType,
128
+ data: dict[str, Any],
129
+ *,
130
+ union_tag: str = _defaults.UNION_TAG,
131
+ ) -> T:
132
+ """Resolve ``${...}`` expressions and construct the target type from a merged config dict.
133
+
134
+ Use this as the second step after ``merge()``, or to load configuration
135
+ from a dict you have assembled yourself.
136
+
137
+ Args:
138
+ target: The dataclass type (or scalar type) to construct.
139
+ data: The raw config dict (e.g. the output of ``merge()``).
140
+ union_tag: The field name used as a discriminator tag in unions.
141
+
142
+ Returns:
143
+ An instance of the target type.
144
+
145
+ Raises:
146
+ MissingFieldError: If a required field is not provided.
147
+ TypeCoercionError: If a value cannot be coerced to the target type.
148
+ AmbiguousUnionError: If a Union cannot be disambiguated.
149
+ CircularReferenceError: If expression references form a cycle.
150
+ UnsafeExpressionError: If an expression contains disallowed constructs.
151
+ MissingReferenceError: If an expression references a field that does not exist.
152
+ ExpressionEvalError: If an expression fails at runtime.
153
+ """
154
+ target_r = _resolve_type(target)
155
+ is_dataclass = _is_struct_like(target_r)
156
+
157
+ resolved = resolve_expressions(data)
158
+
159
+ if not is_dataclass:
160
+ raw = resolved.get("__root__", _MISSING)
161
+ if raw is _MISSING:
162
+ msg = (
163
+ f"No value provided for target type {target_r!r}."
164
+ " Provide a value via CLI flag (--<prefix> <value>), environment variable, or config file."
165
+ )
166
+ raise MissingFieldError(msg)
167
+ return _tc(target_r, raw, union_tag=union_tag)
168
+ return _tc(target_r, resolved, union_tag=union_tag)
169
+
170
+
171
+ def resolve(data: dict[str, Any]) -> dict[str, Any]:
172
+ """Resolve ``${...}`` expressions in a merged config dict.
173
+
174
+ Use this between ``merge()`` and ``from_dict()`` when you need the
175
+ resolved dict itself (e.g. to inspect values or write it to a file):
176
+
177
+ raw = confarg.merge(MyConfig, ...)
178
+ resolved = confarg.resolve(raw)
179
+ confarg.dump_file(resolved, "out.yaml")
180
+ cfg = confarg.from_dict(MyConfig, resolved)
181
+
182
+ Args:
183
+ data: A plain config dict, e.g. the output of ``merge()``.
184
+
185
+ Returns:
186
+ A new dict with all ``${...}`` expression strings replaced by their values.
187
+
188
+ Raises:
189
+ CircularReferenceError: If expression references form a cycle.
190
+ UnsafeExpressionError: If an expression contains disallowed constructs.
191
+ MissingReferenceError: If an expression references a field that does not exist.
192
+ ExpressionEvalError: If an expression fails at runtime.
193
+ """
194
+ return resolve_expressions(data)
195
+
196
+
197
+ @overload
198
+ def from_dict[T](target: type[T], data: dict[str, Any], *, union_tag: str = ...) -> T: ...
199
+ @overload
200
+ def from_dict(target: object, data: dict[str, Any], *, union_tag: str = ...) -> Any: ...
201
+ def from_dict[T](
202
+ target: type[T] | TypeAliasType | UnionType,
203
+ data: dict[str, Any],
204
+ *,
205
+ union_tag: str = _defaults.UNION_TAG,
206
+ ) -> T:
207
+ """Construct a typed object from an already-resolved config dict.
208
+
209
+ Unlike ``build()``, this does NOT resolve ``${...}`` expressions — call
210
+ ``resolve()`` first if needed.
211
+
212
+ Args:
213
+ target: The dataclass or plain-class type to construct.
214
+ data: A resolved config dict (output of resolve() or merge()).
215
+ union_tag: The field name used as a discriminator tag in unions.
216
+
217
+ Returns:
218
+ An instance of the target type.
219
+
220
+ Raises:
221
+ MissingFieldError: If a required field is not provided.
222
+ TypeCoercionError: If a value cannot be coerced to the target type.
223
+ AmbiguousUnionError: If a Union cannot be disambiguated.
224
+ """
225
+ target_r = _resolve_type(target)
226
+ return _tc(target_r, data, union_tag=union_tag)
227
+
228
+
229
+ @overload
230
+ def load[T](
231
+ target: type[T],
232
+ *,
233
+ argv: Sequence[str] | None = ...,
234
+ env: Mapping[str, str] | None = ...,
235
+ env_prefix: str | None = ...,
236
+ env_separator: str = ...,
237
+ cli_prefix: str = ...,
238
+ config_flag: str = ...,
239
+ files: Sequence[str | Path] = ...,
240
+ env_config: str | None = ...,
241
+ union_tag: str = ...,
242
+ ) -> T: ...
243
+ @overload
244
+ def load(
245
+ target: object,
246
+ *,
247
+ argv: Sequence[str] | None = ...,
248
+ env: Mapping[str, str] | None = ...,
249
+ env_prefix: str | None = ...,
250
+ env_separator: str = ...,
251
+ cli_prefix: str = ...,
252
+ config_flag: str = ...,
253
+ files: Sequence[str | Path] = ...,
254
+ env_config: str | None = ...,
255
+ union_tag: str = ...,
256
+ ) -> Any: ...
257
+ def load[T]( # noqa: PLR0913
258
+ target: type[T] | TypeAliasType | UnionType,
259
+ *,
260
+ argv: Sequence[str] | None = None,
261
+ env: Mapping[str, str] | None = None,
262
+ env_prefix: str | None = _defaults.ENV_PREFIX,
263
+ env_separator: str = _defaults.ENV_SEPARATOR,
264
+ cli_prefix: str = "",
265
+ config_flag: str = _defaults.CONFIG_FLAG,
266
+ files: Sequence[str | Path] = (),
267
+ env_config: str | None = None,
268
+ union_tag: str = _defaults.UNION_TAG,
269
+ ) -> T:
270
+ """Merge configuration from all sources and construct the target type.
271
+
272
+ Convenience wrapper for ``merge()`` + ``build()``. For more control —
273
+ e.g. to inspect or save the raw merged dict — call those directly.
274
+ See ``merge()`` for source priority and config file loading order.
275
+
276
+ Args:
277
+ target: The dataclass type (or scalar type) to load configuration into.
278
+ argv: CLI arguments to parse. Defaults to sys.argv[1:].
279
+ env: Environment variable mapping to scan. Defaults to os.environ.
280
+ env_prefix: Prefix that env vars must start with. Defaults to ``None``,
281
+ which disables environment variable parsing entirely. Set to ``""``
282
+ to read all env vars without filtering, or to e.g. ``"MYAPP_"`` to
283
+ read only vars with that prefix.
284
+ env_separator: Separator used to split env var names into nested keys.
285
+ Defaults to ``"__"`` (double underscore).
286
+ cli_prefix: Required prefix for all CLI flags. Defaults to ``""``,
287
+ which means no prefix is required.
288
+ config_flag: Flag name used to specify config files on the CLI
289
+ (``--config path/to/file.yaml``). Set to ``""`` to disable.
290
+ Defaults to ``"config"``.
291
+ files: Paths to config files to load.
292
+ env_config: Name of an env var whose value is a config file path to load.
293
+ Loaded after ``files`` but before CLI ``--config`` files.
294
+ union_tag: Field name used as a discriminator tag in union types.
295
+ Defaults to ``"class"`` — a Python keyword that can never clash
296
+ with a dataclass field name.
297
+
298
+ Returns:
299
+ An instance of the target type populated with the merged configuration.
300
+
301
+ Raises:
302
+ MissingFieldError: If a required field is not provided by any source.
303
+ TypeCoercionError: If a value cannot be coerced to the target type.
304
+ InvalidConfigFileError: If a config file cannot be loaded.
305
+ UnknownArgumentError: If an unrecognized CLI argument is encountered.
306
+ AmbiguousUnionError: If a Union cannot be disambiguated.
307
+ CircularReferenceError: If expression references form a cycle.
308
+ UnsafeExpressionError: If an expression contains disallowed constructs.
309
+ MissingReferenceError: If an expression references a field that does not exist.
310
+ ExpressionEvalError: If an expression fails at runtime.
311
+ """
312
+ data = merge(
313
+ target,
314
+ argv=argv,
315
+ env=env,
316
+ env_prefix=env_prefix,
317
+ env_separator=env_separator,
318
+ cli_prefix=cli_prefix,
319
+ config_flag=config_flag,
320
+ files=files,
321
+ env_config=env_config,
322
+ union_tag=union_tag,
323
+ )
324
+ return build(target, data, union_tag=union_tag)
325
+
326
+
327
+ def _strip_str_tokens(value: Any) -> Any:
328
+ """Recursively convert _StrToken instances to plain str for serialization."""
329
+ if type(value) is _ST:
330
+ return str(value)
331
+ if isinstance(value, dict):
332
+ return {k: _strip_str_tokens(v) for k, v in value.items()}
333
+ if isinstance(value, list):
334
+ return [_strip_str_tokens(v) for v in value]
335
+ return value
336
+
337
+
338
+ def dump(
339
+ value: Any,
340
+ *,
341
+ union_tag: str = _defaults.UNION_TAG,
342
+ tag_policy: TagPolicy = "auto",
343
+ ) -> dict[str, Any]:
344
+ """Serialize a dataclass instance to a config-compatible plain dict.
345
+
346
+ Args:
347
+ value: A dataclass instance.
348
+ union_tag: The field name used as a discriminator tag in unions.
349
+ tag_policy: ``"auto"`` (tag only when needed) or ``"always"`` (tag every union member).
350
+
351
+ Returns:
352
+ A plain dict representation.
353
+
354
+ Raises:
355
+ TypeError: If value is not a dataclass instance.
356
+ """
357
+ if isinstance(value, dict):
358
+ msg = (
359
+ "dump() takes a dataclass instance, not a dict."
360
+ " To write a raw config dict to a file, use dump_file() directly."
361
+ )
362
+ raise TypeError(msg)
363
+ if isinstance(value, type) or not _is_dc(type(value)):
364
+ tp_name = type(value).__name__
365
+ if _is_struct(type(value)):
366
+ msg = (
367
+ f"dump() only supports dataclass instances, not plain classes.\n"
368
+ f"{tp_name} is a plain class — keep the merged dict and dump that instead:\n"
369
+ f" raw = confarg.merge(...)\n"
370
+ f" confarg.dump_file(raw, path)"
371
+ )
372
+ raise TypeError(msg)
373
+ msg = f"Expected a dataclass instance, got {tp_name}"
374
+ raise TypeError(msg)
375
+ tp = type(value)
376
+ return _serialize(tp, value, "", union_tag, tag_policy)
377
+
378
+
379
+ def dump_file(
380
+ value: Any,
381
+ path: str | Path,
382
+ *,
383
+ union_tag: str = _defaults.UNION_TAG,
384
+ tag_policy: TagPolicy = "auto",
385
+ ) -> None:
386
+ """Serialize and write to a config file.
387
+
388
+ Accepts a dataclass instance or a raw config dict (e.g. from ``merge()``
389
+ or ``resolve()``). The output format is determined by the file extension
390
+ (.toml, .yaml, .yml, .json).
391
+
392
+ Args:
393
+ value: A dataclass instance or a raw config dict.
394
+ path: Path to the output file.
395
+ union_tag: The field name used as a discriminator tag in unions.
396
+ tag_policy: ``"auto"`` or ``"always"``.
397
+
398
+ Raises:
399
+ TypeError: If value is not a dataclass instance or a dict.
400
+ InvalidConfigFileError: If the format is unsupported or the required library is not installed.
401
+ """
402
+ if isinstance(value, dict):
403
+ _dump_file(_strip_str_tokens(value), Path(path))
404
+ else:
405
+ _dump_file(dump(value, union_tag=union_tag, tag_policy=tag_policy), Path(path))