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,159 @@
1
+ """``kata config filter`` — is this node visible, and which clause decided (#217).
2
+
3
+ Include/exclude resolution is invisible by nature: nothing in an FQTN says which
4
+ clause claimed it, and "``exclude`` wins over every ``include``" is not something
5
+ anyone should have to simulate in their head against a config file. This command is
6
+ the inspection surface, and it answers from the *same*
7
+ :class:`~katalyst_taxonomy.config.filters.FilterPolicy` object the read paths
8
+ narrow with — a verdict resolved by a second code path is not an explanation of
9
+ the first.
10
+
11
+ Deliberately a **separate module** from :mod:`.config`, following the pattern
12
+ ``config_schemas`` and ``config_plugins`` established: it is added to the group by
13
+ one line there, so this command lands without touching the file that owns
14
+ ``path``/``show``/``sources``/``profiles``/``use``/``explain``/``permissions``.
15
+
16
+ The JSON shape is the contract in
17
+ ``stack-tests/features/cli/35_kata_config_filters.feature``. Extend it additively.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import click
26
+ from katalyst_taxonomy.config.profiles import UnknownProfileError
27
+
28
+ from ..output import ConsoleOutput, OutputPort
29
+ from .config import resolve_profile
30
+
31
+ __all__ = [
32
+ "attach",
33
+ "build_filter_payload",
34
+ "filter_command",
35
+ ]
36
+
37
+ _FORMAT_CHOICE = click.Choice(["text", "json"])
38
+
39
+
40
+ def build_filter_payload(
41
+ fqtn: str = "",
42
+ *,
43
+ profile: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Build the ``kata config filter`` payload.
46
+
47
+ Delegates wholesale to
48
+ :func:`katalyst_taxonomy.services.filters.filter_payload` rather than
49
+ assembling anything here, which is what lets the MCP ``config_filter`` tool be
50
+ byte-identical without ``mcp/`` importing ``cli/``.
51
+
52
+ Raises:
53
+ UnknownProfileError: If the profile cannot be selected.
54
+ ValueError: If the configuration is malformed, if ``filters.include`` was
55
+ declared as an empty list, or if a clause declares ``match.backend``.
56
+ """
57
+ from katalyst_taxonomy.services.filters import filter_payload
58
+
59
+ return filter_payload(fqtn, base_path=Path.cwd(), profile=profile)
60
+
61
+
62
+ def _output() -> OutputPort:
63
+ return ConsoleOutput()
64
+
65
+
66
+ @click.command("filter")
67
+ @click.option(
68
+ "--fqtn",
69
+ default=None,
70
+ help="Node FQTN to decide. Omitted, only the declared clauses are listed.",
71
+ )
72
+ @click.option(
73
+ "--profile",
74
+ "profile",
75
+ default=None,
76
+ help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
77
+ )
78
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
79
+ def filter_command(
80
+ fqtn: str | None,
81
+ profile: str | None,
82
+ fmt: str,
83
+ ) -> None:
84
+ """Report whether an FQTN is visible under its backend's filters, and why.
85
+
86
+ Filters live on backends and apply in every profile that reads them. Resolution is: criteria AND within a clause, clauses OR across a list, and
87
+ ``exclude`` always beats ``include``. An absent ``include`` list admits
88
+ everything; ``include: []`` is refused outright, because an empty list cannot
89
+ be told apart from an omitted one.
90
+
91
+ A backend with no ``filters`` block reports every node as included with a null
92
+ clause and ``clauseType: unfiltered`` — that is the back-compat guarantee, not
93
+ an omission.
94
+
95
+ Pure resolution: it reads the repository's own documents to learn each node's
96
+ kind, wing and labels, and never opens a connection to a remote store. An FQTN
97
+ with no node behind it still resolves — the criteria that need facts simply
98
+ cannot match, which is fail-closed.
99
+ """
100
+ out = _output()
101
+ try:
102
+ payload = build_filter_payload(fqtn or "", profile=resolve_profile(profile))
103
+ except (UnknownProfileError, ValueError) as exc:
104
+ raise click.ClickException(str(exc)) from exc
105
+
106
+ if fmt == "json":
107
+ out.json_out(payload)
108
+ return
109
+
110
+ _render_text(out, payload, asked=bool(fqtn))
111
+
112
+
113
+ def _render_text(out: OutputPort, payload: dict[str, Any], *, asked: bool) -> None:
114
+ """Render the human view: the verdict, then every declared clause."""
115
+ if asked:
116
+ out.key_value(
117
+ [
118
+ ("fqtn", payload["fqtn"]),
119
+ ("backend", payload["backend"] or "-"),
120
+ ("visible", "yes" if payload["included"] else "NO"),
121
+ ("decided by", payload["clauseType"]),
122
+ ("clause", payload["clause"] or "-"),
123
+ ("matched on", payload["matchedOn"] or "-"),
124
+ ("profile", payload["profile"] or "(none)"),
125
+ ("source", payload["source"] or "-"),
126
+ ]
127
+ )
128
+ else:
129
+ out.key_value([("profile", payload["profile"] or "(none)")])
130
+
131
+ clauses: list[dict[str, Any]] = payload["backendClauses"]
132
+ if not clauses:
133
+ out.info("No backend declares filter clauses — every node is visible.")
134
+ return
135
+
136
+ out.heading("Backend clauses")
137
+ out.table(
138
+ ["backend", "type", "clause", "match", "source"],
139
+ [
140
+ [
141
+ str(entry["backend"]),
142
+ str(entry["type"]),
143
+ str(entry["name"] or "-"),
144
+ ", ".join(f"{key}={value}" for key, value in entry["match"].items()) or "*",
145
+ str(entry["source"] or "-"),
146
+ ]
147
+ for entry in clauses
148
+ ],
149
+ )
150
+
151
+
152
+ def attach(group: click.Group) -> None:
153
+ """Add ``filter`` to the ``kata config`` group.
154
+
155
+ A function rather than a bare ``add_command`` call so registration is one line
156
+ in ``commands/config.py`` and this module stays the only place that knows the
157
+ command's name.
158
+ """
159
+ group.add_command(filter_command)
@@ -0,0 +1,179 @@
1
+ """``kata config home`` — list, create and remove named Katalyst homes (#257).
2
+
3
+ A home is the config, state and cache a process uses (:mod:`katalyst_taxonomy.domain.homes`).
4
+ ``kata --home NAME`` or ``KATALYST_HOME=NAME`` selects one; a named home must exist first::
5
+
6
+ kata config home list
7
+ kata config home create NAME [--from HOME]
8
+ kata config home rm NAME [--yes]
9
+
10
+ Path homes (an absolute ``KATALYST_HOME``, for tests and CI) are never listed: they are
11
+ wherever their caller put them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ from typing import Any
20
+
21
+ import click
22
+ from katalyst_taxonomy.adapters.filesystem.homes import (
23
+ create_home,
24
+ home_for,
25
+ named_homes,
26
+ )
27
+ from katalyst_taxonomy.domain.homes import (
28
+ DEFAULT_HOME,
29
+ Home,
30
+ HomeError,
31
+ named_home,
32
+ resolve_home,
33
+ )
34
+ from katalyst_taxonomy.domain.paths import CONFIG_FILE_NAME
35
+
36
+ from ..output import ConsoleOutput
37
+
38
+ __all__ = ["attach", "home"]
39
+
40
+ _FORMAT_CHOICE = click.Choice(["text", "json"])
41
+
42
+
43
+ #: The running API's state record in a home's state directory (one API per home), as
44
+ #: ``katalyst_taxonomy_mcp.rest.state`` writes it.
45
+ _API_RECORD = "server.json"
46
+
47
+
48
+ def _running_api(home: Home) -> dict[str, Any] | None:
49
+ """The API whose state record is in *home*, when its process is alive."""
50
+ try:
51
+ record = json.loads((home.state_dir / _API_RECORD).read_text(encoding="utf-8"))
52
+ pid, host, port = int(record["pid"]), str(record["host"]), int(record["port"])
53
+ except (OSError, ValueError, KeyError, TypeError):
54
+ return None
55
+ try:
56
+ os.kill(pid, 0)
57
+ except OSError:
58
+ return None
59
+ return {"pid": pid, "url": f"http://{host}:{port}"}
60
+
61
+
62
+ def _same(a: Home, b: Home) -> bool:
63
+ return a.kind == b.kind and a.root == b.root
64
+
65
+
66
+ def build_list_payload() -> dict[str, Any]:
67
+ """Every listed home: the default one, then ``~/.katalyst/homes/*``."""
68
+ active = resolve_home()
69
+ homes = [home_for(DEFAULT_HOME), *named_homes()]
70
+ return {
71
+ "active": active.to_dict(),
72
+ "homes": [
73
+ {
74
+ "name": home.name,
75
+ "kind": home.kind.value,
76
+ "root": None if home.root is None else str(home.root),
77
+ "active": _same(home, active),
78
+ "api": _running_api(home),
79
+ }
80
+ for home in homes
81
+ ],
82
+ }
83
+
84
+
85
+ @click.group("home")
86
+ def home() -> None:
87
+ """List, create and remove named homes (~/.katalyst/homes/NAME).
88
+
89
+ Select one with 'kata --home NAME' or KATALYST_HOME=NAME. 'default' is the XDG
90
+ locations (~/.config/katalyst, ~/.local/state/katalyst, ~/.cache/katalyst).
91
+ """
92
+
93
+
94
+ @home.command("list")
95
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
96
+ def home_list(fmt: str) -> None:
97
+ """List the default home and every named home, marking the active one."""
98
+ payload = build_list_payload()
99
+ out = ConsoleOutput()
100
+ if fmt == "json":
101
+ out.json_out(payload)
102
+ return
103
+ out.table(
104
+ ["", "home", "root", "api"],
105
+ [
106
+ [
107
+ "*" if entry["active"] else "",
108
+ entry["name"],
109
+ entry["root"] or "(XDG locations)",
110
+ entry["api"]["url"] if entry["api"] else "-",
111
+ ]
112
+ for entry in payload["homes"]
113
+ ],
114
+ )
115
+ active = payload["active"]
116
+ if active["kind"] == "path":
117
+ out.info(f"Active: {active['name']} (a path home, not listed)")
118
+
119
+
120
+ @home.command("create")
121
+ @click.argument("name")
122
+ @click.option(
123
+ "--from",
124
+ "source",
125
+ default=None,
126
+ metavar="HOME",
127
+ help="Copy config.yaml (only) from this home: 'default', a name, or an absolute path.",
128
+ )
129
+ def home_create(name: str, source: str | None) -> None:
130
+ """Create the named home NAME, empty or with another home's config.yaml."""
131
+ try:
132
+ origin = home_for(source) if source is not None else None
133
+ created = create_home(name)
134
+ except HomeError as exc:
135
+ raise click.ClickException(str(exc)) from exc
136
+ copied = ""
137
+ if origin is not None:
138
+ config_file = origin.config_dir / CONFIG_FILE_NAME
139
+ if config_file.is_file():
140
+ shutil.copy2(config_file, created.config_dir / CONFIG_FILE_NAME)
141
+ copied = f", with config.yaml from {origin.name}"
142
+ ConsoleOutput().success(f"Created home {created.label}{copied}")
143
+
144
+
145
+ @home.command("rm")
146
+ @click.argument("name")
147
+ @click.option("--yes", is_flag=True, help="Do not ask before deleting.")
148
+ def home_rm(name: str, yes: bool) -> None:
149
+ """Delete the named home NAME: its config, state and cache.
150
+
151
+ Refuses the default home, the active home, and a home whose API is running.
152
+ """
153
+ try:
154
+ target = named_home(name)
155
+ except HomeError as exc:
156
+ raise click.ClickException(str(exc)) from exc
157
+ root = target.root
158
+ if root is None or not root.is_dir():
159
+ raise click.ClickException(f"home {name!r} does not exist")
160
+ if _same(target, resolve_home()):
161
+ raise click.ClickException(f"home {name!r} is the active home; select another first")
162
+ api = _running_api(target)
163
+ if api is not None:
164
+ raise click.ClickException(
165
+ f"home {name!r} has a running API ({api['url']}, pid {api['pid']}); stop it first"
166
+ )
167
+ out = ConsoleOutput()
168
+ out.info(f"This deletes {root} and everything in it:")
169
+ for entry in sorted(root.iterdir()):
170
+ out.info(f" {entry.name}{'/' if entry.is_dir() else ''}")
171
+ if not yes and not click.confirm(f"Delete home {name!r}?", default=False):
172
+ raise click.ClickException("not deleted")
173
+ shutil.rmtree(root)
174
+ out.success(f"Deleted home {name!r}")
175
+
176
+
177
+ def attach(group: click.Group) -> None:
178
+ """Add ``home`` to the ``kata config`` group."""
179
+ group.add_command(home)
@@ -0,0 +1,180 @@
1
+ """``kata config plugins`` — which plugins ran, in what order, with what config (#219).
2
+
3
+ Deliberately a **separate module** from :mod:`.config`: it is added to the
4
+ ``config`` group by one line there, so this command can land without touching the
5
+ file that owns ``path``/``show``/``sources``/``profiles``/``use``.
6
+
7
+ Register it with a single line at the end of ``commands/config.py``::
8
+
9
+ from .config_plugins import attach as _attach_plugins; _attach_plugins(config)
10
+
11
+ The JSON shape is the contract in
12
+ ``stack-tests/features/cli/37_kata_config_plugins.feature``. Extend it additively.
13
+
14
+ The command is a *report*, but not a read-only one in the interesting sense:
15
+ building the payload **activates the plugins**, so a plugin that raises during
16
+ ``register()`` or a ``spec.plugins`` entry naming something that is not installed
17
+ fails this command. That is the point — ``kata config plugins`` is where you find
18
+ out, and a report that said "activated: true" without having activated anything
19
+ would be exactly the illusion this issue exists to remove.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ import click
27
+ from katalyst_taxonomy.config.profiles import UnknownProfileError
28
+ from katalyst_taxonomy.plugins.errors import PluginError
29
+
30
+ from ..output import ConsoleOutput, OutputPort
31
+
32
+ __all__ = [
33
+ "attach",
34
+ "build_plugins_payload",
35
+ "plugins",
36
+ ]
37
+
38
+ _FORMAT_CHOICE = click.Choice(["text", "json"])
39
+
40
+
41
+ def build_plugins_payload(
42
+ *,
43
+ profile: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Build the ``kata config plugins`` payload.
46
+
47
+ Resolves the configuration, activates plugins under the resulting selection,
48
+ then reports what actually happened. Activation is process-memoised, so when
49
+ this runs inside a process that already activated — it does not today, since
50
+ ``config`` is not under ``tax``, but a future embedding might — the report
51
+ describes that activation rather than performing a second one.
52
+
53
+ Raises:
54
+ UnknownProfileError: If the profile cannot be selected.
55
+ ValueError: If the configuration is malformed.
56
+ PluginError: If a declared plugin is unknown, cannot be loaded, or raised
57
+ during ``register()``.
58
+ """
59
+ from katalyst_engine.events.bus import EventBus
60
+ from katalyst_taxonomy.config.backends import config_resolver
61
+ from katalyst_taxonomy.config.plugins import selection_from_config
62
+ from katalyst_taxonomy.plugins.activation import activate_once, plugins_payload
63
+ from katalyst_taxonomy.plugins.startup import plugin_schema_registry
64
+
65
+ effective = config_resolver().resolve(profile=profile)
66
+ selection = selection_from_config(effective.config, effective.selection.profile)
67
+
68
+ activation = activate_once(
69
+ selection=selection,
70
+ schema_registry=plugin_schema_registry(),
71
+ event_bus=EventBus(),
72
+ )
73
+ return plugins_payload(
74
+ activation,
75
+ profile=effective.profile_name,
76
+ config_origin=effective.origin.value,
77
+ )
78
+
79
+
80
+ def _output() -> OutputPort:
81
+ return ConsoleOutput()
82
+
83
+
84
+ @click.command("plugins")
85
+ @click.option(
86
+ "--profile",
87
+ "profile",
88
+ default=None,
89
+ help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
90
+ )
91
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
92
+ def plugins(profile: str | None, fmt: str) -> None:
93
+ """List plugins, whether they activated, their order, and their config.
94
+
95
+ Every discovered plugin is listed even when configuration disables it — a
96
+ report that hid a disabled plugin would make "why is this plugin not doing
97
+ anything" unanswerable, which is the opposite of what this command is for.
98
+
99
+ ``activated`` means the plugin's ``register()`` was called and returned.
100
+ """
101
+ # Imported here, not at module scope: ``commands/config.py`` attaches this
102
+ # command at the end of its own module body, so a module-level
103
+ # ``from .config import resolve_profile`` makes importing *this* module first
104
+ # fail with a partially-initialised-module ImportError. Deferring mirrors
105
+ # ``resolve_profile`` itself, which defers ``from ..app import root_profile``
106
+ # for the same reason.
107
+ from .config import resolve_profile
108
+
109
+ out = _output()
110
+ try:
111
+ payload = build_plugins_payload(profile=resolve_profile(profile))
112
+ except (
113
+ UnknownProfileError,
114
+ PluginError,
115
+ ValueError,
116
+ ) as exc:
117
+ raise click.ClickException(str(exc)) from exc
118
+
119
+ if fmt == "json":
120
+ out.json_out(payload)
121
+ return
122
+
123
+ _render_text(out, payload)
124
+
125
+
126
+ def _render_text(out: OutputPort, payload: dict[str, Any]) -> None:
127
+ """Render the human view: the resolved context, then plugins, then the order."""
128
+ out.key_value(
129
+ [
130
+ ("profile", payload["profile"] or "(none)"),
131
+ ("config origin", payload["configOrigin"] or "-"),
132
+ ("plugins declared", "yes" if payload["declared"] else "no"),
133
+ ]
134
+ )
135
+
136
+ out.heading("Plugins")
137
+ entries: list[dict[str, Any]] = payload["plugins"]
138
+ if not entries:
139
+ out.info("No plugins discovered.")
140
+ else:
141
+ out.table(
142
+ ["", "plugin", "version", "priority", "capabilities", "note"],
143
+ [
144
+ [
145
+ "*" if entry["activated"] else "x",
146
+ str(entry["name"]),
147
+ str(entry["version"] or "-"),
148
+ "-" if entry["priority"] is None else str(entry["priority"]),
149
+ ", ".join(entry["capabilities"]) or "-",
150
+ str(entry["reason"] or entry["entryPoint"] or "-"),
151
+ ]
152
+ for entry in entries
153
+ ],
154
+ )
155
+
156
+ order: list[str] = payload["activationOrder"]
157
+ out.heading("Activation order")
158
+ out.echo(" → ".join(order) if order else "(nothing activated)")
159
+
160
+ configured = [entry for entry in entries if entry["config"]]
161
+ if configured:
162
+ out.heading("Configuration")
163
+ out.table(
164
+ ["plugin", "key", "value"],
165
+ [
166
+ [str(entry["name"]), str(key), str(value)]
167
+ for entry in configured
168
+ for key, value in sorted(entry["config"].items())
169
+ ],
170
+ )
171
+
172
+
173
+ def attach(group: click.Group) -> None:
174
+ """Add ``plugins`` to the ``kata config`` group.
175
+
176
+ A function rather than a bare ``add_command`` call so registration is one line
177
+ in ``commands/config.py`` and this module stays the only place that knows the
178
+ command's name.
179
+ """
180
+ group.add_command(plugins)