lib-layered-config 1.0.0__py3-none-any.whl → 1.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.

Potentially problematic release.


This version of lib-layered-config might be problematic. Click here for more details.

@@ -0,0 +1,67 @@
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 = "1.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
+
38
+ def print_info() -> None:
39
+ """Print the summarised metadata block used by the CLI ``info`` command.
40
+
41
+ Why
42
+ Provides a single, auditable rendering function so documentation and
43
+ CLI output always match the system design reference.
44
+
45
+ Side Effects
46
+ Writes to ``stdout``.
47
+
48
+ Examples
49
+ --------
50
+ >>> print_info() # doctest: +ELLIPSIS
51
+ Info for lib_layered_config:
52
+ ...
53
+ """
54
+
55
+ fields = [
56
+ ("name", name),
57
+ ("title", title),
58
+ ("version", version),
59
+ ("homepage", homepage),
60
+ ("author", author),
61
+ ("author_email", author_email),
62
+ ("shell_command", shell_command),
63
+ ]
64
+ pad = max(len(label) for label, _ in fields)
65
+ lines = [f"Info for {name}:", ""]
66
+ lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
67
+ print("\n".join(lines))
@@ -25,18 +25,17 @@ before passing the results to the merge policy.
25
25
  from __future__ import annotations
26
26
 
27
27
  import json
28
+ from importlib import import_module
28
29
  from pathlib import Path
29
30
  from typing import Any, Mapping, NoReturn
31
+ from types import ModuleType
30
32
 
31
33
  import tomllib
32
34
 
33
35
  from ...domain.errors import InvalidFormat, NotFound
34
36
  from ...observability import log_debug, log_error
35
37
 
36
- try:
37
- import yaml # type: ignore[import-not-found]
38
- except ModuleNotFoundError: # pragma: no cover - optional dependency
39
- yaml = None # type: ignore[assignment]
38
+ yaml: ModuleType | None = None
40
39
 
41
40
 
42
41
  FILE_LAYER = "file"
@@ -139,25 +138,73 @@ def _raise_invalid_format(path: str, format_name: str, exc: Exception) -> NoRetu
139
138
 
140
139
 
141
140
  def _ensure_yaml_available() -> None:
142
- """Raise :class:`NotFound` when optional YAML support is missing.
141
+ """Announce clearly whether PyYAML can be reached.
143
142
 
144
143
  Why
145
144
  ----
146
- Fail fast with a friendly message when YAML parsing is requested without the
147
- optional dependency.
145
+ YAML support is optional; the loader must fail fast with guidance when the
146
+ dependency is absent so callers can install the expected extra.
148
147
 
149
148
  Returns
150
149
  -------
151
150
  None
152
151
 
152
+ Raises
153
+ ------
154
+ NotFound
155
+ When the PyYAML package cannot be imported.
156
+ """
157
+
158
+ _require_yaml_module()
159
+
160
+
161
+ def _require_yaml_module() -> ModuleType:
162
+ """Fetch the PyYAML module or explain its absence.
163
+
164
+ Why
165
+ ----
166
+ Downstream helpers need the module object for access to both ``safe_load``
167
+ and the package-specific ``YAMLError`` type.
168
+
169
+ Returns
170
+ -------
171
+ ModuleType
172
+ The imported PyYAML module.
173
+
153
174
  Raises
154
175
  ------
155
176
  NotFound
156
177
  When PyYAML is not installed.
157
178
  """
158
179
 
159
- if yaml is None:
180
+ module = _load_yaml_module()
181
+ if module is None:
160
182
  raise NotFound("PyYAML is required for YAML configuration support")
183
+ return module
184
+
185
+
186
+ def _load_yaml_module() -> ModuleType | None:
187
+ """Import PyYAML on demand, caching the result for future readers.
188
+
189
+ Why
190
+ ----
191
+ Avoid importing optional dependencies unless they are genuinely needed,
192
+ while still ensuring subsequent calls reuse the same module object.
193
+
194
+ Returns
195
+ -------
196
+ ModuleType | None
197
+ The PyYAML module when available; otherwise ``None``.
198
+ """
199
+
200
+ global yaml
201
+ if yaml is not None:
202
+ return yaml
203
+ try:
204
+ yaml = import_module("yaml")
205
+ except ModuleNotFoundError: # pragma: no cover - optional dependency
206
+ yaml = None
207
+ return yaml
161
208
 
162
209
 
163
210
  class BaseFileLoader:
@@ -390,7 +437,7 @@ class YAMLFileLoader(BaseFileLoader):
390
437
 
391
438
  Examples
392
439
  --------
393
- >>> if yaml is not None: # doctest: +SKIP
440
+ >>> if _load_yaml_module() is not None: # doctest: +SKIP
394
441
  ... from tempfile import NamedTemporaryFile
395
442
  ... tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
396
443
  ... _ = tmp.write('key: 1')
@@ -400,11 +447,51 @@ class YAMLFileLoader(BaseFileLoader):
400
447
  """
401
448
 
402
449
  _ensure_yaml_available()
