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,550 @@
|
|
|
1
|
+
"""CLI application root — Click group, command registration, plugin discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping, cast
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from katalyst_taxonomy.domain.output_formats import OUTPUT_FORMATS
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .context import CliContext
|
|
15
|
+
from .discovery import discover_plugin_commands
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from katalyst_taxonomy.ports.config import ConfigPort
|
|
19
|
+
|
|
20
|
+
#: Exit status for a permission denial (#216).
|
|
21
|
+
#:
|
|
22
|
+
#: Reserved for exactly that, and specified before the implementation existed:
|
|
23
|
+
#: ``docs/planning/active/fixme3.md:529`` and ``docs/planning/active/plan6.md:237``.
|
|
24
|
+
#: ``stack-tests/features/cli/34_kata_config_permissions.feature`` asserts it on
|
|
25
|
+
#: every row of the rwx matrix, so it is observable behaviour, not a detail.
|
|
26
|
+
PERMISSION_DENIED_EXIT_CODE = 7
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PermissionDenied(click.ClickException):
|
|
30
|
+
"""A :class:`click.ClickException` that exits 7 instead of 1.
|
|
31
|
+
|
|
32
|
+
Click's ``standalone_mode`` already renders ``ClickException`` as
|
|
33
|
+
``Error: <message>`` on stderr and exits with ``exit_code``, so subclassing is
|
|
34
|
+
the whole implementation — no bespoke error plumbing, and the message keeps the
|
|
35
|
+
same shape as every other user error.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
exit_code = PERMISSION_DENIED_EXIT_CODE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KataGroup(click.Group):
|
|
42
|
+
"""The root group, which turns a permission denial into exit 7.
|
|
43
|
+
|
|
44
|
+
Translation happens **here**, at the single point every command's exception
|
|
45
|
+
must pass through, rather than in the eight commands that can write. Those
|
|
46
|
+
commands do not catch exceptions at all today, and adding a ``try``/``except``
|
|
47
|
+
to each is how you end up with seven that exit 7 and one that exits 1.
|
|
48
|
+
|
|
49
|
+
Group ``invoke`` calls nest, so catching on the root covers ``kata tax …``,
|
|
50
|
+
``kata config …`` and every plugin-contributed subcommand, including ones this
|
|
51
|
+
package has never heard of.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
55
|
+
from katalyst_taxonomy.config.profiles import UnknownProfileError
|
|
56
|
+
from katalyst_taxonomy.domain.homes import HomeError
|
|
57
|
+
from katalyst_taxonomy.domain.permissions import PermissionDeniedError
|
|
58
|
+
from katalyst_taxonomy.plugins.errors import PluginError
|
|
59
|
+
from katalyst_taxonomy.ports.backend import UnsupportedCapabilityError
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return super().invoke(ctx)
|
|
63
|
+
except PermissionDeniedError as exc:
|
|
64
|
+
raise PermissionDenied(str(exc)) from exc
|
|
65
|
+
except UnsupportedCapabilityError as exc:
|
|
66
|
+
# "Backend 'x' does not support UPDATE": a fact about the backend the user
|
|
67
|
+
# named, with the capabilities it does have — not a traceback.
|
|
68
|
+
raise click.ClickException(str(exc)) from exc
|
|
69
|
+
except PluginError as exc:
|
|
70
|
+
# A plugin that will not activate is a user-fixable configuration or
|
|
71
|
+
# environment problem, not a bug in this process — it gets the same
|
|
72
|
+
# `Error: …` shape as every other user error, not a traceback (#219).
|
|
73
|
+
raise click.ClickException(str(exc)) from exc
|
|
74
|
+
except (UnknownProfileError, HomeError) as exc:
|
|
75
|
+
# No profile selected among several, or a home that does not exist: the
|
|
76
|
+
# message says what to do, and a traceback would bury it.
|
|
77
|
+
raise click.ClickException(str(exc)) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TaxGroup(click.Group):
|
|
81
|
+
"""The ``tax`` group, which resolves plugin-contributed commands **lazily**.
|
|
82
|
+
|
|
83
|
+
Before #219 the CLI called ``_register_plugin_commands()`` at module scope, so
|
|
84
|
+
every installed plugin's group was attached before any configuration had been
|
|
85
|
+
read and ``enabled: false`` could not remove it.
|
|
86
|
+
|
|
87
|
+
Deferring, rather than removing commands after the fact, is the only option
|
|
88
|
+
that works for both observable paths:
|
|
89
|
+
|
|
90
|
+
- ``kata tax cicd --help``: Click's ``Group.invoke`` calls ``resolve_command``
|
|
91
|
+
→ :meth:`get_command` **before** ``super().invoke(ctx)`` runs the group
|
|
92
|
+
callback, so there is no callback-time hook that fires early enough to
|
|
93
|
+
remove a command before it is resolved.
|
|
94
|
+
- ``kata tax --help``: the eager ``--help`` option short-circuits during
|
|
95
|
+
parameter processing, so the group callback never runs at all, and only
|
|
96
|
+
:meth:`list_commands` is reached.
|
|
97
|
+
|
|
98
|
+
Both funnel through this class, which is why the gate lives here.
|
|
99
|
+
|
|
100
|
+
Resolution is memoised per instance: Click calls ``list_commands`` and
|
|
101
|
+
``get_command`` repeatedly while formatting help, and re-reading the
|
|
102
|
+
configuration each time would be both slow and inconsistent.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
106
|
+
super().__init__(*args, **kwargs)
|
|
107
|
+
self._plugin_commands_resolved = False
|
|
108
|
+
|
|
109
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
110
|
+
self._ensure_plugin_commands(ctx)
|
|
111
|
+
return super().list_commands(ctx)
|
|
112
|
+
|
|
113
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
114
|
+
self._ensure_plugin_commands(ctx)
|
|
115
|
+
return super().get_command(ctx, cmd_name)
|
|
116
|
+
|
|
117
|
+
def _ensure_plugin_commands(self, ctx: click.Context) -> None:
|
|
118
|
+
if self._plugin_commands_resolved:
|
|
119
|
+
return
|
|
120
|
+
# Set first: a failure inside discovery must not retry on every lookup.
|
|
121
|
+
self._plugin_commands_resolved = True
|
|
122
|
+
for cmd in discover_plugin_commands(is_enabled=_enabled_plugin_predicate(ctx)):
|
|
123
|
+
self.add_command(cmd)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
#: Key the root group's ``--profile`` is stashed under in ``click.Context.meta``.
|
|
127
|
+
#:
|
|
128
|
+
#: ``meta`` rather than ``obj``: ``obj`` is already the ``CliContext`` that ``kata
|
|
129
|
+
#: tax`` builds, and ``kata config`` deliberately has no ``CliContext`` at all
|
|
130
|
+
#: (config resolution decides where ``tax`` operates, so it cannot depend on it).
|
|
131
|
+
#: ``Context.meta`` is shared by every context in the invocation chain, which is
|
|
132
|
+
#: exactly the propagation a root-level flag needs and the only mechanism that
|
|
133
|
+
#: reaches a group Click resolved before the flag's own callback ran.
|
|
134
|
+
ROOT_PROFILE_META_KEY = "katalyst.profile"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def root_profile(ctx: click.Context) -> str | None:
|
|
138
|
+
"""The root ``kata --profile`` value, or ``None``.
|
|
139
|
+
|
|
140
|
+
Read by both subcommand groups so a profile named once at the root is honoured
|
|
141
|
+
everywhere (#222 §3). A subcommand's own ``--profile`` still wins: it is the
|
|
142
|
+
more specific statement, and ``32_kata_config_profiles.feature`` asserts the
|
|
143
|
+
subcommand form directly.
|
|
144
|
+
"""
|
|
145
|
+
value = ctx.meta.get(ROOT_PROFILE_META_KEY)
|
|
146
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _installed_katalyst_packages() -> list[tuple[str, str]]:
|
|
150
|
+
"""Every installed Katalyst distribution, so a version report names the whole stack."""
|
|
151
|
+
import importlib.metadata
|
|
152
|
+
|
|
153
|
+
found: dict[str, str] = {}
|
|
154
|
+
for dist in importlib.metadata.distributions():
|
|
155
|
+
name = (dist.metadata["Name"] or "").strip()
|
|
156
|
+
if name.lower().replace("_", "-").startswith("katalyst-"):
|
|
157
|
+
found[name.lower().replace("_", "-")] = dist.version
|
|
158
|
+
found.pop("katalyst-taxonomy-cli", None)
|
|
159
|
+
return sorted(found.items())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
163
|
+
if not value or ctx.resilient_parsing:
|
|
164
|
+
return
|
|
165
|
+
from katalyst_taxonomy.adapters.filesystem.homes import resolve_existing_home
|
|
166
|
+
from katalyst_taxonomy.domain.homes import HomeError, HomeOrigin
|
|
167
|
+
|
|
168
|
+
from .install import development_location, install_lines
|
|
169
|
+
|
|
170
|
+
click.echo(f"kata {__version__}")
|
|
171
|
+
for name, version in _installed_katalyst_packages():
|
|
172
|
+
click.echo(f" {name} {version}")
|
|
173
|
+
for line in install_lines():
|
|
174
|
+
click.echo(line)
|
|
175
|
+
try:
|
|
176
|
+
home = resolve_existing_home()
|
|
177
|
+
except HomeError as exc:
|
|
178
|
+
raise click.ClickException(str(exc)) from exc
|
|
179
|
+
if home.origin is HomeOrigin.DEFAULT and development_location() is not None:
|
|
180
|
+
click.echo("home: none chosen (a development install needs --home or KATALYST_HOME)")
|
|
181
|
+
elif home.is_default:
|
|
182
|
+
click.echo(f"home: default ({_tilde(home.config_dir)})")
|
|
183
|
+
else:
|
|
184
|
+
click.echo(f"home: {home.label}")
|
|
185
|
+
ctx.exit()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _tilde(path: Path) -> str:
|
|
189
|
+
try:
|
|
190
|
+
return f"~/{path.relative_to(Path.home())}"
|
|
191
|
+
except ValueError:
|
|
192
|
+
return str(path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _select_home(ctx: click.Context, _param: click.Parameter, value: str | None) -> None:
|
|
196
|
+
"""``kata --home``: make it this process's home, and every child's (``KATALYST_HOME``)."""
|
|
197
|
+
if value is None or ctx.resilient_parsing:
|
|
198
|
+
return
|
|
199
|
+
from katalyst_taxonomy.adapters.filesystem.homes import select_home
|
|
200
|
+
from katalyst_taxonomy.domain.homes import HomeError
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
select_home(value)
|
|
204
|
+
except HomeError as exc:
|
|
205
|
+
raise click.BadParameter(str(exc), ctx=ctx, param=_param) from exc
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@click.group(cls=KataGroup)
|
|
209
|
+
@click.option(
|
|
210
|
+
"--home",
|
|
211
|
+
"home",
|
|
212
|
+
default=None,
|
|
213
|
+
expose_value=False,
|
|
214
|
+
is_eager=True,
|
|
215
|
+
callback=_select_home,
|
|
216
|
+
metavar="HOME",
|
|
217
|
+
help=(
|
|
218
|
+
"The Katalyst home: its config, state and cache. 'default' (the XDG locations), "
|
|
219
|
+
"a name (~/.katalyst/homes/NAME, see 'kata config home'), or an absolute path. "
|
|
220
|
+
"Overrides KATALYST_HOME."
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
@click.option(
|
|
224
|
+
"--version",
|
|
225
|
+
is_flag=True,
|
|
226
|
+
expose_value=False,
|
|
227
|
+
# Not eager, so it runs after --home (which is) and reports the home chosen.
|
|
228
|
+
callback=_print_version,
|
|
229
|
+
help=(
|
|
230
|
+
"Show the kata version, every installed Katalyst package, the install (and what it "
|
|
231
|
+
"was built from) and the home, then exit."
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
@click.option(
|
|
235
|
+
"--profile",
|
|
236
|
+
"profile",
|
|
237
|
+
default=None,
|
|
238
|
+
help=(
|
|
239
|
+
"Configuration profile to use, for every subcommand including 'kata config'. "
|
|
240
|
+
"Overrides KATALYST_PROFILE and spec.activeProfile."
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
@click.pass_context
|
|
244
|
+
def cli(ctx: click.Context, profile: str | None) -> None:
|
|
245
|
+
"""Katalyst taxonomy management."""
|
|
246
|
+
# #213 put --profile on the `tax` group, which meant `kata config show` — the
|
|
247
|
+
# command whose entire job is reporting which configuration is in effect —
|
|
248
|
+
# could not be asked about a profile. #222 moves it here; the `tax` and
|
|
249
|
+
# `config` flags stay as the more specific spelling.
|
|
250
|
+
if profile is not None:
|
|
251
|
+
ctx.meta[ROOT_PROFILE_META_KEY] = profile
|
|
252
|
+
_configure_logging()
|
|
253
|
+
# A KATALYST_HOME that names no home fails every command here, before any of them
|
|
254
|
+
# reads or writes anything (`--home` was checked as it was parsed).
|
|
255
|
+
from katalyst_taxonomy.adapters.filesystem.homes import resolve_existing_home
|
|
256
|
+
from katalyst_taxonomy.domain.homes import HomeError
|
|
257
|
+
|
|
258
|
+
from .install import refuse_default_home_for_development
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
home = resolve_existing_home()
|
|
262
|
+
except HomeError as exc:
|
|
263
|
+
raise click.ClickException(str(exc)) from exc
|
|
264
|
+
# `kata config` decides for itself: `kata config home …` manages homes, not uses one.
|
|
265
|
+
# `kata self` updates the install and uses no home.
|
|
266
|
+
if ctx.invoked_subcommand not in ("config", "self"):
|
|
267
|
+
refuse_default_home_for_development(home)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_logging_configured = False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _configure_logging() -> None:
|
|
274
|
+
"""Show warnings and errors on stderr; KATALYST_LOG_LEVEL (e.g. DEBUG) shows more.
|
|
275
|
+
|
|
276
|
+
loguru's default handler prints everything from DEBUG up, which buried a command's own
|
|
277
|
+
output under the services' progress lines. Only that default handler is replaced, once,
|
|
278
|
+
so handlers a caller added (a test capturing logs) are left alone.
|
|
279
|
+
"""
|
|
280
|
+
global _logging_configured
|
|
281
|
+
if _logging_configured:
|
|
282
|
+
return
|
|
283
|
+
import os
|
|
284
|
+
|
|
285
|
+
from loguru import logger
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
logger.remove(0)
|
|
289
|
+
except ValueError:
|
|
290
|
+
return
|
|
291
|
+
level = os.environ.get("KATALYST_LOG_LEVEL", "WARNING").upper()
|
|
292
|
+
logger.add(sys.stderr, level=level, format="<level>{level}</level>: {message}")
|
|
293
|
+
_logging_configured = True
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@cli.command("tui")
|
|
297
|
+
def tui_cmd() -> None:
|
|
298
|
+
"""Launch the interactive terminal UI."""
|
|
299
|
+
try:
|
|
300
|
+
subprocess.run(
|
|
301
|
+
[sys.executable, "-m", "katalyst_taxonomy_tui"],
|
|
302
|
+
check=True,
|
|
303
|
+
)
|
|
304
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
305
|
+
click.echo(f"Error launching TUI: {e}", err=True)
|
|
306
|
+
raise SystemExit(1) from e
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@cli.group("tax", cls=TaxGroup)
|
|
310
|
+
@click.option(
|
|
311
|
+
"--format",
|
|
312
|
+
"output_format",
|
|
313
|
+
type=click.Choice(OUTPUT_FORMATS),
|
|
314
|
+
default=None,
|
|
315
|
+
help="Output format.",
|
|
316
|
+
)
|
|
317
|
+
@click.option("--verbose", is_flag=True, help="Enable verbose output.")
|
|
318
|
+
@click.option(
|
|
319
|
+
"--profile",
|
|
320
|
+
"profile",
|
|
321
|
+
default=None,
|
|
322
|
+
help=(
|
|
323
|
+
"Configuration profile to use. Overrides KATALYST_PROFILE and "
|
|
324
|
+
"spec.activeProfile. See 'kata config profile list'."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
@click.pass_context
|
|
328
|
+
def tax(
|
|
329
|
+
ctx: click.Context,
|
|
330
|
+
output_format: str | None,
|
|
331
|
+
verbose: bool,
|
|
332
|
+
profile: str | None,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Taxonomy operations.
|
|
335
|
+
|
|
336
|
+
Everything starts from the user config file: the repositories are the filesystem
|
|
337
|
+
backends it declares. Commands that work on one repository take --backend NAME;
|
|
338
|
+
without it, the profile's only filesystem backend, else the one the current
|
|
339
|
+
directory is inside, is used.
|
|
340
|
+
"""
|
|
341
|
+
ctx.ensure_object(dict)
|
|
342
|
+
profile = profile or root_profile(ctx)
|
|
343
|
+
ctx.obj = CliContext.build(
|
|
344
|
+
cwd=Path.cwd().resolve(),
|
|
345
|
+
output_format=_resolve_output_format(output_format),
|
|
346
|
+
verbose=verbose,
|
|
347
|
+
profile=profile,
|
|
348
|
+
)
|
|
349
|
+
_register_source_providers(profile)
|
|
350
|
+
if ctx.invoked_subcommand == "plugin":
|
|
351
|
+
# `kata tax plugin remove` is how a broken source plugin is taken out; a plugin
|
|
352
|
+
# that will not activate must not stop the command that removes it.
|
|
353
|
+
from katalyst_taxonomy.plugins.errors import PluginError
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
_activate_plugins(profile=profile)
|
|
357
|
+
except PluginError as exc:
|
|
358
|
+
click.echo(f"Warning: plugins not activated: {exc}", err=True)
|
|
359
|
+
return
|
|
360
|
+
_activate_plugins(profile=profile)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _resolve_output_format(output_format: str | None) -> str | None:
|
|
364
|
+
"""The effective output format: ``--format``, else ``spec.outputFormat``.
|
|
365
|
+
|
|
366
|
+
A malformed config file must not take down every taxonomy command; ``kata config
|
|
367
|
+
show`` is where the parse error gets reported.
|
|
368
|
+
"""
|
|
369
|
+
from katalyst_taxonomy.adapters.filesystem.config import FilesystemConfigAdapter
|
|
370
|
+
from katalyst_taxonomy.domain.config_files import CONFIG_FILE
|
|
371
|
+
from katalyst_taxonomy.services.layered_config import LayeredConfigService
|
|
372
|
+
|
|
373
|
+
if output_format is not None:
|
|
374
|
+
return output_format
|
|
375
|
+
service = LayeredConfigService(FilesystemConfigAdapter())
|
|
376
|
+
try:
|
|
377
|
+
resolved = service.resolve_mapping(CONFIG_FILE, service.context())
|
|
378
|
+
except ValueError:
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
spec: object = resolved.data.get("spec")
|
|
382
|
+
if isinstance(spec, Mapping):
|
|
383
|
+
configured: object = cast("Mapping[str, object]", spec).get("outputFormat")
|
|
384
|
+
if isinstance(configured, str) and configured:
|
|
385
|
+
return configured
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _register_source_providers(profile: str | None) -> None:
|
|
390
|
+
"""Make the bundles and plugins ``config.yaml`` declares with a source discoverable.
|
|
391
|
+
|
|
392
|
+
Registered once per process before any command runs, and before plugin activation:
|
|
393
|
+
the configuration is what says they exist. Each is materialised from the shared
|
|
394
|
+
source cache (:mod:`katalyst_taxonomy.services.source_providers`).
|
|
395
|
+
|
|
396
|
+
A source bundle has to reach *three* consumers, not one: the schema registry (so
|
|
397
|
+
its node kinds exist), the template resolver (so its creation and scaffold
|
|
398
|
+
templates resolve), and the source report — all through the single config-aware
|
|
399
|
+
discovery factory the provider registers with.
|
|
400
|
+
"""
|
|
401
|
+
from katalyst_taxonomy.services.source_providers import ensure_source_providers
|
|
402
|
+
|
|
403
|
+
ensure_source_providers(True, profile=profile)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Plugin activation (#219)
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
def _config_port() -> ConfigPort:
|
|
410
|
+
from katalyst_taxonomy.adapters.filesystem.config import FilesystemConfigAdapter
|
|
411
|
+
|
|
412
|
+
return FilesystemConfigAdapter()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _enabled_plugin_predicate(ctx: click.Context) -> Callable[[str], bool]:
|
|
416
|
+
"""Build the enabled-plugin predicate :class:`TaxGroup` gates commands with.
|
|
417
|
+
|
|
418
|
+
Reads ``--profile`` from *ctx* when it has been parsed. On the ``kata tax --help``
|
|
419
|
+
path it has not: ``--help`` is eager, so it fires before the other options are
|
|
420
|
+
processed and ``ctx.params`` is empty; the root ``--profile`` and the documented
|
|
421
|
+
precedence then decide.
|
|
422
|
+
|
|
423
|
+
Never raises — see :func:`katalyst_taxonomy.plugins.startup.plugin_selection_for`.
|
|
424
|
+
A malformed configuration must not make ``kata tax --help`` unusable, and the
|
|
425
|
+
fallback registers every command, which is the pre-#219 behaviour.
|
|
426
|
+
"""
|
|
427
|
+
from katalyst_taxonomy.plugins.startup import plugin_selection_for
|
|
428
|
+
|
|
429
|
+
raw_profile: object = ctx.params.get("profile")
|
|
430
|
+
profile = raw_profile if isinstance(raw_profile, str) else root_profile(ctx)
|
|
431
|
+
|
|
432
|
+
return plugin_selection_for(_config_port(), profile=profile).is_enabled
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _activate_plugins(*, profile: str | None) -> None:
|
|
436
|
+
"""Give plugins a host (#219, part 1).
|
|
437
|
+
|
|
438
|
+
Before this, ``PluginRegistry.activate_all()`` was never called anywhere in
|
|
439
|
+
``src/`` — no plugin had ever received a ``PluginContext`` in production. This
|
|
440
|
+
is the CLI's half of fixing that, placed in the ``tax`` callback next to
|
|
441
|
+
``_register_source_providers`` because both are per-invocation
|
|
442
|
+
startup wiring that every ``tax`` subcommand needs.
|
|
443
|
+
|
|
444
|
+
A plugin that will not activate fails the command: see
|
|
445
|
+
``katalyst_taxonomy/plugins/errors.py`` for where the line between loud and
|
|
446
|
+
tolerant is drawn, and :meth:`KataGroup.invoke` for how the failure is rendered.
|
|
447
|
+
"""
|
|
448
|
+
from katalyst_taxonomy.plugins.startup import activate_plugins_for
|
|
449
|
+
|
|
450
|
+
activate_plugins_for(_config_port(), profile=profile)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _register_core_commands() -> None:
|
|
454
|
+
"""Register all built-in commands."""
|
|
455
|
+
from .commands import (
|
|
456
|
+
api,
|
|
457
|
+
backend,
|
|
458
|
+
bundle,
|
|
459
|
+
clone,
|
|
460
|
+
create,
|
|
461
|
+
deps,
|
|
462
|
+
diff,
|
|
463
|
+
drafts,
|
|
464
|
+
export,
|
|
465
|
+
init,
|
|
466
|
+
kinds,
|
|
467
|
+
lifecycle,
|
|
468
|
+
lint,
|
|
469
|
+
plugin,
|
|
470
|
+
promote,
|
|
471
|
+
query,
|
|
472
|
+
relate,
|
|
473
|
+
relocate,
|
|
474
|
+
remove,
|
|
475
|
+
resolve,
|
|
476
|
+
rollback,
|
|
477
|
+
self_update,
|
|
478
|
+
show,
|
|
479
|
+
status,
|
|
480
|
+
sync,
|
|
481
|
+
update,
|
|
482
|
+
versions,
|
|
483
|
+
)
|
|
484
|
+
from .commands import (
|
|
485
|
+
config as config_cmd,
|
|
486
|
+
)
|
|
487
|
+
from .commands import (
|
|
488
|
+
core as core_cmd,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# `config` is top-level, not under `tax`: config resolution decides where
|
|
492
|
+
# `tax` operates, so it cannot live inside the thing it configures (#212).
|
|
493
|
+
cli.add_command(config_cmd.config)
|
|
494
|
+
# How this machine's REST API may be reached. Top-level for the same reason: it is
|
|
495
|
+
# about the server, not the taxonomy it serves.
|
|
496
|
+
cli.add_command(api.api)
|
|
497
|
+
cli.add_command(self_update.self_group)
|
|
498
|
+
# katalyst-core, the LinkML base every schema module imports. Top-level: it is
|
|
499
|
+
# about the schema, not one taxonomy.
|
|
500
|
+
cli.add_command(core_cmd.core)
|
|
501
|
+
|
|
502
|
+
tax.add_command(init.init)
|
|
503
|
+
tax.add_command(create.create)
|
|
504
|
+
tax.add_command(clone.clone)
|
|
505
|
+
tax.add_command(show.show)
|
|
506
|
+
tax.add_command(kinds.kinds)
|
|
507
|
+
tax.add_command(lint.lint)
|
|
508
|
+
tax.add_command(deps.deps)
|
|
509
|
+
tax.add_command(status.status)
|
|
510
|
+
tax.add_command(rollback.rollback)
|
|
511
|
+
tax.add_command(sync.sync)
|
|
512
|
+
tax.add_command(relate.relate)
|
|
513
|
+
tax.add_command(relate.unrelate)
|
|
514
|
+
tax.add_command(relocate.rename)
|
|
515
|
+
tax.add_command(relocate.move)
|
|
516
|
+
tax.add_command(update.update)
|
|
517
|
+
tax.add_command(lifecycle.delete)
|
|
518
|
+
tax.add_command(lifecycle.restore)
|
|
519
|
+
tax.add_command(lifecycle.import_)
|
|
520
|
+
tax.add_command(query.query)
|
|
521
|
+
tax.add_command(resolve.resolve)
|
|
522
|
+
tax.add_command(promote.promote)
|
|
523
|
+
tax.add_command(diff.diff)
|
|
524
|
+
tax.add_command(drafts.drafts)
|
|
525
|
+
tax.add_command(versions.versions)
|
|
526
|
+
tax.add_command(bundle.bundle)
|
|
527
|
+
tax.add_command(plugin.plugin)
|
|
528
|
+
tax.add_command(export.export)
|
|
529
|
+
tax.add_command(backend.backend)
|
|
530
|
+
tax.add_command(remove.remove)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def resolve_plugin_commands() -> None:
|
|
534
|
+
"""Force :class:`TaxGroup`'s lazy plugin-command resolution now.
|
|
535
|
+
|
|
536
|
+
Plugin commands are resolved on first lookup, not at import (#219), which is
|
|
537
|
+
what lets ``enabled: false`` remove one. Anything that needs the *whole*
|
|
538
|
+
command tree without invoking Click — the static signature guard in
|
|
539
|
+
``cli/tests/test_command_signatures.py``, for one — calls this. Memoised, so
|
|
540
|
+
calling it and then running a command does not resolve twice.
|
|
541
|
+
"""
|
|
542
|
+
tax.list_commands(click.Context(tax))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# Wire everything up at import time.
|
|
546
|
+
#
|
|
547
|
+
# Core commands only: plugin-contributed commands are resolved by `TaxGroup` on
|
|
548
|
+
# first lookup, because `spec.plugins` has not been read yet at import time and
|
|
549
|
+
# gating a command needs it (#219).
|
|
550
|
+
_register_core_commands()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands package."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""``kata api`` — how this machine's taxonomy REST API may be reached.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
kata api cors list the browser origins allowed to call the API
|
|
6
|
+
kata api cors add ORIGIN allow one (scheme://host[:port], exact)
|
|
7
|
+
kata api cors remove ORIGIN stop allowing it
|
|
8
|
+
|
|
9
|
+
CORS is closed unless an origin is allowed. The allowlist is kept in the state home
|
|
10
|
+
(``api.json``, beside the API's ``server.json``) and read by a running API on every browser
|
|
11
|
+
request, so a change applies at once. Bard needs no entry: its pages call Bard's own
|
|
12
|
+
server, which calls the API.
|
|
13
|
+
|
|
14
|
+
Every request to the API carries its token (``Authorization: Bearer``); a client on this
|
|
15
|
+
machine reads it from ``server.json``, one elsewhere is given it in ``KATALYST_API_TOKEN``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from ..output import ConsoleOutput, OutputPort
|
|
23
|
+
|
|
24
|
+
__all__ = ["api"]
|
|
25
|
+
|
|
26
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
27
|
+
|
|
28
|
+
_APPLIES = "A running API on this machine applies it at once."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _output() -> OutputPort:
|
|
32
|
+
return ConsoleOutput()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group("api")
|
|
36
|
+
def api() -> None:
|
|
37
|
+
"""How the taxonomy REST API may be reached: its CORS allowlist."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@api.group("cors")
|
|
41
|
+
def cors() -> None:
|
|
42
|
+
"""The browser origins allowed to call the API. Closed unless one is added."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cors.command("list")
|
|
46
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
47
|
+
def cors_list(fmt: str) -> None:
|
|
48
|
+
"""List the allowed origins."""
|
|
49
|
+
from katalyst_taxonomy.adapters.orchestration import api_cors_service
|
|
50
|
+
|
|
51
|
+
service = api_cors_service()
|
|
52
|
+
try:
|
|
53
|
+
origins = service.list()
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise click.ClickException(str(exc)) from exc
|
|
56
|
+
out = _output()
|
|
57
|
+
if fmt == "json":
|
|
58
|
+
out.json_out({"origins": list(origins), "path": str(service.location)})
|
|
59
|
+
return
|
|
60
|
+
if not origins:
|
|
61
|
+
out.info("CORS is closed: no browser origin may call the API.")
|
|
62
|
+
return
|
|
63
|
+
for origin in origins:
|
|
64
|
+
out.echo(origin)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cors.command("add")
|
|
68
|
+
@click.argument("origin")
|
|
69
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
70
|
+
def cors_add(origin: str, fmt: str) -> None:
|
|
71
|
+
"""Allow pages from ORIGIN (scheme://host[:port]) to call the API."""
|
|
72
|
+
from katalyst_taxonomy.adapters.orchestration import api_cors_service
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
change = api_cors_service().add(origin)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise click.ClickException(str(exc)) from exc
|
|
78
|
+
out = _output()
|
|
79
|
+
if fmt == "json":
|
|
80
|
+
out.json_out(change.as_payload())
|
|
81
|
+
elif change.changed:
|
|
82
|
+
out.success(f"Allowed {change.origin}. {_APPLIES}")
|
|
83
|
+
else:
|
|
84
|
+
out.info(f"{change.origin} is already allowed.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cors.command("remove")
|
|
88
|
+
@click.argument("origin")
|
|
89
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
90
|
+
def cors_remove(origin: str, fmt: str) -> None:
|
|
91
|
+
"""Stop allowing pages from ORIGIN to call the API."""
|
|
92
|
+
from katalyst_taxonomy.adapters.orchestration import api_cors_service
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
change = api_cors_service().remove(origin)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise click.ClickException(str(exc)) from exc
|
|
98
|
+
out = _output()
|
|
99
|
+
if fmt == "json":
|
|
100
|
+
out.json_out(change.as_payload())
|
|
101
|
+
elif change.changed:
|
|
102
|
+
out.success(f"Removed {change.origin}. {_APPLIES}")
|
|
103
|
+
else:
|
|
104
|
+
out.info(f"{change.origin} was not allowed.")
|