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,12 @@
1
+ """Application layer orchestrators (ports + merge policy).
2
+
3
+ Purpose
4
+ -------
5
+ Define pure coordination code that glues domain value objects to adapter
6
+ interfaces.
7
+
8
+ Contents
9
+ --------
10
+ * :mod:`lib_layered_config.application.ports`
11
+ * :mod:`lib_layered_config.application.merge`
12
+ """
@@ -0,0 +1,442 @@
1
+ """Merge ordered configuration layers while keeping provenance crystal clear.
2
+
3
+ Purpose
4
+ -------
5
+ Implement the merge policy described in ``docs/systemdesign/concept.md`` by
6
+ folding a sequence of layer snapshots into a single mapping plus provenance.
7
+ Preserves the "last writer wins" rule without mutating caller-provided data.
8
+
9
+ Contents
10
+ --------
11
+ - ``LayerSnapshot``: immutable record describing a layer name, payload, and
12
+ origin path.
13
+ - ``merge_layers``: public API returning merged data and provenance mappings.
14
+ - Internal helpers (``_weave_layer``, ``_descend`` …) that manage recursive
15
+ merging, branch clearing, and dotted-key generation.
16
+
17
+ System Role
18
+ -----------
19
+ The composition root assembles layer snapshots and delegates to
20
+ ``merge_layers`` before building the domain ``Config`` value object.
21
+ Adapters and CLI code depend on the provenance structure to explain precedence.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Mapping, MutableMapping
27
+ from collections.abc import Mapping as MappingABC
28
+ from dataclasses import dataclass
29
+ from typing import Iterable, Mapping as TypingMapping, Sequence, TypeGuard, cast
30
+
31
+ from .ports import SourceInfoPayload
32
+
33
+
34
+ @dataclass(frozen=True, eq=False, slots=True)
35
+ class LayerSnapshot:
36
+ """Immutable description of a configuration layer.
37
+
38
+ Why
39
+ ----
40
+ Keeps layer metadata compact and explicit so merge logic can reason about
41
+ precedence without coupling to adapter implementations.
42
+
43
+ Attributes
44
+ ----------
45
+ name:
46
+ Logical name of the layer (``"defaults"``, ``"app"``, ``"host"``,
47
+ ``"user"``, ``"dotenv"``, ``"env"``).
48
+ payload:
49
+ Mapping produced by adapters; expected to contain only JSON-serialisable
50
+ types.
51
+ origin:
52
+ Optional filesystem path (or ``None`` for in-memory sources).
53
+ """
54
+
55
+ name: str
56
+ payload: Mapping[str, object]
57
+ origin: str | None
58
+
59
+
60
+ def merge_layers(layers: Iterable[LayerSnapshot]) -> tuple[dict[str, object], dict[str, SourceInfoPayload]]:
61
+ """Merge ordered layers into data and provenance dictionaries.
62
+
63
+ Why
64
+ ----
65
+ Central policy point for layered configuration. Ensures later layers may
66
+ override earlier ones and that provenance stays aligned with the final data.
67
+
68
+ Parameters
69
+ ----------
70
+ layers:
71
+ Iterable of :class:`LayerSnapshot` instances in merge order (lowest to
72
+ highest precedence).
73
+
74
+ Returns
75
+ -------
76
+ tuple[dict[str, object], dict[str, SourceInfoPayload]]
77
+ The merged configuration mapping and provenance mapping keyed by dotted
78
+ path.
79
+
80
+ Examples
81
+ --------
82
+ >>> base = LayerSnapshot("app", {"db": {"host": "localhost"}}, "/etc/app.toml")
83
+ >>> override = LayerSnapshot("env", {"db": {"host": "prod"}}, None)
84
+ >>> data, provenance = merge_layers([base, override])
85
+ >>> data["db"]["host"], provenance["db.host"]["layer"]
86
+ ('prod', 'env')
87
+ """
88
+
89
+ merged: dict[str, object] = {}
90
+ provenance: dict[str, SourceInfoPayload] = {}
91
+
92
+ for snapshot in layers:
93
+ _weave_layer(merged, provenance, snapshot)
94
+
95
+ return merged, provenance
96
+
97
+
98
+ def _weave_layer(
99
+ target: MutableMapping[str, object],
100
+ provenance: MutableMapping[str, SourceInfoPayload],
101
+ snapshot: LayerSnapshot,
102
+ ) -> None:
103
+ """Clone snapshot payload and fold it into accumulators.
104
+
105
+ Why
106
+ ----
107
+ Provide a single entry point that ensures each snapshot is processed with
108
+ defensive cloning before descending into nested structures.
109
+
110
+ Parameters
111
+ ----------
112
+ target:
113
+ Mutable mapping accumulating merged configuration values.
114
+ provenance:
115
+ Mutable mapping capturing dotted-path provenance entries.
116
+ snapshot:
117
+ Layer snapshot being merged into the accumulators.
118
+
119
+ Side Effects
120
+ ------------
121
+ Mutates *target* and *provenance* in place.
122
+
123
+ Examples
124
+ --------
125
+ >>> merged, prov = {}, {}
126
+ >>> snap = LayerSnapshot('env', {'flag': True}, None)
127
+ >>> _weave_layer(merged, prov, snap)
128
+ >>> merged['flag'], prov['flag']['layer']
129
+ (True, 'env')
130
+ """
131
+
132
+ _descend(target, provenance, snapshot.payload, snapshot, [])
133
+
134
+
135
+ def _descend(
136
+ target: MutableMapping[str, object],
137
+ provenance: MutableMapping[str, SourceInfoPayload],
138
+ incoming: Mapping[str, object],
139
+ snapshot: LayerSnapshot,
140
+ segments: list[str],
141
+ ) -> None:
142
+ """Walk each key/value pair, updating scalars or branches as needed.
143
+
144
+ Why
145
+ ----
146
+ Implements the recursive merge algorithm that honours nested structures and
147
+ ensures provenance stays aligned with the final data.
148
+
149
+ Parameters
150
+ ----------
151
+ target:
152
+ Mutable mapping receiving merged values.
153
+ provenance:
154
+ Mutable mapping storing provenance per dotted path.
155
+ incoming:
156
+ Mapping representing the current layer payload.
157
+ snapshot:
158
+ Layer metadata used for provenance entries.
159
+ segments:
160
+ Accumulated path segments used to compute dotted keys during recursion.
161
+
162
+ Side Effects
163
+ ------------
164
+ Mutates *target* and *provenance* as it walks through *incoming*.
165
+ """
166
+
167
+ for key, value in incoming.items():
168
+ dotted = _join_segments(segments, key)
169
+ if _looks_like_mapping(value):
170
+ _store_branch(target, provenance, key, value, dotted, snapshot, segments)
171
+ else:
172
+ _store_scalar(target, provenance, key, value, dotted, snapshot)
173
+
174
+
175
+ def _store_branch(
176
+ target: MutableMapping[str, object],
177
+ provenance: MutableMapping[str, SourceInfoPayload],
178
+ key: str,
179
+ value: Mapping[str, object],
180
+ dotted: str,
181
+ snapshot: LayerSnapshot,
182
+ segments: list[str],
183
+ ) -> None:
184
+ """Ensure a nested mapping exists before descending into it.
185
+
186
+ Parameters
187
+ ----------
188
+ target:
189
+ Mutable mapping currently being merged into.
190
+ provenance:
191
+ Provenance accumulator updated as recursion progresses.
192
+ key:
193
+ Current key being processed.
194
+ value:
195
+ Mapping representing the nested branch from the incoming layer.
196
+ dotted:
197
+ Dotted representation of the branch path for provenance updates.
198
+ snapshot:
199
+ Metadata describing the active layer.
200
+ segments:
201
+ Mutable list containing the path segments of the current recursion.
202
+
203
+ Side Effects
204
+ ------------
205
+ Mutates *target*, *provenance*, and *segments* while recursing.
206
+
207
+ Examples
208
+ --------
209
+ >>> target, prov = {}, {}
210
+ >>> branch_snapshot = LayerSnapshot('env', {'child': {'enabled': True}}, None)
211
+ >>> _store_branch(target, prov, 'child', {'enabled': True}, 'child', branch_snapshot, [])
212
+ >>> target['child']['enabled']
213
+ True
214
+ """
215
+
216
+ branch = _ensure_branch(target, key)
217
+ segments.append(key)
218
+ _descend(branch, provenance, value, snapshot, segments)
219
+ segments.pop()
220
+ _clear_branch_if_empty(branch, dotted, provenance)
221
+
222
+
223
+ def _store_scalar(
224
+ target: MutableMapping[str, object],
225
+ provenance: MutableMapping[str, SourceInfoPayload],
226
+ key: str,
227
+ value: object,
228
+ dotted: str,
229
+ snapshot: LayerSnapshot,
230
+ ) -> None:
231
+ """Set the scalar value and update provenance in lockstep.
232
+
233
+ Parameters
234
+ ----------
235
+ target:
236
+ Mutable mapping receiving the scalar value.
237
+ provenance:
238
+ Mutable mapping storing provenance metadata.
239
+ key:
240
+ Immediate key to update within *target*.
241
+ value:
242
+ Value drawn from the incoming layer.
243
+ dotted:
244
+ Fully-qualified dotted key for provenance lookups.
245
+ snapshot:
246
+ Metadata describing the active layer.
247
+
248
+ Side Effects
249
+ ------------
250
+ Mutates both *target* and *provenance*.
251
+
252
+ Examples
253
+ --------
254
+ >>> target, prov = {}, {}
255
+ >>> snap = LayerSnapshot('env', {'flag': True}, None)
256
+ >>> _store_scalar(target, prov, 'flag', True, 'flag', snap)
257
+ >>> target['flag'], prov['flag']['layer']
258
+ (True, 'env')
259
+ """
260
+
261
+ target[key] = _clone_leaf(value)
262
+ provenance[dotted] = {
263
+ "layer": snapshot.name,
264
+ "path": snapshot.origin,
265
+ "key": dotted,
266
+ }
267
+
268
+
269
+ def _clone_leaf(value: object) -> object:
270
+ """Return a defensive copy of mutable leaf values.
271
+
272
+ Why
273
+ ----
274
+ Prevents callers from mutating adapter-provided data after the merge,
275
+ preserving immutability guarantees described in the system design.
276
+
277
+ Parameters
278
+ ----------
279
+ value:
280
+ Leaf value drawn from the incoming layer.
281
+
282
+ Returns
283
+ -------
284
+ object
285
+ Clone of the input value; immutable types are returned unchanged.
286
+
287
+ Examples
288
+ --------
289
+ >>> original = {'items': [1, 2]}
290
+ >>> cloned = _clone_leaf(original)
291
+ >>> cloned is original
292
+ False
293
+ >>> cloned['items'][0] = 42
294
+ >>> original['items'][0]
295
+ 1
296
+ """
297
+
298
+ if isinstance(value, dict):
299
+ mapping = cast(dict[str, object], value)
300
+ return {key: _clone_leaf(item) for key, item in mapping.items()}
301
+ if isinstance(value, list):
302
+ sequence = cast(list[object], value)
303
+ return [_clone_leaf(item) for item in sequence]
304
+ if isinstance(value, set):
305
+ members = cast(set[object], value)
306
+ return {_clone_leaf(item) for item in members}
307
+ if isinstance(value, tuple):
308
+ items = cast(tuple[object, ...], value)
309
+ return tuple(_clone_leaf(item) for item in items)
310
+ return value
311
+
312
+
313
+ def _ensure_branch(target: MutableMapping[str, object], key: str) -> MutableMapping[str, object]:
314
+ """Return an existing branch or create a fresh empty one.
315
+
316
+ Parameters
317
+ ----------
318
+ target:
319
+ Mutable mapping holding the current branch.
320
+ key:
321
+ Key that should reference a nested mapping.
322
+
323
+ Returns
324
+ -------
325
+ MutableMapping[str, object]
326
+ Existing branch when present or a new one inserted into *target*.
327
+
328
+ Side Effects
329
+ ------------
330
+ Inserts a new mutable mapping into *target* when needed.
331
+
332
+ Examples
333
+ --------
334
+ >>> branch = _ensure_branch({}, 'child')
335
+ >>> isinstance(branch, MutableMapping)
336
+ True
337
+ >>> second = _ensure_branch({'child': branch}, 'child')
338
+ >>> second is branch
339
+ True
340
+ """
341
+
342
+ current = target.get(key)
343
+ if _looks_like_mapping(current):
344
+ return cast(MutableMapping[str, object], current)
345
+
346
+ new_branch: MutableMapping[str, object] = {}
347
+ target[key] = new_branch
348
+ return new_branch
349
+
350
+
351
+ def _clear_branch_if_empty(
352
+ branch: MutableMapping[str, object], dotted: str, provenance: MutableMapping[str, SourceInfoPayload]
353
+ ) -> None:
354
+ """Remove empty branches from provenance when overwritten by scalars.
355
+
356
+ Parameters
357
+ ----------
358
+ branch:
359
+ Mutable mapping representing the nested branch just processed.
360
+ dotted:
361
+ Dotted key corresponding to the branch.
362
+ provenance:
363
+ Provenance mapping to prune when the branch becomes empty.
364
+
365
+ Side Effects
366
+ ------------
367
+ Mutates *provenance* by removing entries when the branch no longer has data.
368
+
369
+ Examples
370
+ --------
371
+ >>> prov = {'a.b': {'layer': 'env', 'path': None, 'key': 'a.b'}}
372
+ >>> _clear_branch_if_empty({}, 'a.b', prov)
373
+ >>> 'a.b' in prov
374
+ False
375
+ """
376
+
377
+ if branch:
378
+ return
379
+ provenance.pop(dotted, None)
380
+
381
+
382
+ def _join_segments(segments: Sequence[str], key: str) -> str:
383
+ """Join the current path segments with the new key.
384
+
385
+ Parameters
386
+ ----------
387
+ segments:
388
+ Tuple of parent path segments accumulated so far.
389
+ key:
390
+ Current key being appended to the dotted path.
391
+
392
+ Returns
393
+ -------
394
+ str
395
+ Dotted path string combining *segments* and *key*.
396
+
397
+ Examples
398
+ --------
399
+ >>> _join_segments(('db', 'config'), 'host')
400
+ 'db.config.host'
401
+ >>> _join_segments((), 'timeout')
402
+ 'timeout'
403
+ """
404
+
405
+ if not segments:
406
+ return key
407
+ return ".".join((*segments, key))
408
+
409
+
410
+ def _looks_like_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
411
+ """Return ``True`` when *value* is a mapping with string keys.
412
+
413
+ Why
414
+ ----
415
+ Guards recursion so scalars are handled separately from nested mappings.
416
+
417
+ Parameters
418
+ ----------
419
+ value:
420
+ Candidate object inspected during recursion.
421
+
422
+ Returns
423
+ -------
424
+ bool
425
+ ``True`` when *value* behaves like ``Mapping[str, object]``.
426
+
427
+ Examples
428
+ --------
429
+ >>> _looks_like_mapping({'a': 1})
430
+ True
431
+ >>> _looks_like_mapping(['not', 'mapping'])
432
+ False
433
+ """
434
+
435
+ if not isinstance(value, MappingABC):
436
+ return False
437
+ mapping = cast(TypingMapping[object, object], value)
438
+ keys = cast(Iterable[object], mapping.keys())
439
+ return all(isinstance(k, str) for k in keys)
440
+
441
+
442
+ __all__ = ["LayerSnapshot", "merge_layers"]
@@ -0,0 +1,109 @@
1
+ """Runtime-checkable protocols defining adapter contracts.
2
+
3
+ Purpose
4
+ -------
5
+ Ensure the composition root depends on abstractions instead of concrete
6
+ implementations, mirroring the Clean Architecture layering in the system design.
7
+
8
+ Contents
9
+ --------
10
+ - ``SourceInfoPayload``: typed dictionary describing provenance for merged keys.
11
+ - Protocols for each adapter type (path resolver, file loader, dotenv loader,
12
+ environment loader) plus the merge interface consumed by tests and tooling.
13
+
14
+ System Role
15
+ -----------
16
+ Adapters must implement these protocols; tests (`tests/adapters/test_port_contracts.py`)
17
+ use ``isinstance`` checks to enforce compliance at runtime.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Iterable, Mapping, Protocol, Tuple, TypedDict, runtime_checkable
23
+
24
+
25
+ class SourceInfoPayload(TypedDict):
26
+ """Structured provenance emitted by the merge policy.
27
+
28
+ Why
29
+ ----
30
+ Downstream consumers (CLI JSON output, deploy helpers) rely on consistent
31
+ keys when rendering provenance information.
32
+
33
+ Fields
34
+ ------
35
+ layer:
36
+ Logical layer name contributing the value.
37
+ path:
38
+ Optional filesystem path associated with the entry.
39
+ key:
40
+ Fully-qualified dotted key.
41
+ """
42
+
43
+ layer: str
44
+ path: str | None
45
+ key: str
46
+
47
+
48
+ @runtime_checkable
49
+ class PathResolver(Protocol):
50
+ """Provide ordered path iterables for each configuration layer.
51
+
52
+ Methods mirror the precedence hierarchy documented in
53
+ ``docs/systemdesign/concept.md``.
54
+ """
55
+
56
+ def app(self) -> Iterable[str]: ... # pragma: no cover - protocol
57
+
58
+ def host(self) -> Iterable[str]: ... # pragma: no cover - protocol
59
+
60
+ def user(self) -> Iterable[str]: ... # pragma: no cover - protocol
61
+
62
+ def dotenv(self) -> Iterable[str]: ... # pragma: no cover - protocol
63
+
64
+
65
+ @runtime_checkable
66
+ class FileLoader(Protocol):
67
+ """Parse a structured configuration file into a mapping."""
68
+
69
+ def load(self, path: str) -> Mapping[str, object]: ... # pragma: no cover - protocol
70
+
71
+
72
+ @runtime_checkable
73
+ class DotEnvLoader(Protocol):
74
+ """Convert `.env` files into nested mappings respecting prefix semantics."""
75
+
76
+ def load(self, start_dir: str | None = None) -> Mapping[str, object]: ... # pragma: no cover - protocol
77
+
78
+ @property
79
+ def last_loaded_path(self) -> str | None: # pragma: no cover - attribute contract
80
+ ...
81
+
82
+
83
+ @runtime_checkable
84
+ class EnvLoader(Protocol):
85
+ """Translate prefixed environment variables into nested mappings."""
86
+
87
+ def load(self, prefix: str) -> Mapping[str, object]: ... # pragma: no cover - protocol
88
+
89
+
90
+ @runtime_checkable
91
+ class Merger(Protocol):
92
+ """Combine ordered layers into merged data and provenance structures."""
93
+
94
+ def merge(
95
+ self, layers: Iterable[Tuple[str, Mapping[str, object], str | None]]
96
+ ) -> Tuple[
97
+ Mapping[str, object],
98
+ Mapping[str, SourceInfoPayload],
99
+ ]: ... # pragma: no cover - protocol
100
+
101
+
102
+ __all__ = [
103
+ "SourceInfoPayload",
104
+ "PathResolver",
105
+ "FileLoader",
106
+ "DotEnvLoader",
107
+ "EnvLoader",
108
+ "Merger",
109
+ ]