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,257 @@
1
+ """``kata config schemas`` — which bundle provides what, and is it on (#218).
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_schemas import attach as _attach_schemas; _attach_schemas(config)
10
+
11
+ The JSON shape is the contract in
12
+ ``stack-tests/features/cli/36_kata_config_schemas.feature``. Extend it additively.
13
+
14
+ The command is a *report*, but it is not a read-only one in the interesting sense:
15
+ building the payload builds the registry, so a version pin that is not satisfied,
16
+ two bundles claiming one kind, or a declared bundle that cannot be parsed all fail
17
+ the command. That is the point — ``kata config schemas`` is where you find out.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import click
25
+ from katalyst_engine.core.errors import EngineConfigurationError
26
+ from katalyst_taxonomy.config.profiles import UnknownProfileError
27
+
28
+ from ..output import ConsoleOutput, OutputPort
29
+
30
+ __all__ = [
31
+ "attach",
32
+ "build_schemas_payload",
33
+ "schemas",
34
+ ]
35
+
36
+ _FORMAT_CHOICE = click.Choice(["text", "json"])
37
+
38
+
39
+ def build_schemas_payload(
40
+ *,
41
+ profile: str | None = None,
42
+ ) -> dict[str, Any]:
43
+ """Build the ``kata config schemas`` payload.
44
+
45
+ Resolves the configuration, installs the resulting selection into the process
46
+ so the registry is built under it, then composes the report from registry
47
+ provenance plus the one config-aware discovery factory.
48
+
49
+ Raises:
50
+ UnknownProfileError: If the profile cannot be selected.
51
+ ValueError: If the configuration is malformed.
52
+ EngineConfigurationError: If a version pin is unsatisfied, a declared
53
+ bundle's manifest cannot be read, or two bundles claim the same kind
54
+ or the same install destination.
55
+ RuntimeError: If a declared bundle cannot be loaded.
56
+ """
57
+ from katalyst_taxonomy.adapters.filesystem.real import RealFilesystem
58
+ from katalyst_taxonomy.adapters.orchestration import template_bundles
59
+ from katalyst_taxonomy.config.backends import config_resolver
60
+ from katalyst_taxonomy.config.schemas import selection_from_config
61
+ from katalyst_taxonomy.engine import bridge
62
+ from katalyst_taxonomy.services.schema_gate import check_declared_bundles
63
+ from katalyst_taxonomy.services.schema_report import schemas_payload
64
+ from katalyst_taxonomy.sources.template_resolver import TemplateResolver
65
+
66
+ effective = config_resolver().resolve(profile=profile)
67
+ selection = selection_from_config(effective.config, effective.selection.profile)
68
+
69
+ bridge.configure_schema_bundles(selection)
70
+ try:
71
+ # Probing the declared sources is what makes an unreadable manifest and an
72
+ # install-target collision visible. It is opt-in per caller because it is
73
+ # I/O, and ``kata config schemas`` is the caller that exists to pay for it.
74
+ declared = check_declared_bundles(
75
+ selection, repo_root=None, templates=template_bundles(), probe_sources=True
76
+ )
77
+ factory = bridge.bundle_discovery_factory()
78
+ registry = bridge.get_default_registry()
79
+ resolver = TemplateResolver()
80
+ candidates = factory.candidates()
81
+ return schemas_payload(
82
+ selection=selection,
83
+ registry=registry,
84
+ bundle_names={
85
+ candidate.name: candidate.discovered.entry_point for candidate in candidates
86
+ },
87
+ distributions={
88
+ candidate.name: candidate.distribution
89
+ for candidate in candidates
90
+ if candidate.distribution
91
+ },
92
+ declared=declared,
93
+ scaffold_providers=resolver.scaffold_template_providers(),
94
+ repo_root=None,
95
+ filesystem=RealFilesystem(),
96
+ profile=effective.profile_name,
97
+ config_origin=effective.origin.value,
98
+ )
99
+ finally:
100
+ bridge.configure_schema_bundles(None)
101
+
102
+
103
+ def _output() -> OutputPort:
104
+ return ConsoleOutput()
105
+
106
+
107
+ @click.command("schemas")
108
+ @click.option(
109
+ "--profile",
110
+ "profile",
111
+ default=None,
112
+ help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
113
+ )
114
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
115
+ def schemas(profile: str | None, fmt: str) -> None:
116
+ """List schema bundles, the kinds they provide, and which are enabled.
117
+
118
+ Every kind is attributed to the bundle that registered it — both by the name
119
+ ``spec.schemas`` uses to select it and by the distribution you install it from
120
+ — and every declared bundle is listed even when the active profile excludes
121
+ it, because a report that hid a disabled bundle would make "why is my kind
122
+ missing" unanswerable. Variants the profile excludes move to
123
+ ``disabledScaffoldVariants``, with the reason, so ``scaffoldVariants`` means
124
+ "what you can actually scaffold".
125
+
126
+ Fails, rather than reporting, when the configuration cannot be honoured: an
127
+ unsatisfied version pin, a declared bundle whose manifest cannot be read, or
128
+ two bundles claiming one kind or one shared install destination.
129
+ """
130
+ out = _output()
131
+ try:
132
+ payload = build_schemas_payload(profile=_selected_profile(profile))
133
+ except (
134
+ UnknownProfileError,
135
+ EngineConfigurationError,
136
+ RuntimeError,
137
+ ValueError,
138
+ ) as exc:
139
+ raise click.ClickException(str(exc)) from exc
140
+
141
+ if fmt == "json":
142
+ out.json_out(payload)
143
+ return
144
+
145
+ _render_text(out, payload)
146
+
147
+
148
+ def _render_text(out: OutputPort, payload: dict[str, Any]) -> None:
149
+ """Render the human view: bundles, then kinds, then variants, then conflicts."""
150
+ out.key_value(
151
+ [
152
+ ("profile", payload["profile"] or "(none)"),
153
+ ("config origin", payload["configOrigin"] or "-"),
154
+ ("schemas declared", "yes" if payload["declared"] else "no"),
155
+ ]
156
+ )
157
+
158
+ out.heading("Bundles")
159
+ bundles: list[dict[str, Any]] = payload["bundles"]
160
+ if not bundles:
161
+ out.info("No schema bundles discovered.")
162
+ else:
163
+ out.table(
164
+ ["", "bundle", "distribution", "version", "kinds", "note"],
165
+ [
166
+ [
167
+ "*" if entry["enabled"] else "x",
168
+ str(entry["name"]),
169
+ str(entry.get("distribution") or "-"),
170
+ str(entry.get("resolvedVersion") or entry["version"] or "-"),
171
+ str(len(entry["kinds"])),
172
+ str(
173
+ entry["reason"] or entry.get("sourceWarning") or entry["entryPoint"] or "-"
174
+ ),
175
+ ]
176
+ for entry in bundles
177
+ ],
178
+ )
179
+
180
+ out.heading("Kinds")
181
+ out.table(
182
+ ["kind", "bundle", "distribution", "wing"],
183
+ [
184
+ [
185
+ str(entry["kind"]),
186
+ str(entry["bundle"] or "-"),
187
+ str(entry.get("distribution") or "-"),
188
+ str(entry["wing"] or "-"),
189
+ ]
190
+ for entry in payload["kinds"]
191
+ ],
192
+ )
193
+
194
+ variants: list[dict[str, Any]] = payload["scaffoldVariants"]
195
+ if variants:
196
+ out.heading("Scaffold variants")
197
+ out.table(
198
+ ["variant", "node type", "bundle", "origin"],
199
+ [
200
+ [
201
+ str(entry["name"]),
202
+ str(entry["nodeType"]),
203
+ str(entry["bundle"]),
204
+ str(entry["origin"]),
205
+ ]
206
+ for entry in variants
207
+ ],
208
+ )
209
+
210
+ # Rendered as its own section rather than as ``x`` rows above, for the same
211
+ # reason the payload splits the two keys: a reader scanning "what can I
212
+ # scaffold?" should not have to filter, and a reader asking "why is my variant
213
+ # missing?" needs the reason, which only this section has room for.
214
+ disabled: list[dict[str, Any]] = payload.get("disabledScaffoldVariants", [])
215
+ if disabled:
216
+ out.heading("Disabled scaffold variants")
217
+ out.table(
218
+ ["variant", "node type", "bundle", "reason"],
219
+ [
220
+ [
221
+ str(entry["name"]),
222
+ str(entry["nodeType"]),
223
+ str(entry["bundle"]),
224
+ str(entry["reason"] or "-"),
225
+ ]
226
+ for entry in disabled
227
+ ],
228
+ )
229
+
230
+ conflicts: list[dict[str, Any]] = payload["conflicts"]
231
+ if conflicts:
232
+ out.heading("Conflicts")
233
+ out.table(
234
+ ["scope", "key", "providers"],
235
+ [
236
+ [str(entry["scope"]), str(entry["key"]), ", ".join(entry["providers"])]
237
+ for entry in conflicts
238
+ ],
239
+ )
240
+
241
+
242
+ def attach(group: click.Group) -> None:
243
+ """Add ``schemas`` to the ``kata config`` group.
244
+
245
+ A function rather than a bare ``add_command`` call so registration is one line
246
+ in ``commands/config.py`` and this module stays the only place that knows the
247
+ command's name.
248
+ """
249
+ group.add_command(schemas)
250
+
251
+
252
+ def _selected_profile(explicit: str | None) -> str | None:
253
+ # Imported here: ``config`` attaches this module's command at import time, so a
254
+ # module-level import back into it is a cycle whenever this module loads first.
255
+ from .config import resolve_profile
256
+
257
+ return resolve_profile(explicit)
@@ -0,0 +1,141 @@
1
+ """``kata config sync`` and ``kata config status`` — profiles sourced from elsewhere.
2
+
3
+ A profile may name a ``source`` (a ``git:``/``github:`` repository holding a config file,
4
+ or another kata API) instead of declaring its backends. These commands are thin shells
5
+ over :class:`~katalyst_taxonomy.services.config_sources.ConfigSourceService`, the same
6
+ service the MCP ``config_sync``/``config_status`` tools and REST ``/config/sources`` call::
7
+
8
+ kata config sync [PROFILE...] fetch each sourced profile and pin it
9
+ kata config status [--offline] each sourced profile's source, pin and last fetch,
10
+ and whether the source is ahead of the pin
11
+
12
+ The pin and the fetched profile are kept in the state home, never in config.yaml; every
13
+ other command reads the pinned copy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import click
21
+
22
+ from ..output import ConsoleOutput, OutputPort
23
+
24
+ if TYPE_CHECKING: # pragma: no cover - typing only
25
+ from katalyst_taxonomy.services.config_sources import SourceStatus, SourceSync
26
+
27
+ __all__ = ["attach"]
28
+
29
+ _FORMAT_CHOICE = click.Choice(["text", "json"])
30
+
31
+
32
+ def _output() -> OutputPort:
33
+ return ConsoleOutput()
34
+
35
+
36
+ def _short(pin: str | None) -> str:
37
+ """A commit sha or content hash shortened to 12 characters; an ETag as it is."""
38
+ if not pin:
39
+ return "-"
40
+ if pin.startswith("sha256:"):
41
+ return pin[: len("sha256:") + 12]
42
+ return pin[:12] if len(pin) in (40, 64) else pin
43
+
44
+
45
+ @click.command("sync")
46
+ @click.argument("names", nargs=-1, metavar="[PROFILE]...")
47
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
48
+ def config_sync(names: tuple[str, ...], fmt: str) -> None:
49
+ """Fetch every sourced profile (or each PROFILE) and pin it.
50
+
51
+ A git source is pinned to its commit, a kata API to its ETag or content hash. Each
52
+ repository a sourced profile's filesystem backends name is found under
53
+ spec.workspace by its remote URL, or cloned there. Exits 1 if any fetch failed; a
54
+ failed one keeps its previous pin.
55
+ """
56
+ from katalyst_taxonomy.adapters.orchestration import sync_config_sources
57
+
58
+ out = _output()
59
+ try:
60
+ results: list[SourceSync] = sync_config_sources(names)
61
+ except ValueError as exc:
62
+ raise click.ClickException(str(exc)) from exc
63
+ if fmt == "json":
64
+ out.json_out({"profiles": [r.as_payload() for r in results]})
65
+ elif not results:
66
+ out.info("No profile is sourced from elsewhere; nothing to sync.")
67
+ for result in results if fmt == "text" else ():
68
+ if not result.ok:
69
+ out.warning(f"{result.profile}: {result.error}")
70
+ continue
71
+ change = (
72
+ f"pinned {_short(result.pin)}"
73
+ if not result.previous
74
+ else f"{_short(result.previous)} -> {_short(result.pin)}"
75
+ if result.changed
76
+ else f"unchanged at {_short(result.pin)}"
77
+ )
78
+ out.success(f"{result.profile}: {result.source} {change}")
79
+ for clone in result.clones:
80
+ how = "cloned to" if clone.cloned else "using"
81
+ out.info(f" {clone.repository}: {how} {clone.path}")
82
+ if any(not r.ok for r in results):
83
+ raise SystemExit(1)
84
+
85
+
86
+ @click.command("status")
87
+ @click.option(
88
+ "--offline",
89
+ is_flag=True,
90
+ help="Do not ask the sources whether they have moved; report the pins only.",
91
+ )
92
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
93
+ def config_status(offline: bool, fmt: str) -> None:
94
+ """Each sourced profile: its source, pin and last fetch, and whether the source is ahead."""
95
+ from katalyst_taxonomy.adapters.orchestration import config_source_status
96
+
97
+ out = _output()
98
+ try:
99
+ rows: list[SourceStatus] = config_source_status(check=not offline)
100
+ except ValueError as exc:
101
+ raise click.ClickException(str(exc)) from exc
102
+ if fmt == "json":
103
+ out.json_out({"profiles": [r.as_payload() for r in rows]})
104
+ return
105
+ if not rows:
106
+ out.info("No profile is sourced from elsewhere.")
107
+ return
108
+ out.table(
109
+ ["profile", "source", "pin", "fetched", "upstream"],
110
+ [
111
+ [
112
+ row.profile
113
+ if row.source_profile == row.profile
114
+ else f"{row.profile} ({row.source_profile})",
115
+ row.source,
116
+ _short(row.pin) if row.pin else "never fetched",
117
+ row.fetched_at or "-",
118
+ _upstream(row, offline),
119
+ ]
120
+ for row in rows
121
+ ],
122
+ )
123
+ for row in rows:
124
+ if row.error:
125
+ out.warning(f"{row.profile}: {row.error}")
126
+
127
+
128
+ def _upstream(row: Any, offline: bool) -> str:
129
+ if offline:
130
+ return "not checked"
131
+ if row.behind is None:
132
+ return "unknown"
133
+ if row.behind:
134
+ return f"behind ({_short(row.latest)}) — run 'kata config sync {row.profile}'"
135
+ return "current"
136
+
137
+
138
+ def attach(group: click.Group) -> None:
139
+ """Add ``sync`` and ``status`` to the ``kata config`` group."""
140
+ group.add_command(config_sync)
141
+ group.add_command(config_status)