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.

Files changed (39) hide show
  1. lib_layered_config/__init__.py +60 -0
  2. lib_layered_config/__main__.py +19 -0
  3. lib_layered_config/_layers.py +457 -0
  4. lib_layered_config/_platform.py +200 -0
  5. lib_layered_config/adapters/__init__.py +13 -0
  6. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  7. lib_layered_config/adapters/dotenv/default.py +438 -0
  8. lib_layered_config/adapters/env/__init__.py +5 -0
  9. lib_layered_config/adapters/env/default.py +509 -0
  10. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  11. lib_layered_config/adapters/file_loaders/structured.py +410 -0
  12. lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
  13. lib_layered_config/adapters/path_resolvers/default.py +727 -0
  14. lib_layered_config/application/__init__.py +12 -0
  15. lib_layered_config/application/merge.py +442 -0
  16. lib_layered_config/application/ports.py +109 -0
  17. lib_layered_config/cli/__init__.py +162 -0
  18. lib_layered_config/cli/common.py +232 -0
  19. lib_layered_config/cli/constants.py +12 -0
  20. lib_layered_config/cli/deploy.py +70 -0
  21. lib_layered_config/cli/fail.py +21 -0
  22. lib_layered_config/cli/generate.py +60 -0
  23. lib_layered_config/cli/info.py +31 -0
  24. lib_layered_config/cli/read.py +117 -0
  25. lib_layered_config/core.py +384 -0
  26. lib_layered_config/domain/__init__.py +7 -0
  27. lib_layered_config/domain/config.py +490 -0
  28. lib_layered_config/domain/errors.py +65 -0
  29. lib_layered_config/examples/__init__.py +29 -0
  30. lib_layered_config/examples/deploy.py +305 -0
  31. lib_layered_config/examples/generate.py +537 -0
  32. lib_layered_config/observability.py +306 -0
  33. lib_layered_config/py.typed +0 -0
  34. lib_layered_config/testing.py +55 -0
  35. lib_layered_config-1.0.0.dist-info/METADATA +366 -0
  36. lib_layered_config-1.0.0.dist-info/RECORD +39 -0
  37. lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
  38. lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
  39. 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."""