lib-layered-config 4.1.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.
Files changed (47) hide show
  1. lib_layered_config/__init__.py +58 -0
  2. lib_layered_config/__init__conf__.py +74 -0
  3. lib_layered_config/__main__.py +18 -0
  4. lib_layered_config/_layers.py +310 -0
  5. lib_layered_config/_platform.py +166 -0
  6. lib_layered_config/adapters/__init__.py +13 -0
  7. lib_layered_config/adapters/_nested_keys.py +126 -0
  8. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  9. lib_layered_config/adapters/dotenv/default.py +143 -0
  10. lib_layered_config/adapters/env/__init__.py +5 -0
  11. lib_layered_config/adapters/env/default.py +288 -0
  12. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  13. lib_layered_config/adapters/file_loaders/structured.py +376 -0
  14. lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
  15. lib_layered_config/adapters/path_resolvers/_base.py +166 -0
  16. lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
  17. lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
  18. lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
  19. lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
  20. lib_layered_config/adapters/path_resolvers/default.py +194 -0
  21. lib_layered_config/application/__init__.py +12 -0
  22. lib_layered_config/application/merge.py +379 -0
  23. lib_layered_config/application/ports.py +115 -0
  24. lib_layered_config/cli/__init__.py +92 -0
  25. lib_layered_config/cli/common.py +381 -0
  26. lib_layered_config/cli/constants.py +12 -0
  27. lib_layered_config/cli/deploy.py +71 -0
  28. lib_layered_config/cli/fail.py +19 -0
  29. lib_layered_config/cli/generate.py +57 -0
  30. lib_layered_config/cli/info.py +29 -0
  31. lib_layered_config/cli/read.py +120 -0
  32. lib_layered_config/core.py +301 -0
  33. lib_layered_config/domain/__init__.py +7 -0
  34. lib_layered_config/domain/config.py +372 -0
  35. lib_layered_config/domain/errors.py +59 -0
  36. lib_layered_config/domain/identifiers.py +366 -0
  37. lib_layered_config/examples/__init__.py +29 -0
  38. lib_layered_config/examples/deploy.py +333 -0
  39. lib_layered_config/examples/generate.py +406 -0
  40. lib_layered_config/observability.py +209 -0
  41. lib_layered_config/py.typed +0 -0
  42. lib_layered_config/testing.py +46 -0
  43. lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
  44. lib_layered_config-4.1.0.dist-info/RECORD +47 -0
  45. lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
  46. lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
  47. lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,301 @@
