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.
Files changed (51) hide show
  1. katalyst_taxonomy_cli/__init__.py +14 -0
  2. katalyst_taxonomy_cli/__main__.py +12 -0
  3. katalyst_taxonomy_cli/_wiring.py +248 -0
  4. katalyst_taxonomy_cli/app.py +550 -0
  5. katalyst_taxonomy_cli/commands/__init__.py +1 -0
  6. katalyst_taxonomy_cli/commands/api.py +104 -0
  7. katalyst_taxonomy_cli/commands/backend.py +96 -0
  8. katalyst_taxonomy_cli/commands/bundle.py +106 -0
  9. katalyst_taxonomy_cli/commands/clone.py +68 -0
  10. katalyst_taxonomy_cli/commands/config.py +748 -0
  11. katalyst_taxonomy_cli/commands/config_filters.py +159 -0
  12. katalyst_taxonomy_cli/commands/config_home.py +179 -0
  13. katalyst_taxonomy_cli/commands/config_plugins.py +180 -0
  14. katalyst_taxonomy_cli/commands/config_profile.py +604 -0
  15. katalyst_taxonomy_cli/commands/config_schemas.py +257 -0
  16. katalyst_taxonomy_cli/commands/config_sources.py +141 -0
  17. katalyst_taxonomy_cli/commands/core.py +468 -0
  18. katalyst_taxonomy_cli/commands/create.py +145 -0
  19. katalyst_taxonomy_cli/commands/deps.py +48 -0
  20. katalyst_taxonomy_cli/commands/diff.py +63 -0
  21. katalyst_taxonomy_cli/commands/drafts.py +63 -0
  22. katalyst_taxonomy_cli/commands/export.py +186 -0
  23. katalyst_taxonomy_cli/commands/init.py +84 -0
  24. katalyst_taxonomy_cli/commands/kinds.py +302 -0
  25. katalyst_taxonomy_cli/commands/lifecycle.py +127 -0
  26. katalyst_taxonomy_cli/commands/lint.py +256 -0
  27. katalyst_taxonomy_cli/commands/plugin.py +148 -0
  28. katalyst_taxonomy_cli/commands/promote.py +95 -0
  29. katalyst_taxonomy_cli/commands/query.py +187 -0
  30. katalyst_taxonomy_cli/commands/relate.py +137 -0
  31. katalyst_taxonomy_cli/commands/relocate.py +129 -0
  32. katalyst_taxonomy_cli/commands/remove.py +95 -0
  33. katalyst_taxonomy_cli/commands/resolve.py +48 -0
  34. katalyst_taxonomy_cli/commands/rollback.py +51 -0
  35. katalyst_taxonomy_cli/commands/self_update.py +530 -0
  36. katalyst_taxonomy_cli/commands/show.py +33 -0
  37. katalyst_taxonomy_cli/commands/status.py +107 -0
  38. katalyst_taxonomy_cli/commands/sync.py +60 -0
  39. katalyst_taxonomy_cli/commands/update.py +112 -0
  40. katalyst_taxonomy_cli/commands/versions.py +54 -0
  41. katalyst_taxonomy_cli/context.py +145 -0
  42. katalyst_taxonomy_cli/discovery.py +157 -0
  43. katalyst_taxonomy_cli/fields.py +70 -0
  44. katalyst_taxonomy_cli/install.py +330 -0
  45. katalyst_taxonomy_cli/options.py +20 -0
  46. katalyst_taxonomy_cli/output.py +92 -0
  47. katalyst_taxonomy_cli/py.typed +0 -0
  48. katalyst_taxonomy_cli-2.0.1a1.dist-info/METADATA +12 -0
  49. katalyst_taxonomy_cli-2.0.1a1.dist-info/RECORD +51 -0
  50. katalyst_taxonomy_cli-2.0.1a1.dist-info/WHEEL +4 -0
  51. 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.")