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,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)
|