katalyst-taxonomy-cli 2.0.1a1__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.
- katalyst_taxonomy_cli/__init__.py +14 -0
- katalyst_taxonomy_cli/__main__.py +12 -0
- katalyst_taxonomy_cli/_wiring.py +248 -0
- katalyst_taxonomy_cli/app.py +550 -0
- katalyst_taxonomy_cli/commands/__init__.py +1 -0
- katalyst_taxonomy_cli/commands/api.py +104 -0
- katalyst_taxonomy_cli/commands/backend.py +96 -0
- katalyst_taxonomy_cli/commands/bundle.py +106 -0
- katalyst_taxonomy_cli/commands/clone.py +68 -0
- katalyst_taxonomy_cli/commands/config.py +748 -0
- katalyst_taxonomy_cli/commands/config_filters.py +159 -0
- katalyst_taxonomy_cli/commands/config_home.py +179 -0
- katalyst_taxonomy_cli/commands/config_plugins.py +180 -0
- katalyst_taxonomy_cli/commands/config_profile.py +604 -0
- katalyst_taxonomy_cli/commands/config_schemas.py +257 -0
- katalyst_taxonomy_cli/commands/config_sources.py +141 -0
- katalyst_taxonomy_cli/commands/core.py +468 -0
- katalyst_taxonomy_cli/commands/create.py +145 -0
- katalyst_taxonomy_cli/commands/deps.py +48 -0
- katalyst_taxonomy_cli/commands/diff.py +63 -0
- katalyst_taxonomy_cli/commands/drafts.py +63 -0
- katalyst_taxonomy_cli/commands/export.py +186 -0
- katalyst_taxonomy_cli/commands/init.py +84 -0
- katalyst_taxonomy_cli/commands/kinds.py +302 -0
- katalyst_taxonomy_cli/commands/lifecycle.py +127 -0
- katalyst_taxonomy_cli/commands/lint.py +256 -0
- katalyst_taxonomy_cli/commands/plugin.py +148 -0
- katalyst_taxonomy_cli/commands/promote.py +95 -0
- katalyst_taxonomy_cli/commands/query.py +187 -0
- katalyst_taxonomy_cli/commands/relate.py +137 -0
- katalyst_taxonomy_cli/commands/relocate.py +129 -0
- katalyst_taxonomy_cli/commands/remove.py +95 -0
- katalyst_taxonomy_cli/commands/resolve.py +48 -0
- katalyst_taxonomy_cli/commands/rollback.py +51 -0
- katalyst_taxonomy_cli/commands/self_update.py +530 -0
- katalyst_taxonomy_cli/commands/show.py +33 -0
- katalyst_taxonomy_cli/commands/status.py +107 -0
- katalyst_taxonomy_cli/commands/sync.py +60 -0
- katalyst_taxonomy_cli/commands/update.py +112 -0
- katalyst_taxonomy_cli/commands/versions.py +54 -0
- katalyst_taxonomy_cli/context.py +145 -0
- katalyst_taxonomy_cli/discovery.py +157 -0
- katalyst_taxonomy_cli/fields.py +70 -0
- katalyst_taxonomy_cli/install.py +330 -0
- katalyst_taxonomy_cli/options.py +20 -0
- katalyst_taxonomy_cli/output.py +92 -0
- katalyst_taxonomy_cli/py.typed +0 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/METADATA +12 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/RECORD +51 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/WHEEL +4 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"""``kata config`` — inspect and switch resolved configuration (#212, #213).
|
|
2
|
+
|
|
3
|
+
There is one configuration file, ``<user config home>/config.yaml``. Every command
|
|
4
|
+
here works from any directory — none needs a repository:
|
|
5
|
+
|
|
6
|
+
``kata config path``
|
|
7
|
+
The active home (its name, which rule chose it, and its config, state and cache
|
|
8
|
+
directories), where the config file is, and where each of the other (layered)
|
|
9
|
+
files lives.
|
|
10
|
+
``kata config show``
|
|
11
|
+
The profile-resolved configuration.
|
|
12
|
+
``kata config sources``
|
|
13
|
+
Which file every resolved key came from.
|
|
14
|
+
``kata config profile ...``
|
|
15
|
+
List, create, change, delete and activate profiles and the backends each declares
|
|
16
|
+
(:mod:`.config_profile`).
|
|
17
|
+
``kata config sync`` / ``kata config status``
|
|
18
|
+
Fetch and pin the profiles sourced from elsewhere, and report their pins
|
|
19
|
+
(:mod:`.config_sources`).
|
|
20
|
+
|
|
21
|
+
The group deliberately sits at the top level (``kata config ...``, not
|
|
22
|
+
``kata tax config ...``) because config resolution is what decides where ``tax``
|
|
23
|
+
operates — it cannot live inside the thing it configures.
|
|
24
|
+
|
|
25
|
+
Structure for follow-on issues: every subcommand is an independent
|
|
26
|
+
``@config.command`` that calls a module-level ``build_*_payload`` helper and then
|
|
27
|
+
renders. Adding ``explain`` (#214) means adding a command plus a payload builder —
|
|
28
|
+
no restructuring, and the payload builders are importable, which is how the MCP
|
|
29
|
+
``config_show`` tool stays byte-identical to the CLI.
|
|
30
|
+
|
|
31
|
+
JSON shapes are the contract in
|
|
32
|
+
``stack-tests/features/cli/31_kata_config_discovery.feature`` and
|
|
33
|
+
``32_kata_config_profiles.feature``. Extend them additively.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
import click
|
|
42
|
+
from katalyst_taxonomy.config.profiles import UnknownProfileError
|
|
43
|
+
from katalyst_taxonomy.config.resolution import ConfigResolver
|
|
44
|
+
from katalyst_taxonomy.domain.config_context import ConfigContext
|
|
45
|
+
from katalyst_taxonomy.domain.config_files import (
|
|
46
|
+
CONFIG_FILE,
|
|
47
|
+
ConfigFileSpec,
|
|
48
|
+
MergeStrategy,
|
|
49
|
+
config_file_specs,
|
|
50
|
+
spec_for,
|
|
51
|
+
)
|
|
52
|
+
from katalyst_taxonomy.domain.config_layers import MISSING, lookup_dotted
|
|
53
|
+
from katalyst_taxonomy.services.layered_config import LayeredConfigService
|
|
54
|
+
|
|
55
|
+
from ..output import ConsoleOutput, OutputPort
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"build_explain_payload",
|
|
59
|
+
"build_path_payload",
|
|
60
|
+
"build_permissions_payload",
|
|
61
|
+
"build_show_payload",
|
|
62
|
+
"build_sources_payload",
|
|
63
|
+
"config",
|
|
64
|
+
"resolve_profile",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
68
|
+
|
|
69
|
+
_PROFILE_OPTION = click.option(
|
|
70
|
+
"--profile",
|
|
71
|
+
"profile",
|
|
72
|
+
default=None,
|
|
73
|
+
help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_profile(explicit: str | None) -> str | None:
|
|
78
|
+
"""Resolve ``--profile`` for a ``kata config`` subcommand (#222 §3).
|
|
79
|
+
|
|
80
|
+
Falls back to the root group's ``kata --profile``, which is where #222 moved the
|
|
81
|
+
flag so that ``kata config`` — the command whose entire job is reporting which
|
|
82
|
+
configuration is in effect — can be asked about a profile at all. The
|
|
83
|
+
subcommand's own flag still wins, being the more specific statement.
|
|
84
|
+
|
|
85
|
+
Reads the live Click context rather than taking it as a parameter so that every
|
|
86
|
+
subcommand needs one call and none of them needs a ``@click.pass_context``.
|
|
87
|
+
"""
|
|
88
|
+
if explicit is not None:
|
|
89
|
+
return explicit
|
|
90
|
+
from ..app import root_profile
|
|
91
|
+
|
|
92
|
+
ctx = click.get_current_context(silent=True)
|
|
93
|
+
return root_profile(ctx) if ctx is not None else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Composition
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
def _service() -> LayeredConfigService:
|
|
100
|
+
"""Build the layered config service against the real filesystem."""
|
|
101
|
+
from katalyst_taxonomy.adapters.filesystem.config import FilesystemConfigAdapter
|
|
102
|
+
|
|
103
|
+
return LayeredConfigService(FilesystemConfigAdapter())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolver() -> ConfigResolver:
|
|
107
|
+
"""Build the (read-only) config resolver against the real filesystem."""
|
|
108
|
+
from katalyst_taxonomy.config.backends import config_resolver
|
|
109
|
+
|
|
110
|
+
return config_resolver()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def repository_root(backend: str | None = None) -> tuple[str | None, Path | None]:
|
|
114
|
+
"""The repository whose copies of the layered files count, as ``(name, root)``.
|
|
115
|
+
|
|
116
|
+
Named by ``--backend``; otherwise the profile's only filesystem backend, else the
|
|
117
|
+
one the current directory is inside. With neither, ``(None, None)`` — config
|
|
118
|
+
commands work anywhere, and the files then layer the invocation directory.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If *backend* names no filesystem backend the profile reads.
|
|
122
|
+
"""
|
|
123
|
+
from katalyst_taxonomy.config.targets import resolve_repository_for
|
|
124
|
+
|
|
125
|
+
if backend:
|
|
126
|
+
target = resolve_repository_for(backend, cwd=Path.cwd())
|
|
127
|
+
return target.name, target.root
|
|
128
|
+
try:
|
|
129
|
+
target = resolve_repository_for(None, cwd=Path.cwd())
|
|
130
|
+
except Exception: # noqa: BLE001 - no single repository: the invocation directory
|
|
131
|
+
return None, None
|
|
132
|
+
return target.name, target.root
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_context(backend: str | None = None) -> ConfigContext:
|
|
136
|
+
"""Resolve the config home and the repository the layered files are read from.
|
|
137
|
+
|
|
138
|
+
Never fails outside a repository: the configuration does not live in one.
|
|
139
|
+
"""
|
|
140
|
+
return _service().context(explicit_path=repository_root(backend)[1])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Payload builders — shared with the MCP config_show tool
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
def _optional_str(value: Path | None) -> str | None:
|
|
147
|
+
return str(value) if value is not None else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_path_payload(*, backend: str | None = None) -> dict[str, Any]:
|
|
151
|
+
"""Build the ``kata config path`` payload.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The active home (``home``: name, kind, root, the three directories and the
|
|
155
|
+
rule that chose it), the config file and whether it exists, the repository the command would work on (``repository``,
|
|
156
|
+
``repoRoot`` — ``null`` when there is no single one), the working directory,
|
|
157
|
+
and a per-file table.
|
|
158
|
+
"""
|
|
159
|
+
service = _service()
|
|
160
|
+
name, root = repository_root(backend)
|
|
161
|
+
ctx = service.context(explicit_path=root)
|
|
162
|
+
layers = service.layer_paths(ctx, specs=all_specs())
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"repository": name,
|
|
166
|
+
"repoRoot": _optional_str(root),
|
|
167
|
+
"repoRootFound": root is not None,
|
|
168
|
+
"workingDirectory": str(Path.cwd().resolve()),
|
|
169
|
+
"userConfigHome": str(ctx.user_config_home),
|
|
170
|
+
"home": ctx.home.to_dict() if ctx.home is not None else None,
|
|
171
|
+
"configFile": str(ctx.config_file),
|
|
172
|
+
"configExists": ctx.config_file.is_file(),
|
|
173
|
+
"files": [
|
|
174
|
+
{
|
|
175
|
+
"key": entry.key,
|
|
176
|
+
"strategy": entry.strategy.value,
|
|
177
|
+
"description": entry.description,
|
|
178
|
+
"repoPath": _optional_str(entry.repo_path),
|
|
179
|
+
"repoPresent": entry.repo_present,
|
|
180
|
+
"userPath": str(entry.user_path),
|
|
181
|
+
"userPresent": entry.user_present,
|
|
182
|
+
}
|
|
183
|
+
for entry in layers
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_show_payload(
|
|
189
|
+
*,
|
|
190
|
+
backend: str | None = None,
|
|
191
|
+
file: str = CONFIG_FILE.key,
|
|
192
|
+
key: str | None = None,
|
|
193
|
+
profile: str | None = None,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Build the ``kata config show`` payload.
|
|
196
|
+
|
|
197
|
+
For a deep-merged file the merged mapping is spread at the top level — so
|
|
198
|
+
``$.spec.outputFormat`` addresses the resolved config directly. The config file
|
|
199
|
+
reports ``configFile``; a layered file reports its ``repoConfig``/``userConfig``
|
|
200
|
+
paths. For a ``by-name`` file the merged stream is under ``documents``. For a text
|
|
201
|
+
file it is ``text``.
|
|
202
|
+
|
|
203
|
+
For the ``KatalystConfig`` document, ``spec`` is the declared view with
|
|
204
|
+
``activeProfile`` set to the profile actually in force. ``profile`` names it.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
backend: The repository whose copies of the layered files count.
|
|
208
|
+
file: Which file to show. Defaults to the ``KatalystConfig`` document.
|
|
209
|
+
key: Optional dotted key to narrow to.
|
|
210
|
+
profile: Explicit profile (the ``--profile`` flag).
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If *file* is not a layered config file, or the configuration
|
|
214
|
+
is malformed.
|
|
215
|
+
UnknownProfileError: If the profile cannot be selected.
|
|
216
|
+
"""
|
|
217
|
+
spec = spec_for(file, all_specs())
|
|
218
|
+
service = _service()
|
|
219
|
+
ctx = resolve_context(backend)
|
|
220
|
+
|
|
221
|
+
payload: dict[str, Any]
|
|
222
|
+
if spec.strategy is MergeStrategy.BY_NAME:
|
|
223
|
+
named = service.resolve_named(spec, ctx)
|
|
224
|
+
payload = {"documents": [dict(doc) for doc in named.documents]}
|
|
225
|
+
repo_path, user_path = named.repo_path, named.user_path
|
|
226
|
+
elif spec.strategy is MergeStrategy.CONCATENATE:
|
|
227
|
+
text = service.resolve_text(spec, ctx)
|
|
228
|
+
payload = {"text": text.text, "origins": list(text.origins)}
|
|
229
|
+
repo_path, user_path = text.repo_path, text.user_path
|
|
230
|
+
else:
|
|
231
|
+
merged = service.resolve_mapping(spec, ctx)
|
|
232
|
+
payload = dict(merged.data)
|
|
233
|
+
repo_path, user_path = merged.repo_path, merged.user_path
|
|
234
|
+
if spec.key == CONFIG_FILE.key:
|
|
235
|
+
# Inspecting an incomplete configuration must still show it — this is the
|
|
236
|
+
# command someone runs to find out what is missing. So a document that
|
|
237
|
+
# does not resolve yet reports why, instead of failing.
|
|
238
|
+
from katalyst_taxonomy.config.resolution import NoBackendConfiguredError
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
payload.update(_resolver().resolve(ctx=ctx, profile=profile).profile_view())
|
|
242
|
+
payload["configured"] = True
|
|
243
|
+
except NoBackendConfiguredError as exc:
|
|
244
|
+
payload["configured"] = False
|
|
245
|
+
payload["reason"] = str(exc)
|
|
246
|
+
# Literal credentials are never shown; `${VAR}` references are, as written.
|
|
247
|
+
from katalyst_taxonomy.config.remote import redact_config_document
|
|
248
|
+
|
|
249
|
+
payload = redact_config_document(payload)
|
|
250
|
+
if key is not None:
|
|
251
|
+
value = lookup_dotted(payload, key)
|
|
252
|
+
payload = {
|
|
253
|
+
"key": key,
|
|
254
|
+
"value": None if value is MISSING else value,
|
|
255
|
+
"present": value is not MISSING,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
payload["file"] = spec.key
|
|
259
|
+
payload.update(_file_paths(spec, ctx, repo_path, user_path))
|
|
260
|
+
return payload
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _file_paths(
|
|
264
|
+
spec: ConfigFileSpec,
|
|
265
|
+
ctx: ConfigContext,
|
|
266
|
+
repo_path: Path | None,
|
|
267
|
+
user_path: Path | None,
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
"""Where *spec* was read from: ``configFile`` for config.yaml, both layers otherwise."""
|
|
270
|
+
if spec.key == CONFIG_FILE.key:
|
|
271
|
+
return {"configFile": str(ctx.config_file), "configExists": user_path is not None}
|
|
272
|
+
return {"repoConfig": _optional_str(repo_path), "userConfig": _optional_str(user_path)}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def build_explain_payload(
|
|
276
|
+
address: str,
|
|
277
|
+
*,
|
|
278
|
+
profile: str | None = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""Build the ``kata config explain`` payload (#214).
|
|
281
|
+
|
|
282
|
+
Delegates wholesale to
|
|
283
|
+
:func:`katalyst_taxonomy.services.routing_explain.explain_payload` rather than
|
|
284
|
+
assembling anything here, which is what lets the MCP ``config_explain`` tool
|
|
285
|
+
be byte-identical without ``mcp/`` importing ``cli/``.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
UnknownBackendError: If a named backend is undeclared.
|
|
289
|
+
UnknownProfileError: If the profile cannot be selected.
|
|
290
|
+
ValueError: If the configuration is malformed.
|
|
291
|
+
"""
|
|
292
|
+
from katalyst_taxonomy.services.routing_explain import explain_payload
|
|
293
|
+
|
|
294
|
+
return explain_payload(address, base_path=Path.cwd(), profile=profile)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_permissions_payload(
|
|
298
|
+
*,
|
|
299
|
+
fqtn: str | None = None,
|
|
300
|
+
operation: str | None = None,
|
|
301
|
+
profile: str | None = None,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Build the ``kata config permissions`` payload (#216).
|
|
304
|
+
|
|
305
|
+
Fail-closed resolution that cannot explain itself is undebuggable, and
|
|
306
|
+
most-specific-wins-then-deny-wins is not something anyone should have to
|
|
307
|
+
simulate in their head. This reports the *effective* access for the active
|
|
308
|
+
profile and **which grant produced it**, plus every declared grant with the file
|
|
309
|
+
that declared it.
|
|
310
|
+
|
|
311
|
+
Delegates the whole computation to
|
|
312
|
+
:meth:`katalyst_taxonomy.config.permissions.PermissionPolicy.report`, so the
|
|
313
|
+
report is produced by the same object the port gate enforces with. An
|
|
314
|
+
explanation resolved by a second code path is not an explanation.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
fqtn: Optional target FQTN. Omitted, the report is store-wide.
|
|
318
|
+
operation: Optional ``r``/``w``/``x`` (or ``read``/``write``/``execute``) to
|
|
319
|
+
report a single bit's verdict under ``allowed``.
|
|
320
|
+
profile: Explicit profile (the ``--profile`` flag).
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
ValueError: If *operation* is not a recognized operation.
|
|
324
|
+
UnknownProfileError: If the profile cannot be selected.
|
|
325
|
+
"""
|
|
326
|
+
from katalyst_taxonomy.config.backends import load_permission_policy
|
|
327
|
+
|
|
328
|
+
policy = load_permission_policy(None, profile=profile)
|
|
329
|
+
from katalyst_taxonomy.services.permissions import report_target
|
|
330
|
+
|
|
331
|
+
target = report_target(None, fqtn or "", profile=profile)
|
|
332
|
+
payload = policy.report(target)
|
|
333
|
+
if operation is not None:
|
|
334
|
+
resolved = _operation(operation)
|
|
335
|
+
payload["operation"] = str(resolved)
|
|
336
|
+
payload["allowed"] = policy.allows(resolved, target)
|
|
337
|
+
return payload
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _operation(value: str): # noqa: ANN202 - Operation, imported lazily
|
|
341
|
+
"""Parse the ``--op`` flag, accepting a single bit or the full word.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: If *value* names no operation.
|
|
345
|
+
"""
|
|
346
|
+
from katalyst_taxonomy.domain.permissions import Operation
|
|
347
|
+
|
|
348
|
+
aliases = {"r": Operation.READ, "w": Operation.WRITE, "x": Operation.EXECUTE}
|
|
349
|
+
lowered = value.strip().lower()
|
|
350
|
+
if lowered in aliases:
|
|
351
|
+
return aliases[lowered]
|
|
352
|
+
for operation in Operation:
|
|
353
|
+
if lowered == str(operation):
|
|
354
|
+
return operation
|
|
355
|
+
valid = ", ".join(f"{op.value} ({op.value[0]})" for op in Operation)
|
|
356
|
+
msg = f"Unknown operation {value!r}. Expected one of: {valid}."
|
|
357
|
+
raise ValueError(msg)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_sources_payload(
|
|
361
|
+
*,
|
|
362
|
+
backend: str | None = None,
|
|
363
|
+
file: str = CONFIG_FILE.key,
|
|
364
|
+
) -> dict[str, Any]:
|
|
365
|
+
"""Build the ``kata config sources`` payload.
|
|
366
|
+
|
|
367
|
+
``sources`` maps every resolved dotted key to the absolute path of the file
|
|
368
|
+
that set it — for config.yaml, always the one config file. ``layers`` lists the
|
|
369
|
+
participating files strongest-first with their numeric priority.
|
|
370
|
+
"""
|
|
371
|
+
spec = spec_for(file, all_specs())
|
|
372
|
+
service = _service()
|
|
373
|
+
ctx = resolve_context(backend)
|
|
374
|
+
|
|
375
|
+
if spec.strategy is MergeStrategy.BY_NAME:
|
|
376
|
+
named = service.resolve_named(spec, ctx)
|
|
377
|
+
provenance = named.provenance
|
|
378
|
+
presence = named.layers
|
|
379
|
+
elif spec.strategy is MergeStrategy.CONCATENATE:
|
|
380
|
+
text = service.resolve_text(spec, ctx)
|
|
381
|
+
provenance = {f"{index}": origin for index, origin in enumerate(text.origins)}
|
|
382
|
+
presence = text.layers
|
|
383
|
+
else:
|
|
384
|
+
merged = service.resolve_mapping(spec, ctx)
|
|
385
|
+
provenance = merged.provenance
|
|
386
|
+
presence = merged.layers
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"file": spec.key,
|
|
390
|
+
"repoRoot": str(ctx.repo_root),
|
|
391
|
+
"userConfigHome": str(ctx.user_config_home),
|
|
392
|
+
"sources": dict(sorted(provenance.items())),
|
|
393
|
+
"layers": [
|
|
394
|
+
{
|
|
395
|
+
"layer": entry.layer.label,
|
|
396
|
+
"priority": entry.layer.priority,
|
|
397
|
+
"path": _optional_str(entry.path),
|
|
398
|
+
"present": entry.present,
|
|
399
|
+
}
|
|
400
|
+
for entry in presence
|
|
401
|
+
],
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# Command group
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
@click.group("config")
|
|
409
|
+
@click.pass_context
|
|
410
|
+
def config(ctx: click.Context) -> None:
|
|
411
|
+
"""Inspect and edit the Katalyst configuration.
|
|
412
|
+
|
|
413
|
+
There is one configuration file, config.yaml in the active home (kata --home or
|
|
414
|
+
KATALYST_HOME: 'default', a name under ~/.katalyst/homes, or an absolute path; see
|
|
415
|
+
'kata config home'). Each profile in it declares
|
|
416
|
+
the backends it reads; a repository is a filesystem backend of a profile. Select a
|
|
417
|
+
profile with --profile or KATALYST_PROFILE; change them with 'kata config profile'.
|
|
418
|
+
"""
|
|
419
|
+
# A development install needs a home chosen (Q1), except to manage homes.
|
|
420
|
+
if ctx.invoked_subcommand != "home":
|
|
421
|
+
from ..install import refuse_default_home_for_development
|
|
422
|
+
|
|
423
|
+
refuse_default_home_for_development()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _output() -> OutputPort:
|
|
427
|
+
return ConsoleOutput()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _fail(message: str) -> None:
|
|
431
|
+
raise click.ClickException(message)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
_ORIGIN_LABELS = {"flag": "--home", "env": "KATALYST_HOME", "default": "no home selected"}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _home_label(home: dict[str, Any]) -> str:
|
|
438
|
+
"""``dev (~/.katalyst/homes/dev)``, a path home's path, or ``default``."""
|
|
439
|
+
name = str(home.get("name") or "default")
|
|
440
|
+
if home.get("kind") == "named" and home.get("root"):
|
|
441
|
+
return f"{name} ({home['root']})"
|
|
442
|
+
return name
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@config.command("path")
|
|
446
|
+
@click.option(
|
|
447
|
+
"--backend",
|
|
448
|
+
default=None,
|
|
449
|
+
help="Repository (filesystem backend) whose copies of the layered files count.",
|
|
450
|
+
)
|
|
451
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
452
|
+
def config_path(backend: str | None, fmt: str) -> None:
|
|
453
|
+
"""Show where the config file is, and which repository a command would work on."""
|
|
454
|
+
out = _output()
|
|
455
|
+
try:
|
|
456
|
+
payload = build_path_payload(backend=backend)
|
|
457
|
+
except ValueError as exc:
|
|
458
|
+
_fail(str(exc))
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if fmt == "json":
|
|
462
|
+
out.json_out(payload)
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
home: dict[str, Any] = payload["home"] or {}
|
|
466
|
+
out.key_value(
|
|
467
|
+
[
|
|
468
|
+
("home", f"{_home_label(home)} ({_ORIGIN_LABELS.get(str(home.get('origin')), '-')})"),
|
|
469
|
+
(
|
|
470
|
+
"config file",
|
|
471
|
+
payload["configFile"] + ("" if payload["configExists"] else " (missing)"),
|
|
472
|
+
),
|
|
473
|
+
("config dir", payload["userConfigHome"]),
|
|
474
|
+
("state dir", str(home.get("stateDir") or "-")),
|
|
475
|
+
("cache dir", str(home.get("cacheDir") or "-")),
|
|
476
|
+
(
|
|
477
|
+
"repository",
|
|
478
|
+
f"{payload['repository']} ({payload['repoRoot']})"
|
|
479
|
+
if payload["repoRootFound"]
|
|
480
|
+
else "(none — pass --backend NAME)",
|
|
481
|
+
),
|
|
482
|
+
]
|
|
483
|
+
)
|
|
484
|
+
out.heading("Layered files")
|
|
485
|
+
out.table(
|
|
486
|
+
["file", "strategy", "repository", "user"],
|
|
487
|
+
[
|
|
488
|
+
[
|
|
489
|
+
entry["key"],
|
|
490
|
+
entry["strategy"],
|
|
491
|
+
entry["repoPath"] if entry["repoPresent"] else "-",
|
|
492
|
+
entry["userPath"] if entry["userPresent"] else "-",
|
|
493
|
+
]
|
|
494
|
+
for entry in payload["files"]
|
|
495
|
+
],
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@config.command("show")
|
|
500
|
+
@click.option(
|
|
501
|
+
"--backend",
|
|
502
|
+
default=None,
|
|
503
|
+
help="Repository (filesystem backend) whose copies of the layered files count.",
|
|
504
|
+
)
|
|
505
|
+
@click.option("--file", "file_key", default=CONFIG_FILE.key, help="Layered file to show.")
|
|
506
|
+
@click.option("--key", default=None, help="Dotted key to narrow to, e.g. spec.outputFormat.")
|
|
507
|
+
@_PROFILE_OPTION
|
|
508
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
509
|
+
def config_show(
|
|
510
|
+
backend: str | None,
|
|
511
|
+
file_key: str,
|
|
512
|
+
key: str | None,
|
|
513
|
+
profile: str | None,
|
|
514
|
+
fmt: str,
|
|
515
|
+
) -> None:
|
|
516
|
+
"""Show the profile-resolved configuration."""
|
|
517
|
+
out = _output()
|
|
518
|
+
try:
|
|
519
|
+
payload = build_show_payload(
|
|
520
|
+
backend=backend,
|
|
521
|
+
file=file_key,
|
|
522
|
+
key=key,
|
|
523
|
+
profile=resolve_profile(profile),
|
|
524
|
+
)
|
|
525
|
+
except (UnknownProfileError, ValueError) as exc:
|
|
526
|
+
_fail(str(exc))
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
if fmt == "json":
|
|
530
|
+
out.json_out(payload)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
import yaml
|
|
534
|
+
|
|
535
|
+
out.echo(yaml.safe_dump(payload, sort_keys=False, default_flow_style=False).rstrip())
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@config.command("sources")
|
|
539
|
+
@click.option(
|
|
540
|
+
"--backend",
|
|
541
|
+
default=None,
|
|
542
|
+
help="Repository (filesystem backend) whose copies of the layered files count.",
|
|
543
|
+
)
|
|
544
|
+
@click.option("--file", "file_key", default=CONFIG_FILE.key, help="Layered file to inspect.")
|
|
545
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
546
|
+
def config_sources(backend: str | None, file_key: str, fmt: str) -> None:
|
|
547
|
+
"""Show which file each resolved key came from, in precedence order."""
|
|
548
|
+
out = _output()
|
|
549
|
+
try:
|
|
550
|
+
payload = build_sources_payload(backend=backend, file=file_key)
|
|
551
|
+
except ValueError as exc:
|
|
552
|
+
_fail(str(exc))
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
if fmt == "json":
|
|
556
|
+
out.json_out(payload)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
sources: dict[str, str] = payload["sources"]
|
|
560
|
+
if not sources:
|
|
561
|
+
out.info(f"No configuration resolved for {payload['file']!r}.")
|
|
562
|
+
else:
|
|
563
|
+
out.table(["key", "source"], [[key, value] for key, value in sources.items()])
|
|
564
|
+
|
|
565
|
+
out.heading("Layers (strongest first)")
|
|
566
|
+
out.table(
|
|
567
|
+
["layer", "priority", "path", "present"],
|
|
568
|
+
[
|
|
569
|
+
[
|
|
570
|
+
entry["layer"],
|
|
571
|
+
str(entry["priority"]),
|
|
572
|
+
entry["path"] or "-",
|
|
573
|
+
"yes" if entry["present"] else "no",
|
|
574
|
+
]
|
|
575
|
+
for entry in payload["layers"]
|
|
576
|
+
],
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def all_specs() -> tuple[ConfigFileSpec, ...]:
|
|
581
|
+
"""Every layered config file: the runtime's own and each installed plugin's."""
|
|
582
|
+
from katalyst_taxonomy.plugins.loader import plugin_context
|
|
583
|
+
|
|
584
|
+
return config_file_specs(plugin_context().config_files)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
# Addressing
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
@config.command("explain")
|
|
591
|
+
@click.argument("address")
|
|
592
|
+
@_PROFILE_OPTION
|
|
593
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
594
|
+
def config_explain(address: str, profile: str | None, fmt: str) -> None:
|
|
595
|
+
"""Explain what ADDRESS names, and whether it is there.
|
|
596
|
+
|
|
597
|
+
ADDRESS may carry a ``#kind`` selector — ``filesystem:payments.backend#stack`` — for a
|
|
598
|
+
path two kinds both claim; without one, such a path fails naming the selectors.
|
|
599
|
+
|
|
600
|
+
ADDRESS is ``backend:fqtn``. Reports whether that backend is one the profile reads,
|
|
601
|
+
and whether it holds a node there and of what kind. An unprefixed address is refused
|
|
602
|
+
with the backends that do hold the name.
|
|
603
|
+
|
|
604
|
+
Pure resolution — it reads the repository's own documents to learn each node's
|
|
605
|
+
kind and wing, and never opens a connection to a remote store. An unknown
|
|
606
|
+
backend name fails and lists the declared ones; it is never a silent fallback.
|
|
607
|
+
"""
|
|
608
|
+
out = _output()
|
|
609
|
+
try:
|
|
610
|
+
payload = build_explain_payload(address, profile=resolve_profile(profile))
|
|
611
|
+
except (UnknownProfileError, ValueError) as exc:
|
|
612
|
+
_fail(str(exc))
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
if fmt == "json":
|
|
616
|
+
out.json_out(payload)
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
out.key_value(
|
|
620
|
+
[
|
|
621
|
+
("address", payload["address"]),
|
|
622
|
+
("backend", payload["backend"]),
|
|
623
|
+
("fqtn", payload["fqtn"]),
|
|
624
|
+
("exists", "yes" if payload["exists"] else "no"),
|
|
625
|
+
("kind", payload["kind"] or "(unknown)"),
|
|
626
|
+
("tier", payload["tier"]),
|
|
627
|
+
("profile reads", ", ".join(payload["backends"]) or "-"),
|
|
628
|
+
("profile", payload["profile"] or "(none)"),
|
|
629
|
+
]
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ---------------------------------------------------------------------------
|
|
634
|
+
# Permissions (#216)
|
|
635
|
+
# ---------------------------------------------------------------------------
|
|
636
|
+
@config.command("permissions")
|
|
637
|
+
@click.option("--fqtn", default=None, help="Target FQTN. Omitted, the report is store-wide.")
|
|
638
|
+
@click.option("--op", "operation", default=None, help="Operation to check: r, w, x.")
|
|
639
|
+
@_PROFILE_OPTION
|
|
640
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
641
|
+
def config_permissions(
|
|
642
|
+
fqtn: str | None,
|
|
643
|
+
operation: str | None,
|
|
644
|
+
profile: str | None,
|
|
645
|
+
fmt: str,
|
|
646
|
+
) -> None:
|
|
647
|
+
"""Report the effective access for a target, and which backend grant decided.
|
|
648
|
+
|
|
649
|
+
Rules live on backends: the target's backend's grants decide, in every profile.
|
|
650
|
+
Resolution is most-specific-wins — any ``fqtn`` match outranks a ``kind`` or
|
|
651
|
+
``wing`` match, and more literal dot-segments outrank fewer — with the more
|
|
652
|
+
restrictive grant winning at equal specificity. An unmatched target falls
|
|
653
|
+
through to the backend's ``permissions.default``, and a ``permissions`` block that
|
|
654
|
+
declares no ``default`` denies.
|
|
655
|
+
|
|
656
|
+
A backend with no ``permissions`` block reports ``rwx`` from no grant
|
|
657
|
+
(``decidedBy: unrestricted``): that is the back-compat guarantee, not an omission.
|
|
658
|
+
"""
|
|
659
|
+
out = _output()
|
|
660
|
+
try:
|
|
661
|
+
payload = build_permissions_payload(
|
|
662
|
+
fqtn=fqtn,
|
|
663
|
+
operation=operation,
|
|
664
|
+
profile=resolve_profile(profile),
|
|
665
|
+
)
|
|
666
|
+
except (UnknownProfileError, ValueError) as exc:
|
|
667
|
+
_fail(str(exc))
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
if fmt == "json":
|
|
671
|
+
out.json_out(payload)
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
rows = [
|
|
675
|
+
("profile", payload["profile"] or "(none)"),
|
|
676
|
+
("fqtn", payload["fqtn"] or "(store-wide)"),
|
|
677
|
+
("backend", payload["backend"] or "-"),
|
|
678
|
+
("access", payload["access"]),
|
|
679
|
+
("decided by", payload["decidedBy"]),
|
|
680
|
+
("grant", payload["grant"] or "-"),
|
|
681
|
+
("default", payload["default"] or "(none declared)"),
|
|
682
|
+
("source", payload["source"] or "-"),
|
|
683
|
+
]
|
|
684
|
+
if "operation" in payload:
|
|
685
|
+
rows.append((payload["operation"], "allowed" if payload["allowed"] else "DENIED"))
|
|
686
|
+
out.key_value(rows)
|
|
687
|
+
|
|
688
|
+
backends: list[dict[str, Any]] = payload["backendRules"]
|
|
689
|
+
if not backends:
|
|
690
|
+
out.info("No backend declares permission rules; every writable backend is rwx.")
|
|
691
|
+
return
|
|
692
|
+
out.heading("Backend grants")
|
|
693
|
+
out.table(
|
|
694
|
+
["backend", "grant", "access", "match", "source"],
|
|
695
|
+
[
|
|
696
|
+
[
|
|
697
|
+
rules["backend"],
|
|
698
|
+
entry["name"] or "-",
|
|
699
|
+
entry["access"],
|
|
700
|
+
", ".join(f"{key}={value}" for key, value in entry["match"].items()) or "*",
|
|
701
|
+
entry["source"] or "-",
|
|
702
|
+
]
|
|
703
|
+
for rules in backends
|
|
704
|
+
for entry in rules["grants"]
|
|
705
|
+
]
|
|
706
|
+
+ [
|
|
707
|
+
[rules["backend"], "(default)", rules["default"], "*", rules["source"] or "-"]
|
|
708
|
+
for rules in backends
|
|
709
|
+
if rules["default"] is not None
|
|
710
|
+
],
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# Subcommands contributed by sibling modules
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
# `schemas` lives in its own module so that the command's name and payload
|
|
718
|
+
# builder stay in one place (see #218). Attaching here, at import time, keeps
|
|
719
|
+
# `kata config` the single discovery point for every subcommand.
|
|
720
|
+
from .config_schemas import attach as _attach_schemas # noqa: E402
|
|
721
|
+
|
|
722
|
+
_attach_schemas(config)
|
|
723
|
+
|
|
724
|
+
from .config_plugins import attach as _attach_plugins # noqa: E402
|
|
725
|
+
|
|
726
|
+
_attach_plugins(config)
|
|
727
|
+
|
|
728
|
+
# `filter` (#217) follows the same pattern: its own module, one line here. It
|
|
729
|
+
# reports whether a node is visible under the active profile's include/exclude
|
|
730
|
+
# clauses and which clause decided.
|
|
731
|
+
from .config_filters import attach as _attach_filters # noqa: E402
|
|
732
|
+
|
|
733
|
+
_attach_filters(config)
|
|
734
|
+
|
|
735
|
+
# `profile` — creating and changing profiles and their backends.
|
|
736
|
+
from .config_profile import attach as _attach_profile # noqa: E402
|
|
737
|
+
|
|
738
|
+
_attach_profile(config)
|
|
739
|
+
|
|
740
|
+
# `sync` and `status` — profiles sourced from elsewhere, fetched and pinned.
|
|
741
|
+
from .config_sources import attach as _attach_sources # noqa: E402
|
|
742
|
+
|
|
743
|
+
_attach_sources(config)
|
|
744
|
+
|
|
745
|
+
# `home` — the named homes `kata --home` selects (#257).
|
|
746
|
+
from .config_home import attach as _attach_home # noqa: E402
|
|
747
|
+
|
|
748
|
+
_attach_home(config)
|