lib-layered-config 4.1.0__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.
- lib_layered_config/__init__.py +58 -0
- lib_layered_config/__init__conf__.py +74 -0
- lib_layered_config/__main__.py +18 -0
- lib_layered_config/_layers.py +310 -0
- lib_layered_config/_platform.py +166 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/_nested_keys.py +126 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +143 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +288 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +376 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
- lib_layered_config/adapters/path_resolvers/_base.py +166 -0
- lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
- lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
- lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
- lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
- lib_layered_config/adapters/path_resolvers/default.py +194 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +379 -0
- lib_layered_config/application/ports.py +115 -0
- lib_layered_config/cli/__init__.py +92 -0
- lib_layered_config/cli/common.py +381 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +71 -0
- lib_layered_config/cli/fail.py +19 -0
- lib_layered_config/cli/generate.py +57 -0
- lib_layered_config/cli/info.py +29 -0
- lib_layered_config/cli/read.py +120 -0
- lib_layered_config/core.py +301 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +372 -0
- lib_layered_config/domain/errors.py +59 -0
- lib_layered_config/domain/identifiers.py +366 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +333 -0
- lib_layered_config/examples/generate.py +406 -0
- lib_layered_config/observability.py +209 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +46 -0
- lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
- lib_layered_config-4.1.0.dist-info/RECORD +47 -0
- lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
- lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Composition root tying adapters, merge policy, and domain objects together.
|
|
2
|
+
|
|
3
|
+
Implement the orchestration described in ``docs/systemdesign/concept.md`` by
|
|
4
|
+
discovering configuration layers, merging them with provenance, and returning a
|
|
5
|
+
domain-level :class:`Config` value object. Also provides convenience helpers for
|
|
6
|
+
JSON output and CLI wiring.
|
|
7
|
+
|
|
8
|
+
Contents:
|
|
9
|
+
- ``read_config`` / ``read_config_json`` / ``read_config_raw``: public APIs used
|
|
10
|
+
by library consumers and the CLI.
|
|
11
|
+
- ``LayerLoadError``: wraps adapter failures with a consistent exception type.
|
|
12
|
+
- Private helpers for resolver/builder construction, JSON dumping, and
|
|
13
|
+
configuration composition.
|
|
14
|
+
|
|
15
|
+
System Role:
|
|
16
|
+
This module sits at the composition layer of the architecture. It instantiates
|
|
17
|
+
adapters from ``lib_layered_config.adapters.*``, invokes
|
|
18
|
+
``lib_layered_config._layers.collect_layers``, and converts merge results into
|
|
19
|
+
domain objects returned to callers.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ._layers import collect_layers, merge_or_empty
|
|
29
|
+
from .adapters.dotenv.default import DefaultDotEnvLoader
|
|
30
|
+
from .adapters.env.default import DefaultEnvLoader, default_env_prefix
|
|
31
|
+
from .adapters.path_resolvers.default import DefaultPathResolver
|
|
32
|
+
from .application.merge import MergeResult
|
|
33
|
+
from .application.ports import SourceInfoPayload
|
|
34
|
+
from .domain.config import EMPTY_CONFIG, Config
|
|
35
|
+
from .domain.errors import (
|
|
36
|
+
ConfigError,
|
|
37
|
+
InvalidFormatError,
|
|
38
|
+
NotFoundError,
|
|
39
|
+
ValidationError,
|
|
40
|
+
)
|
|
41
|
+
from .observability import bind_trace_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LayerLoadError(ConfigError):
|
|
45
|
+
"""Adapter failure raised during layer collection.
|
|
46
|
+
|
|
47
|
+
Provides a single exception type for callers who need to distinguish merge
|
|
48
|
+
orchestration errors from other configuration issues.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_config(
|
|
53
|
+
*,
|
|
54
|
+
vendor: str,
|
|
55
|
+
app: str,
|
|
56
|
+
slug: str,
|
|
57
|
+
profile: str | None = None,
|
|
58
|
+
prefer: Sequence[str] | None = None,
|
|
59
|
+
start_dir: str | None = None,
|
|
60
|
+
default_file: str | Path | None = None,
|
|
61
|
+
) -> Config:
|
|
62
|
+
"""Return an immutable :class:`Config` built from all reachable layers.
|
|
63
|
+
|
|
64
|
+
Most consumers want the merged configuration value object rather than raw
|
|
65
|
+
dictionaries. This function wraps the lower-level helper and constructs the
|
|
66
|
+
domain aggregate in one step.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
vendor / app / slug: Identifiers used by adapters to compute filesystem paths and prefixes.
|
|
70
|
+
profile: Optional profile name for environment-specific configurations
|
|
71
|
+
(e.g., "test", "production"). When set, paths include a
|
|
72
|
+
``profile/<name>/`` subdirectory.
|
|
73
|
+
prefer: Optional sequence of preferred file suffixes (``["toml", "json"]``).
|
|
74
|
+
start_dir: Optional directory that seeds `.env` discovery.
|
|
75
|
+
default_file: Optional lowest-precedence file injected before filesystem layers.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Immutable configuration with provenance metadata.
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> from pathlib import Path
|
|
82
|
+
>>> tmp = Path('.') # doctest: +SKIP (illustrative)
|
|
83
|
+
>>> config = read_config(vendor="Acme", app="Demo", slug="demo", start_dir=str(tmp)) # doctest: +SKIP
|
|
84
|
+
>>> isinstance(config, Config)
|
|
85
|
+
True
|
|
86
|
+
"""
|
|
87
|
+
result = read_config_raw(
|
|
88
|
+
vendor=vendor,
|
|
89
|
+
app=app,
|
|
90
|
+
slug=slug,
|
|
91
|
+
profile=profile,
|
|
92
|
+
prefer=prefer,
|
|
93
|
+
start_dir=start_dir,
|
|
94
|
+
default_file=_stringify_path(default_file),
|
|
95
|
+
)
|
|
96
|
+
return _compose_config(result.data, result.provenance)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_config_json(
|
|
100
|
+
*,
|
|
101
|
+
vendor: str,
|
|
102
|
+
app: str,
|
|
103
|
+
slug: str,
|
|
104
|
+
profile: str | None = None,
|
|
105
|
+
prefer: Sequence[str] | None = None,
|
|
106
|
+
start_dir: str | Path | None = None,
|
|
107
|
+
indent: int | None = None,
|
|
108
|
+
default_file: str | Path | None = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Return configuration and provenance as JSON suitable for tooling.
|
|
111
|
+
|
|
112
|
+
CLI commands and automation scripts often prefer JSON to Python objects.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
vendor / app / slug / profile / prefer / start_dir / default_file: Same meaning as :func:`read_config`.
|
|
116
|
+
indent: Optional indentation level passed to ``json.dumps``.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
JSON document containing ``{"config": ..., "provenance": ...}``.
|
|
120
|
+
"""
|
|
121
|
+
result = read_config_raw(
|
|
122
|
+
vendor=vendor,
|
|
123
|
+
app=app,
|
|
124
|
+
slug=slug,
|
|
125
|
+
profile=profile,
|
|
126
|
+
prefer=prefer,
|
|
127
|
+
start_dir=_stringify_path(start_dir),
|
|
128
|
+
default_file=_stringify_path(default_file),
|
|
129
|
+
)
|
|
130
|
+
return _dump_json({"config": result.data, "provenance": result.provenance}, indent)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_config_raw(
|
|
134
|
+
*,
|
|
135
|
+
vendor: str,
|
|
136
|
+
app: str,
|
|
137
|
+
slug: str,
|
|
138
|
+
profile: str | None = None,
|
|
139
|
+
prefer: Sequence[str] | None = None,
|
|
140
|
+
start_dir: str | None = None,
|
|
141
|
+
default_file: str | Path | None = None,
|
|
142
|
+
) -> MergeResult:
|
|
143
|
+
"""Return raw merged data and provenance for advanced tooling.
|
|
144
|
+
|
|
145
|
+
Unlike :func:`read_config`, returns mutable dictionaries instead of the
|
|
146
|
+
immutable :class:`Config` abstraction. Raises :class:`LayerLoadError`
|
|
147
|
+
when a structured file loader encounters invalid content.
|
|
148
|
+
"""
|
|
149
|
+
resolver = _build_resolver(vendor=vendor, app=app, slug=slug, profile=profile, start_dir=start_dir)
|
|
150
|
+
dotenv_loader, env_loader = _build_loaders(resolver)
|
|
151
|
+
|
|
152
|
+
bind_trace_id(None)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
layers = collect_layers(
|
|
156
|
+
resolver=resolver,
|
|
157
|
+
prefer=prefer,
|
|
158
|
+
default_file=_stringify_path(default_file),
|
|
159
|
+
dotenv_loader=dotenv_loader,
|
|
160
|
+
env_loader=env_loader,
|
|
161
|
+
slug=slug,
|
|
162
|
+
start_dir=start_dir,
|
|
163
|
+
)
|
|
164
|
+
except InvalidFormatError as exc: # pragma: no cover - adapter tests exercise
|
|
165
|
+
raise LayerLoadError(str(exc)) from exc
|
|
166
|
+
|
|
167
|
+
return merge_or_empty(layers)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _compose_config(
|
|
171
|
+
data: dict[str, object],
|
|
172
|
+
raw_meta: dict[str, SourceInfoPayload],
|
|
173
|
+
) -> Config:
|
|
174
|
+
"""Wrap merged data and provenance into an immutable :class:`Config`.
|
|
175
|
+
|
|
176
|
+
Keep the boundary between application-layer dictionaries and the domain
|
|
177
|
+
value object explicit so provenance typing stays consistent.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
data: Mutable mapping returned by :func:`merge_layers`.
|
|
181
|
+
raw_meta: Provenance mapping keyed by dotted path as produced by the merge policy.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Immutable configuration aggregate. Returns :data:`EMPTY_CONFIG` when
|
|
185
|
+
*data* is empty.
|
|
186
|
+
|
|
187
|
+
Side Effects:
|
|
188
|
+
None beyond constructing the dataclass instance.
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
>>> cfg = _compose_config({'debug': True}, {'debug': {'layer': 'env', 'path': None, 'key': 'debug'}})
|
|
192
|
+
>>> cfg['debug'], cfg.origin('debug')['layer']
|
|
193
|
+
(True, 'env')
|
|
194
|
+
"""
|
|
195
|
+
if not data:
|
|
196
|
+
return EMPTY_CONFIG
|
|
197
|
+
return Config(data, raw_meta)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build_resolver(
|
|
201
|
+
*,
|
|
202
|
+
vendor: str,
|
|
203
|
+
app: str,
|
|
204
|
+
slug: str,
|
|
205
|
+
profile: str | None,
|
|
206
|
+
start_dir: str | None,
|
|
207
|
+
) -> DefaultPathResolver:
|
|
208
|
+
"""Create a path resolver configured with optional ``start_dir`` context.
|
|
209
|
+
|
|
210
|
+
Reuse the same resolver wiring for CLI and library entry points while
|
|
211
|
+
keeping construction logic centralised for testing.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
vendor / app / slug: Identifiers forwarded to :class:`DefaultPathResolver`.
|
|
215
|
+
profile: Optional profile name for environment-specific configuration paths.
|
|
216
|
+
start_dir: Optional directory that seeds project-relative resolution (used for
|
|
217
|
+
`.env` discovery); ``None`` preserves resolver defaults.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Resolver instance ready for layer discovery.
|
|
221
|
+
|
|
222
|
+
Examples:
|
|
223
|
+
>>> resolver = _build_resolver(vendor='Acme', app='Demo', slug='demo', profile=None, start_dir=None)
|
|
224
|
+
>>> resolver.slug
|
|
225
|
+
'demo'
|
|
226
|
+
"""
|
|
227
|
+
return DefaultPathResolver(
|
|
228
|
+
vendor=vendor, app=app, slug=slug, profile=profile, cwd=Path(start_dir) if start_dir else None
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_loaders(resolver: DefaultPathResolver) -> tuple[DefaultDotEnvLoader, DefaultEnvLoader]:
|
|
233
|
+
"""Instantiate dotenv and environment loaders sharing resolver context.
|
|
234
|
+
|
|
235
|
+
Keeps loader construction aligned with the resolver extras (e.g., additional
|
|
236
|
+
dotenv directories) and centralises wiring for tests.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
resolver: Resolver supplying platform-specific extras for dotenv discovery.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Pair of loader instances ready for layer collection.
|
|
243
|
+
"""
|
|
244
|
+
return DefaultDotEnvLoader(extras=resolver.dotenv()), DefaultEnvLoader()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _stringify_path(value: str | Path | None) -> str | None:
|
|
248
|
+
"""Convert ``Path`` or string inputs into plain string values for adapters.
|
|
249
|
+
|
|
250
|
+
Adapters expect plain strings while public APIs accept :class:`Path` objects
|
|
251
|
+
for user convenience. Centralising the conversion avoids duplicate logic.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
value: Optional path expressed as either a string or :class:`pathlib.Path`.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Stringified path or ``None`` when *value* is ``None``.
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
>>> _stringify_path(Path('/tmp/config.toml'))
|
|
261
|
+
'/tmp/config.toml'
|
|
262
|
+
>>> _stringify_path(None) is None
|
|
263
|
+
True
|
|
264
|
+
"""
|
|
265
|
+
if isinstance(value, Path):
|
|
266
|
+
return str(value)
|
|
267
|
+
return value
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _dump_json(payload: object, indent: int | None) -> str:
|
|
271
|
+
"""Serialise *payload* to JSON while preserving non-ASCII characters.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
payload: JSON-serialisable object to dump.
|
|
275
|
+
indent: Optional indentation level mirroring :func:`json.dumps`. ``None`` produces
|
|
276
|
+
the most compact output.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
JSON document encoded as UTF-8 friendly text.
|
|
280
|
+
|
|
281
|
+
Examples:
|
|
282
|
+
>>> _dump_json({"a": 1}, indent=None)
|
|
283
|
+
'{"a":1}'
|
|
284
|
+
>>> "\n" in _dump_json({"a": 1}, indent=2)
|
|
285
|
+
True
|
|
286
|
+
"""
|
|
287
|
+
return json.dumps(payload, indent=indent, separators=(",", ":"), ensure_ascii=False)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = [
|
|
291
|
+
"Config",
|
|
292
|
+
"ConfigError",
|
|
293
|
+
"InvalidFormatError",
|
|
294
|
+
"ValidationError",
|
|
295
|
+
"NotFoundError",
|
|
296
|
+
"LayerLoadError",
|
|
297
|
+
"read_config",
|
|
298
|
+
"read_config_json",
|
|
299
|
+
"read_config_raw",
|
|
300
|
+
"default_env_prefix",
|
|
301
|
+
]
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Immutable configuration value object with provenance tracking.
|
|
2
|
+
|
|
3
|
+
Provide the "configuration aggregate" described in
|
|
4
|
+
``docs/systemdesign/concept.md``: an immutable mapping that preserves both the
|
|
5
|
+
final merged values and the metadata explaining *where* every dotted key was
|
|
6
|
+
sourced. The application and adapter layers rely on this module to honour the
|
|
7
|
+
precedence rules documented for layered configuration.
|
|
8
|
+
|
|
9
|
+
Contents:
|
|
10
|
+
- ``SourceInfo``: typed dictionary describing layer, path, and dotted key.
|
|
11
|
+
- ``Config``: frozen mapping-like dataclass exposing lookup, provenance, and
|
|
12
|
+
serialisation helpers.
|
|
13
|
+
- Internal helpers (``_follow_path``, ``_clone_map`` ...) that keep traversal
|
|
14
|
+
logic pure and testable.
|
|
15
|
+
- ``EMPTY_CONFIG``: canonical empty instance shared across the composition
|
|
16
|
+
root and CLI utilities.
|
|
17
|
+
|
|
18
|
+
System Role:
|
|
19
|
+
The composition root builds ``Config`` instances after merging layer snapshots.
|
|
20
|
+
Presentation layers (CLI, examples) consume the public API to render human or
|
|
21
|
+
JSON output without re-implementing provenance rules.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
28
|
+
from collections.abc import Mapping as MappingABC
|
|
29
|
+
from collections.abc import Mapping as MappingType
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from types import MappingProxyType
|
|
32
|
+
from typing import Any, TypedDict, TypeGuard, TypeVar, cast
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SourceInfo(TypedDict):
|
|
36
|
+
"""Describe the provenance of a configuration value.
|
|
37
|
+
|
|
38
|
+
Downstream tooling (CLI, deploy helpers) needs to display where a value
|
|
39
|
+
originated so operators can trace precedence decisions.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
layer: Name of the logical layer (``"defaults"``, ``"app"``, ``"host"``,
|
|
43
|
+
``"user"``, ``"dotenv"``, or ``"env"``).
|
|
44
|
+
path: Absolute filesystem path when known; ``None`` for ephemeral sources
|
|
45
|
+
such as environment variables.
|
|
46
|
+
key: Fully-qualified dotted key corresponding to the stored value.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
layer: str
|
|
50
|
+
path: str | None
|
|
51
|
+
key: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
T = TypeVar("T")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class Config(MappingABC[str, Any]):
|
|
59
|
+
"""Immutable mapping plus provenance metadata for a merged configuration.
|
|
60
|
+
|
|
61
|
+
The system design mandates that merged configuration stays read-only after
|
|
62
|
+
assembly so every layer sees a consistent snapshot. ``Config`` enforces that
|
|
63
|
+
contract while providing ergonomic helpers for dotted lookups and
|
|
64
|
+
serialisation.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
_data: Mapping containing the merged configuration tree. Stored as a
|
|
68
|
+
``MappingProxyType`` to prevent mutation.
|
|
69
|
+
_meta: Mapping of dotted keys to :class:`SourceInfo`, allowing provenance
|
|
70
|
+
queries via :meth:`origin`.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_data: Mapping[str, Any]
|
|
74
|
+
_meta: Mapping[str, SourceInfo]
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
"""Freeze internal mappings immediately after construction."""
|
|
78
|
+
object.__setattr__(self, "_data", _lock_map(self._data))
|
|
79
|
+
object.__setattr__(self, "_meta", _lock_map(self._meta))
|
|
80
|
+
|
|
81
|
+
def __getitem__(self, key: str) -> Any:
|
|
82
|
+
"""Return the value stored under a top-level key.
|
|
83
|
+
|
|
84
|
+
Consumers expect ``Config`` to behave like a standard mapping.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Top-level key to retrieve.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Stored value.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
KeyError: When *key* does not exist.
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
>>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
|
|
97
|
+
>>> cfg["debug"]
|
|
98
|
+
True
|
|
99
|
+
"""
|
|
100
|
+
return self._data[key]
|
|
101
|
+
|
|
102
|
+
def __iter__(self) -> Iterator[str]:
|
|
103
|
+
"""Iterate over top-level keys in insertion order."""
|
|
104
|
+
return iter(self._data)
|
|
105
|
+
|
|
106
|
+
def __len__(self) -> int:
|
|
107
|
+
"""Return the number of stored top-level keys."""
|
|
108
|
+
return len(self._data)
|
|
109
|
+
|
|
110
|
+
def as_dict(self) -> dict[str, Any]:
|
|
111
|
+
"""Return a deep, mutable copy of the configuration tree.
|
|
112
|
+
|
|
113
|
+
Callers occasionally need to serialise or further mutate the data in a
|
|
114
|
+
context that does not require provenance.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Independent copy of the configuration data.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
|
|
121
|
+
>>> clone = cfg.as_dict()
|
|
122
|
+
>>> clone["debug"]
|
|
123
|
+
True
|
|
124
|
+
>>> clone["debug"] = False
|
|
125
|
+
>>> cfg["debug"]
|
|
126
|
+
True
|
|
127
|
+
"""
|
|
128
|
+
return _clone_map(self._data)
|
|
129
|
+
|
|
130
|
+
def to_json(self, *, indent: int | None = None) -> str:
|
|
131
|
+
"""Serialise the configuration as JSON.
|
|
132
|
+
|
|
133
|
+
CLI tooling and documentation examples render the merged configuration
|
|
134
|
+
in JSON to support piping into other scripts.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
indent: Optional indentation level mirroring ``json.dumps`` semantics.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
JSON payload containing the cloned configuration data.
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
>>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
|
|
144
|
+
>>> cfg.to_json()
|
|
145
|
+
'{"debug":true}'
|
|
146
|
+
>>> "\\n \\"debug\\"" in cfg.to_json(indent=2)
|
|
147
|
+
True
|
|
148
|
+
"""
|
|
149
|
+
return json.dumps(self.as_dict(), indent=indent, separators=(",", ":"), ensure_ascii=False)
|
|
150
|
+
|
|
151
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
152
|
+
"""Return the value for *key* or a default when the path is missing.
|
|
153
|
+
|
|
154
|
+
Layered configuration relies on dotted keys (e.g. ``"db.host"``).
|
|
155
|
+
This helper avoids repetitive traversal code at call sites.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
key: Dotted path identifying nested entries.
|
|
159
|
+
default: Value to return when the path does not resolve or encounters a
|
|
160
|
+
non-mapping.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The resolved value or *default* when missing.
|
|
164
|
+
|
|
165
|
+
Examples:
|
|
166
|
+
>>> cfg = Config({"db": {"host": "localhost"}}, {"db.host": {"layer": "app", "path": None, "key": "db.host"}})
|
|
167
|
+
>>> cfg.get("db.host")
|
|
168
|
+
'localhost'
|
|
169
|
+
>>> cfg.get("db.port", default=5432)
|
|
170
|
+
5432
|
|
171
|
+
"""
|
|
172
|
+
return _follow_path(self._data, key, default)
|
|
173
|
+
|
|
174
|
+
def origin(self, key: str) -> SourceInfo | None:
|
|
175
|
+
"""Return provenance metadata for *key* when available.
|
|
176
|
+
|
|
177
|
+
Operators need to understand which layer supplied a value to debug
|
|
178
|
+
precedence questions.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
key: Dotted key in the metadata map.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Metadata dictionary or ``None`` if the key was never observed.
|
|
185
|
+
|
|
186
|
+
Examples:
|
|
187
|
+
>>> meta = {"db.host": {"layer": "app", "path": "/etc/app.toml", "key": "db.host"}}
|
|
188
|
+
>>> cfg = Config({"db": {"host": "localhost"}}, meta)
|
|
189
|
+
>>> cfg.origin("db.host")["layer"]
|
|
190
|
+
'app'
|
|
191
|
+
>>> cfg.origin("missing") is None
|
|
192
|
+
True
|
|
193
|
+
"""
|
|
194
|
+
return self._meta.get(key)
|
|
195
|
+
|
|
196
|
+
def with_overrides(self, overrides: Mapping[str, Any]) -> Config:
|
|
197
|
+
"""Return a new configuration with shallow top-level overrides applied.
|
|
198
|
+
|
|
199
|
+
CLI helpers allow callers to inject ad-hoc overrides while keeping the
|
|
200
|
+
original snapshot intact. This method produces that variant.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
overrides: Top-level keys and values to override.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
New configuration instance sharing provenance with the original.
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
>>> cfg = Config({"feature": False}, {"feature": {"layer": "app", "path": None, "key": "feature"}})
|
|
210
|
+
>>> cfg.with_overrides({"feature": True})["feature"], cfg["feature"]
|
|
211
|
+
(True, False)
|
|
212
|
+
"""
|
|
213
|
+
tinted = _blend_top_level(self._data, overrides)
|
|
214
|
+
return Config(tinted, self._meta)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _lock_map(mapping: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
218
|
+
"""Return a read-only view of *mapping*.
|
|
219
|
+
|
|
220
|
+
Internal state must remain immutable to uphold the domain contract.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
mapping: Mapping to wrap. A shallow copy protects against caller mutation.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
``MappingProxyType`` over a copy of the source mapping.
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
>>> view = _lock_map({"flag": True})
|
|
230
|
+
>>> view["flag"], isinstance(view, MappingProxyType)
|
|
231
|
+
(True, True)
|
|
232
|
+
"""
|
|
233
|
+
return MappingProxyType(dict(mapping))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _blend_top_level(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
|
|
237
|
+
"""Return a shallow copy of *base* with *overrides* applied.
|
|
238
|
+
|
|
239
|
+
``Config.with_overrides`` depends on a pure helper so it can reuse
|
|
240
|
+
provenance metadata without mutation.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
base: Original mapping.
|
|
244
|
+
overrides: Mapping whose keys replace entries in *base*.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
New dictionary with updated top-level values.
|
|
248
|
+
|
|
249
|
+
Examples:
|
|
250
|
+
>>> _blend_top_level({"port": 8000}, {"port": 9000})["port"]
|
|
251
|
+
9000
|
|
252
|
+
"""
|
|
253
|
+
tinted = dict(base)
|
|
254
|
+
tinted.update(overrides)
|
|
255
|
+
return tinted
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _follow_path(source: Mapping[str, Any], dotted: str, default: Any) -> Any:
|
|
259
|
+
"""Traverse *source* using dotted notation.
|
|
260
|
+
|
|
261
|
+
Nested configuration should be accessible without exposing internal data
|
|
262
|
+
structures. This helper powers :meth:`Config.get`.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
source: Mapping to traverse.
|
|
266
|
+
dotted: Dotted path, e.g. ``"db.host"``.
|
|
267
|
+
default: Fallback when traversal fails.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Resolved value or *default*.
|
|
271
|
+
|
|
272
|
+
Examples:
|
|
273
|
+
>>> payload = {"db": {"host": "localhost"}}
|
|
274
|
+
>>> _follow_path(payload, "db.host", default=None)
|
|
275
|
+
'localhost'
|
|
276
|
+
>>> _follow_path(payload, "db.port", default=5432)
|
|
277
|
+
5432
|
|
278
|
+
"""
|
|
279
|
+
current: object = source
|
|
280
|
+
for fragment in dotted.split("."):
|
|
281
|
+
if not _looks_like_mapping(current):
|
|
282
|
+
return default
|
|
283
|
+
if fragment not in current:
|
|
284
|
+
return default
|
|
285
|
+
current = current[fragment]
|
|
286
|
+
return cast(Any, current)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _clone_map(mapping: MappingType[str, Any]) -> dict[str, Any]:
|
|
290
|
+
"""Deep-clone *mapping* while preserving container types.
|
|
291
|
+
|
|
292
|
+
``Config.as_dict`` and JSON serialisation must not leak references to the
|
|
293
|
+
internal immutable structures.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
mapping: Mapping to clone.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Deep copy containing cloned containers and scalar values.
|
|
300
|
+
|
|
301
|
+
Examples:
|
|
302
|
+
>>> original = {"levels": (1, 2), "queue": [1, 2]}
|
|
303
|
+
>>> cloned = _clone_map(original)
|
|
304
|
+
>>> cloned["levels"], cloned["queue"]
|
|
305
|
+
((1, 2), [1, 2])
|
|
306
|
+
>>> cloned["queue"].append(3)
|
|
307
|
+
>>> original["queue"]
|
|
308
|
+
[1, 2]
|
|
309
|
+
"""
|
|
310
|
+
sculpted: dict[str, Any] = {}
|
|
311
|
+
for key, value in mapping.items():
|
|
312
|
+
sculpted[key] = _clone_value(value)
|
|
313
|
+
return sculpted
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _clone_value(value: Any) -> Any:
|
|
317
|
+
"""Return a clone of *value*, respecting the container type.
|
|
318
|
+
|
|
319
|
+
``_clone_map`` delegates element cloning here so complex structures (lists,
|
|
320
|
+
sets, tuples, nested mappings) remain detached from the immutable source.
|
|
321
|
+
|
|
322
|
+
Examples:
|
|
323
|
+
>>> cloned = _clone_value(({"flag": True},))
|
|
324
|
+
>>> cloned
|
|
325
|
+
({'flag': True},)
|
|
326
|
+
>>> cloned is _clone_value(({"flag": True},))
|
|
327
|
+
False
|
|
328
|
+
"""
|
|
329
|
+
if isinstance(value, MappingABC):
|
|
330
|
+
nested = cast(MappingType[str, Any], value)
|
|
331
|
+
return _clone_map(nested)
|
|
332
|
+
if isinstance(value, list):
|
|
333
|
+
items = cast(list[Any], value)
|
|
334
|
+
return [_clone_value(item) for item in items]
|
|
335
|
+
if isinstance(value, set):
|
|
336
|
+
items = cast(set[Any], value)
|
|
337
|
+
return {_clone_value(item) for item in items}
|
|
338
|
+
if isinstance(value, tuple):
|
|
339
|
+
items = cast(tuple[Any, ...], value)
|
|
340
|
+
return tuple(_clone_value(item) for item in items)
|
|
341
|
+
return value
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _looks_like_mapping(value: object) -> TypeGuard[MappingType[str, Any]]:
|
|
345
|
+
"""Return ``True`` when *value* is a mapping with string keys.
|
|
346
|
+
|
|
347
|
+
Dotted traversal should stop when encountering scalars or non-string-keyed
|
|
348
|
+
mappings to avoid surprising behaviour.
|
|
349
|
+
|
|
350
|
+
Examples:
|
|
351
|
+
>>> _looks_like_mapping({"key": 1})
|
|
352
|
+
True
|
|
353
|
+
>>> _looks_like_mapping({1: "value"})
|
|
354
|
+
False
|
|
355
|
+
>>> _looks_like_mapping(["not", "mapping"])
|
|
356
|
+
False
|
|
357
|
+
"""
|
|
358
|
+
if not isinstance(value, MappingABC):
|
|
359
|
+
return False
|
|
360
|
+
for key in cast(Iterable[object], value.keys()):
|
|
361
|
+
if not isinstance(key, str):
|
|
362
|
+
return False
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
EMPTY_CONFIG = Config(MappingProxyType({}), MappingProxyType({}))
|
|
367
|
+
"""Shared empty configuration used by the composition root and CLI helpers.
|
|
368
|
+
|
|
369
|
+
Avoids repeated allocations when no layers contribute values. The empty
|
|
370
|
+
instance satisfies the domain contract (immutability, provenance available but
|
|
371
|
+
empty) and is safe to reuse across contexts.
|
|
372
|
+
"""
|