1
+ """Composition root tying adapters, merge policy, and domain objects together.
2
+
3
+ Implement the orchestration described in ``docs/systemdesign/concept.md`` by
4
+ discovering configuration layers, merging them with provenance, and returning a
5
+ domain-level :class:`Config` value object. Also provides convenience helpers for
6
+ JSON output and CLI wiring.
7
+
8
+ Contents:
9
+ - ``read_config`` / ``read_config_json`` / ``read_config_raw``: public APIs used
10
+ by library consumers and the CLI.
11
+ - ``LayerLoadError``: wraps adapter failures with a consistent exception type.
12
+ - Private helpers for resolver/builder construction, JSON dumping, and
13
+ configuration composition.
14
+
15
+ System Role:
16
+ This module sits at the composition layer of the architecture. It instantiates
17
+ adapters from ``lib_layered_config.adapters.*``, invokes
18
+ ``lib_layered_config._layers.collect_layers``, and converts merge results into
19
+ domain objects returned to callers.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Sequence
26
+ from pathlib import Path
27
+
28
+ from ._layers import collect_layers, merge_or_empty
29
+ from .adapters.dotenv.default import DefaultDotEnvLoader
30
+ from .adapters.env.default import DefaultEnvLoader, default_env_prefix
31
+ from .adapters.path_resolvers.default import DefaultPathResolver
32
+ from .application.merge import MergeResult
33
+ from .application.ports import SourceInfoPayload
34
+ from .domain.config import EMPTY_CONFIG, Config
35
+ from .domain.errors import (
36
+ ConfigError,
37
+ InvalidFormatError,
38
+ NotFoundError,
39
+ ValidationError,
40
+ )
41
+ from .observability import bind_trace_id
42
+
43
+
44
+ class LayerLoadError(ConfigError):
45
+ """Adapter failure raised during layer collection.
46
+
47
+ Provides a single exception type for callers who need to distinguish merge
48
+ orchestration errors from other configuration issues.
49
+ """
50
+
51
+
52
+ def read_config(
53
+ *,
54
+ vendor: str,
55
+ app: str,
56
+ slug: str,
57
+ profile: str | None = None,
58
+ prefer: Sequence[str] | None = None,
59
+ start_dir: str | None = None,
60
+ default_file: str | Path | None = None,
61
+ ) -> Config:
62
+ """Return an immutable :class:`Config` built from all reachable layers.
63
+
64
+ Most consumers want the merged configuration value object rather than raw
65
+ dictionaries. This function wraps the lower-level helper and constructs the
66
+ domain aggregate in one step.
67
+
68
+ Args:
69
+ vendor / app / slug: Identifiers used by adapters to compute filesystem paths and prefixes.
70
+ profile: Optional profile name for environment-specific configurations
71
+ (e.g., "test", "production"). When set, paths include a
72
+ ``profile/<name>/`` subdirectory.
73
+ prefer: Optional sequence of preferred file suffixes (``["toml", "json"]``).
74
+ start_dir: Optional directory that seeds `.env` discovery.
75
+ default_file: Optional lowest-precedence file injected before filesystem layers.
76
+
77
+ Returns:
78
+ Immutable configuration with provenance metadata.
79
+
80
+ Examples:
81
+ >>> from pathlib import Path
82
+ >>> tmp = Path('.') # doctest: +SKIP (illustrative)
83
+ >>> config = read_config(vendor="Acme", app="Demo", slug="demo", start_dir=str(tmp)) # doctest: +SKIP
84
+ >>> isinstance(config, Config)
85
+ True
86
+ """
87
+ result = read_config_raw(
88
+ vendor=vendor,
89
+ app=app,
90
+ slug=slug,
91
+ profile=profile,
92
+ prefer=prefer,
93
+ start_dir=start_dir,
94
+ default_file=_stringify_path(default_file),
95
+ )
96
+ return _compose_config(result.data, result.provenance)
97
+
98
+
99
+ def read_config_json(
100
+ *,
101
+ vendor: str,
102
+ app: str,
103
+ slug: str,
104
+ profile: str | None = None,
105
+ prefer: Sequence[str] | None = None,
106
+ start_dir: str | Path | None = None,
107
+ indent: int | None = None,
108
+ default_file: str | Path | None = None,
109
+ ) -> str:
110
+ """Return configuration and provenance as JSON suitable for tooling.
111
+
112
+ CLI commands and automation scripts often prefer JSON to Python objects.
113
+
114
+ Args:
115
+ vendor / app / slug / profile / prefer / start_dir / default_file: Same meaning as :func:`read_config`.
116
+ indent: Optional indentation level passed to ``json.dumps``.
117
+
118
+ Returns:
119
+ JSON document containing ``{"config": ..., "provenance": ...}``.
120
+ """
121
+ result = read_config_raw(
122
+ vendor=vendor,
123
+ app=app,
124
+ slug=slug,
125
+ profile=profile,
126
+ prefer=prefer,
127
+ start_dir=_stringify_path(start_dir),
128
+ default_file=_stringify_path(default_file),
129
+ )
130
+ return _dump_json({"config": result.data, "provenance": result.provenance}, indent)
131
+
132
+
133
+ def read_config_raw(
134
+ *,
135
+ vendor: str,
136
+ app: str,
137
+ slug: str,
138
+ profile: str | None = None,
139
+ prefer: Sequence[str] | None = None,
140
+ start_dir: str | None = None,
141
+ default_file: str | Path | None = None,
142
+ ) -> MergeResult:
143
+ """Return raw merged data and provenance for advanced tooling.
144
+
145
+ Unlike :func:`read_config`, returns mutable dictionaries instead of the
146
+ immutable :class:`Config` abstraction. Raises :class:`LayerLoadError`
147
+ when a structured file loader encounters invalid content.
148
+ """
149
+ resolver = _build_resolver(vendor=vendor, app=app, slug=slug, profile=profile, start_dir=start_dir)
150
+ dotenv_loader, env_loader = _build_loaders(resolver)
151
+
152
+ bind_trace_id(None)
153
+
154
+ try:
155
+ layers = collect_layers(
156
+ resolver=resolver,
157
+ prefer=prefer,
158
+ default_file=_stringify_path(default_file),
159
+ dotenv_loader=dotenv_loader,
160
+ env_loader=env_loader,
161
+ slug=slug,
162
+ start_dir=start_dir,
163
+ )
164
+ except InvalidFormatError as exc: # pragma: no cover - adapter tests exercise
165
+ raise LayerLoadError(str(exc)) from exc
166
+
167
+ return merge_or_empty(layers)
168
+
169
+
170
+ def _compose_config(
171
+ data: dict[str, object],
172
+ raw_meta: dict[str, SourceInfoPayload],
173
+ ) -> Config:
174
+ """Wrap merged data and provenance into an immutable :class:`Config`.
175
+
176
+ Keep the boundary between application-layer dictionaries and the domain
177
+ value object explicit so provenance typing stays consistent.
178
+
179
+ Args:
180
+ data: Mutable mapping returned by :func:`merge_layers`.
181
+ raw_meta: Provenance mapping keyed by dotted path as produced by the merge policy.
182
+
183
+ Returns:
184
+ Immutable configuration aggregate. Returns :data:`EMPTY_CONFIG` when
185
+ *data* is empty.
186
+
187
+ Side Effects:
188
+ None beyond constructing the dataclass instance.
189
+
190
+ Examples:
191
+ >>> cfg = _compose_config({'debug': True}, {'debug': {'layer': 'env', 'path': None, 'key': 'debug'}})
192
+ >>> cfg['debug'], cfg.origin('debug')['layer']
193
+ (True, 'env')
194
+ """
195
+ if not data:
196
+ return EMPTY_CONFIG
197
+ return Config(data, raw_meta)
198
+
199
+
200
+ def _build_resolver(
201
+ *,
202
+ vendor: str,
203
+ app: str,
204
+ slug: str,
205
+ profile: str | None,
206
+ start_dir: str | None,
207
+ ) -> DefaultPathResolver:
208
+ """Create a path resolver configured with optional ``start_dir`` context.
209
+
210
+ Reuse the same resolver wiring for CLI and library entry points while
211
+ keeping construction logic centralised for testing.
212
+
213
+ Args:
214
+ vendor / app / slug: Identifiers forwarded to :class:`DefaultPathResolver`.
215
+ profile: Optional profile name for environment-specific configuration paths.
216
+ start_dir: Optional directory that seeds project-relative resolution (used for
217
+ `.env` discovery); ``None`` preserves resolver defaults.
218
+
219
+ Returns:
220
+ Resolver instance ready for layer discovery.
221
+
222
+ Examples:
223
+ >>> resolver = _build_resolver(vendor='Acme', app='Demo', slug='demo', profile=None, start_dir=None)
224
+ >>> resolver.slug
225
+ 'demo'
226
+ """
227
+ return DefaultPathResolver(
228
+ vendor=vendor, app=app, slug=slug, profile=profile, cwd=Path(start_dir) if start_dir else None
229
+ )
230
+
231
+
232
+ def _build_loaders(resolver: DefaultPathResolver) -> tuple[DefaultDotEnvLoader, DefaultEnvLoader]:
233
+ """Instantiate dotenv and environment loaders sharing resolver context.
234
+
235
+ Keeps loader construction aligned with the resolver extras (e.g., additional
236
+ dotenv directories) and centralises wiring for tests.
237
+
238
+ Args:
239
+ resolver: Resolver supplying platform-specific extras for dotenv discovery.
240
+
241
+ Returns:
242
+ Pair of loader instances ready for layer collection.
243
+ """
244
+ return DefaultDotEnvLoader(extras=resolver.dotenv()), DefaultEnvLoader()
245
+
246
+
247
+ def _stringify_path(value: str | Path | None) -> str | None:
248
+ """Convert ``Path`` or string inputs into plain string values for adapters.
249
+
250
+ Adapters expect plain strings while public APIs accept :class:`Path` objects
251
+ for user convenience. Centralising the conversion avoids duplicate logic.
252
+
253
+ Args:
254
+ value: Optional path expressed as either a string or :class:`pathlib.Path`.
255
+
256
+ Returns:
257
+ Stringified path or ``None`` when *value* is ``None``.
258
+
259
+ Examples:
260
+ >>> _stringify_path(Path('/tmp/config.toml'))
261
+ '/tmp/config.toml'
262
+ >>> _stringify_path(None) is None
263
+ True
264
+ """
265
+ if isinstance(value, Path):
266
+ return str(value)
267
+ return value
268
+
269
+
270
+ def _dump_json(payload: object, indent: int | None) -> str:
271
+ """Serialise *payload* to JSON while preserving non-ASCII characters.
272
+
273
+ Args:
274
+ payload: JSON-serialisable object to dump.
275
+ indent: Optional indentation level mirroring :func:`json.dumps`. ``None`` produces
276
+ the most compact output.
277
+
278
+ Returns:
279
+ JSON document encoded as UTF-8 friendly text.
280
+
281
+ Examples:
282
+ >>> _dump_json({"a": 1}, indent=None)
283
+ '{"a":1}'
284
+ >>> "\n" in _dump_json({"a": 1}, indent=2)
285
+ True
286
+ """
287
+ return json.dumps(payload, indent=indent, separators=(",", ":"), ensure_ascii=False)
288
+
289
+
290
+ __all__ = [
291
+ "Config",
292
+ "ConfigError",
293
+ "InvalidFormatError",
294
+ "ValidationError",
295
+ "NotFoundError",
296
+ "LayerLoadError",
297
+ "read_config",
298
+ "read_config_json",
299
+ "read_config_raw",
300
+ "default_env_prefix",
301
+ ]
@@ -0,0 +1,7 @@
1
+ """Domain entities for ``lib_layered_config`` (value objects + errors).
2
+
3
+ Purpose
4
+ -------
5
+ Federate immutable value objects and the shared error hierarchy so outer layers
6
+ can depend on them without pulling in adapters.
7
+ """
@@ -0,0 +1,372 @@
1
+ """Immutable configuration value object with provenance tracking.
2
+
3
+ Provide the "configuration aggregate" described in
4
+ ``docs/systemdesign/concept.md``: an immutable mapping that preserves both the
5
+ final merged values and the metadata explaining *where* every dotted key was
6
+ sourced. The application and adapter layers rely on this module to honour the
7
+ precedence rules documented for layered configuration.
8
+
9
+ Contents:
10
+ - ``SourceInfo``: typed dictionary describing layer, path, and dotted key.
11
+ - ``Config``: frozen mapping-like dataclass exposing lookup, provenance, and
12
+ serialisation helpers.
13
+ - Internal helpers (``_follow_path``, ``_clone_map`` ...) that keep traversal
14
+ logic pure and testable.
15
+ - ``EMPTY_CONFIG``: canonical empty instance shared across the composition
16
+ root and CLI utilities.
17
+
18
+ System Role:
19
+ The composition root builds ``Config`` instances after merging layer snapshots.
20
+ Presentation layers (CLI, examples) consume the public API to render human or
21
+ JSON output without re-implementing provenance rules.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections.abc import Iterable, Iterator, Mapping
28
+ from collections.abc import Mapping as MappingABC
29
+ from collections.abc import Mapping as MappingType
30
+ from dataclasses import dataclass
31
+ from types import MappingProxyType
32
+ from typing import Any, TypedDict, TypeGuard, TypeVar, cast
33
+
34
+
35
+ class SourceInfo(TypedDict):
36
+ """Describe the provenance of a configuration value.
37
+
38
+ Downstream tooling (CLI, deploy helpers) needs to display where a value
39
+ originated so operators can trace precedence decisions.
40
+
41
+ Attributes:
42
+ layer: Name of the logical layer (``"defaults"``, ``"app"``, ``"host"``,
43
+ ``"user"``, ``"dotenv"``, or ``"env"``).
44
+ path: Absolute filesystem path when known; ``None`` for ephemeral sources
45
+ such as environment variables.
46
+ key: Fully-qualified dotted key corresponding to the stored value.
47
+ """
48
+
49
+ layer: str
50
+ path: str | None
51
+ key: str
52
+
53
+
54
+ T = TypeVar("T")
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Config(MappingABC[str, Any]):
59
+ """Immutable mapping plus provenance metadata for a merged configuration.
60
+
61
+ The system design mandates that merged configuration stays read-only after
62
+ assembly so every layer sees a consistent snapshot. ``Config`` enforces that
63
+ contract while providing ergonomic helpers for dotted lookups and
64
+ serialisation.
65
+
66
+ Attributes:
67
+ _data: Mapping containing the merged configuration tree. Stored as a
68
+ ``MappingProxyType`` to prevent mutation.
69
+ _meta: Mapping of dotted keys to :class:`SourceInfo`, allowing provenance
70
+ queries via :meth:`origin`.
71
+ """
72
+
73
+ _data: Mapping[str, Any]
74
+ _meta: Mapping[str, SourceInfo]
75
+
76
+ def __post_init__(self) -> None:
77
+ """Freeze internal mappings immediately after construction."""
78
+ object.__setattr__(self, "_data", _lock_map(self._data))
79
+ object.__setattr__(self, "_meta", _lock_map(self._meta))
80
+
81
+ def __getitem__(self, key: str) -> Any:
82
+ """Return the value stored under a top-level key.
83
+
84
+ Consumers expect ``Config`` to behave like a standard mapping.
85
+
86
+ Args:
87
+ key: Top-level key to retrieve.
88
+
89
+ Returns:
90
+ Stored value.
91
+
92
+ Raises:
93
+ KeyError: When *key* does not exist.
94
+
95
+ Examples:
96
+ >>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
97
+ >>> cfg["debug"]
98
+ True
99
+ """
100
+ return self._data[key]
101
+
102
+ def __iter__(self) -> Iterator[str]:
103
+ """Iterate over top-level keys in insertion order."""
104
+ return iter(self._data)
105
+
106
+ def __len__(self) -> int:
107
+ """Return the number of stored top-level keys."""
108
+ return len(self._data)
109
+
110
+ def as_dict(self) -> dict[str, Any]:
111
+ """Return a deep, mutable copy of the configuration tree.
112
+
113
+ Callers occasionally need to serialise or further mutate the data in a
114
+ context that does not require provenance.
115
+
116
+ Returns:
117
+ Independent copy of the configuration data.
118
+
119
+ Examples:
120
+ >>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
121
+ >>> clone = cfg.as_dict()
122
+ >>> clone["debug"]
123
+ True
124
+ >>> clone["debug"] = False
125
+ >>> cfg["debug"]
126
+ True
127
+ """
128
+ return _clone_map(self._data)
129
+
130
+ def to_json(self, *, indent: int | None = None) -> str:
131
+ """Serialise the configuration as JSON.
132
+
133
+ CLI tooling and documentation examples render the merged configuration
134
+ in JSON to support piping into other scripts.
135
+
136
+ Args:
137
+ indent: Optional indentation level mirroring ``json.dumps`` semantics.
138
+
139
+ Returns:
140
+ JSON payload containing the cloned configuration data.
141
+
142
+ Examples:
143
+ >>> cfg = Config({"debug": True}, {"debug": {"layer": "env", "path": None, "key": "debug"}})
144
+ >>> cfg.to_json()
145
+ '{"debug":true}'
146
+ >>> "\\n \\"debug\\"" in cfg.to_json(indent=2)
147
+ True
148
+ """
149
+ return json.dumps(self.as_dict(), indent=indent, separators=(",", ":"), ensure_ascii=False)
150
+
151
+ def get(self, key: str, default: Any = None) -> Any:
152
+ """Return the value for *key* or a default when the path is missing.
153
+
154
+ Layered configuration relies on dotted keys (e.g. ``"db.host"``).
155
+ This helper avoids repetitive traversal code at call sites.
156
+
157
+ Args:
158
+ key: Dotted path identifying nested entries.
159
+ default: Value to return when the path does not resolve or encounters a
160
+ non-mapping.
161
+
162
+ Returns:
163
+ The resolved value or *default* when missing.
164
+
165
+ Examples:
166
+ >>> cfg = Config({"db": {"host": "localhost"}}, {"db.host": {"layer": "app", "path": None, "key": "db.host"}})
167
+ >>> cfg.get("db.host")
168
+ 'localhost'
169
+ >>> cfg.get("db.port", default=5432)
170
+ 5432
171
+ """
172
+ return _follow_path(self._data, key, default)
173
+
174
+ def origin(self, key: str) -> SourceInfo | None:
175
+ """Return provenance metadata for *key* when available.
176
+
177
+ Operators need to understand which layer supplied a value to debug
178
+ precedence questions.
179
+
180
+ Args:
181
+ key: Dotted key in the metadata map.
182
+
183
+ Returns:
184
+ Metadata dictionary or ``None`` if the key was never observed.
185
+
186
+ Examples:
187
+ >>> meta = {"db.host": {"layer": "app", "path": "/etc/app.toml", "key": "db.host"}}
188
+ >>> cfg = Config({"db": {"host": "localhost"}}, meta)
189
+ >>> cfg.origin("db.host")["layer"]
190
+ 'app'
191
+ >>> cfg.origin("missing") is None
192
+ True
193
+ """
194
+ return self._meta.get(key)
195
+
196
+ def with_overrides(self, overrides: Mapping[str, Any]) -> Config:
197
+ """Return a new configuration with shallow top-level overrides applied.
198
+
199
+ CLI helpers allow callers to inject ad-hoc overrides while keeping the
200
+ original snapshot intact. This method produces that variant.
201
+
202
+ Args:
203
+ overrides: Top-level keys and values to override.
204
+
205
+ Returns:
206
+ New configuration instance sharing provenance with the original.
207
+
208
+ Examples:
209
+ >>> cfg = Config({"feature": False}, {"feature": {"layer": "app", "path": None, "key": "feature"}})
210
+ >>> cfg.with_overrides({"feature": True})["feature"], cfg["feature"]
211
+ (True, False)
212
+ """
213
+ tinted = _blend_top_level(self._data, overrides)
214
+ return Config(tinted, self._meta)
215
+
216
+
217
+ def _lock_map(mapping: Mapping[str, Any]) -> Mapping[str, Any]:
218
+ """Return a read-only view of *mapping*.
219
+
220
+ Internal state must remain immutable to uphold the domain contract.
221
+
222
+ Args:
223
+ mapping: Mapping to wrap. A shallow copy protects against caller mutation.
224
+
225
+ Returns:
226
+ ``MappingProxyType`` over a copy of the source mapping.
227
+
228
+ Examples:
229
+ >>> view = _lock_map({"flag": True})
230
+ >>> view["flag"], isinstance(view, MappingProxyType)
231
+ (True, True)
232
+ """
233
+ return MappingProxyType(dict(mapping))
234
+
235
+
236
+ def _blend_top_level(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
237
+ """Return a shallow copy of *base* with *overrides* applied.
238
+
239
+ ``Config.with_overrides`` depends on a pure helper so it can reuse
240
+ provenance metadata without mutation.
241
+
242
+ Args:
243
+ base: Original mapping.
244
+ overrides: Mapping whose keys replace entries in *base*.
245
+
246
+ Returns:
247
+ New dictionary with updated top-level values.
248
+
249
+ Examples:
250
+ >>> _blend_top_level({"port": 8000}, {"port": 9000})["port"]
251
+ 9000
252
+ """
253
+ tinted = dict(base)
254
+ tinted.update(overrides)
255
+ return tinted
256
+
257
+
258
+ def _follow_path(source: Mapping[str, Any], dotted: str, default: Any) -> Any:
259
+ """Traverse *source* using dotted notation.
260
+
261
+ Nested configuration should be accessible without exposing internal data
262
+ structures. This helper powers :meth:`Config.get`.
263
+
264
+ Args:
265
+ source: Mapping to traverse.
266
+ dotted: Dotted path, e.g. ``"db.host"``.
267
+ default: Fallback when traversal fails.
268
+
269
+ Returns:
270
+ Resolved value or *default*.
271
+
272
+ Examples:
273
+ >>> payload = {"db": {"host": "localhost"}}
274
+ >>> _follow_path(payload, "db.host", default=None)
275
+ 'localhost'
276
+ >>> _follow_path(payload, "db.port", default=5432)
277
+ 5432
278
+ """
279
+ current: object = source
280
+ for fragment in dotted.split("."):
281
+ if not _looks_like_mapping(current):
282
+ return default
283
+ if fragment not in current:
284
+ return default
285
+ current = current[fragment]
286
+ return cast(Any, current)
287
+
288
+
289
+ def _clone_map(mapping: MappingType[str, Any]) -> dict[str, Any]:
290
+ """Deep-clone *mapping* while preserving container types.
291
+
292
+ ``Config.as_dict`` and JSON serialisation must not leak references to the
293
+ internal immutable structures.
294
+
295
+ Args:
296
+ mapping: Mapping to clone.
297
+
298
+ Returns:
299
+ Deep copy containing cloned containers and scalar values.
300
+
301
+ Examples:
302
+ >>> original = {"levels": (1, 2), "queue": [1, 2]}
303
+ >>> cloned = _clone_map(original)
304
+ >>> cloned["levels"], cloned["queue"]
305
+ ((1, 2), [1, 2])
306
+ >>> cloned["queue"].append(3)
307
+ >>> original["queue"]
308
+ [1, 2]
309
+ """
310
+ sculpted: dict[str, Any] = {}
311
+ for key, value in mapping.items():
312
+ sculpted[key] = _clone_value(value)
313
+ return sculpted
314
+
315
+
316
+ def _clone_value(value: Any) -> Any:
317
+ """Return a clone of *value*, respecting the container type.
318
+
319
+ ``_clone_map`` delegates element cloning here so complex structures (lists,
320
+ sets, tuples, nested mappings) remain detached from the immutable source.
321
+
322
+ Examples:
323
+ >>> cloned = _clone_value(({"flag": True},))
324
+ >>> cloned
325
+ ({'flag': True},)
326
+ >>> cloned is _clone_value(({"flag": True},))
327
+ False
328
+ """
329
+ if isinstance(value, MappingABC):
330
+ nested = cast(MappingType[str, Any], value)
331
+ return _clone_map(nested)
332
+ if isinstance(value, list):
333
+ items = cast(list[Any], value)
334
+ return [_clone_value(item) for item in items]
335
+ if isinstance(value, set):
336
+ items = cast(set[Any], value)
337
+ return {_clone_value(item) for item in items}
338
+ if isinstance(value, tuple):
339
+ items = cast(tuple[Any, ...], value)
340
+ return tuple(_clone_value(item) for item in items)
341
+ return value
342
+
343
+
344
+ def _looks_like_mapping(value: object) -> TypeGuard[MappingType[str, Any]]:
345
+ """Return ``True`` when *value* is a mapping with string keys.
346
+
347
+ Dotted traversal should stop when encountering scalars or non-string-keyed
348
+ mappings to avoid surprising behaviour.
349
+
350
+ Examples:
351
+ >>> _looks_like_mapping({"key": 1})
352
+ True
353
+ >>> _looks_like_mapping({1: "value"})
354
+ False
355
+ >>> _looks_like_mapping(["not", "mapping"])
356
+ False
357
+ """
358
+ if not isinstance(value, MappingABC):
359
+ return False
360
+ for key in cast(Iterable[object], value.keys()):
361
+ if not isinstance(key, str):
362
+ return False
363
+ return True
364
+
365
+
366
+ EMPTY_CONFIG = Config(MappingProxyType({}), MappingProxyType({}))
367
+ """Shared empty configuration used by the composition root and CLI helpers.
368
+
369
+ Avoids repeated allocations when no layers contribute values. The empty
370
+ instance satisfies the domain contract (immutability, provenance available but
371
+ empty) and is safe to reuse across contexts.
372
+ """