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.
- lib_layered_config/__init__.py +58 -0
- lib_layered_config/__init__conf__.py +74 -0
- lib_layered_config/__main__.py +18 -0
- lib_layered_config/_layers.py +310 -0
- lib_layered_config/_platform.py +166 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/_nested_keys.py +126 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +143 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +288 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +376 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
- lib_layered_config/adapters/path_resolvers/_base.py +166 -0
- lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
- lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
- lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
- lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
- lib_layered_config/adapters/path_resolvers/default.py +194 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +379 -0
- lib_layered_config/application/ports.py +115 -0
- lib_layered_config/cli/__init__.py +92 -0
- lib_layered_config/cli/common.py +381 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +71 -0
- lib_layered_config/cli/fail.py +19 -0
- lib_layered_config/cli/generate.py +57 -0
- lib_layered_config/cli/info.py +29 -0
- lib_layered_config/cli/read.py +120 -0
- lib_layered_config/core.py +301 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +372 -0
- lib_layered_config/domain/errors.py +59 -0
- lib_layered_config/domain/identifiers.py +366 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +333 -0
- lib_layered_config/examples/generate.py +406 -0
- lib_layered_config/observability.py +209 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +46 -0
- lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
- lib_layered_config-4.1.0.dist-info/RECORD +47 -0
- lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
- lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Public API surface for ``lib_layered_config``.
|
|
2
|
+
|
|
3
|
+
Expose the curated, stable symbols that consumers need to interact with the
|
|
4
|
+
library: reader functions, value object, error taxonomy, and observability
|
|
5
|
+
helpers.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
* :func:`lib_layered_config.core.read_config`
|
|
9
|
+
* :func:`lib_layered_config.core.read_config_raw`
|
|
10
|
+
* :func:`lib_layered_config.examples.deploy.deploy_config`
|
|
11
|
+
* :class:`lib_layered_config.domain.config.Config`
|
|
12
|
+
* Error hierarchy (:class:`ConfigError`, :class:`InvalidFormatError`, etc.)
|
|
13
|
+
* Diagnostics helpers (:func:`lib_layered_config.testing.i_should_fail`)
|
|
14
|
+
* Observability bindings (:func:`bind_trace_id`, :func:`get_logger`)
|
|
15
|
+
|
|
16
|
+
System Role:
|
|
17
|
+
Acts as the frontline module imported by applications, keeping the public
|
|
18
|
+
surface area deliberate and well-documented (see
|
|
19
|
+
``docs/systemdesign/module_reference.md``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from .core import (
|
|
25
|
+
Config,
|
|
26
|
+
ConfigError,
|
|
27
|
+
InvalidFormatError,
|
|
28
|
+
LayerLoadError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
default_env_prefix,
|
|
32
|
+
read_config,
|
|
33
|
+
read_config_json,
|
|
34
|
+
read_config_raw,
|
|
35
|
+
)
|
|
36
|
+
from .domain.identifiers import Layer
|
|
37
|
+
from .examples import deploy_config, generate_examples
|
|
38
|
+
from .observability import bind_trace_id, get_logger
|
|
39
|
+
from .testing import i_should_fail
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Config",
|
|
43
|
+
"ConfigError",
|
|
44
|
+
"InvalidFormatError",
|
|
45
|
+
"ValidationError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"LayerLoadError",
|
|
48
|
+
"Layer",
|
|
49
|
+
"read_config",
|
|
50
|
+
"read_config_json",
|
|
51
|
+
"read_config_raw",
|
|
52
|
+
"deploy_config",
|
|
53
|
+
"generate_examples",
|
|
54
|
+
"default_env_prefix",
|
|
55
|
+
"i_should_fail",
|
|
56
|
+
"bind_trace_id",
|
|
57
|
+
"get_logger",
|
|
58
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Static package metadata surfaced to CLI commands and documentation.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Expose the current project metadata as simple constants. These values are kept
|
|
6
|
+
in sync with ``pyproject.toml`` by development automation (tests, push
|
|
7
|
+
pipelines), so runtime code does not query packaging metadata.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
--------
|
|
11
|
+
* Module-level constants describing the published package.
|
|
12
|
+
* :func:`print_info` rendering the constants for the CLI ``info`` command.
|
|
13
|
+
|
|
14
|
+
System Role
|
|
15
|
+
-----------
|
|
16
|
+
Lives in the adapters/platform layer; CLI transports import these constants to
|
|
17
|
+
present authoritative project information without invoking packaging APIs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
#: Distribution name declared in ``pyproject.toml``.
|
|
23
|
+
name = "lib_layered_config"
|
|
24
|
+
#: Human-readable summary shown in CLI help output.
|
|
25
|
+
title = "Cross-platform layered configuration loader for Python"
|
|
26
|
+
#: Current release version pulled from ``pyproject.toml`` by automation.
|
|
27
|
+
version = "4.1.0"
|
|
28
|
+
#: Repository homepage presented to users.
|
|
29
|
+
homepage = "https://github.com/bitranox/lib_layered_config"
|
|
30
|
+
#: Author attribution surfaced in CLI output.
|
|
31
|
+
author = "bitranox"
|
|
32
|
+
#: Contact email surfaced in CLI output.
|
|
33
|
+
author_email = "bitranox@gmail.com"
|
|
34
|
+
#: Console-script name published by the package.
|
|
35
|
+
shell_command = "lib-layered-config"
|
|
36
|
+
|
|
37
|
+
#: Vendor identifier for lib_layered_config paths (macOS/Windows)
|
|
38
|
+
LAYEREDCONF_VENDOR: str = "bitranox"
|
|
39
|
+
#: Application display name for lib_layered_config paths (macOS/Windows)
|
|
40
|
+
LAYEREDCONF_APP: str = "Lib Layered Config"
|
|
41
|
+
#: Configuration slug for lib_layered_config Linux paths and environment variables
|
|
42
|
+
LAYEREDCONF_SLUG: str = "lib-layered-config"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def print_info() -> None:
|
|
46
|
+
"""Print the summarised metadata block used by the CLI ``info`` command.
|
|
47
|
+
|
|
48
|
+
Why
|
|
49
|
+
Provides a single, auditable rendering function so documentation and
|
|
50
|
+
CLI output always match the system design reference.
|
|
51
|
+
|
|
52
|
+
Side Effects
|
|
53
|
+
Writes to ``stdout``.
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> print_info() # doctest: +ELLIPSIS
|
|
58
|
+
Info for lib_layered_config:
|
|
59
|
+
...
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
fields = [
|
|
63
|
+
("name", name),
|
|
64
|
+
("title", title),
|
|
65
|
+
("version", version),
|
|
66
|
+
("homepage", homepage),
|
|
67
|
+
("author", author),
|
|
68
|
+
("author_email", author_email),
|
|
69
|
+
("shell_command", shell_command),
|
|
70
|
+
]
|
|
71
|
+
pad = max(len(label) for label, _ in fields)
|
|
72
|
+
lines = [f"Info for {name}:", ""]
|
|
73
|
+
lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
|
|
74
|
+
print("\n".join(lines))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Let ``python -m lib_layered_config`` feel as gentle as ``cli.main``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from .cli import main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_module(arguments: Sequence[str] | None = None) -> int:
|
|
11
|
+
"""Forward *arguments* to :func:`lib_layered_config.cli.main` and return the exit code."""
|
|
12
|
+
return main(arguments, restore_traceback=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
raise SystemExit(run_module(sys.argv[1:]))
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Assemble configuration layers prior to merging.
|
|
2
|
+
|
|
3
|
+
Provide a composition helper that coordinates filesystem discovery, dotenv
|
|
4
|
+
loading, environment ingestion, and defaults injection before passing
|
|
5
|
+
``LayerSnapshot`` instances to the merge policy.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
- ``collect_layers``: orchestrator returning a list of snapshots.
|
|
9
|
+
- ``merge_or_empty``: convenience wrapper combining collect/merge behaviour.
|
|
10
|
+
- Internal generators that yield defaults, filesystem, dotenv, and environment
|
|
11
|
+
snapshots in documented precedence order.
|
|
12
|
+
|
|
13
|
+
System Role:
|
|
14
|
+
Invoked exclusively by ``lib_layered_config.core``. Keeps orchestration logic
|
|
15
|
+
separate from adapters while remaining independent of the domain layer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .adapters.dotenv.default import DefaultDotEnvLoader
|
|
24
|
+
from .adapters.env.default import DefaultEnvLoader, default_env_prefix
|
|
25
|
+
from .adapters.file_loaders.structured import JSONFileLoader, TOMLFileLoader, YAMLFileLoader
|
|
26
|
+
from .adapters.path_resolvers.default import DefaultPathResolver
|
|
27
|
+
from .application.merge import LayerSnapshot, MergeResult, merge_layers
|
|
28
|
+
from .domain.errors import InvalidFormatError, NotFoundError
|
|
29
|
+
from .domain.identifiers import Layer
|
|
30
|
+
from .observability import log_debug, log_info, make_event
|
|
31
|
+
|
|
32
|
+
#: Mapping from file suffix to loader instance. The ordering preserves the
|
|
33
|
+
#: precedence documented for structured configuration formats while keeping all
|
|
34
|
+
#: logic in one place.
|
|
35
|
+
_FILE_LOADERS = {
|
|
36
|
+
".toml": TOMLFileLoader(),
|
|
37
|
+
".json": JSONFileLoader(),
|
|
38
|
+
".yaml": YAMLFileLoader(),
|
|
39
|
+
".yml": YAMLFileLoader(),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
__all__ = ["collect_layers", "merge_or_empty"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_layers(
|
|
46
|
+
*,
|
|
47
|
+
resolver: DefaultPathResolver,
|
|
48
|
+
prefer: Sequence[str] | None,
|
|
49
|
+
default_file: str | None,
|
|
50
|
+
dotenv_loader: DefaultDotEnvLoader,
|
|
51
|
+
env_loader: DefaultEnvLoader,
|
|
52
|
+
slug: str,
|
|
53
|
+
start_dir: str | None,
|
|
54
|
+
) -> list[LayerSnapshot]:
|
|
55
|
+
"""Return layer snapshots in precedence order (defaults → app → host → user → dotenv → env).
|
|
56
|
+
|
|
57
|
+
Centralises discovery so callers stay focused on error handling.
|
|
58
|
+
Emits structured logging events when layers are discovered.
|
|
59
|
+
"""
|
|
60
|
+
return list(
|
|
61
|
+
_snapshots_in_merge_sequence(
|
|
62
|
+
resolver=resolver,
|
|
63
|
+
prefer=prefer,
|
|
64
|
+
default_file=default_file,
|
|
65
|
+
dotenv_loader=dotenv_loader,
|
|
66
|
+
env_loader=env_loader,
|
|
67
|
+
slug=slug,
|
|
68
|
+
start_dir=start_dir,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _snapshots_in_merge_sequence(
|
|
74
|
+
*,
|
|
75
|
+
resolver: DefaultPathResolver,
|
|
76
|
+
prefer: Sequence[str] | None,
|
|
77
|
+
default_file: str | None,
|
|
78
|
+
dotenv_loader: DefaultDotEnvLoader,
|
|
79
|
+
env_loader: DefaultEnvLoader,
|
|
80
|
+
slug: str,
|
|
81
|
+
start_dir: str | None,
|
|
82
|
+
) -> Iterator[LayerSnapshot]:
|
|
83
|
+
"""Yield layer snapshots in the documented merge order.
|
|
84
|
+
|
|
85
|
+
Capture the precedence hierarchy (`defaults → app → host → user → dotenv → env`)
|
|
86
|
+
in one generator so callers cannot accidentally skip a layer.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
resolver / prefer / default_file / dotenv_loader / env_loader / slug / start_dir:
|
|
90
|
+
Same meaning as :func:`collect_layers`.
|
|
91
|
+
|
|
92
|
+
Yields:
|
|
93
|
+
LayerSnapshot: Snapshot tuples ready for the merge policy.
|
|
94
|
+
"""
|
|
95
|
+
yield from _default_snapshots(default_file)
|
|
96
|
+
yield from _filesystem_snapshots(resolver, prefer)
|
|
97
|
+
yield from _dotenv_snapshots(dotenv_loader, start_dir)
|
|
98
|
+
yield from _env_snapshots(env_loader, slug)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def merge_or_empty(layers: list[LayerSnapshot]) -> MergeResult:
|
|
102
|
+
"""Merge collected layers or return empty result when none exist.
|
|
103
|
+
|
|
104
|
+
Provides a guard so callers do not have to special-case empty layer collections.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
layers: Layer snapshots in precedence order.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
MergeResult: Dataclass containing merged configuration data and provenance mappings.
|
|
111
|
+
|
|
112
|
+
Side Effects:
|
|
113
|
+
Emits ``configuration_empty`` or ``configuration_merged`` events depending on
|
|
114
|
+
the layer count.
|
|
115
|
+
"""
|
|
116
|
+
if not layers:
|
|
117
|
+
_note_configuration_empty()
|
|
118
|
+
return MergeResult(data={}, provenance={})
|
|
119
|
+
|
|
120
|
+
result = merge_layers(layers)
|
|
121
|
+
_note_merge_complete(len(layers))
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _default_snapshots(default_file: str | None) -> Iterator[LayerSnapshot]:
|
|
126
|
+
"""Yield a defaults snapshot when *default_file* is supplied.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
default_file: Absolute path string to the optional defaults file.
|
|
130
|
+
|
|
131
|
+
Yields:
|
|
132
|
+
LayerSnapshot: Snapshot describing the defaults layer.
|
|
133
|
+
|
|
134
|
+
Side Effects:
|
|
135
|
+
Emits ``layer_loaded`` events when a defaults file is parsed.
|
|
136
|
+
"""
|
|
137
|
+
if not default_file:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
snapshot = _load_entry(Layer.DEFAULTS, default_file)
|
|
141
|
+
if snapshot is None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
_note_layer_loaded(snapshot.name, snapshot.origin, {"keys": len(snapshot.payload)})
|
|
145
|
+
yield snapshot
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _filesystem_snapshots(resolver: DefaultPathResolver, prefer: Sequence[str] | None) -> Iterator[LayerSnapshot]:
|
|
149
|
+
"""Yield filesystem-backed layer snapshots in precedence order.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
resolver: Path resolver supplying candidate paths per layer.
|
|
153
|
+
prefer: Optional suffix ordering applied when multiple files exist.
|
|
154
|
+
|
|
155
|
+
Yields:
|
|
156
|
+
LayerSnapshot: Snapshots for ``app``/``host``/``user`` layers.
|
|
157
|
+
"""
|
|
158
|
+
for layer, paths in (
|
|
159
|
+
(Layer.APP, resolver.app()),
|
|
160
|
+
(Layer.HOST, resolver.host()),
|
|
161
|
+
(Layer.USER, resolver.user()),
|
|
162
|
+
):
|
|
163
|
+
snapshots = list(_snapshots_from_paths(layer, paths, prefer))
|
|
164
|
+
if snapshots:
|
|
165
|
+
_note_layer_loaded(layer, None, {"files": len(snapshots)})
|
|
166
|
+
yield from snapshots
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _dotenv_snapshots(loader: DefaultDotEnvLoader, start_dir: str | None) -> Iterator[LayerSnapshot]:
|
|
170
|
+
"""Yield a snapshot for dotenv-provided values when present.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
loader: Dotenv loader that handles discovery and parsing.
|
|
174
|
+
start_dir: Optional starting directory for the upward search.
|
|
175
|
+
|
|
176
|
+
Yields:
|
|
177
|
+
LayerSnapshot: Snapshot representing the ``dotenv`` layer when a file exists.
|
|
178
|
+
"""
|
|
179
|
+
data = loader.load(start_dir)
|
|
180
|
+
if not data:
|
|
181
|
+
return
|
|
182
|
+
_note_layer_loaded(Layer.DOTENV, loader.last_loaded_path, {"keys": len(data)})
|
|
183
|
+
yield LayerSnapshot(Layer.DOTENV, data, loader.last_loaded_path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _env_snapshots(loader: DefaultEnvLoader, slug: str) -> Iterator[LayerSnapshot]:
|
|
187
|
+
"""Yield a snapshot for environment-variable configuration.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
loader: Environment loader converting prefixed variables into nested mappings.
|
|
191
|
+
slug: Slug identifying the configuration family.
|
|
192
|
+
|
|
193
|
+
Yields:
|
|
194
|
+
LayerSnapshot: Snapshot for the ``env`` layer when variables are present.
|
|
195
|
+
"""
|
|
196
|
+
prefix = default_env_prefix(slug)
|
|
197
|
+
data = loader.load(prefix)
|
|
198
|
+
if not data:
|
|
199
|
+
return
|
|
200
|
+
_note_layer_loaded(Layer.ENV, None, {"keys": len(data)})
|
|
201
|
+
yield LayerSnapshot(Layer.ENV, data, None)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _snapshots_from_paths(layer: str, paths: Iterable[str], prefer: Sequence[str] | None) -> Iterator[LayerSnapshot]:
|
|
205
|
+
"""Yield snapshots for every supported file inside *paths*.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
layer: Logical layer name the files belong to.
|
|
209
|
+
paths: Iterable of candidate file paths.
|
|
210
|
+
prefer: Optional suffix ordering hint passed by the CLI/API.
|
|
211
|
+
|
|
212
|
+
Yields:
|
|
213
|
+
LayerSnapshot: Snapshot for each successfully loaded file.
|
|
214
|
+
"""
|
|
215
|
+
for path in _paths_in_preferred_order(paths, prefer):
|
|
216
|
+
snapshot = _load_entry(layer, path)
|
|
217
|
+
if snapshot is not None:
|
|
218
|
+
yield snapshot
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_entry(layer: str, path: str) -> LayerSnapshot | None:
|
|
222
|
+
"""Load *path* using the configured file loaders and return a snapshot.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
layer: Logical layer name associated with the file.
|
|
226
|
+
path: Absolute path to the candidate configuration file.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
LayerSnapshot | None: Snapshot when parsing succeeds and data is non-empty; otherwise ``None``.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
InvalidFormatError: When the loader encounters invalid content. The exception is logged
|
|
233
|
+
and re-raised so callers can surface context to users.
|
|
234
|
+
"""
|
|
235
|
+
loader = _FILE_LOADERS.get(Path(path).suffix.lower())
|
|
236
|
+
if loader is None:
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
data = loader.load(path)
|
|
240
|
+
except NotFoundError:
|
|
241
|
+
return None
|
|
242
|
+
except InvalidFormatError as exc: # pragma: no cover - validated by adapter tests
|
|
243
|
+
_note_layer_error(layer, path, exc)
|
|
244
|
+
raise
|
|
245
|
+
if not data:
|
|
246
|
+
return None
|
|
247
|
+
return LayerSnapshot(layer, data, path)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _paths_in_preferred_order(paths: Iterable[str], prefer: Sequence[str] | None) -> list[str]:
|
|
251
|
+
"""Return candidate paths honouring the optional *prefer* order.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
paths: Iterable of candidate file paths.
|
|
255
|
+
prefer: Optional sequence of preferred suffixes ordered by priority.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
list[str]: Candidate paths sorted according to preferred suffix ranking.
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
>>> _paths_in_preferred_order(
|
|
262
|
+
... ['a.toml', 'b.yaml'],
|
|
263
|
+
... prefer=('yaml', 'toml'),
|
|
264
|
+
... )
|
|
265
|
+
['b.yaml', 'a.toml']
|
|
266
|
+
"""
|
|
267
|
+
ordered = list(paths)
|
|
268
|
+
if not prefer:
|
|
269
|
+
return ordered
|
|
270
|
+
ranking = {suffix.lower().lstrip("."): index for index, suffix in enumerate(prefer)}
|
|
271
|
+
return sorted(ordered, key=lambda candidate: ranking.get(Path(candidate).suffix.lower().lstrip("."), len(ranking)))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _note_layer_loaded(layer: str, path: str | None, details: Mapping[str, object]) -> None:
|
|
275
|
+
"""Emit a debug event capturing successful layer discovery.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
layer: Logical layer name.
|
|
279
|
+
path: Optional path associated with the event.
|
|
280
|
+
details: Additional structured metadata (e.g., number of files or keys).
|
|
281
|
+
|
|
282
|
+
Side Effects:
|
|
283
|
+
Calls :func:`log_debug` with the structured event payload.
|
|
284
|
+
"""
|
|
285
|
+
log_debug("layer_loaded", **make_event(layer, path, dict(details)))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _note_layer_error(layer: str, path: str, exc: Exception) -> None:
|
|
289
|
+
"""Emit a debug event describing a recoverable layer error.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
layer: Layer currently being processed.
|
|
293
|
+
path: File path that triggered the error.
|
|
294
|
+
exc: Exception raised by the loader.
|
|
295
|
+
"""
|
|
296
|
+
log_debug("layer_error", **make_event(layer, path, {"error": str(exc)}))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _note_configuration_empty() -> None:
|
|
300
|
+
"""Emit an info event signalling that no configuration was discovered."""
|
|
301
|
+
log_info("configuration_empty", layer="none", path=None)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _note_merge_complete(total_layers: int) -> None:
|
|
305
|
+
"""Emit an info event summarising the merge outcome.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
total_layers: Number of layers processed in the merge.
|
|
309
|
+
"""
|
|
310
|
+
log_info("configuration_merged", layer="final", path=None, total_layers=total_layers)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Shared helpers for normalising user-provided platform aliases.
|
|
2
|
+
|
|
3
|
+
Bridge CLI/example inputs with resolver internals by translating human-friendly
|
|
4
|
+
platform strings into the canonical identifiers expected across adapters and
|
|
5
|
+
documentation.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
- ``normalise_resolver_platform``: map CLI adapter aliases to ``sys.platform``
|
|
9
|
+
style identifiers.
|
|
10
|
+
- ``normalise_examples_platform``: map example-generation aliases to the two
|
|
11
|
+
supported documentation families.
|
|
12
|
+
- ``_sanitize`` plus canonical mapping constants that keep user inputs tidy and
|
|
13
|
+
predictable.
|
|
14
|
+
|
|
15
|
+
System Role:
|
|
16
|
+
Reusable utilities consumed by CLI commands and example tooling to ensure
|
|
17
|
+
terminology matches ``docs/systemdesign/concept.md`` regardless of user input
|
|
18
|
+
quirks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Final
|
|
24
|
+
|
|
25
|
+
#: Canonical resolver identifiers used when wiring the path resolver adapter.
|
|
26
|
+
#: Values mirror ``sys.platform`` strings so downstream code can branch safely.
|
|
27
|
+
_CANONICAL_RESOLVER: Final[dict[str, str]] = {
|
|
28
|
+
"linux": "linux",
|
|
29
|
+
"posix": "linux",
|
|
30
|
+
"darwin": "darwin",
|
|
31
|
+
"mac": "darwin",
|
|
32
|
+
"macos": "darwin",
|
|
33
|
+
"windows": "win32",
|
|
34
|
+
"win": "win32",
|
|
35
|
+
"win32": "win32",
|
|
36
|
+
"wine": "win32",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#: Canonical families used by documentation/example helpers. They collapse the
|
|
40
|
+
#: wide variety of aliases into the two supported directory layouts.
|
|
41
|
+
_CANONICAL_EXAMPLES: Final[dict[str, str]] = {
|
|
42
|
+
"posix": "posix",
|
|
43
|
+
"linux": "posix",
|
|
44
|
+
"darwin": "posix",
|
|
45
|
+
"mac": "posix",
|
|
46
|
+
"macos": "posix",
|
|
47
|
+
"windows": "windows",
|
|
48
|
+
"win": "windows",
|
|
49
|
+
"win32": "windows",
|
|
50
|
+
"wine": "windows",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sanitize(alias: str | None) -> str | None:
|
|
55
|
+
"""Return a lower-cased alias stripped of whitespace when *alias* is truthy.
|
|
56
|
+
|
|
57
|
+
User input may include spacing or mixed casing; sanitising up front keeps the
|
|
58
|
+
canonical lookup tables compact and dependable.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
alias: Optional raw alias provided by a user or CLI flag. ``None`` indicates no
|
|
62
|
+
override.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Lower-case alias when *alias* contains characters, otherwise ``None`` when
|
|
66
|
+
no override is requested.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If *alias* contains only whitespace, because such inputs indicate a user
|
|
70
|
+
error that should surface immediately.
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
>>> _sanitize(' MacOS ')
|
|
74
|
+
'macos'
|
|
75
|
+
>>> _sanitize(None) is None
|
|
76
|
+
True
|
|
77
|
+
>>> _sanitize(' ')
|
|
78
|
+
Traceback (most recent call last):
|
|
79
|
+
...
|
|
80
|
+
ValueError: Platform alias cannot be empty.
|
|
81
|
+
"""
|
|
82
|
+
if alias is None:
|
|
83
|
+
return None
|
|
84
|
+
stripped = alias.strip().lower()
|
|
85
|
+
if not stripped:
|
|
86
|
+
raise ValueError("Platform alias cannot be empty.")
|
|
87
|
+
return stripped
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def normalise_resolver_platform(alias: str | None) -> str | None:
|
|
91
|
+
"""Return canonical resolver platform identifiers for *alias*.
|
|
92
|
+
|
|
93
|
+
The path resolver adapter expects ``sys.platform`` style identifiers. This
|
|
94
|
+
helper converts human-friendly values (``"mac"``, ``"win"``) into the canonical
|
|
95
|
+
tokens documented in the system design.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
alias: User-provided alias or ``None``. ``None`` preserves auto-detection.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Canonical resolver identifier or ``None`` when auto-detection should be
|
|
102
|
+
used.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If *alias* is not recognised. The error message enumerates valid options
|
|
106
|
+
so CLI tooling can surface helpful guidance.
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
>>> normalise_resolver_platform('mac')
|
|
110
|
+
'darwin'
|
|
111
|
+
>>> normalise_resolver_platform('win32')
|
|
112
|
+
'win32'
|
|
113
|
+
>>> normalise_resolver_platform(None) is None
|
|
114
|
+
True
|
|
115
|
+
>>> normalise_resolver_platform('beos')
|
|
116
|
+
Traceback (most recent call last):
|
|
117
|
+
...
|
|
118
|
+
ValueError: Platform must be one of: darwin, linux, mac, macos, posix, win, win32, windows, wine.
|
|
119
|
+
"""
|
|
120
|
+
sanitized = _sanitize(alias)
|
|
121
|
+
if sanitized is None:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
return _CANONICAL_RESOLVER[sanitized]
|
|
125
|
+
except KeyError as exc: # pragma: no cover - exercised via caller tests
|
|
126
|
+
allowed = ", ".join(sorted(_CANONICAL_RESOLVER))
|
|
127
|
+
raise ValueError(f"Platform must be one of: {allowed}.") from exc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def normalise_examples_platform(alias: str | None) -> str | None:
|
|
131
|
+
"""Return the example-generation platform family for *alias*.
|
|
132
|
+
|
|
133
|
+
Documentation and example helpers target two directory layouts (POSIX and
|
|
134
|
+
Windows). This function collapses a wide variety of synonyms into those
|
|
135
|
+
families for predictable template generation.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
alias: User-provided alias or ``None`` to let the caller choose a default.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Canonical example platform (``"posix"`` or ``"windows"``) or ``None`` when
|
|
142
|
+
the caller should rely on runtime defaults.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If *alias* is provided but not known.
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
>>> normalise_examples_platform('darwin')
|
|
149
|
+
'posix'
|
|
150
|
+
>>> normalise_examples_platform('windows')
|
|
151
|
+
'windows'
|
|
152
|
+
>>> normalise_examples_platform(None) is None
|
|
153
|
+
True
|
|
154
|
+
>>> normalise_examples_platform('amiga')
|
|
155
|
+
Traceback (most recent call last):
|
|
156
|
+
...
|
|
157
|
+
ValueError: Platform must be one of: darwin, linux, mac, macos, posix, win, win32, windows, wine.
|
|
158
|
+
"""
|
|
159
|
+
sanitized = _sanitize(alias)
|
|
160
|
+
if sanitized is None:
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
return _CANONICAL_EXAMPLES[sanitized]
|
|
164
|
+
except KeyError as exc: # pragma: no cover - exercised via caller tests
|
|
165
|
+
allowed = ", ".join(sorted(_CANONICAL_EXAMPLES))
|
|
166
|
+
raise ValueError(f"Platform must be one of: {allowed}.") from exc
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Adapter implementations for ``lib_layered_config``.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Group concrete boundary code (filesystem, dotenv, environment, file parsers)
|
|
6
|
+
that fulfils the application layer's ports.
|
|
7
|
+
|
|
8
|
+
System Role
|
|
9
|
+
-----------
|
|
10
|
+
Modules inside this package implement contracts defined in
|
|
11
|
+
:mod:`lib_layered_config.application.ports` and are wired together by the
|
|
12
|
+
composition root.
|
|
13
|
+
"""
|