403
- try:
404
- payload: Any = yaml.safe_load(self._read(path)) # type: ignore[operator]
405
- except yaml.YAMLError as exc: # type: ignore[attr-defined]
406
- _raise_invalid_format(path, "yaml", exc)
407
- data: object = dict[str, object]() if payload is None else payload
408
- result = self._ensure_mapping(data, path=path)
450
+ yaml_module = _require_yaml_module()
451
+ raw_bytes = self._read(path)
452
+ parsed = _parse_yaml_bytes(raw_bytes, yaml_module, path)
453
+ mapping = self._ensure_mapping(parsed, path=path)
409
454
  _log_file_loaded(path, "yaml")
410
- return result
455
+ return mapping
456
+
457
+
458
+ def _parse_yaml_bytes(payload: bytes, module: ModuleType, path: str) -> object:
459
+ """Turn YAML bytes into a Python shape that mirrors the file.
460
+
461
+ Why
462
+ ----
463
+ Normalise the PyYAML parsing contract so callers always receive a mapping,
464
+ raising a domain-specific error when the parser signals invalid syntax.
465
+
466
+ Parameters
467
+ ----------
468
+ payload:
469
+ Raw YAML document supplied as bytes.
470
+ module:
471
+ PyYAML module providing ::func:`safe_load` and the ``YAMLError`` base class.
472
+ path:
473
+ Source identifier used to enrich error messages.
474
+
475
+ Returns
476
+ -------
477
+ object
478
+ Parsed document; an empty dict when the YAML payload evaluates to ``None``.
479
+
480
+ Raises
481
+ ------
482
+ InvalidFormat
483
+ When PyYAML raises ``YAMLError`` while parsing the payload.
484
+
485
+ Examples
486
+ --------
487
+ >>> from types import SimpleNamespace
488
+ >>> fake = SimpleNamespace(safe_load=lambda data: {"key": data.decode("utf-8")}, YAMLError=Exception)
489
+ >>> _parse_yaml_bytes(b"value", fake, "memory.yaml") # doctest: +ELLIPSIS
490
+ {'key': 'value'}
491
+ """
492
+
493
+ try:
494
+ document = module.safe_load(payload)
495
+ except module.YAMLError as exc: # type: ignore[attr-defined]
496
+ _raise_invalid_format(path, "yaml", exc)
497
+ return {} if document is None else document
@@ -2,31 +2,24 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Callable, Iterable, Mapping, Optional, Sequence, cast
6
-
7
- from importlib import metadata as _metadata
8
-
9
5
  from pathlib import Path
6
+ from typing import Callable, Iterable, Mapping, Optional, Sequence, cast
10
7
 
11
- import lib_cli_exit_tools
12
8
  import rich_click as click
9
+ from lib_cli_exit_tools import cli_session
13
10
 
14
- from ..core import default_env_prefix as _default_env_prefix # backward compatibility import
11
+ from ..application.ports import SourceInfoPayload
15
12
  from .common import (
13
+ describe_distribution,
16
14
  format_scalar,
17
15
  json_paths,
18
- load_distribution_metadata,
19
16
  normalise_examples_platform_option,
20
17
  normalise_platform_option,
21
18
  normalise_prefer,
22
19
  render_human,
23
- toggle_traceback,
24
20
  version_string,
25
21
  )
26
22
  from .constants import CLICK_CONTEXT_SETTINGS, TRACEBACK_SUMMARY, TRACEBACK_VERBOSE
27
- from ..application.ports import SourceInfoPayload
28
-
29
-
30
23
  from .read import read_command as cli_read_config, read_json_command as cli_read_config_json
31
24
 
32
25
 
@@ -46,33 +39,51 @@ from .read import read_command as cli_read_config, read_json_command as cli_read
46
39
  default=False,
47
40
  help="Show full Python traceback on errors",
48
41
  )
49
- def cli(traceback: bool) -> None:
50
- """Root command that remembers whether tracebacks should flow."""
42
+ @click.pass_context
43
+ def cli(ctx: click.Context, traceback: bool) -> None:
44
+ """Root command storing the requested traceback preference."""
51
45
 
52
- toggle_traceback(traceback)
46
+ ctx.ensure_object(dict)
47
+ ctx.obj["traceback"] = traceback
53
48
 
54
49
 
55
50
  def main(argv: Optional[Sequence[str]] = None, *, restore_traceback: bool = True) -> int:
56
- """Entry point that restores traceback preferences on exit."""
51
+ """Entry point wiring the CLI through ``lib_cli_exit_tools.cli_session``."""
52
+
53
+ args_list = list(argv) if argv is not None else None
54
+ overrides = _session_overrides(args_list)
55
+
56
+ with cli_session(
57
+ summary_limit=TRACEBACK_SUMMARY,
58
+ verbose_limit=TRACEBACK_VERBOSE,
59
+ overrides=overrides or None,
60
+ restore=restore_traceback,
61
+ ) as run:
62
+ runner = cast("Callable[..., int]", run)
63
+ return runner(
64
+ cli,
65
+ argv=args_list,
66
+ prog_name="lib_layered_config",
67
+ )
68
+
69
+
70
+ def _session_overrides(argv: Sequence[str] | None) -> dict[str, object]:
71
+ """Derive configuration overrides for ``cli_session`` based on CLI args."""
72
+
73
+ if not argv:
74
+ return {}
75
+
76
+ try:
77
+ ctx = cli.make_context("lib_layered_config", list(argv), resilient_parsing=True)
78
+ except click.ClickException:
79
+ return {}
57
80
 
