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,381 @@
1
+ """Utilities shared by CLI command modules.
2
+
3
+ Tell the CLI story in small, declarative helpers so commands remain tiny. These
4
+ functions construct read queries, choose output modes, format human summaries,
5
+ and surface metadata drawn from ``__init__conf__``.
6
+
7
+ Contents:
8
+ * :class:`ReadQuery` — frozen bundle capturing the parameters for configuration reads.
9
+ * :class:`OutputFormat` — enum for CLI output format selection.
10
+ * Metadata helpers (:func:`version_string`, :func:`describe_distribution`).
11
+ * Query shaping (:func:`build_read_query`, :func:`normalise_prefer`, :func:`stringify`).
12
+ * Output shaping (:func:`json_payload`, :func:`human_payload`, :func:`render_human`).
13
+ * Human-friendly utilities (:func:`format_scalar`, :func:`json_paths`).
14
+
15
+ System Role:
16
+ Commands import these helpers to stay declarative. They rely on the application
17
+ layer (`read_config*` functions) and on platform utilities for normalisation.
18
+ Updates here must be mirrored in ``docs/systemdesign/module_reference.md`` to
19
+ keep documentation and behaviour aligned.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Iterable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from enum import Enum
28
+ from pathlib import Path
29
+ from typing import Protocol, cast
30
+
31
+ import rich_click as click
32
+
33
+ from .. import __init__conf__
34
+ from .._platform import normalise_examples_platform as _normalise_examples_platform
35
+ from .._platform import normalise_resolver_platform as _normalise_resolver_platform
36
+ from ..application.ports import SourceInfoPayload
37
+ from ..core import read_config, read_config_json, read_config_raw
38
+ from .constants import DEFAULT_JSON_INDENT
39
+
40
+
41
+ class _PackageMetadata(Protocol):
42
+ """Protocol describing package metadata exported by ``__init__conf__``."""
43
+
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
+ """Return human-readable info lines for CLI display."""
54
+ ...
55
+
56
+ def metadata_fields(self) -> tuple[tuple[str, str], ...]:
57
+ """Return key-value pairs of package metadata."""
58
+ ...
59
+
60
+
61
+ package_metadata: _PackageMetadata = cast(_PackageMetadata, __init__conf__)
62
+
63
+
64
+ class OutputFormat(str, Enum):
65
+ """Output format options for CLI commands.
66
+
67
+ Provides type-safe selection between human-readable and machine-readable output.
68
+ """
69
+
70
+ HUMAN = "human"
71
+ JSON = "json"
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ReadQuery:
76
+ """Immutable bundle of parameters required to execute read commands.
77
+
78
+ Capture CLI parameters in a frozen dataclass so functions can accept a
79
+ self-explanatory object rather than many loose arguments.
80
+
81
+ Attributes:
82
+ vendor: Vendor namespace requested by the user.
83
+ app: Application identifier within the vendor namespace.
84
+ slug: Configuration slug (environment/project).
85
+ profile: Optional profile name for environment-specific configuration paths.
86
+ prefer: Ordered tuple of preferred file extensions, lowercased; ``None`` when the CLI falls back to defaults.
87
+ start_dir: Starting directory as a string or ``None`` to use the current working directory.
88
+ default_file: Optional baseline configuration file to load before layered overrides.
89
+ """
90
+
91
+ vendor: str
92
+ app: str
93
+ slug: str
94
+ profile: str | None
95
+ prefer: tuple[str, ...] | None
96
+ start_dir: str | None
97
+ default_file: str | None
98
+
99
+
100
+ def version_string() -> str:
101
+ """Echo the project version declared in ``__init__conf__``.
102
+
103
+ The CLI `--version` option should reflect the single source of truth
104
+ maintained by release automation.
105
+
106
+ Returns:
107
+ Semantic version string from the generated metadata module.
108
+ """
109
+ return package_metadata.version
110
+
111
+
112
+ def describe_distribution() -> Iterable[str]:
113
+ """Yield human-readable metadata lines sourced from ``__init__conf__``.
114
+
115
+ Support the `info` command with pre-formatted lines so the CLI stays thin.
116
+
117
+ Returns:
118
+ Sequence of descriptive lines suitable for printing with ``click.echo``.
119
+ """
120
+ lines_provider = getattr(package_metadata, "info_lines", None)
121
+ if callable(lines_provider):
122
+ yield from cast(Iterable[str], lines_provider())
123
+ return
124
+ yield from _fallback_info_lines()
125
+
126
+
127
+ def build_read_query(
128
+ vendor: str,
129
+ app: str,
130
+ slug: str,
131
+ profile: str | None,
132
+ prefer: Sequence[str],
133
+ start_dir: Path | None,
134
+ default_file: Path | None,
135
+ ) -> ReadQuery:
136
+ """Shape CLI parameters into a :class:`ReadQuery`.
137
+
138
+ Centralise normalisation so every command builds queries in the same way.
139
+
140
+ Args:
141
+ vendor: Raw CLI string describing the vendor namespace.
142
+ app: Raw CLI string describing the application name.
143
+ slug: Raw CLI string describing the configuration slice to read.
144
+ profile: Optional profile name for environment-specific configuration.
145
+ prefer: List of extensions supplied via ``--prefer`` (possibly empty).
146
+ start_dir: Optional explicit starting directory.
147
+ default_file: Optional explicit baseline file.
148
+
149
+ Returns:
150
+ Frozen, normalised dataclass instance.
151
+ """
152
+ return ReadQuery(
153
+ vendor=vendor,
154
+ app=app,
155
+ slug=slug,
156
+ profile=profile,
157
+ prefer=normalise_prefer(prefer),
158
+ start_dir=stringify(start_dir),
159
+ default_file=stringify(default_file),
160
+ )
161
+
162
+
163
+ def normalise_prefer(values: Sequence[str]) -> tuple[str, ...] | None:
164
+ """Normalise preferred extensions by lowercasing and trimming dots.
165
+
166
+ Returns:
167
+ Tuple of cleaned extensions, or ``None`` when no values were supplied.
168
+ """
169
+ if not values:
170
+ return None
171
+ return tuple(value.lower().lstrip(".") for value in values)
172
+
173
+
174
+ def normalise_targets(values: Sequence[str]) -> tuple[str, ...]:
175
+ """Normalise deployment targets to lowercase for resolver routing.
176
+
177
+ Deployment helpers expect stable lowercase slugs regardless of user input.
178
+
179
+ Returns:
180
+ Lowercased targets suitable for lookups.
181
+ """
182
+ return tuple(value.lower() for value in values)
183
+
184
+
185
+ def normalise_platform_option(value: str | None) -> str | None:
186
+ """Map friendly platform aliases to canonical resolver identifiers.
187
+
188
+ Keep command options flexible without leaking resolver-specific tokens.
189
+
190
+ Raises:
191
+ click.BadParameter: When the alias is unrecognised.
192
+ """
193
+ try:
194
+ return _normalise_resolver_platform(value)
195
+ except ValueError as exc:
196
+ raise click.BadParameter(str(exc), param_hint="--platform") from exc
197
+
198
+
199
+ def normalise_examples_platform_option(value: str | None) -> str | None:
200
+ """Map example-generation platform aliases to canonical values.
201
+
202
+ Example templates use only ``posix`` or ``windows``; synonyms must collapse
203
+ to those keys.
204
+
205
+ Raises:
206
+ click.BadParameter: When the alias is unrecognised.
207
+ """
208
+ try:
209
+ return _normalise_examples_platform(value)
210
+ except ValueError as exc:
211
+ raise click.BadParameter(str(exc), param_hint="--platform") from exc
212
+
213
+
214
+ def stringify(path: Path | None) -> str | None:
215
+ """Return an absolute path string or ``None`` when the input is ``None``.
216
+
217
+ Downstream helpers prefer plain strings (for JSON serialization) while
218
+ preserving the absence of a path.
219
+ """
220
+ return None if path is None else str(path)
221
+
222
+
223
+ def wants_json(output_format: OutputFormat) -> bool:
224
+ """State plainly whether the caller requested JSON output.
225
+
226
+ Commands toggle between human and JSON representations; clarity matters.
227
+ """
228
+ return output_format == OutputFormat.JSON
229
+
230
+
231
+ def parse_output_format(value: str) -> OutputFormat:
232
+ """Parse a string into an OutputFormat enum at the CLI boundary.
233
+
234
+ Args:
235
+ value: Raw string from CLI (e.g., "human", "json").
236
+
237
+ Returns:
238
+ Corresponding OutputFormat enum member.
239
+ """
240
+ return OutputFormat(value.strip().lower())
241
+
242
+
243
+ def resolve_indent(enabled: bool) -> int | None:
244
+ """Return the default JSON indentation when pretty-printing is enabled.
245
+
246
+ Provide a single source for the CLI's JSON formatting decision.
247
+ """
248
+ return DEFAULT_JSON_INDENT if enabled else None
249
+
250
+
251
+ def json_payload(query: ReadQuery, indent: int | None, include_provenance: bool) -> str:
252
+ """Build a JSON payload for the provided query.
253
+
254
+ Commands should share the same logic when emitting machine-readable output.
255
+
256
+ Args:
257
+ query: Normalised read parameters.
258
+ indent: Indentation width or ``None`` for compact output.
259
+ include_provenance: When ``True`` use :func:`read_config_json` to include source metadata.
260
+
261
+ Returns:
262
+ JSON document ready for ``click.echo``.
263
+ """
264
+ if include_provenance:
265
+ return read_config_json(
266
+ vendor=query.vendor,
267
+ app=query.app,
268
+ slug=query.slug,
269
+ profile=query.profile,
270
+ prefer=query.prefer,
271
+ start_dir=query.start_dir,
272
+ default_file=query.default_file,
273
+ indent=indent,
274
+ )
275
+ config = read_config(
276
+ vendor=query.vendor,
277
+ app=query.app,
278
+ slug=query.slug,
279
+ profile=query.profile,
280
+ prefer=query.prefer,
281
+ start_dir=query.start_dir,
282
+ default_file=query.default_file,
283
+ )
284
+ return config.to_json(indent=indent)
285
+
286
+
287
+ def render_human(data: Mapping[str, object], provenance: Mapping[str, SourceInfoPayload]) -> str:
288
+ """Render configuration values and provenance as friendly prose.
289
+
290
+ Args:
291
+ data: Nested mapping of configuration values.
292
+ provenance: Mapping of dotted keys to source metadata.
293
+
294
+ Returns:
295
+ Multi-line description highlighting value and origin.
296
+ """
297
+ entries = list(iter_leaf_items(data))
298
+ if not entries:
299
+ return "No configuration values were found."
300
+
301
+ lines: list[str] = []
302
+ for dotted, value in entries:
303
+ lines.append(f"{dotted}: {format_scalar(value)}")
304
+ info = provenance.get(dotted)
305
+ if info:
306
+ path = info["path"] or "(memory)"
307
+ lines.append(f" provenance: layer={info['layer']}, path={path}")
308
+ return "\n".join(lines)
309
+
310
+
311
+ def iter_leaf_items(mapping: Mapping[str, object], prefix: tuple[str, ...] = ()) -> Iterable[tuple[str, object]]:
312
+ """Yield dotted paths and values for every leaf node in *mapping*.
313
+
314
+ Flatten nested structures so human-readable output can focus on leaves.
315
+ """
316
+ for key, value in mapping.items():
317
+ dotted = ".".join((*prefix, key))
318
+ if isinstance(value, Mapping):
319
+ nested = cast(Mapping[str, object], value)
320
+ yield from iter_leaf_items(nested, (*prefix, key))
321
+ else:
322
+ yield dotted, value
323
+
324
+
325
+ def format_scalar(value: object) -> str:
326
+ """Format a scalar value for human output.
327
+
328
+ Keep representation consistent across CLI messages (booleans lowercase,
329
+ ``None`` as ``null``).
330
+ """
331
+ if isinstance(value, bool):
332
+ return "true" if value else "false"
333
+ if value is None:
334
+ return "null"
335
+ return str(value)
336
+
337
+
338
+ def json_paths(paths: Iterable[Path]) -> str:
339
+ """Return a JSON array of stringified paths written by helper commands.
340
+
341
+ Provide machine-readable artifacts for deployment/generation commands.
342
+ """
343
+ return json.dumps([str(path) for path in paths], indent=2)
344
+
345
+
346
+ def human_payload(query: ReadQuery) -> str:
347
+ """Return prose describing config values and provenance.
348
+
349
+ Offer a human-first view that mirrors the JSON content yet remains readable.
350
+ """
351
+ result = read_config_raw(
352
+ vendor=query.vendor,
353
+ app=query.app,
354
+ slug=query.slug,
355
+ profile=query.profile,
356
+ prefer=query.prefer,
357
+ start_dir=query.start_dir,
358
+ default_file=query.default_file,
359
+ )
360
+ return render_human(result.data, result.provenance)
361
+
362
+
363
+ def _fallback_info_lines() -> tuple[str, ...]:
364
+ """Construct info lines from metadata constants when helpers are absent."""
365
+ fields_provider = getattr(package_metadata, "metadata_fields", None)
366
+ if callable(fields_provider):
367
+ fields = cast(tuple[tuple[str, str], ...], fields_provider())
368
+ else:
369
+ fields: tuple[tuple[str, str], ...] = (
370
+ ("name", package_metadata.name),
371
+ ("title", package_metadata.title),
372
+ ("version", package_metadata.version),
373
+ ("homepage", package_metadata.homepage),
374
+ ("author", package_metadata.author),
375
+ ("author_email", package_metadata.author_email),
376
+ ("shell_command", package_metadata.shell_command),
377
+ )
378
+ pad = max(len(label) for label, _ in fields)
379
+ lines = [f"Info for {package_metadata.name}:", ""]
380
+ lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
381
+ return tuple(lines)
@@ -0,0 +1,12 @@
1
+ """Shared CLI constants used across command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ CLICK_CONTEXT_SETTINGS: Final[dict[str, tuple[str, str]]] = {"help_option_names": ("-h", "--help")}
8
+ TRACEBACK_SUMMARY: Final[int] = 500
9
+ TRACEBACK_VERBOSE: Final[int] = 10_000
10
+ TARGET_CHOICES: Final[tuple[str, ...]] = ("app", "host", "user")
11
+ EXAMPLE_PLATFORM_CHOICES: Final[tuple[str, ...]] = ("posix", "windows")
12
+ DEFAULT_JSON_INDENT: Final[int] = 2
@@ -0,0 +1,71 @@
1
+ """CLI command for deploying configuration files into layer directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ import rich_click as click
9
+
10
+ from ..examples import deploy_config as deploy_config_impl
11
+ from .common import json_paths, normalise_platform_option, normalise_targets
12
+ from .constants import CLICK_CONTEXT_SETTINGS, TARGET_CHOICES
13
+
14
+
15
+ @click.command("deploy", context_settings=CLICK_CONTEXT_SETTINGS)
16
+ @click.option(
17
+ "--source",
18
+ type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=False, readable=True),
19
+ required=True,
20
+ help="Path to the configuration file that should be copied",
21
+ )
22
+ @click.option("--vendor", required=True, help="Vendor namespace")
23
+ @click.option("--app", required=True, help="Application name")
24
+ @click.option("--slug", required=True, help="Slug identifying the configuration set")
25
+ @click.option("--profile", default=None, help="Configuration profile name (e.g., 'test', 'production')")
26
+ @click.option(
27
+ "--target",
28
+ "targets",
29
+ multiple=True,
30
+ required=True,
31
+ type=click.Choice(TARGET_CHOICES, case_sensitive=False),
32
+ help="Layer targets to deploy to (repeatable)",
33
+ )
34
+ @click.option(
35
+ "--platform",
36
+ default=None,
37
+ help="Override auto-detected platform (linux, darwin, windows)",
38
+ )
39
+ @click.option(
40
+ "--force/--no-force",
41
+ default=False,
42
+ show_default=True,
43
+ help="Overwrite existing files at the destination",
44
+ )
45
+ def deploy_command(
46
+ source: Path,
47
+ vendor: str,
48
+ app: str,
49
+ slug: str,
50
+ profile: str | None,
51
+ targets: Sequence[str],
52
+ platform: str | None,
53
+ force: bool,
54
+ ) -> None:
55
+ """Copy a source file into the requested layered directories."""
56
+ created = deploy_config_impl(
57
+ source,
58
+ vendor=vendor,
59
+ app=app,
60
+ slug=slug,
61
+ profile=profile,
62
+ targets=normalise_targets(targets),
63
+ platform=normalise_platform_option(platform),
64
+ force=force,
65
+ )
66
+ click.echo(json_paths(created))
67
+
68
+
69
+ def register(cli_group: click.Group) -> None:
70
+ """Register the deploy command with the root CLI group."""
71
+ cli_group.add_command(deploy_command)
@@ -0,0 +1,19 @@
1
+ """Debugging helpers exposed via the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import rich_click as click
6
+
7
+ from ..testing import i_should_fail
8
+ from .constants import CLICK_CONTEXT_SETTINGS
9
+
10
+
11
+ @click.command("fail", context_settings=CLICK_CONTEXT_SETTINGS)
12
+ def fail_command() -> None:
13
+ """Intentionally raise a runtime error for test harnesses."""
14
+ i_should_fail()
15
+
16
+
17
+ def register(cli_group: click.Group) -> None:
18
+ """Register the fail command with the root CLI group."""
19
+ cli_group.add_command(fail_command)
@@ -0,0 +1,57 @@
1
+ """CLI command for generating example configuration trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+
9
+ from ..examples import generate_examples as generate_examples_impl
10
+ from .common import json_paths, normalise_examples_platform_option
11
+ from .constants import CLICK_CONTEXT_SETTINGS
12
+
13
+
14
+ @click.command("generate-examples", context_settings=CLICK_CONTEXT_SETTINGS)
15
+ @click.option(
16
+ "--destination",
17
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True, resolve_path=True),
18
+ required=True,
19
+ help="Directory that will receive the example tree",
20
+ )
21
+ @click.option("--slug", required=True, help="Slug identifying the configuration set")
22
+ @click.option("--vendor", required=True, help="Vendor namespace")
23
+ @click.option("--app", required=True, help="Application name")
24
+ @click.option(
25
+ "--platform",
26
+ default=None,
27
+ help="Override platform layout (posix/windows)",
28
+ )
29
+ @click.option(
30
+ "--force/--no-force",
31
+ default=False,
32
+ show_default=True,
33
+ help="Overwrite existing example files",
34
+ )
35
+ def generate_examples_command(
36
+ destination: Path,
37
+ slug: str,
38
+ vendor: str,
39
+ app: str,
40
+ platform: str | None,
41
+ force: bool,
42
+ ) -> None:
43
+ """Create reference example trees for documentation or onboarding."""
44
+ created = generate_examples_impl(
45
+ destination,
46
+ slug=slug,
47
+ vendor=vendor,
48
+ app=app,
49
+ force=force,
50
+ platform=normalise_examples_platform_option(platform),
51
+ )
52
+ click.echo(json_paths(created))
53
+
54
+
55
+ def register(cli_group: click.Group) -> None:
56
+ """Register the generate-examples command."""
57
+ cli_group.add_command(generate_examples_command)
@@ -0,0 +1,29 @@
1
+ """Metadata-related CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import rich_click as click
6
+
7
+ from ..core import default_env_prefix
8
+ from .common import describe_distribution
9
+ from .constants import CLICK_CONTEXT_SETTINGS
10
+
11
+
12
+ @click.command("info", context_settings=CLICK_CONTEXT_SETTINGS)
13
+ def info_command() -> None:
14
+ """Print package metadata in friendly lines."""
15
+ for line in describe_distribution():
16
+ click.echo(line)
17
+
18
+
19
+ @click.command("env-prefix", context_settings=CLICK_CONTEXT_SETTINGS)
20
+ @click.argument("slug")
21
+ def env_prefix_command(slug: str) -> None:
22
+ """Echo the canonical environment variable prefix for a slug."""
23
+ click.echo(default_env_prefix(slug))
24
+
25
+
26
+ def register(cli_group: click.Group) -> None:
27
+ """Register metadata commands with the root CLI group."""
28
+ cli_group.add_command(info_command)
29
+ cli_group.add_command(env_prefix_command)
@@ -0,0 +1,120 @@
1
+ """CLI commands related to reading configuration layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ import rich_click as click
9
+
10
+ from .common import (
11
+ build_read_query,
12
+ human_payload,
13
+ json_payload,
14
+ parse_output_format,
15
+ resolve_indent,
16
+ wants_json,
17
+ )
18
+ from .constants import CLICK_CONTEXT_SETTINGS
19
+
20
+
21
+ @click.command("read", context_settings=CLICK_CONTEXT_SETTINGS)
22
+ @click.option("--vendor", required=True, help="Vendor namespace")
23
+ @click.option("--app", required=True, help="Application name")
24
+ @click.option("--slug", required=True, help="Slug identifying the configuration set")
25
+ @click.option("--profile", default=None, help="Configuration profile name (e.g., 'test', 'production')")
26
+ @click.option("--prefer", multiple=True, help="Preferred file suffix ordering (repeatable)")
27
+ @click.option(
28
+ "--start-dir",
29
+ type=click.Path(path_type=Path, exists=True, file_okay=False, dir_okay=True, readable=True),
30
+ default=None,
31
+ help="Starting directory for .env upward search",
32
+ )
33
+ @click.option(
34
+ "--default-file",
35
+ type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=False, readable=True),
36
+ default=None,
37
+ help="Optional lowest-precedence defaults file",
38
+ )
39
+ @click.option(
40
+ "--format",
41
+ "output_format",
42
+ type=click.Choice(["human", "json"], case_sensitive=False),
43
+ default="human",
44
+ show_default=True,
45
+ help="Choose between human prose or JSON",
46
+ )
47
+ @click.option(
48
+ "--indent/--no-indent",
49
+ default=True,
50
+ show_default=True,
51
+ help="Pretty-print JSON output",
52
+ )
53
+ @click.option(
54
+ "--provenance/--no-provenance",
55
+ default=True,
56
+ show_default=True,
57
+ help="Include provenance metadata in JSON output",
58
+ )
59
+ def read_command(
60
+ vendor: str,
61
+ app: str,
62
+ slug: str,
63
+ profile: str | None,
64
+ prefer: Sequence[str],
65
+ start_dir: Path | None,
66
+ default_file: Path | None,
67
+ output_format: str,
68
+ indent: bool,
69
+ provenance: bool,
70
+ ) -> None:
71
+ """Read configuration and print either human prose or JSON."""
72
+ query = build_read_query(vendor, app, slug, profile, prefer, start_dir, default_file)
73
+ fmt = parse_output_format(output_format)
74
+ if wants_json(fmt):
75
+ click.echo(json_payload(query, resolve_indent(indent), provenance))
76
+ return
77
+ click.echo(human_payload(query))
78
+
79
+
80
+ @click.command("read-json", context_settings=CLICK_CONTEXT_SETTINGS)
81
+ @click.option("--vendor", required=True)
82
+ @click.option("--app", required=True)
83
+ @click.option("--slug", required=True)
84
+ @click.option("--profile", default=None, help="Configuration profile name")
85
+ @click.option("--prefer", multiple=True)
86
+ @click.option(
87
+ "--start-dir",
88
+ type=click.Path(path_type=Path, exists=True, file_okay=False, dir_okay=True, readable=True),
89
+ default=None,
90
+ )
91
+ @click.option(
92
+ "--default-file",
93
+ type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=False, readable=True),
94
+ default=None,
95
+ )
96
+ @click.option(
97
+ "--indent/--no-indent",
98
+ default=True,
99
+ show_default=True,
100
+ help="Pretty-print JSON output",
101
+ )
102
+ def read_json_command(
103
+ vendor: str,
104
+ app: str,
105
+ slug: str,
106
+ profile: str | None,
107
+ prefer: Sequence[str],
108
+ start_dir: Path | None,
109
+ default_file: Path | None,
110
+ indent: bool,
111
+ ) -> None:
112
+ """Always emit combined JSON (config + provenance)."""
113
+ query = build_read_query(vendor, app, slug, profile, prefer, start_dir, default_file)
114
+ click.echo(json_payload(query, resolve_indent(indent), include_provenance=True))
115
+
116
+
117
+ def register(cli_group: click.Group) -> None:
118
+ """Register CLI commands defined in this module."""
119
+ cli_group.add_command(read_command)
120
+ cli_group.add_command(read_json_command)