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,410 @@
|
|
|
1
|
+
"""Structured configuration file loaders.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Convert on-disk artifacts into Python mappings that the merge layer understands.
|
|
6
|
+
Adapters are small wrappers around ``tomllib``/``json``/``yaml.safe_load`` so
|
|
7
|
+
error handling, observability, and immutability policies live in one place.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
- ``BaseFileLoader``: shared primitives for reading files and asserting
|
|
11
|
+
mapping outputs.
|
|
12
|
+
- ``TOMLFileLoader`` / ``JSONFileLoader`` / ``YAMLFileLoader``: thin
|
|
13
|
+
adapters that delegate to parser-specific helpers.
|
|
14
|
+
- ``_log_file_read`` / ``_log_file_loaded`` / ``_log_file_invalid``:
|
|
15
|
+
structured logging helpers reused across loaders.
|
|
16
|
+
- ``_ensure_yaml_available``: guard ensuring YAML support is present before
|
|
17
|
+
attempting to parse.
|
|
18
|
+
|
|
19
|
+
System Role
|
|
20
|
+
-----------
|
|
21
|
+
Invoked by :func:`lib_layered_config.core._load_files` to parse structured files
|
|
22
|
+
before passing the results to the merge policy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Mapping, NoReturn
|
|
30
|
+
|
|
31
|
+
import tomllib
|
|
32
|
+
|
|
33
|
+
from ...domain.errors import InvalidFormat, NotFound
|
|
34
|
+
from ...observability import log_debug, log_error
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import yaml # type: ignore[import-not-found]
|
|
38
|
+
except ModuleNotFoundError: # pragma: no cover - optional dependency
|
|
39
|
+
yaml = None # type: ignore[assignment]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
FILE_LAYER = "file"
|
|
43
|
+
"""Layer label used in structured logging for file-oriented events.
|
|
44
|
+
|
|
45
|
+
Why
|
|
46
|
+
----
|
|
47
|
+
Tag observability events originating from file loaders with a consistent name.
|
|
48
|
+
|
|
49
|
+
What
|
|
50
|
+
----
|
|
51
|
+
Constant referenced by logging helpers within this module.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _log_file_read(path: str, size: int) -> None:
|
|
56
|
+
"""Record that *path* was read with *size* bytes.
|
|
57
|
+
|
|
58
|
+
Why
|
|
59
|
+
----
|
|
60
|
+
Provide insight into which files were accessed and their size for
|
|
61
|
+
troubleshooting.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
path:
|
|
66
|
+
Absolute path read from disk.
|
|
67
|
+
size:
|
|
68
|
+
Number of bytes read.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
log_debug("config_file_read", layer=FILE_LAYER, path=path, size=size)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _log_file_loaded(path: str, format_name: str) -> None:
|
|
75
|
+
"""Record a successful parse for *path* and *format_name*.
|
|
76
|
+
|
|
77
|
+
Why
|
|
78
|
+
----
|
|
79
|
+
Trace successful parsing events and note which parser handled the file.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
path:
|
|
84
|
+
Absolute file path.
|
|
85
|
+
format_name:
|
|
86
|
+
Parser identifier (e.g., ``"toml"``).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
log_debug("config_file_loaded", layer=FILE_LAYER, path=path, format=format_name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _log_file_invalid(path: str, format_name: str, exc: Exception) -> None:
|
|
93
|
+
"""Capture parser failures for diagnostics.
|
|
94
|
+
|
|
95
|
+
Why
|
|
96
|
+
----
|
|
97
|
+
Surface parse errors with enough context (path, format, message) for quick
|
|
98
|
+
troubleshooting.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
path:
|
|
103
|
+
File path that failed to parse.
|
|
104
|
+
format_name:
|
|
105
|
+
Parser identifier.
|
|
106
|
+
exc:
|
|
107
|
+
Exception raised by the parser.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
log_error(
|
|
111
|
+
"config_file_invalid",
|
|
112
|
+
layer=FILE_LAYER,
|
|
113
|
+
path=path,
|
|
114
|
+
format=format_name,
|
|
115
|
+
error=str(exc),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _raise_invalid_format(path: str, format_name: str, exc: Exception) -> NoReturn:
|
|
120
|
+
"""Log and raise :class:`InvalidFormat` for parser errors.
|
|
121
|
+
|
|
122
|
+
Why
|
|
123
|
+
----
|
|
124
|
+
Reuse logging side-effects while presenting callers with a uniform
|
|
125
|
+
exception type.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
path:
|
|
130
|
+
File path being parsed.
|
|
131
|
+
format_name:
|
|
132
|
+
Parser identifier.
|
|
133
|
+
exc:
|
|
134
|
+
Original exception raised by the parser.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_log_file_invalid(path, format_name, exc)
|
|
138
|
+
raise InvalidFormat(f"Invalid {format_name.upper()} in {path}: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ensure_yaml_available() -> None:
|
|
142
|
+
"""Raise :class:`NotFound` when optional YAML support is missing.
|
|
143
|
+
|
|
144
|
+
Why
|
|
145
|
+
----
|
|
146
|
+
Fail fast with a friendly message when YAML parsing is requested without the
|
|
147
|
+
optional dependency.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
None
|
|
152
|
+
|
|
153
|
+
Raises
|
|
154
|
+
------
|
|
155
|
+
NotFound
|
|
156
|
+
When PyYAML is not installed.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
if yaml is None:
|
|
160
|
+
raise NotFound("PyYAML is required for YAML configuration support")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class BaseFileLoader:
|
|
164
|
+
"""Common utilities shared by the structured file loaders.
|
|
165
|
+
|
|
166
|
+
Why
|
|
167
|
+
----
|
|
168
|
+
Avoid duplicating file I/O, error handling, and mapping validation across
|
|
169
|
+
individual loaders.
|
|
170
|
+
|
|
171
|
+
What
|
|
172
|
+
----
|
|
173
|
+
Provides reusable helpers for reading files and asserting parser outputs.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def _read(self, path: str) -> bytes:
|
|
177
|
+
"""Read *path* as bytes, raising :class:`NotFound` when the file is missing.
|
|
178
|
+
|
|
179
|
+
Why
|
|
180
|
+
----
|
|
181
|
+
Centralise file existence checks and logging so all loaders behave
|
|
182
|
+
consistently.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
path:
|
|
187
|
+
Absolute file path expected to exist.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
bytes
|
|
192
|
+
Raw file contents.
|
|
193
|
+
|
|
194
|
+
Side Effects
|
|
195
|
+
------------
|
|
196
|
+
Emits ``config_file_read`` debug events.
|
|
197
|
+
|
|
198
|
+
Examples
|
|
199
|
+
--------
|
|
200
|
+
>>> from tempfile import NamedTemporaryFile
|
|
201
|
+
>>> tmp = NamedTemporaryFile(delete=False)
|
|
202
|
+
>>> _ = tmp.write(b"key = 'value'")
|
|
203
|
+
>>> tmp.close()
|
|
204
|
+
>>> BaseFileLoader()._read(tmp.name)[:3]
|
|
205
|
+
b'key'
|
|
206
|
+
>>> Path(tmp.name).unlink()
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
file_path = Path(path)
|
|
210
|
+
if not file_path.is_file():
|
|
211
|
+
raise NotFound(f"Configuration file not found: {path}")
|
|
212
|
+
payload = file_path.read_bytes()
|
|
213
|
+
_log_file_read(path, len(payload))
|
|
214
|
+
return payload
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _ensure_mapping(data: object, *, path: str) -> Mapping[str, object]:
|
|
218
|
+
"""Ensure *data* behaves like a mapping, otherwise raise ``InvalidFormat``.
|
|
219
|
+
|
|
220
|
+
Why
|
|
221
|
+
----
|
|
222
|
+
Merging logic expects mapping-like structures; other types indicate a
|
|
223
|
+
malformed configuration file.
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
data:
|
|
228
|
+
Object produced by the parser.
|
|
229
|
+
path:
|
|
230
|
+
Originating file path used for error messaging.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
Mapping[str, object]
|
|
235
|
+
The validated mapping.
|
|
236
|
+
|
|
237
|
+
Examples
|
|
238
|
+
--------
|
|
239
|
+
>>> BaseFileLoader._ensure_mapping({"key": 1}, path="demo")
|
|
240
|
+
{'key': 1}
|
|
241
|
+
>>> BaseFileLoader._ensure_mapping(42, path="demo")
|
|
242
|
+
Traceback (most recent call last):
|
|
243
|
+
...
|
|
244
|
+
lib_layered_config.domain.errors.InvalidFormat: File demo did not produce a mapping
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
if not isinstance(data, Mapping):
|
|
248
|
+
raise InvalidFormat(f"File {path} did not produce a mapping")
|
|
249
|
+
return data # type: ignore[return-value]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TOMLFileLoader(BaseFileLoader):
|
|
253
|
+
"""Load TOML documents using the standard library parser."""
|
|
254
|
+
|
|
255
|
+
def load(self, path: str) -> Mapping[str, object]:
|
|
256
|
+
"""Return mapping extracted from TOML file at *path*.
|
|
257
|
+
|
|
258
|
+
Why
|
|
259
|
+
----
|
|
260
|
+
TOML is the primary structured format in the documentation; this loader
|
|
261
|
+
provides friendly error messages and structured logging.
|
|
262
|
+
|
|
263
|
+
Parameters
|
|
264
|
+
----------
|
|
265
|
+
path:
|
|
266
|
+
Absolute path to a TOML document.
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
Mapping[str, object]
|
|
271
|
+
Parsed configuration data.
|
|
272
|
+
|
|
273
|
+
Side Effects
|
|
274
|
+
------------
|
|
275
|
+
Emits ``config_file_loaded`` debug events.
|
|
276
|
+
|
|
277
|
+
Examples
|
|
278
|
+
--------
|
|
279
|
+
>>> from tempfile import NamedTemporaryFile
|
|
280
|
+
>>> tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
|
|
281
|
+
>>> _ = tmp.write('key = "value"')
|
|
282
|
+
>>> tmp.close()
|
|
283
|
+
>>> TOMLFileLoader().load(tmp.name)["key"]
|
|
284
|
+
'value'
|
|
285
|
+
>>> Path(tmp.name).unlink()
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
raw_bytes = self._read(path)
|
|
290
|
+
decoded = raw_bytes.decode("utf-8")
|
|
291
|
+
parsed = tomllib.loads(decoded)
|
|
292
|
+
except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: # type: ignore[attr-defined]
|
|
293
|
+
_raise_invalid_format(path, "toml", exc)
|
|
294
|
+
result = self._ensure_mapping(parsed, path=path)
|
|
295
|
+
_log_file_loaded(path, "toml")
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class JSONFileLoader(BaseFileLoader):
|
|
300
|
+
"""Load JSON documents.
|
|
301
|
+
|
|
302
|
+
Why
|
|
303
|
+
----
|
|
304
|
+
Provide a drop-in parser for JSON configuration files.
|
|
305
|
+
|
|
306
|
+
What
|
|
307
|
+
----
|
|
308
|
+
Uses :mod:`json` to parse files and delegates validation/logging to the base class.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def load(self, path: str) -> Mapping[str, object]:
|
|
312
|
+
"""Return mapping extracted from JSON file at *path*.
|
|
313
|
+
|
|
314
|
+
Why
|
|
315
|
+
----
|
|
316
|
+
Provide parity with TOML for teams that prefer JSON configuration.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
path:
|
|
321
|
+
Absolute path to a JSON document.
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
Mapping[str, object]
|
|
326
|
+
Parsed configuration mapping.
|
|
327
|
+
|
|
328
|
+
Side Effects
|
|
329
|
+
------------
|
|
330
|
+
Emits ``config_file_loaded`` debug events.
|
|
331
|
+
|
|
332
|
+
Examples
|
|
333
|
+
--------
|
|
334
|
+
>>> from tempfile import NamedTemporaryFile
|
|
335
|
+
>>> tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
|
|
336
|
+
>>> _ = tmp.write('{"enabled": true}')
|
|
337
|
+
>>> tmp.close()
|
|
338
|
+
>>> JSONFileLoader().load(tmp.name)["enabled"]
|
|
339
|
+
True
|
|
340
|
+
>>> Path(tmp.name).unlink()
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
payload: Any = json.loads(self._read(path))
|
|
345
|
+
except json.JSONDecodeError as exc:
|
|
346
|
+
_raise_invalid_format(path, "json", exc)
|
|
347
|
+
result = self._ensure_mapping(payload, path=path)
|
|
348
|
+
_log_file_loaded(path, "json")
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class YAMLFileLoader(BaseFileLoader):
|
|
353
|
+
"""Load YAML documents when PyYAML is available.
|
|
354
|
+
|
|
355
|
+
Why
|
|
356
|
+
----
|
|
357
|
+
Support teams that rely on YAML without imposing a mandatory dependency.
|
|
358
|
+
|
|
359
|
+
What
|
|
360
|
+
----
|
|
361
|
+
Guards on PyYAML availability before delegating to :func:`yaml.safe_load`.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def load(self, path: str) -> Mapping[str, object]:
|
|
365
|
+
"""Return mapping extracted from YAML file at *path*.
|
|
366
|
+
|
|
367
|
+
Why
|
|
368
|
+
----
|
|
369
|
+
Some teams rely on YAML for configuration; this loader keeps behaviour
|
|
370
|
+
consistent with TOML/JSON while remaining optional.
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
path:
|
|
375
|
+
Absolute path to a YAML document.
|
|
376
|
+
|
|
377
|
+
Returns
|
|
378
|
+
-------
|
|
379
|
+
Mapping[str, object]
|
|
380
|
+
Parsed configuration mapping.
|
|
381
|
+
|
|
382
|
+
Raises
|
|
383
|
+
------
|
|
384
|
+
NotFound
|
|
385
|
+
When PyYAML is not installed.
|
|
386
|
+
|
|
387
|
+
Side Effects
|
|
388
|
+
------------
|
|
389
|
+
Emits ``config_file_loaded`` debug events.
|
|
390
|
+
|
|
391
|
+
Examples
|
|
392
|
+
--------
|
|
393
|
+
>>> if yaml is not None: # doctest: +SKIP
|
|
394
|
+
... from tempfile import NamedTemporaryFile
|
|
395
|
+
... tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
|
|
396
|
+
... _ = tmp.write('key: 1')
|
|
397
|
+
... tmp.close()
|
|
398
|
+
... YAMLFileLoader().load(tmp.name)["key"]
|
|
399
|
+
... Path(tmp.name).unlink()
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
_ensure_yaml_available()
|
|
403
|
+
try:
|
|
404
|
+
payload: Any = yaml.safe_load(self._read(path)) # type: ignore[operator]
|
|
405
|
+
except yaml.YAMLError as exc: # type: ignore[attr-defined]
|
|
406
|
+
_raise_invalid_format(path, "yaml", exc)
|
|
407
|
+
data: object = dict[str, object]() if payload is None else payload
|
|
408
|
+
result = self._ensure_mapping(data, path=path)
|
|
409
|
+
_log_file_loaded(path, "yaml")
|
|
410
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Path resolver adapters for platform-specific search order."""
|