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,490 @@
1
+ """Immutable configuration value object with provenance tracking.
2
+
3
+ Purpose
4
+ -------
5
+ Provide the "configuration aggregate" described in
6
+ ``docs/systemdesign/concept.md``: an immutable mapping that preserves both the
7
+ final merged values and the metadata explaining *where* every dotted key was
8
+ sourced. The application and adapter layers rely on this module to honour the
9
+ precedence rules documented for layered configuration.
10
+
11
+ Contents
12
+ --------
13
+ - ``SourceInfo``: typed dictionary describing layer, path, and dotted key.
14
+ - ``Config``: frozen mapping-like dataclass exposing lookup, provenance, and
15
+ serialisation helpers.
16
+ - Internal helpers (``_follow_path``, ``_clone_map`` …) that keep traversal
17
+ logic pure and testable.
18
+ - ``EMPTY_CONFIG``: canonical empty instance shared across the composition
19
+ root and CLI utilities.
20
+
21
+ System Role
22
+ -----------
23
+ The composition root builds ``Config`` instances after merging layer snapshots.
24
+ Presentation layers (CLI, examples) consume the public API to render human or
25
+ JSON output without re-implementing provenance rules.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from collections.abc import Mapping, Mapping as MappingABC
32
+ from dataclasses import dataclass
33
+ from types import MappingProxyType
34
+ from typing import Any, Iterable, Iterator, Mapping as MappingType, TypedDict, TypeGuard, TypeVar, cast
35
+
36
+
37
+ class SourceInfo(TypedDict):
38
+ """Describe the provenance of a configuration value.
39
+
40
+ Why
41
+ ----
42
+ Downstream tooling (CLI, deploy helpers) needs to display where a value
43
+ originated so operators can trace precedence decisions.
44
+
45
+ Fields
46
+ ------
47
+ layer:
48
+ Name of the logical layer (``"defaults"``, ``"app"``, ``"host"``,
49
+ ``"user"``, ``"dotenv"``, or ``"env"``).
50
+ path:
51
+ Absolute filesystem path when known; ``None`` for ephemeral sources
52
+ such as environment variables.
53
+ key:
54
+ Fully-qualified dotted key corresponding to the stored value.
55
+ """
56
+
57
+ layer: str
58
+ path: str | None
59
+ key: str
60
+
61
+
62
+ T = TypeVar("T")
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class Config(MappingABC[str, Any]):
67
+ """Immutable mapping plus provenance metadata for a merged configuration.
68
+
69
+ Why
70
+ ----
71
+ The system design mandates that merged configuration stays read-only after
72
+ assembly so every layer sees a consistent snapshot. ``Config`` enforces that
73
+ contract while providing ergonomic helpers for dotted lookups and
74
+ serialisation.
75
+
76
+ Attributes
77
+ ----------
78
+ _data:
79
+ Mapping containing the merged configuration tree. Stored as a
80
+ ``MappingProxyType`` to prevent mutation.
81
+ _meta:
82
+ Mapping of dotted keys to :class:`SourceInfo`, allowing provenance
83
+ queries via :meth:`origin`.
84
+ """
85
+
86
+ _data: Mapping[str, Any]
87
+ _meta: Mapping[str, SourceInfo]
88
+
89
+ def __post_init__(self) -> None:
90
+ """Freeze internal mappings immediately after construction."""
91
+
92
+ object.__setattr__(self, "_data", _lock_map(self._data))
93
+ object.__setattr__(self, "_meta", _lock_map(self._meta))
94
+
95
+ def __getitem__(self, key: str) -> Any:
96
+ """Return the value stored under a top-level key.
97
+
98
+ Why
99
+ ----
100
+ Consumers expect ``Config`` to behave like a standard mapping.
101
+
102
+ Parameters
103
+ ----------
104
+ key:
105
+ Top-level key to retrieve.
106
+
107
+ Returns
108
+ -------
109
+ Any
110
+ Stored value.
111
+
112
+ Raises
113
+ ------
114
+ KeyError
115
+ When *key* does not exist.
116
+
117
+ Examples
118
+ --------
119
+ >>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
120
+ >>> cfg["debug"]
121
+ True
122
+ """
123
+
124
+ return self._data[key]
125
+
126
+ def __iter__(self) -> Iterator[str]:
127
+ """Iterate over top-level keys in insertion order."""
128
+
129
+ return iter(self._data)
130
+
131
+ def __len__(self) -> int:
132
+ """Return the number of stored top-level keys."""
133
+
134
+ return len(self._data)
135
+
136
+ def as_dict(self) -> dict[str, Any]:
137
+ """Return a deep, mutable copy of the configuration tree.
138
+
139
+ Why
140
+ ----
141
+ Callers occasionally need to serialise or further mutate the data in a
142
+ context that does not require provenance.
143
+
144
+ Returns
145
+ -------
146
+ dict[str, Any]
147
+ Independent copy of the configuration data.
148
+
149
+ Side Effects
150
+ ------------
151
+ None. The original mapping remains locked.
152
+
153
+ Examples
154
+ --------
155
+ >>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
156
+ >>> clone = cfg.as_dict()
157
+ >>> clone["debug"]
158
+ True
159
+ >>> clone["debug"] = False
160
+ >>> cfg["debug"]
161
+ True
162
+ """
163
+
164
+ return _clone_map(self._data)
165
+
166
+ def to_json(self, *, indent: int | None = None) -> str:
167
+ """Serialise the configuration as JSON.
168
+
169
+ Why
170
+ ----
171
+ CLI tooling and documentation examples render the merged configuration
172
+ in JSON to support piping into other scripts.
173
+
174
+ Parameters
175
+ ----------
176
+ indent:
177
+ Optional indentation level mirroring ``json.dumps`` semantics.
178
+
179
+ Returns
180
+ -------
181
+ str
182
+ JSON payload containing the cloned configuration data.
183
+
184
+ Examples
185
+ --------
186
+ >>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
187
+ >>> cfg.to_json()
188
+ '{"debug":true}'
189
+ >>> "\n \"debug\"" in cfg.to_json(indent=2)
190
+ True
191
+ """
192
+
193
+ return json.dumps(self.as_dict(), indent=indent, separators=(",", ":"), ensure_ascii=False)
194
+
195
+ def get(self, key: str, default: Any = None) -> Any:
196
+ """Return the value for *key* or a default when the path is missing.
197
+
198
+ Why
199
+ ----
200
+ Layered configuration relies on dotted keys (e.g. ``"db.host"``).
201
+ This helper avoids repetitive traversal code at call sites.
202
+
203
+ Parameters
204
+ ----------
205
+ key:
206
+ Dotted path identifying nested entries.
207
+ default:
208
+ Value to return when the path does not resolve or encounters a
209
+ non-mapping.
210
+
211
+ Returns
212
+ -------
213
+ Any
214
+ The resolved value or *default* when missing.
215
+
216
+ Examples
217
+ --------
218
+ >>> cfg = Config({"db": {"host": "localhost"}}, {"db.host": {"layer": "app", "path": None, "key": "db.host"}})
219
+ >>> cfg.get("db.host")
220
+ 'localhost'
221
+ >>> cfg.get("db.port", default=5432)
222
+ 5432
223
+ """
224
+
225
+ return _follow_path(self._data, key, default)
226
+
227
+ def origin(self, key: str) -> SourceInfo | None:
228
+ """Return provenance metadata for *key* when available.
229
+
230
+ Why
231
+ ----
232
+ Operators need to understand which layer supplied a value to debug
233
+ precedence questions.
234
+
235
+ Parameters
236
+ ----------
237
+ key:
238
+ Dotted key in the metadata map.
239
+
240
+ Returns
241
+ -------
242
+ SourceInfo | None
243
+ Metadata dictionary or ``None`` if the key was never observed.
244
+
245
+ Examples
246
+ --------
247
+ >>> meta = {"db.host": {"layer": "app", "path": "/etc/app.toml", "key": "db.host"}}
248
+ >>> cfg = Config({"db": {"host": "localhost"}}, meta)
249
+ >>> cfg.origin("db.host")["layer"]
250
+ 'app'
251
+ >>> cfg.origin("missing") is None
252
+ True
253
+ """
254
+
255
+ return self._meta.get(key)
256
+
257
+ def with_overrides(self, overrides: Mapping[str, Any]) -> Config:
258
+ """Return a new configuration with shallow top-level overrides applied.
259
+
260
+ Why
261
+ ----
262
+ CLI helpers allow callers to inject ad-hoc overrides while keeping the
263
+ original snapshot intact. This method produces that variant.
264
+
265
+ Parameters
266
+ ----------
267
+ overrides:
268
+ Top-level keys and values to override.
269
+
270
+ Returns
271
+ -------
272
+ Config
273
+ New configuration instance sharing provenance with the original.
274
+
275
+ Side Effects
276
+ ------------
277
+ None. Both instances remain independent thanks to cloning.
278
+
279
+ Examples
280
+ --------
281
+ >>> cfg = Config({"feature": False}, {"feature": {"layer": "app", "path": None, "key": "feature"}})
282
+ >>> cfg.with_overrides({"feature": True})["feature"], cfg["feature"]
283
+ (True, False)
284
+ """
285
+
286
+ tinted = _blend_top_level(self._data, overrides)
287
+ return Config(tinted, self._meta)
288
+
289
+
290
+ def _lock_map(mapping: Mapping[str, Any]) -> Mapping[str, Any]:
291
+ """Return a read-only view of *mapping*.
292
+
293
+ Why
294
+ ----
295
+ Internal state must remain immutable to uphold the domain contract.
296
+
297
+ Parameters
298
+ ----------
299
+ mapping:
300
+ Mapping to wrap. A shallow copy protects against caller mutation.
301
+
302
+ Returns
303
+ -------
304
+ Mapping[str, Any]
305
+ ``MappingProxyType`` over a copy of the source mapping.
306
+
307
+ Examples
308
+ --------
309
+ >>> view = _lock_map({"flag": True})
310
+ >>> view["flag"], isinstance(view, MappingProxyType)
311
+ (True, True)
312
+ """
313
+
314
+ return MappingProxyType(dict(mapping))
315
+
316
+
317
+ def _blend_top_level(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
318
+ """Return a shallow copy of *base* with *overrides* applied.
319
+
320
+ Why
321
+ ----
322
+ ``Config.with_overrides`` depends on a pure helper so it can reuse
323
+ provenance metadata without mutation.
324
+
325
+ Parameters
326
+ ----------
327
+ base:
328
+ Original mapping.
329
+ overrides:
330
+ Mapping whose keys replace entries in *base*.
331
+
332
+ Returns
333
+ -------
334
+ dict[str, Any]
335
+ New dictionary with updated top-level values.
336
+
337
+ Examples
338
+ --------
339
+ >>> _blend_top_level({"port": 8000}, {"port": 9000})["port"]
340
+ 9000
341
+ """
342
+
343
+ tinted = dict(base)
344
+ tinted.update(overrides)
345
+ return tinted
346
+
347
+
348
+ def _follow_path(source: Mapping[str, Any], dotted: str, default: Any) -> Any:
349
+ """Traverse *source* using dotted notation.
350
+
351
+ Why
352
+ ----
353
+ Nested configuration should be accessible without exposing internal data
354
+ structures. This helper powers :meth:`Config.get`.
355
+
356
+ Parameters
357
+ ----------
358
+ source:
359
+ Mapping to traverse.
360
+ dotted:
361
+ Dotted path, e.g. ``"db.host"``.
362
+ default:
363
+ Fallback when traversal fails.
364
+
365
+ Returns
366
+ -------
367
+ Any
368
+ Resolved value or *default*.
369
+
370
+ Examples
371
+ --------
372
+ >>> payload = {"db": {"host": "localhost"}}
373
+ >>> _follow_path(payload, "db.host", default=None)
374
+ 'localhost'
375
+ >>> _follow_path(payload, "db.port", default=5432)
376
+ 5432
377
+ """
378
+
379
+ current: object = source
380
+ for fragment in dotted.split("."):
381
+ if not _looks_like_mapping(current):
382
+ return default
383
+ if fragment not in current:
384
+ return default
385
+ current = current[fragment]
386
+ return cast(Any, current)
387
+
388
+
389
+ def _clone_map(mapping: MappingType[str, Any]) -> dict[str, Any]:
390
+ """Deep-clone *mapping* while preserving container types.
391
+
392
+ Why
393
+ ----
394
+ ``Config.as_dict`` and JSON serialisation must not leak references to the
395
+ internal immutable structures.
396
+
397
+ Parameters
398
+ ----------
399
+ mapping:
400
+ Mapping to clone.
401
+
402
+ Returns
403
+ -------
404
+ dict[str, Any]
405
+ Deep copy containing cloned containers and scalar values.
406
+
407
+ Examples
408
+ --------
409
+ >>> original = {"levels": (1, 2), "queue": [1, 2]}
410
+ >>> cloned = _clone_map(original)
411
+ >>> cloned["levels"], cloned["queue"]
412
+ ((1, 2), [1, 2])
413
+ >>> cloned["queue"].append(3)
414
+ >>> original["queue"]
415
+ [1, 2]
416
+ """
417
+
418
+ sculpted: dict[str, Any] = {}
419
+ for key, value in mapping.items():
420
+ sculpted[key] = _clone_value(value)
421
+ return sculpted
422
+
423
+
424
+ def _clone_value(value: Any) -> Any:
425
+ """Return a clone of *value*, respecting the container type.
426
+
427
+ Why
428
+ ----
429
+ ``_clone_map`` delegates element cloning here so complex structures (lists,
430
+ sets, tuples, nested mappings) remain detached from the immutable source.
431
+
432
+ Examples
433
+ --------
434
+ >>> cloned = _clone_value(({"flag": True},))
435
+ >>> cloned
436
+ ({'flag': True},)
437
+ >>> cloned is _clone_value(({"flag": True},))
438
+ False
439
+ """
440
+
441
+ if isinstance(value, MappingABC):
442
+ nested = cast(MappingType[str, Any], value)
443
+ return _clone_map(nested)
444
+ if isinstance(value, list):
445
+ items = cast(list[Any], value)
446
+ return [_clone_value(item) for item in items]
447
+ if isinstance(value, set):
448
+ items = cast(set[Any], value)
449
+ return {_clone_value(item) for item in items}
450
+ if isinstance(value, tuple):
451
+ items = cast(tuple[Any, ...], value)
452
+ return tuple(_clone_value(item) for item in items)
453
+ return value
454
+
455
+
456
+ def _looks_like_mapping(value: object) -> TypeGuard[MappingType[str, Any]]:
457
+ """Return ``True`` when *value* is a mapping with string keys.
458
+
459
+ Why
460
+ ----
461
+ Dotted traversal should stop when encountering scalars or non-string-keyed
462
+ mappings to avoid surprising behaviour.
463
+
464
+ Examples
465
+ --------
466
+ >>> _looks_like_mapping({"key": 1})
467
+ True
468
+ >>> _looks_like_mapping({1: "value"})
469
+ False
470
+ >>> _looks_like_mapping(["not", "mapping"])
471
+ False
472
+ """
473
+
474
+ if not isinstance(value, MappingABC):
475
+ return False
476
+ for key in cast(Iterable[object], value.keys()):
477
+ if not isinstance(key, str):
478
+ return False
479
+ return True
480
+
481
+
482
+ EMPTY_CONFIG = Config(MappingProxyType({}), MappingProxyType({}))
483
+ """Shared empty configuration used by the composition root and CLI helpers.
484
+
485
+ Why
486
+ ---
487
+ Avoids repeated allocations when no layers contribute values. The empty
488
+ instance satisfies the domain contract (immutability, provenance available but
489
+ empty) and is safe to reuse across contexts.
490
+ """
@@ -0,0 +1,65 @@
1
+ """Domain error taxonomy shared across layers.
2
+
3
+ Purpose
4
+ -------
5
+ codifies the error classes referenced throughout ``docs/systemdesign`` so the
6
+ application and adapter layers can communicate failures without depending on
7
+ concrete implementations.
8
+
9
+ Contents
10
+ --------
11
+ - ``ConfigError``: base class for every library-specific exception.
12
+ - ``InvalidFormat``: raised when structured configuration cannot be parsed.
13
+ - ``ValidationError``: reserved for semantic validation of configuration
14
+ payloads once implemented.
15
+ - ``NotFound``: indicates optional configuration sources were absent.
16
+
17
+ System Role
18
+ -----------
19
+ Adapters raise these exceptions; the composition root and CLI translate them
20
+ into operator-facing messages without leaking implementation details.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ __all__ = [
26
+ "ConfigError",
27
+ "InvalidFormat",
28
+ "ValidationError",
29
+ "NotFound",
30
+ ]
31
+
32
+
33
+ class ConfigError(Exception):
34
+ """Base class for all configuration-related errors in the library.
35
+
36
+ Why
37
+ ----
38
+ Centralises exception handling so callers can catch a single type when
39
+ operating at library boundaries.
40
+ """
41
+
42
+
43
+ class InvalidFormat(ConfigError):
44
+ """Raised when a configuration source cannot be parsed.
45
+
46
+ Typical sources include malformed TOML, JSON, YAML, or dotenv files. The
47
+ message should reference the offending path for operator debugging.
48
+ """
49
+
50
+
51
+ class ValidationError(ConfigError):
52
+ """Placeholder for semantic configuration validation failures.
53
+
54
+ The current release does not perform semantic validation, but the class is
55
+ reserved so downstream integrations already depend on a stable type.
56
+ """
57
+
58
+
59
+ class NotFound(ConfigError):
60
+ """Indicates an optional configuration source was not discovered.
61
+
62
+ Used when files, directory entries, or environment variable namespaces are
63
+ genuinely missing; callers generally treat this as informational rather than
64
+ fatal.
65
+ """
@@ -0,0 +1,29 @@
1
+ """Expose example-generation and deployment helpers as a tidy façade.
2
+
3
+ Purpose
4
+ Provide a single import point for notebooks and docs that showcase layered
5
+ configuration scenarios. Keeps consumers away from internal module layout.
6
+
7
+ Contents
8
+ - :func:`deploy_config`: copy template files into etc/xdg directories.
9
+ - :class:`ExampleSpec`: describes example assets to generate.
10
+ - :data:`DEFAULT_HOST_PLACEHOLDER`: default hostname marker for templates.
11
+ - :func:`generate_examples`: materialise example configs on disk.
12
+
13
+ System Integration
14
+ Re-exports live in the ``examples`` namespace so tutorials can call
15
+ ``lib_layered_config.examples.generate_examples`` without traversing the
16
+ package internals.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .deploy import deploy_config
22
+ from .generate import DEFAULT_HOST_PLACEHOLDER, ExampleSpec, generate_examples
23
+
24
+ __all__ = (
25
+ "deploy_config",
26
+ "ExampleSpec",
27
+ "DEFAULT_HOST_PLACEHOLDER",
28
+ "generate_examples",
29
+ )