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