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,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
|
+
]
|