58
- previous_traceback = getattr(lib_cli_exit_tools.config, "traceback", False)
59
- previous_force_color = getattr(lib_cli_exit_tools.config, "traceback_force_color", False)
60
81
  try:
61
- try:
62
- run_cli = cast(Callable[..., int], lib_cli_exit_tools.run_cli) # pyright: ignore[reportUnknownMemberType]
63
- return run_cli(cli, argv=list(argv) if argv is not None else None, prog_name="lib_layered_config")
64
- except BaseException as exc: # noqa: BLE001
65
- print_exception = cast(Callable[..., None], lib_cli_exit_tools.print_exception_message) # pyright: ignore[reportUnknownMemberType]
66
- print_exception(
67
- trace_back=lib_cli_exit_tools.config.traceback,
68
- length_limit=TRACEBACK_VERBOSE if lib_cli_exit_tools.config.traceback else TRACEBACK_SUMMARY,
69
- )
70
- exit_code_fn = cast(Callable[[BaseException], int], lib_cli_exit_tools.get_system_exit_code) # pyright: ignore[reportUnknownMemberType]
71
- return exit_code_fn(exc)
82
+ enabled = bool(ctx.params.get("traceback", False))
72
83
  finally:
73
- if restore_traceback:
74
- lib_cli_exit_tools.config.traceback = previous_traceback
75
- lib_cli_exit_tools.config.traceback_force_color = previous_force_color
84
+ ctx.close()
85
+
86
+ return {"traceback": enabled} if enabled else {}
76
87
 
77
88
 
78
89
  def _register_commands() -> None:
@@ -84,8 +95,6 @@ def _register_commands() -> None:
84
95
 
85
96
  _register_commands()
86
97
 
87
- metadata = _metadata
88
- _toggle_traceback = toggle_traceback
89
98
  _version_string = version_string
90
99
 
91
100
 
@@ -113,44 +122,17 @@ def _normalise_prefer(values: Sequence[str]) -> tuple[str, ...] | None: # pyrig
113
122
  return normalise_prefer(values)
114
123
 
115
124
 
116
- def _load_distribution_metadata() -> _metadata.PackageMetadata | None:
117
- """Wrapper that allows tests to monkeypatch metadata loading."""
118
-
119
- return load_distribution_metadata()
120
-
121
-
122
125
  def _describe_distribution() -> tuple[str, ...]:
123
- """Yield human-readable metadata lines about the installed distribution."""
124
-
125
- meta = _load_distribution_metadata()
126
- if meta is None:
127
- return ("lib_layered_config (metadata unavailable)",)
128
-
129
- lines = [f"Info for {meta.get('Name', 'lib_layered_config')}:"]
130
- lines.append(f" Version : {meta.get('Version', version_string())}")
131
- lines.append(f" Requires-Python : {meta.get('Requires-Python', '>=3.13')}")
132
- summary = meta.get("Summary")
133
- if summary:
134
- lines.append(f" Summary : {summary}")
135
-
136
- def _no_urls(_: str) -> Iterable[str] | None:
137
- return None
126
+ """Expose CLI metadata lines for backwards-compatible tests."""
138
127
 
139
- get_all = cast(Callable[[str], Iterable[str] | None], getattr(meta, "get_all", _no_urls))
140
- for entry in get_all("Project-URL") or []:
141
- lines.append(f" {entry}")
142
- return tuple(lines)
128
+ return tuple(describe_distribution())
143
129
 
144
130
 
