lib-layered-config 1.0.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.
Potentially problematic release.
This version of lib-layered-config might be problematic. Click here for more details.
- lib_layered_config/__init__.py +60 -0
- lib_layered_config/__main__.py +19 -0
- lib_layered_config/_layers.py +457 -0
- lib_layered_config/_platform.py +200 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +438 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +509 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +410 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
- lib_layered_config/adapters/path_resolvers/default.py +727 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +442 -0
- lib_layered_config/application/ports.py +109 -0
- lib_layered_config/cli/__init__.py +162 -0
- lib_layered_config/cli/common.py +232 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +70 -0
- lib_layered_config/cli/fail.py +21 -0
- lib_layered_config/cli/generate.py +60 -0
- lib_layered_config/cli/info.py +31 -0
- lib_layered_config/cli/read.py +117 -0
- lib_layered_config/core.py +384 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +490 -0
- lib_layered_config/domain/errors.py +65 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +305 -0
- lib_layered_config/examples/generate.py +537 -0
- lib_layered_config/observability.py +306 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +55 -0
- lib_layered_config-1.0.0.dist-info/METADATA +366 -0
- lib_layered_config-1.0.0.dist-info/RECORD +39 -0
- lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
- lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-1.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Public API surface for ``lib_layered_config``.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Expose the curated, stable symbols that consumers need to interact with the
|
|
6
|
+
library: reader functions, value object, error taxonomy, and observability
|
|
7
|
+
helpers.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
--------
|
|
11
|
+
* :func:`lib_layered_config.core.read_config`
|
|
12
|
+
* :func:`lib_layered_config.core.read_config_raw`
|
|
13
|
+
* :func:`lib_layered_config.examples.deploy.deploy_config`
|
|
14
|
+
* :class:`lib_layered_config.domain.config.Config`
|
|
15
|
+
* Error hierarchy (:class:`ConfigError`, :class:`InvalidFormat`, etc.)
|
|
16
|
+
* Diagnostics helpers (:func:`lib_layered_config.testing.i_should_fail`)
|
|
17
|
+
* Observability bindings (:func:`bind_trace_id`, :func:`get_logger`)
|
|
18
|
+
|
|
19
|
+
System Role
|
|
20
|
+
-----------
|
|
21
|
+
Acts as the frontline module imported by applications, keeping the public
|
|
22
|
+
surface area deliberate and well-documented (see
|
|
23
|
+
``docs/systemdesign/module_reference.md``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from .core import (
|
|
29
|
+
Config,
|
|
30
|
+
ConfigError,
|
|
31
|
+
InvalidFormat,
|
|
32
|
+
LayerLoadError,
|
|
33
|
+
NotFound,
|
|
34
|
+
ValidationError,
|
|
35
|
+
default_env_prefix,
|
|
36
|
+
read_config,
|
|
37
|
+
read_config_json,
|
|
38
|
+
read_config_raw,
|
|
39
|
+
)
|
|
40
|
+
from .observability import bind_trace_id, get_logger
|
|
41
|
+
from .examples import deploy_config, generate_examples
|
|
42
|
+
from .testing import i_should_fail
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Config",
|
|
46
|
+
"ConfigError",
|
|
47
|
+
"InvalidFormat",
|
|
48
|
+
"ValidationError",
|
|
49
|
+
"NotFound",
|
|
50
|
+
"LayerLoadError",
|
|
51
|
+
"read_config",
|
|
52
|
+
"read_config_json",
|
|
53
|
+
"read_config_raw",
|
|
54
|
+
"deploy_config",
|
|
55
|
+
"generate_examples",
|
|
56
|
+
"default_env_prefix",
|
|
57
|
+
"i_should_fail",
|
|
58
|
+
"bind_trace_id",
|
|
59
|
+
"get_logger",
|
|
60
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Let ``python -m lib_layered_config`` feel as gentle as ``cli.main``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from .cli import main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_module(arguments: Sequence[str] | None = None) -> int:
|
|
11
|
+
"""Forward *arguments* to :func:`lib_layered_config.cli.main` and return the exit code."""
|
|
12
|
+
|
|
13
|
+
return main(arguments, restore_traceback=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
raise SystemExit(run_module(sys.argv[1:]))
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Assemble configuration layers prior to merging.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Provide a composition helper that coordinates filesystem discovery, dotenv
|
|
6
|
+
loading, environment ingestion, and defaults injection before passing
|
|
7
|
+
``LayerSnapshot`` instances to the merge policy.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
--------
|
|
11
|
+
- ``collect_layers``: orchestrator returning a list of snapshots.
|
|
12
|
+
- ``merge_or_empty``: convenience wrapper combining collect/merge behaviour.
|
|
13
|
+
- Internal generators that yield defaults, filesystem, dotenv, and environment
|
|
14
|
+
snapshots in documented precedence order.
|
|
15
|
+
|
|
16
|
+
System Role
|
|
17
|
+
-----------
|
|
18
|
+
Invoked exclusively by ``lib_layered_config.core``. Keeps orchestration logic
|
|
19
|
+
separate from adapters while remaining independent of the domain layer.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Iterable, Iterator, Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
from .application.merge import LayerSnapshot, SourceInfoPayload, merge_layers
|
|
28
|
+
from .adapters.dotenv.default import DefaultDotEnvLoader
|
|
29
|
+
from .adapters.env.default import DefaultEnvLoader, default_env_prefix
|
|
30
|
+
from .adapters.file_loaders.structured import JSONFileLoader, TOMLFileLoader, YAMLFileLoader
|
|
31
|
+
from .adapters.path_resolvers.default import DefaultPathResolver
|
|
32
|
+
from .domain.errors import InvalidFormat, NotFound
|
|
33
|
+
from .observability import log_debug, log_info, make_event
|
|
34
|
+
|
|
35
|
+
#: Mapping from file suffix to loader instance. The ordering preserves the
|
|
36
|
+
#: precedence documented for structured configuration formats while keeping all
|
|
37
|
+
#: logic in one place.
|
|
38
|
+
_FILE_LOADERS = {
|
|
39
|
+
".toml": TOMLFileLoader(),
|
|
40
|
+
".json": JSONFileLoader(),
|
|
41
|
+
".yaml": YAMLFileLoader(),
|
|
42
|
+
".yml": YAMLFileLoader(),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
__all__ = ["collect_layers", "merge_or_empty"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def collect_layers(
|
|
49
|
+
*,
|
|
50
|
+
resolver: DefaultPathResolver,
|
|
51
|
+
prefer: Sequence[str] | None,
|
|
52
|
+
default_file: str | None,
|
|
53
|
+
dotenv_loader: DefaultDotEnvLoader,
|
|
54
|
+
env_loader: DefaultEnvLoader,
|
|
55
|
+
slug: str,
|
|
56
|
+
start_dir: str | None,
|
|
57
|
+
) -> list[LayerSnapshot]:
|
|
58
|
+
"""Return layer snapshots in precedence order.
|
|
59
|
+
|
|
60
|
+
Why
|
|
61
|
+
----
|
|
62
|
+
Centralises discovery so :func:`lib_layered_config.core.read_config_raw`
|
|
63
|
+
stays focused on error handling and orchestration while keeping precedence
|
|
64
|
+
logic self-contained.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
resolver:
|
|
69
|
+
Path resolver supplying filesystem candidates for ``app``/``host``/``user``
|
|
70
|
+
layers.
|
|
71
|
+
prefer:
|
|
72
|
+
Optional ordered list of preferred suffixes (e.g. ``["toml", "json"]``)
|
|
73
|
+
influencing filesystem candidate sorting.
|
|
74
|
+
default_file:
|
|
75
|
+
Optional lowest-precedence configuration file injected before filesystem
|
|
76
|
+
layers.
|
|
77
|
+
dotenv_loader:
|
|
78
|
+
Loader used to parse ``.env`` files.
|
|
79
|
+
env_loader:
|
|
80
|
+
Loader used to translate environment variables using the documented
|
|
81
|
+
prefix rules.
|
|
82
|
+
slug:
|
|
83
|
+
Slug identifying the configuration family (used for environment prefix
|
|
84
|
+
construction when no ``default_file`` is provided).
|
|
85
|
+
start_dir:
|
|
86
|
+
Optional directory that seeds the ``.env`` upward search.
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
list[LayerSnapshot]
|
|
91
|
+
Snapshot sequence ordered from lowest to highest precedence.
|
|
92
|
+
|
|
93
|
+
Side Effects
|
|
94
|
+
------------
|
|
95
|
+
Emits structured logging events via ``_note_layer_loaded`` when layers are
|
|
96
|
+
discovered.
|
|
97
|
+
|
|
98
|
+
Examples
|
|
99
|
+
--------
|
|
100
|
+
>>> from tempfile import TemporaryDirectory
|
|
101
|
+
>>> class StubResolver:
|
|
102
|
+
... def app(self):
|
|
103
|
+
... return ()
|
|
104
|
+
... def host(self):
|
|
105
|
+
... return ()
|
|
106
|
+
... def user(self):
|
|
107
|
+
... return ()
|
|
108
|
+
>>> class StubDotenv:
|
|
109
|
+
... last_loaded_path = None
|
|
110
|
+
... def load(self, start_dir):
|
|
111
|
+
... return {}
|
|
112
|
+
>>> class StubEnv:
|
|
113
|
+
... def load(self, prefix):
|
|
114
|
+
... return {}
|
|
115
|
+
>>> tmp = TemporaryDirectory()
|
|
116
|
+
>>> defaults = Path(tmp.name) / 'defaults.toml'
|
|
117
|
+
>>> _ = defaults.write_text('value = 1', encoding='utf-8')
|
|
118
|
+
>>> snapshots = collect_layers(
|
|
119
|
+
... resolver=StubResolver(),
|
|
120
|
+
... prefer=None,
|
|
121
|
+
... default_file=str(defaults),
|
|
122
|
+
... dotenv_loader=StubDotenv(),
|
|
123
|
+
... env_loader=StubEnv(),
|
|
124
|
+
... slug='demo',
|
|
125
|
+
... start_dir=None,
|
|
126
|
+
... )
|
|
127
|
+
>>> [(snap.name, snap.origin.endswith('defaults.toml')) for snap in snapshots]
|
|
128
|
+
[('defaults', True)]
|
|
129
|
+
>>> tmp.cleanup()
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
return list(
|
|
133
|
+
_snapshots_in_merge_sequence(
|
|
134
|
+
resolver=resolver,
|
|
135
|
+
prefer=prefer,
|
|
136
|
+
default_file=default_file,
|
|
137
|
+
dotenv_loader=dotenv_loader,
|
|
138
|
+
env_loader=env_loader,
|
|
139
|
+
slug=slug,
|
|
140
|
+
start_dir=start_dir,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _snapshots_in_merge_sequence(
|
|
146
|
+
*,
|
|
147
|
+
resolver: DefaultPathResolver,
|
|
148
|
+
prefer: Sequence[str] | None,
|
|
149
|
+
default_file: str | None,
|
|
150
|
+
dotenv_loader: DefaultDotEnvLoader,
|
|
151
|
+
env_loader: DefaultEnvLoader,
|
|
152
|
+
slug: str,
|
|
153
|
+
start_dir: str | None,
|
|
154
|
+
) -> Iterator[LayerSnapshot]:
|
|
155
|
+
"""Yield layer snapshots in the documented merge order.
|
|
156
|
+
|
|
157
|
+
Why
|
|
158
|
+
----
|
|
159
|
+
Capture the precedence hierarchy (`defaults → app → host → user → dotenv → env`)
|
|
160
|
+
in one generator so callers cannot accidentally skip a layer.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
resolver / prefer / default_file / dotenv_loader / env_loader / slug / start_dir:
|
|
165
|
+
Same meaning as :func:`collect_layers`.
|
|
166
|
+
|
|
167
|
+
Yields
|
|
168
|
+
------
|
|
169
|
+
LayerSnapshot
|
|
170
|
+
Snapshot tuples ready for the merge policy.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
yield from _default_snapshots(default_file)
|
|
174
|
+
yield from _filesystem_snapshots(resolver, prefer)
|
|
175
|
+
yield from _dotenv_snapshots(dotenv_loader, start_dir)
|
|
176
|
+
yield from _env_snapshots(env_loader, slug)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def merge_or_empty(layers: list[LayerSnapshot]) -> tuple[dict[str, object], dict[str, SourceInfoPayload]]:
|
|
180
|
+
"""Merge collected layers or return empty dictionaries when none exist.
|
|
181
|
+
|
|
182
|
+
Why
|
|
183
|
+
----
|
|
184
|
+
Provides a guard so callers do not have to special-case empty layer collections.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
layers:
|
|
189
|
+
Layer snapshots in precedence order.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
tuple[dict[str, object], dict[str, SourceInfoPayload]]
|
|
194
|
+
Pair containing merged configuration data and provenance mappings.
|
|
195
|
+
|
|
196
|
+
Side Effects
|
|
197
|
+
------------
|
|
198
|
+
Emits ``configuration_empty`` or ``configuration_merged`` events depending on
|
|
199
|
+
the layer count.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
if not layers:
|
|
203
|
+
_note_configuration_empty()
|
|
204
|
+
return {}, {}
|
|
205
|
+
|
|
206
|
+
merged = merge_layers(layers)
|
|
207
|
+
_note_merge_complete(len(layers))
|
|
208
|
+
return merged
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _default_snapshots(default_file: str | None) -> Iterator[LayerSnapshot]:
|
|
212
|
+
"""Yield a defaults snapshot when *default_file* is supplied.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
default_file:
|
|
217
|
+
Absolute path string to the optional defaults file.
|
|
218
|
+
|
|
219
|
+
Yields
|
|
220
|
+
------
|
|
221
|
+
LayerSnapshot
|
|
222
|
+
Snapshot describing the defaults layer.
|
|
223
|
+
|
|
224
|
+
Side Effects
|
|
225
|
+
------------
|
|
226
|
+
Emits ``layer_loaded`` events when a defaults file is parsed.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
if not default_file:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
snapshot = _load_entry("defaults", default_file)
|
|
233
|
+
if snapshot is None:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
_note_layer_loaded(snapshot.name, snapshot.origin, {"keys": len(snapshot.payload)})
|
|
237
|
+
yield snapshot
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _filesystem_snapshots(resolver: DefaultPathResolver, prefer: Sequence[str] | None) -> Iterator[LayerSnapshot]:
|
|
241
|
+
"""Yield filesystem-backed layer snapshots in precedence order.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
resolver:
|
|
246
|
+
Path resolver supplying candidate paths per layer.
|
|
247
|
+
prefer:
|
|
248
|
+
Optional suffix ordering applied when multiple files exist.
|
|
249
|
+
|
|
250
|
+
Yields
|
|
251
|
+
------
|
|
252
|
+
LayerSnapshot
|
|
253
|
+
Snapshots for ``app``/``host``/``user`` layers.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
for layer, paths in (
|
|
257
|
+
("app", resolver.app()),
|
|
258
|
+
("host", resolver.host()),
|
|
259
|
+
("user", resolver.user()),
|
|
260
|
+
):
|
|
261
|
+
snapshots = list(_snapshots_from_paths(layer, paths, prefer))
|
|
262
|
+
if snapshots:
|
|
263
|
+
_note_layer_loaded(layer, None, {"files": len(snapshots)})
|
|
264
|
+
yield from snapshots
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _dotenv_snapshots(loader: DefaultDotEnvLoader, start_dir: str | None) -> Iterator[LayerSnapshot]:
|
|
268
|
+
"""Yield a snapshot for dotenv-provided values when present.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
loader:
|
|
273
|
+
Dotenv loader that handles discovery and parsing.
|
|
274
|
+
start_dir:
|
|
275
|
+
Optional starting directory for the upward search.
|
|
276
|
+
|
|
277
|
+
Yields
|
|
278
|
+
------
|
|
279
|
+
LayerSnapshot
|
|
280
|
+
Snapshot representing the ``dotenv`` layer when a file exists.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
data = loader.load(start_dir)
|
|
284
|
+
if not data:
|
|
285
|
+
return
|
|
286
|
+
_note_layer_loaded("dotenv", loader.last_loaded_path, {"keys": len(data)})
|
|
287
|
+
yield LayerSnapshot("dotenv", data, loader.last_loaded_path)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _env_snapshots(loader: DefaultEnvLoader, slug: str) -> Iterator[LayerSnapshot]:
|
|
291
|
+
"""Yield a snapshot for environment-variable configuration.
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
loader:
|
|
296
|
+
Environment loader converting prefixed variables into nested mappings.
|
|
297
|
+
slug:
|
|
298
|
+
Slug identifying the configuration family.
|
|
299
|
+
|
|
300
|
+
Yields
|
|
301
|
+
------
|
|
302
|
+
LayerSnapshot
|
|
303
|
+
Snapshot for the ``env`` layer when variables are present.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
prefix = default_env_prefix(slug)
|
|
307
|
+
data = loader.load(prefix)
|
|
308
|
+
if not data:
|
|
309
|
+
return
|
|
310
|
+
_note_layer_loaded("env", None, {"keys": len(data)})
|
|
311
|
+
yield LayerSnapshot("env", data, None)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _snapshots_from_paths(layer: str, paths: Iterable[str], prefer: Sequence[str] | None) -> Iterator[LayerSnapshot]:
|
|
315
|
+
"""Yield snapshots for every supported file inside *paths*.
|
|
316
|
+
|
|
317
|
+
Parameters
|
|
318
|
+
----------
|
|
319
|
+
layer:
|
|
320
|
+
Logical layer name the files belong to.
|
|
321
|
+
paths:
|
|
322
|
+
Iterable of candidate file paths.
|
|
323
|
+
prefer:
|
|
324
|
+
Optional suffix ordering hint passed by the CLI/API.
|
|
325
|
+
|
|
326
|
+
Yields
|
|
327
|
+
------
|
|
328
|
+
LayerSnapshot
|
|
329
|
+
Snapshot for each successfully loaded file.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
for path in _paths_in_preferred_order(paths, prefer):
|
|
333
|
+
snapshot = _load_entry(layer, path)
|
|
334
|
+
if snapshot is not None:
|
|
335
|
+
yield snapshot
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _load_entry(layer: str, path: str) -> LayerSnapshot | None:
|
|
339
|
+
"""Load *path* using the configured file loaders and return a snapshot.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
layer:
|
|
344
|
+
Logical layer name associated with the file.
|
|
345
|
+
path:
|
|
346
|
+
Absolute path to the candidate configuration file.
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
LayerSnapshot | None
|
|
351
|
+
Snapshot when parsing succeeds and data is non-empty; otherwise ``None``.
|
|
352
|
+
|
|
353
|
+
Raises
|
|
354
|
+
------
|
|
355
|
+
InvalidFormat
|
|
356
|
+
When the loader encounters invalid content. The exception is logged and
|
|
357
|
+
re-raised so callers can surface context to users.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
loader = _FILE_LOADERS.get(Path(path).suffix.lower())
|
|
361
|
+
if loader is None:
|
|
362
|
+
return None
|
|
363
|
+
try:
|
|
364
|
+
data = loader.load(path)
|
|
365
|
+
except NotFound:
|
|
366
|
+
return None
|
|
367
|
+
except InvalidFormat as exc: # pragma: no cover - validated by adapter tests
|
|
368
|
+
_note_layer_error(layer, path, exc)
|
|
369
|
+
raise
|
|
370
|
+
if not data:
|
|
371
|
+
return None
|
|
372
|
+
return LayerSnapshot(layer, data, path)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _paths_in_preferred_order(paths: Iterable[str], prefer: Sequence[str] | None) -> list[str]:
|
|
376
|
+
"""Return candidate paths honouring the optional *prefer* order.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
paths:
|
|
381
|
+
Iterable of candidate file paths.
|
|
382
|
+
prefer:
|
|
383
|
+
Optional sequence of preferred suffixes ordered by priority.
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
-------
|
|
387
|
+
list[str]
|
|
388
|
+
Candidate paths sorted according to preferred suffix ranking.
|
|
389
|
+
|
|
390
|
+
Examples
|
|
391
|
+
--------
|
|
392
|
+
>>> _paths_in_preferred_order(
|
|
393
|
+
... ['a.toml', 'b.yaml'],
|
|
394
|
+
... prefer=('yaml', 'toml'),
|
|
395
|
+
... )
|
|
396
|
+
['b.yaml', 'a.toml']
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
ordered = list(paths)
|
|
400
|
+
if not prefer:
|
|
401
|
+
return ordered
|
|
402
|
+
ranking = {suffix.lower().lstrip("."): index for index, suffix in enumerate(prefer)}
|
|
403
|
+
return sorted(ordered, key=lambda candidate: ranking.get(Path(candidate).suffix.lower().lstrip("."), len(ranking)))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _note_layer_loaded(layer: str, path: str | None, details: Mapping[str, object]) -> None:
|
|
407
|
+
"""Emit a debug event capturing successful layer discovery.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
layer:
|
|
412
|
+
Logical layer name.
|
|
413
|
+
path:
|
|
414
|
+
Optional path associated with the event.
|
|
415
|
+
details:
|
|
416
|
+
Additional structured metadata (e.g., number of files or keys).
|
|
417
|
+
|
|
418
|
+
Side Effects
|
|
419
|
+
------------
|
|
420
|
+
Calls :func:`log_debug` with the structured event payload.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
log_debug("layer_loaded", **make_event(layer, path, dict(details)))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _note_layer_error(layer: str, path: str, exc: Exception) -> None:
|
|
427
|
+
"""Emit a debug event describing a recoverable layer error.
|
|
428
|
+
|
|
429
|
+
Parameters
|
|
430
|
+
----------
|
|
431
|
+
layer:
|
|
432
|
+
Layer currently being processed.
|
|
433
|
+
path:
|
|
434
|
+
File path that triggered the error.
|
|
435
|
+
exc:
|
|
436
|
+
Exception raised by the loader.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
log_debug("layer_error", **make_event(layer, path, {"error": str(exc)}))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _note_configuration_empty() -> None:
|
|
443
|
+
"""Emit an info event signalling that no configuration was discovered."""
|
|
444
|
+
|
|
445
|
+
log_info("configuration_empty", layer="none", path=None)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _note_merge_complete(total_layers: int) -> None:
|
|
449
|
+
"""Emit an info event summarising the merge outcome.
|
|
450
|
+
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
total_layers:
|
|
454
|
+
Number of layers processed in the merge.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
log_info("configuration_merged", layer="final", path=None, total_layers=total_layers)
|