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,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)