145
131
  __all__ = [
146
132
  "cli",
147
133
  "main",
148
- "_default_env_prefix",
149
- "metadata",
150
- "_toggle_traceback",
151
134
  "_version_string",
152
135
  "_describe_distribution",
153
- "_load_distribution_metadata",
154
136
  "_normalise_platform",
155
137
  "_normalise_examples_platform",
156
138
  "_json_paths",
@@ -1,16 +1,37 @@
1
- """Utilities shared by CLI command modules."""
1
+ """Utilities shared by CLI command modules.
2
+
3
+ Purpose
4
+ -------
5
+ Tell the CLI story in small, declarative helpers so commands remain tiny. These
6
+ functions construct read queries, choose output modes, format human summaries,
7
+ and surface metadata drawn from ``__init__conf__``.
8
+
9
+ Contents
10
+ --------
11
+ * :class:`ReadQuery` — frozen bundle capturing the parameters for configuration reads.
12
+ * Metadata helpers (:func:`version_string`, :func:`describe_distribution`).
13
+ * Query shaping (:func:`build_read_query`, :func:`normalise_prefer`, :func:`stringify`).
14
+ * Output shaping (:func:`json_payload`, :func:`human_payload`, :func:`render_human`).
15
+ * Human-friendly utilities (:func:`format_scalar`, :func:`json_paths`).
16
+
17
+ System Role
18
+ -----------
19
+ Commands import these helpers to stay declarative. They rely on the application
20
+ layer (`read_config*` functions) and on platform utilities for normalisation.
21
+ Updates here must be mirrored in ``docs/systemdesign/module_reference.md`` to
22
+ keep documentation and behaviour aligned.
23
+ """
2
24
 
3
25
  from __future__ import annotations
4
26
 
5
27
  import json
6
28
  from dataclasses import dataclass
7
- from importlib import metadata
8
29
  from pathlib import Path
9
- from typing import Iterable, Mapping, Optional, Sequence, cast
30
+ from typing import Iterable, Mapping, Optional, Protocol, Sequence, cast
10
31
 
11
- import lib_cli_exit_tools
12
32
  import rich_click as click
13
33
 
34
+ from .. import __init__conf__
14
35
  from .._platform import normalise_examples_platform as _normalise_examples_platform
15
36
  from .._platform import normalise_resolver_platform as _normalise_resolver_platform
16
37
  from ..application.ports import SourceInfoPayload
@@ -19,9 +40,47 @@ from .constants import DEFAULT_JSON_INDENT
19
40
  from ..core import read_config, read_config_json, read_config_raw
20
41
 
21
42
 
22
- @dataclass(frozen=True)
43
+ class _PackageMetadata(Protocol):
44
+ name: str
45
+ title: str
46
+ version: str
47
+ homepage: str
48
+ author: str
49
+ author_email: str
50
+ shell_command: str
51
+
52
+ def info_lines(self) -> tuple[str, ...]: ...
53
+
54
+ def metadata_fields(self) -> tuple[tuple[str, str], ...]: ...
55
+
56
+
57
+ package_metadata: _PackageMetadata = cast(_PackageMetadata, __init__conf__)
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
23
61
  class ReadQuery:
24
- """Immutable bundle of parameters required to execute read commands."""
62
+ """Immutable bundle of parameters required to execute read commands.
63
+
64
+ Why
65
+ ----
66
+ Capture CLI parameters in a frozen dataclass so functions can accept a
67
+ self-explanatory object rather than many loose arguments.
68
+
69
+ Attributes
70
+ ----------
71
+ vendor:
72
+ Vendor namespace requested by the user.
73
+ app:
74
+ Application identifier within the vendor namespace.
75
+ slug:
76
+ Configuration slug (environment/project).
77
+ prefer:
78
+ Ordered tuple of preferred file extensions, lowercased; ``None`` when the CLI falls back to defaults.
79
+ start_dir:
80
+ Starting directory as a string or ``None`` to use the current working directory.
81
+ default_file:
82
+ Optional baseline configuration file to load before layered overrides.
83
+ """
25
84
 
26
85
  vendor: str
27
86
  app: str
@@ -31,46 +90,41 @@ class ReadQuery:
31
90
  default_file: str | None
32
91
 
33
92
 
34
- def toggle_traceback(show: bool) -> None:
35
- """Synchronise ``lib_cli_exit_tools`` traceback flags with *show*."""
36
-
37
- lib_cli_exit_tools.config.traceback = show
38
- lib_cli_exit_tools.config.traceback_force_color = show
93
+ def version_string() -> str:
94
+ """Echo the project version declared in ``__init__conf__``.
39
95
 
96
+ Why
97
+ ----
98
+ The CLI `--version` option should reflect the single source of truth
99
+ maintained by release automation.
40
100
 
41
- def version_string() -> str:
42
- """Return the installed distribution version or a fallback placeholder."""
101
+ Returns
102
+ -------
103
+ str
104
+ Semantic version string from the generated metadata module.
105
+ """
43
106
 
44
- try:
45
- return metadata.version("lib_layered_config")
46
- except metadata.PackageNotFoundError:
47
- return "0.0.0"
107
+ return package_metadata.version
48
108
 
49
109
 
50
110
  def describe_distribution() -> Iterable[str]:
51
- """Yield human-readable metadata lines about the installed distribution."""
52
-
53
- meta = load_distribution_metadata()
54
- if meta is None:
55
- yield "lib_layered_config (metadata unavailable)"
56
- return
57
- yield f"Info for {meta.get('Name', 'lib_layered_config')}:"
58
- yield f" Version : {meta.get('Version', version_string())}"
59
- yield f" Requires-Python : {meta.get('Requires-Python', '>=3.13')}"
60
- summary = meta.get("Summary")
61
- if summary:
62
- yield f" Summary : {summary}"
63
- for entry in meta.get_all("Project-URL") or []:
64
- yield f" {entry}"
111
+ """Yield human-readable metadata lines sourced from ``__init__conf__``.
65
112
 
113
+ Why
114
+ ----
115
+ Support the `info` command with pre-formatted lines so the CLI stays thin.
66
116
 
67
- def load_distribution_metadata() -> metadata.PackageMetadata | None:
68
- """Return importlib metadata when the package is installed locally."""
117
+ Returns
118
+ -------
119
+ Iterable[str]
120
+ Sequence of descriptive lines suitable for printing with ``click.echo``.
121
+ """
69
122
 
70
- try:
71
- return metadata.metadata("lib_layered_config")
72
- except metadata.PackageNotFoundError:
73
- return None
123
+ lines_provider = getattr(package_metadata, "info_lines", None)
124
+ if callable(lines_provider):
125
+ yield from cast(Iterable[str], lines_provider())
126
+ return
127
+ yield from _fallback_info_lines()
74
128
 
75
129
 
76
130
  def build_read_query(
@@ -81,7 +135,28 @@ def build_read_query(
81
135
  start_dir: Optional[Path],
82
136
  default_file: Optional[Path],
83
137
  ) -> ReadQuery:
84
- """Shape CLI parameters into a read query."""
138
+ """Shape CLI parameters into a :class:`ReadQuery`.
139
+
140
+ Why
141
+ ----
142
+ Centralise normalisation so every command builds queries in the same way.
143
+
144
+ Parameters
145
+ ----------
146
+ vendor, app, slug:
147
+ Raw CLI strings describing the configuration slice to read.
148
+ prefer:
149
+ List of extensions supplied via ``--prefer`` (possibly empty).
150
+ start_dir:
151
+ Optional explicit starting directory.
152
+ default_file:
153
+ Optional explicit baseline file.
154
+
155
+ Returns
156
+ -------
157
+ ReadQuery
158
+ Frozen, normalised dataclass instance.
159
+ """
85
160
 
86
161
  return ReadQuery(
87
162
  vendor=vendor,
@@ -94,7 +169,13 @@ def build_read_query(
94
169
 
95
170
 
96
171
  def normalise_prefer(values: Sequence[str]) -> tuple[str, ...] | None:
97
- """Lowercase supplied extensions and strip leading dots."""
172
+ """Normalise preferred extensions by lowercasing and trimming dots.
173
+
174
+ Returns
175
+ -------
176
+ tuple[str, ...] | None
177
+ Tuple of cleaned extensions, or ``None`` when no values were supplied.
178
+ """
98
179
 
99
180
  if not values:
100
181
  return None
@@ -102,13 +183,33 @@ def normalise_prefer(values: Sequence[str]) -> tuple[str, ...] | None:
102
183
 
103
184
 
104
185
  def normalise_targets(values: Sequence[str]) -> tuple[str, ...]:
105
- """Normalise deployment targets to lowercase for resolver routing."""
186
+ """Normalise deployment targets to lowercase for resolver routing.
187
+
188
+ Why
189
+ ----
190
+ Deployment helpers expect stable lowercase slugs regardless of user input.
191
+
192
+ Returns
193
+ -------
194
+ tuple[str, ...]
195
+ Lowercased targets suitable for lookups.
196
+ """
106
197
 
107
198
  return tuple(value.lower() for value in values)
108
199
 
109
200
 
110
201
  def normalise_platform_option(value: Optional[str]) -> Optional[str]:
111
- """Map user-friendly platform aliases to canonical resolver identifiers."""
202
+ """Map friendly platform aliases to canonical resolver identifiers.
203
+
204
+ Why
205
+ ----
206
+ Keep command options flexible without leaking resolver-specific tokens.
207
+
208
+ Raises
209
+ ------
210
+ click.BadParameter
211
+ When the alias is unrecognised.
212
+ """
112
213
 
113
214
  try:
114
215
  return _normalise_resolver_platform(value)
@@ -117,7 +218,18 @@ def normalise_platform_option(value: Optional[str]) -> Optional[str]:
117
218
 
118
219
 
119
220
  def normalise_examples_platform_option(value: Optional[str]) -> Optional[str]:
120
- """Map example-generation platform aliases to canonical values."""
221
+ """Map example-generation platform aliases to canonical values.
222
+
223
+ Why
224
+ ----
225
+ Example templates use only ``posix`` or ``windows``; synonyms must collapse
226
+ to those keys.
227
+
228
+ Raises
229
+ ------
230
+ click.BadParameter
231
+ When the alias is unrecognised.
232
+ """
121
233
 
122
234
  try:
123
235
  return _normalise_examples_platform(value)
@@ -126,25 +238,60 @@ def normalise_examples_platform_option(value: Optional[str]) -> Optional[str]:
126
238
 
127
239
 
128
240
  def stringify(path: Optional[Path]) -> Optional[str]:
129
- """Return stringified path or ``None`` when *path* is ``None``."""
241
+ """Return an absolute path string or ``None`` when the input is ``None``.
242
+
243
+ Why
244
+ ----
245
+ Downstream helpers prefer plain strings (for JSON serialization) while
246
+ preserving the absence of a path.
247
+ """
130
248
 
131
249
  return None if path is None else str(path)
132
250
 
133
251
 
134
252
  def wants_json(output_format: str) -> bool:
135
- """Return ``True`` when JSON output was requested."""
253
+ """State plainly whether the caller requested JSON output.
254
+
255
+ Why
256
+ ----
257
+ Commands toggle between human and JSON representations; clarity matters.
258
+ """
136
259
 
137
260
  return output_format.strip().lower() == "json"
138
261
 
139
262
 
140
263
  def resolve_indent(enabled: bool) -> int | None:
141
- """Return default JSON indentation when *enabled* is true."""
264
+ """Return the default JSON indentation when pretty-printing is enabled.
265
+
266
+ Why
267
+ ----
268
+ Provide a single source for the CLI's JSON formatting decision.
269
+ """
142
270
 
143
271
  return DEFAULT_JSON_INDENT if enabled else None
144
272
 
145
273
 
146
274
  def json_payload(query: ReadQuery, indent: int | None, include_provenance: bool) -> str:
147
- """Build JSON payload for a query."""
275
+ """Build a JSON payload for the provided query.
276
+
277
+ Why
278
+ ----
279
+ Commands should share the same logic when emitting machine-readable output.
280
+
281
+ Parameters
282
+ ----------
283
+ query:
284
+ Normalised read parameters.
285
+ indent:
286
+ Indentation width or ``None`` for compact output.
287
+ include_provenance:
288
+ When ``True`` use :func:`read_config_json` to include source metadata.
289
+
290
+ Returns
291
+ -------
292
+ str
293
+ JSON document ready for ``click.echo``.
294
+ """
148
295
 
149
296
  if include_provenance:
150
297
  return read_config_json(
@@ -168,7 +315,20 @@ def json_payload(query: ReadQuery, indent: int | None, include_provenance: bool)
168
315
 
169
316
 
170
317
  def render_human(data: Mapping[str, object], provenance: Mapping[str, SourceInfoPayload]) -> str:
171
- """Return a human-readable description of config values and provenance."""
318
+ """Render configuration values and provenance as friendly prose.
319
+
320
+ Parameters
321
+ ----------
322
+ data:
323
+ Nested mapping of configuration values.
324
+ provenance:
325
+ Mapping of dotted keys to source metadata.
326
+
327
+ Returns
328
+ -------
329
+ str
330
+ Multi-line description highlighting value and origin.
331
+ """
172
332
 
173
333
  entries = list(iter_leaf_items(data))
174
334
  if not entries:
@@ -185,7 +345,12 @@ def render_human(data: Mapping[str, object], provenance: Mapping[str, SourceInfo
185
345
 
186
346
 
187
347
  def iter_leaf_items(mapping: Mapping[str, object], prefix: tuple[str, ...] = ()) -> Iterable[tuple[str, object]]:
188
- """Yield dotted paths and values for every leaf entry in *mapping*."""
348
+ """Yield dotted paths and values for every leaf node in *mapping*.
349
+
350
+ Why
351
+ ----
352
+ Flatten nested structures so human-readable output can focus on leaves.
353
+ """
189
354
 
190
355
  for key, value in mapping.items():
191
356
  dotted = ".".join((*prefix, key))
@@ -197,7 +362,13 @@ def iter_leaf_items(mapping: Mapping[str, object], prefix: tuple[str, ...] = ())
197
362
 
198
363
 
199
364
  def format_scalar(value: object) -> str:
200
- """Return string representation used in human output for *value*."""
365
+ """Format a scalar value for human output.
366
+
367
+ Why
368
+ ----
369
+ Keep representation consistent across CLI messages (booleans lowercase,
370
+ ``None`` as ``null``).
371
+ """
201
372
 
202
373
  if isinstance(value, bool):
203
374
  return "true" if value else "false"
@@ -207,13 +378,23 @@ def format_scalar(value: object) -> str:
207
378
 
208
379
 
209
380
  def json_paths(paths: Iterable[Path]) -> str:
210
- """Return JSON array of stringified paths written by helper commands."""
381
+ """Return a JSON array of stringified paths written by helper commands.
382
+
383
+ Why
384
+ ----
385
+ Provide machine-readable artifacts for deployment/generation commands.
386
+ """
211
387
 
212
388
  return json.dumps([str(path) for path in paths], indent=2)
213
389
 
214
390
 
215
391
  def human_payload(query: ReadQuery) -> str:
216
- """Return prose describing config values and provenance."""
392
+ """Return prose describing config values and provenance.
393
+
394
+ Why
395
+ ----
396
+ Offer a human-first view that mirrors the JSON content yet remains readable.
397
+ """
217
398
 
218
399
  data, meta = read_config_raw(
219
400
  vendor=query.vendor,
@@ -227,6 +408,33 @@ def human_payload(query: ReadQuery) -> str:
227
408
 
228
409
 
229
410
  def default_env_prefix(slug: str) -> str:
230
- """Expose the canonical environment prefix for CLI/commands."""
411
+ """Expose the canonical environment prefix for CLI/commands.
412
+
413
+ Why
414
+ ----
415
+ Sustain backward compatibility for callers that relied on the CLI proxy.
416
+ """
231
417
 
232
418
  return compute_default_env_prefix(slug)
419
+
420
+
421
+ def _fallback_info_lines() -> tuple[str, ...]:
422
+ """Construct info lines from metadata constants when helpers are absent."""
423
+
424
+ fields_provider = getattr(package_metadata, "metadata_fields", None)
425
+ if callable(fields_provider):
426
+ fields = cast(tuple[tuple[str, str], ...], fields_provider())
427
+ else:
428
+ fields: tuple[tuple[str, str], ...] = (
429
+ ("name", package_metadata.name),
430
+ ("title", package_metadata.title),
431
+ ("version", package_metadata.version),
432
+ ("homepage", package_metadata.homepage),
433
+ ("author", package_metadata.author),
434
+ ("author_email", package_metadata.author_email),
435
+ ("shell_command", package_metadata.shell_command),
436
+ )
437
+ pad = max(len(label) for label, _ in fields)
438
+ lines = [f"Info for {package_metadata.name}:", ""]
439
+ lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
440
+ return tuple(lines)
@@ -57,7 +57,7 @@ class ExampleSpec:
57
57
  content: str
58
58
 
59
59
 
60
- @dataclass(frozen=True)
60
+ @dataclass(frozen=True, slots=True)
61
61
  class ExamplePlan:
62
62
  """Plan describing how example files should be generated.
63
63
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lib_layered_config
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Cross-platform layered configuration loader for Python
5
5
  Project-URL: Homepage, https://github.com/bitranox/lib_layered_config
6
6
  Project-URL: Repository, https://github.com/bitranox/lib_layered_config.git
@@ -16,26 +16,26 @@ Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.13
19
- Requires-Dist: lib-cli-exit-tools>=1.5.0
20
- Requires-Dist: rich-click>=1.9.2
19
+ Requires-Dist: lib-cli-exit-tools>=2.1.0
20
+ Requires-Dist: rich-click>=1.9.3
21
21
  Provides-Extra: dev
22
- Requires-Dist: bandit>=1.7.9; extra == 'dev'
23
- Requires-Dist: build>=1.3; extra == 'dev'
24
- Requires-Dist: codecov-cli>=0.6; extra == 'dev'
22
+ Requires-Dist: bandit>=1.8.6; extra == 'dev'
23
+ Requires-Dist: build>=1.3.0; extra == 'dev'
24
+ Requires-Dist: codecov-cli>=11.2.3; extra == 'dev'
25
25
  Requires-Dist: coverage[toml]>=7.10.7; extra == 'dev'
26
26
  Requires-Dist: hypothesis>=6.140.3; extra == 'dev'
27
- Requires-Dist: import-linter>=2.0; extra == 'dev'
28
- Requires-Dist: pip-audit>=2.7; extra == 'dev'
29
- Requires-Dist: pyright>=1.1; extra == 'dev'
30
- Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
31
- Requires-Dist: pytest-cov>=7.0; extra == 'dev'
32
- Requires-Dist: pytest>=8.4; extra == 'dev'
33
- Requires-Dist: pyyaml>=6.0; extra == 'dev'
34
- Requires-Dist: ruff>=0.13.3; extra == 'dev'
35
- Requires-Dist: textual>=6.1.0; extra == 'dev'
36
- Requires-Dist: twine>=6.2; extra == 'dev'
27
+ Requires-Dist: import-linter>=2.5.2; extra == 'dev'
28
+ Requires-Dist: pip-audit>=2.9.0; extra == 'dev'
29
+ Requires-Dist: pyright>=1.1.406; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=1.2.0; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.4.2; extra == 'dev'
33
+ Requires-Dist: pyyaml>=6.0.3; extra == 'dev'
34
+ Requires-Dist: ruff>=0.14.0; extra == 'dev'
35
+ Requires-Dist: textual>=6.3.0; extra == 'dev'
36
+ Requires-Dist: twine>=6.2.0; extra == 'dev'
37
37
  Provides-Extra: yaml
38
- Requires-Dist: pyyaml>=6.0; extra == 'yaml'
38
+ Requires-Dist: pyyaml>=6.0.3; extra == 'yaml'
39
39
  Description-Content-Type: text/markdown
40
40
 
41
41
  # lib_layered_config
@@ -342,6 +342,10 @@ make build # build wheel / sdist artifacts
342
342
  make run -- --help # run the CLI via the repo entrypoint
343
343
  ```
344
344
 
345
+ The development extra now targets the latest stable releases of the toolchain
346
+ (pytest 8.4.2, ruff 0.14.0, codecov-cli 11.2.3, etc.), so upgrading your local
347
+ environment before running `make` is recommended.
348
+
345
349
  *Formatting gate:* Ruff formatting runs in check mode during `make test`. Run `ruff format .` (or `pre-commit run --all-files`) before pushing and consider `pre-commit install` to keep local edits aligned.
346
350
 
347
351
  *Coverage gate:* the maintained test suite must stay ≥90% (see `pyproject.toml`). Add targeted unit tests if you extend functionality.
@@ -357,7 +361,8 @@ The GitHub Actions workflow executes three jobs:
357
361
 
358
362
  - **Test matrix** (Linux/macOS/Windows, Python 3.13 + latest 3.x) running the same pipeline as `make test`.
359
363
  - **pipx / uv verification** to prove the built wheel installs cleanly with the common Python app launchers.
360
- - **Notebook smoke test** that executes `notebooks/Quickstart.ipynb` to keep the tutorial in sync.
364
+ - **Notebook smoke test** that executes `notebooks/Quickstart.ipynb` to keep the tutorial in sync using the native nbformat workflow (no compatibility shims required).
365
+ - CLI jobs run through `lib_cli_exit_tools.cli_session`, ensuring the `--traceback` flag behaves the same locally and in automation.
361
366
 
362
367
  Packaging-specific jobs (conda, Nix, Homebrew sync) were retired; the Python packaging metadata in `pyproject.toml` remains the single source of truth.
363
368
 
@@ -1,4 +1,5 @@
1
1
  lib_layered_config/__init__.py,sha256=nJZkUMWFr_LhE71vquCUojqIy8miwo_T4WexbRRSA0o,1578
2
+ lib_layered_config/__init__conf__.py,sha256=NEGCiwr_pri2exixloo6pWs893u_oTKyTXYIMvDvuKY,2154
2
3
  lib_layered_config/__main__.py,sha256=8zOwFdtTW_y8WBSefP1qYr99x3MWp6JOsTA1E0n-ceY,467
3
4
  lib_layered_config/_layers.py,sha256=21a_6pLIM2Hb-N54BAXVQxzbhmnNNXWNnJppKHl6ht4,13334
4
5
  lib_layered_config/_platform.py,sha256=eIvp_P8Cn-0meIzkxgZEQpKdgA9yU-fUYYKkp0Af1Qo,5819
@@ -12,14 +13,14 @@ lib_layered_config/adapters/dotenv/default.py,sha256=ghsgSgw99k14aQxPoXXsVk_oE_m
12
13
  lib_layered_config/adapters/env/__init__.py,sha256=EZY49s8TGhHvPWfEleRCIRc7W2eeAArpZPkaGTxUXX8,110
13
14
  lib_layered_config/adapters/env/default.py,sha256=r0SCfn24NxhVgmn5gFX07bPtTFTw-kk9AWtX0-b5rtI,13136
14
15
  lib_layered_config/adapters/file_loaders/__init__.py,sha256=V4KQJvY7xKJRMnY0cktkwj7SOILS6hO3eAfVBHP5B1k,45
15
- lib_layered_config/adapters/file_loaders/structured.py,sha256=sYe6QHZ3uoPUkVa86nz9-bRVdNUWTq5Bct2UsOkZt20,11198
16
+ lib_layered_config/adapters/file_loaders/structured.py,sha256=eDq2o2_V2vObBq8X0hitjDrhCv8rXMMmyVcaLw998s0,13444
16
17
  lib_layered_config/adapters/path_resolvers/__init__.py,sha256=-SEC9-2kwEZcW4hOrVbdfpMqmPKQ9UlI_PgvxLZ9m8I,65
17
18
  lib_layered_config/adapters/path_resolvers/default.py,sha256=7JE6xHuiplwqXptsH30m_q5PmqRHZMi7YQ8nuQSU5mk,21358
18
19
  lib_layered_config/application/__init__.py,sha256=fGbDqRVtb6tIXhdjYwL22548P1opLgau8BkzmXs_KiA,276
19
20
  lib_layered_config/application/merge.py,sha256=ZpRRQoLhFBcmubCXeXpKbOCFse1w_2GljXuCnCR-VTQ,12536
20
21
  lib_layered_config/application/ports.py,sha256=8HyqTTVlFxZurhjR805tmEV1D3MdzZdTXbjfUTSuoac,3047
21
- lib_layered_config/cli/__init__.py,sha256=EEFQRfAcD-cYROns2GRg84MpjEl4X6snpUnIM3ByPKU,5433
22
- lib_layered_config/cli/common.py,sha256=swjIGqA52jFP15Kd4AzC5QtPj5kndxeTnK8-vsJqQuY,7120
22
+ lib_layered_config/cli/__init__.py,sha256=Br31K5W-afy2e-1ZIcqyro-vrbbEc-WjY2SPDhLJUbc,4060
23
+ lib_layered_config/cli/common.py,sha256=IUrYoT9Iyn46SDJymKrdrU2wkZVeZyF7JwnYE-zRrBM,12449
23
24
  lib_layered_config/cli/constants.py,sha256=15Cu0OfkP-iOmHTecKEDLK4YShYxgMeSWNKThAZV4DA,467
24
25
  lib_layered_config/cli/deploy.py,sha256=ZhxUbMx1rjWLwNTVDx8nfM63H7-6GFp60S6_zl2vGGg,2025
25
26
  lib_layered_config/cli/fail.py,sha256=PBuHrTvSdZ1mv-UGlZYbWUF24ii09IzQYnHqN1GR6JE,523
@@ -31,9 +32,9 @@ lib_layered_config/domain/config.py,sha256=j6L9Igxyxi9emW3wlqLDZWhdNhB0R-L06DuGs
31
32
  lib_layered_config/domain/errors.py,sha256=811KWV98R8eF142ReSYjKZ2LXCQdys75y3SpKwWAn3E,1929
32
33
  lib_layered_config/examples/__init__.py,sha256=1ShHdxvsxIxWzRAYfOxaIeL2m9Je32B2I9bX8MUwXHw,996
33
34
  lib_layered_config/examples/deploy.py,sha256=1aHXsIzw9L94dXgi8xr_rTshaDUpezKV_csU81nkx6w,11211
34
- lib_layered_config/examples/generate.py,sha256=B8RgooeA3nGGXF0Zk7zU1tcmv2dZLSBTJGIHsiLOexQ,13808
35
- lib_layered_config-1.0.0.dist-info/METADATA,sha256=HtWFNt0ndl2Qe91FmDp9m1XtX9gap_4W2zxJPuK2b2c,16330
36
- lib_layered_config-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
37
- lib_layered_config-1.0.0.dist-info/entry_points.txt,sha256=peN9XKyKARqvDRE-DpK4eeZRc1JeiQqPpzx4BGv_EkE,116
38
- lib_layered_config-1.0.0.dist-info/licenses/LICENSE,sha256=6s9SL2InRrwynPjJYiER7ONHBshbGj_yC5EBzfo7l9I,1072
39
- lib_layered_config-1.0.0.dist-info/RECORD,,
35
+ lib_layered_config/examples/generate.py,sha256=YCmUms5oqWze5lv1NRN7sjT0MVO7zr6-70a7yCWnX_E,13820
36
+ lib_layered_config-1.1.0.dist-info/METADATA,sha256=48RYeqs_KwWgwQlvgdyBMel_PoroSV5CE_1_dcW10rE,16764
37
+ lib_layered_config-1.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
38
+ lib_layered_config-1.1.0.dist-info/entry_points.txt,sha256=peN9XKyKARqvDRE-DpK4eeZRc1JeiQqPpzx4BGv_EkE,116
39
+ lib_layered_config-1.1.0.dist-info/licenses/LICENSE,sha256=6s9SL2InRrwynPjJYiER7ONHBshbGj_yC5EBzfo7l9I,1072
40
+ lib_layered_config-1.1.0.dist-info/RECORD,,