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,509 @@
1
+ """Environment variable adapter.
2
+
3
+ Purpose
4
+ -------
5
+ Translate process environment variables into nested configuration dictionaries.
6
+ It implements the port described in ``docs/systemdesign/module_reference.md``
7
+ and forms the final precedence layer in ``lib_layered_config``.
8
+
9
+ Contents
10
+ - ``default_env_prefix``: canonical prefix builder for a slug.
11
+ - ``DefaultEnvLoader``: orchestrates filtering, coercion, and nesting.
12
+ - ``assign_nested`` / ``_ensure_child_mapping`` / ``_resolve_key``: shared
13
+ helpers re-used by dotenv parsing to keep shapes aligned.
14
+ - ``_coerce`` plus tiny predicate helpers that translate strings into
15
+ Python primitives.
16
+ - ``_normalize_prefix`` / ``_iter_namespace_entries`` / ``_collect_keys``:
17
+ small verbs that keep the loader body declarative.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from collections.abc import Iterable, Iterator
24
+ from typing import cast
25
+
26
+ from ...observability import log_debug
27
+
28
+
29
+ def default_env_prefix(slug: str) -> str:
30
+ """Return the canonical environment prefix for *slug*.
31
+
32
+ Why
33
+ ----
34
+ Namespacing prevents unrelated environment variables from leaking into the
35
+ configuration payload.
36
+
37
+ Parameters
38
+ ----------
39
+ slug:
40
+ Package/application slug (typically ``kebab-case``).
41
+
42
+ Returns
43
+ -------
44
+ str
45
+ Upper-case prefix with dashes converted to underscores.
46
+
47
+ Examples
48
+ --------
49
+ >>> default_env_prefix('lib-layered-config')
50
+ 'LIB_LAYERED_CONFIG'
51
+ """
52
+
53
+ return slug.replace("-", "_").upper()
54
+
55
+
56
+ class DefaultEnvLoader:
57
+ """Load environment variables that belong to the configuration namespace.
58
+
59
+ Why
60
+ ----
61
+ Implements the :class:`lib_layered_config.application.ports.EnvLoader` port,
62
+ translating process environment variables into merge-ready payloads.
63
+
64
+ What
65
+ ----
66
+ Filters environment entries by prefix, nests values using ``__`` separators,
67
+ performs primitive coercion, and emits observability events.
68
+ """
69
+
70
+ def __init__(self, *, environ: dict[str, str] | None = None) -> None:
71
+ """Initialise the loader with a specific ``environ`` mapping for testability.
72
+
73
+ Why
74
+ ----
75
+ Allow tests and callers to supply deterministic environments.
76
+
77
+ Parameters
78
+ ----------
79
+ environ:
80
+ Mapping to read from. Defaults to :data:`os.environ`.
81
+ """
82
+
83
+ self._environ = os.environ if environ is None else environ
84
+
85
+ def load(self, prefix: str) -> dict[str, object]:
86
+ """Return a nested mapping containing variables with the supplied *prefix*.
87
+
88
+ Why
89
+ ----
90
+ Environment variables should integrate with the merge pipeline using the
91
+ same nesting semantics as `.env` files.
92
+
93
+ What
94
+ ----
95
+ Normalises the prefix, filters matching entries, coerces values, nests
96
+ keys via :func:`assign_nested`, and logs the summarised result.
97
+
98
+ Parameters
99
+ ----------
100
+ prefix:
101
+ Prefix filter (upper-case). The loader appends ``_`` if missing.
102
+
103
+ Returns
104
+ -------
105
+ dict[str, object]
106
+ Nested mapping suitable for the merge algorithm. Keys are stored in
107
+ lowercase to align with file-based layers.
108
+
109
+ Side Effects
110
+ ------------
111
+ Emits ``env_variables_loaded`` debug events with summarised keys.
112
+
113
+ Examples
114
+ --------
115
+ >>> env = {
116
+ ... 'DEMO_SERVICE__ENABLED': 'true',
117
+ ... 'DEMO_SERVICE__RETRIES': '3',
118
+ ... }
119
+ >>> loader = DefaultEnvLoader(environ=env)
120
+ >>> payload = loader.load('DEMO')
121
+ >>> payload['service']['retries']
122
+ 3
123
+ >>> payload['service']['enabled']
124
+ True
125
+ """
126
+
127
+ normalized_prefix = _normalize_prefix(prefix)
128
+ collected: dict[str, object] = {}
129
+ for raw_key, value in _iter_namespace_entries(self._environ.items(), normalized_prefix):
130
+ assign_nested(collected, raw_key, _coerce(value))
131
+ log_debug("env_variables_loaded", layer="env", path=None, keys=_collect_keys(collected))
132
+ return collected
133
+
134
+
135
+ def _normalize_prefix(prefix: str) -> str:
136
+ """Ensure the prefix ends with an underscore when non-empty.
137
+
138
+ Why
139
+ ----
140
+ Aligns environment variable filtering semantics regardless of user input.
141
+
142
+ Parameters
143
+ ----------
144
+ prefix:
145
+ Raw prefix string (upper-case expected but not enforced).
146
+
147
+ Returns
148
+ -------
149
+ str
150
+ Prefix guaranteed to end with ``_`` when non-empty.
151
+
152
+ Examples
153
+ --------
154
+ >>> _normalize_prefix('DEMO')
155
+ 'DEMO_'
156
+ >>> _normalize_prefix('DEMO_')
157
+ 'DEMO_'
158
+ >>> _normalize_prefix('')
159
+ ''
160
+ """
161
+
162
+ if prefix and not prefix.endswith("_"):
163
+ return f"{prefix}_"
164
+ return prefix
165
+
166
+
167
+ def _iter_namespace_entries(
168
+ items: Iterable[tuple[str, str]],
169
+ prefix: str,
170
+ ) -> Iterator[tuple[str, str]]:
171
+ """Yield ``(stripped_key, value)`` pairs that match *prefix*.
172
+
173
+ Why
174
+ ----
175
+ Encapsulate prefix filtering so caller code stays declarative.
176
+
177
+ Parameters
178
+ ----------
179
+ items:
180
+ Iterable of environment items to examine.
181
+ prefix:
182
+ Normalised prefix (including trailing underscore) to filter on.
183
+
184
+ Returns
185
+ -------
186
+ Iterator[tuple[str, str]]
187
+ Pairs whose keys share the prefix with the prefix removed.
188
+
189
+ Examples
190
+ --------
191
+ >>> list(_iter_namespace_entries([('DEMO_FLAG', '1'), ('OTHER', '0')], 'DEMO_'))
192
+ [('FLAG', '1')]
193
+ >>> list(_iter_namespace_entries([('DEMO', '1')], 'DEMO_'))
194
+ []
195
+ """
196
+
197
+ for key, value in items:
198
+ if prefix and not key.startswith(prefix):
199
+ continue
200
+ stripped = key[len(prefix) :] if prefix else key
201
+ if not stripped:
202
+ continue
203
+ yield stripped, value
204
+
205
+
206
+ def _collect_keys(mapping: dict[str, object]) -> list[str]:
207
+ """Return sorted top-level keys for logging.
208
+
209
+ Why
210
+ ----
211
+ Provide compact telemetry context without dumping entire payloads.
212
+
213
+ Parameters
214
+ ----------
215
+ mapping:
216
+ Nested mapping produced by environment parsing.
217
+
218
+ Returns
219
+ -------
220
+ list[str]
221
+ Sorted list of top-level keys.
222
+
223
+ Examples
224
+ --------
225
+ >>> _collect_keys({'service': {}, 'logging': {}})
226
+ ['logging', 'service']
227
+ """
228
+
229
+ return sorted(mapping.keys())
230
+
231
+
232
+ def assign_nested(target: dict[str, object], key: str, value: object) -> None:
233
+ """Assign ``value`` inside ``target`` using ``__`` as a nesting delimiter.
234
+
235
+ Why
236
+ ----
237
+ Reuse the same semantics as dotenv parsing so callers see consistent shapes.
238
+
239
+ What
240
+ ----
241
+ Splits the key on ``__``, ensures intermediate mappings exist, resolves
242
+ case-insensitive keys, and assigns the final value.
243
+
244
+ Parameters
245
+ ----------
246
+ target:
247
+ Mapping being mutated in place.
248
+ key:
249
+ Namespaced key using ``__`` separators.
250
+ value:
251
+ Value to store at the resolved path.
252
+
253
+ Returns
254
+ -------
255
+ None
256
+
257
+ Side Effects
258
+ ------------
259
+ Mutates ``target``.
260
+
261
+ Examples
262
+ --------
263
+ >>> data: dict[str, object] = {}
264
+ >>> assign_nested(data, 'SERVICE__TIMEOUT', 5)
265
+ >>> data
266
+ {'service': {'timeout': 5}}
267
+ """
268
+
269
+ parts = key.split("__")
270
+ cursor = target
271
+ for part in parts[:-1]:
272
+ cursor = _ensure_child_mapping(cursor, part, error_cls=ValueError)
273
+ final_key = _resolve_key(cursor, parts[-1])
274
+ cursor[final_key] = value
275
+
276
+
277
+ def _resolve_key(mapping: dict[str, object], key: str) -> str:
278
+ """Return an existing key that matches ``key`` (case-insensitive) or a new lowercase key.
279
+
280
+ Why
281
+ ----
282
+ Preserve case stability while avoiding duplicates that differ only by case.
283
+
284
+ Parameters
285
+ ----------
286
+ mapping:
287
+ Mutable mapping being inspected.
288
+ key:
289
+ Incoming key to resolve.
290
+
291
+ Returns
292
+ -------
293
+ str
294
+ Existing key name or newly normalised lowercase variant.
295
+
296
+ Examples
297
+ --------
298
+ >>> target = {'timeout': 5}
299
+ >>> _resolve_key(target, 'TIMEOUT')
300
+ 'timeout'
301
+ >>> _resolve_key({}, 'Endpoint')
302
+ 'endpoint'
303
+ """
304
+
305
+ lower = key.lower()
306
+ for existing in mapping.keys():
307
+ if existing.lower() == lower:
308
+ return existing
309
+ return lower
310
+
311
+
312
+ def _ensure_child_mapping(mapping: dict[str, object], key: str, *, error_cls: type[Exception]) -> dict[str, object]:
313
+ """Ensure ``mapping[key]`` is a ``dict`` (creating or validating as necessary).
314
+
315
+ Why
316
+ ----
317
+ Prevent accidental overwrites of scalar values when nested keys are
318
+ introduced.
319
+
320
+ What
321
+ ----
322
+ Resolves the key case-insensitively, creates an empty mapping when missing,
323
+ or raises ``error_cls`` if a scalar already occupies the slot.
324
+
325
+ Parameters
326
+ ----------
327
+ mapping:
328
+ Mutable mapping being mutated.
329
+ key:
330
+ Candidate key to ensure.
331
+ error_cls:
332
+ Exception type raised when a scalar collision occurs.
333
+
334
+ Returns
335
+ -------
336
+ dict[str, object]
337
+ Child mapping stored at the resolved key.
338
+
339
+ Side Effects
340
+ ------------
341
+ Mutates ``mapping`` by inserting a new child mapping when missing.
342
+
343
+ Examples
344
+ --------
345
+ >>> target = {}
346
+ >>> child = _ensure_child_mapping(target, 'SERVICE', error_cls=ValueError)
347
+ >>> child == {}
348
+ True
349
+ >>> target
350
+ {'service': {}}
351
+ >>> target['service'] = 1
352
+ >>> _ensure_child_mapping(target, 'SERVICE', error_cls=ValueError)
353
+ Traceback (most recent call last):
354
+ ...
355
+ ValueError: Cannot override scalar with mapping for key SERVICE
356
+ """
357
+
358
+ resolved = _resolve_key(mapping, key)
359
+ if resolved not in mapping:
360
+ mapping[resolved] = dict[str, object]()
361
+ child = mapping[resolved]
362
+ if not isinstance(child, dict):
363
+ raise error_cls(f"Cannot override scalar with mapping for key {key}")
364
+ typed_child = cast(dict[str, object], child)
365
+ mapping[resolved] = typed_child
366
+ return typed_child
367
+
368
+
369
+ def _coerce(value: str) -> object:
370
+ """Coerce textual environment values to Python primitives where possible.
371
+
372
+ Why
373
+ ----
374
+ Convert human-friendly strings (``true``, ``5``, ``3.14``) into their Python
375
+ equivalents before merging.
376
+
377
+ What
378
+ ----
379
+ Applies boolean, null, integer, and float heuristics in sequence, returning
380
+ the original string when none match.
381
+
382
+ Returns
383
+ -------
384
+ object
385
+ Parsed primitive or original string when coercion is not possible.
386
+
387
+ Examples
388
+ --------
389
+ >>> _coerce('true'), _coerce('10'), _coerce('3.5'), _coerce('hello'), _coerce('null')
390
+ (True, 10, 3.5, 'hello', None)
391
+ """
392
+
393
+ lowered = value.lower()
394
+ if _looks_like_bool(lowered):
395
+ return lowered == "true"
396
+ if _looks_like_null(lowered):
397
+ return None
398
+ if _looks_like_int(value):
399
+ return int(value)
400
+ return _maybe_float(value)
401
+
402
+
403
+ def _looks_like_bool(value: str) -> bool:
404
+ """Return ``True`` when *value* spells a boolean literal.
405
+
406
+ Why
407
+ ----
408
+ Support `_coerce` in recognising booleans without repeated literal sets.
409
+
410
+ Parameters
411
+ ----------
412
+ value:
413
+ Lower-cased string to inspect.
414
+
415
+ Returns
416
+ -------
417
+ bool
418
+ ``True`` when the value is ``"true"`` or ``"false"``.
419
+
420
+ Examples
421
+ --------
422
+ >>> _looks_like_bool('true'), _looks_like_bool('false'), _looks_like_bool('maybe')
423
+ (True, True, False)
424
+ """
425
+
426
+ return value in {"true", "false"}
427
+
428
+
429
+ def _looks_like_null(value: str) -> bool:
430
+ """Return ``True`` when *value* represents a null literal.
431
+
432
+ Why
433
+ ----
434
+ Allow `_coerce` to map textual null representations to ``None``.
435
+
436
+ Parameters
437
+ ----------
438
+ value:
439
+ Lower-cased string to inspect.
440
+
441
+ Returns
442
+ -------
443
+ bool
444
+ ``True`` when the value is ``"null"`` or ``"none"``.
445
+
446
+ Examples
447
+ --------
448
+ >>> _looks_like_null('null'), _looks_like_null('none'), _looks_like_null('nil')
449
+ (True, True, False)
450
+ """
451
+
452
+ return value in {"null", "none"}
453
+
454
+
455
+ def _looks_like_int(value: str) -> bool:
456
+ """Return ``True`` when *value* can be parsed as an integer.
457
+
458
+ Why
459
+ ----
460
+ Let `_coerce` distinguish integers before attempting float conversion.
461
+
462
+ Parameters
463
+ ----------
464
+ value:
465
+ String to inspect (not yet normalised).
466
+
467
+ Returns
468
+ -------
469
+ bool
470
+ ``True`` when the value represents a base-10 integer literal.
471
+
472
+ Examples
473
+ --------
474
+ >>> _looks_like_int('42'), _looks_like_int('-7'), _looks_like_int('3.14')
475
+ (True, True, False)
476
+ """
477
+
478
+ if value.startswith("-"):
479
+ return value[1:].isdigit()
480
+ return value.isdigit()
481
+
482
+
483
+ def _maybe_float(value: str) -> object:
484
+ """Return a float when *value* looks numeric; otherwise return the original string.
485
+
486
+ Why
487
+ ----
488
+ Provide a final numeric coercion step after integer detection fails.
489
+
490
+ Parameters
491
+ ----------
492
+ value:
493
+ String candidate for float conversion.
494
+
495
+ Returns
496
+ -------
497
+ object
498
+ Float value or the original string when conversion fails.
499
+
500
+ Examples
501
+ --------
502
+ >>> _maybe_float('2.5'), _maybe_float('not-a-number')
503
+ (2.5, 'not-a-number')
504
+ """
505
+
506
+ try:
507
+ return float(value)
508
+ except ValueError:
509
+ return value
@@ -0,0 +1 @@
1
+ """Structured configuration file loaders."""