katalyst-plugin-scaffolding 1.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.
@@ -0,0 +1,5 @@
1
+ """Scaffolding plugin — universal scaffold for node creation."""
2
+
3
+ from .plugin import ScaffoldingPlugin
4
+
5
+ __all__ = ["ScaffoldingPlugin"]
@@ -0,0 +1,15 @@
1
+ """LinkML modules this package ships, on katalyst-core.
2
+
3
+ Registered under the ``katalyst_core.modules`` entry point, so core loads them by name.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ __all__ = ["MODULES"]
11
+
12
+ MODULES: dict[str, Path] = {
13
+ path.stem: path for path in sorted(Path(__file__).parent.glob("*.yaml"))
14
+ }
15
+ """Module name (as an ``imports:`` line writes it) to its YAML file."""
@@ -0,0 +1 @@
1
+ cb3f7942331947e4995dc5a067f51435bd59e2ea7292eabdf1be26eb2141340b scaffolding.yaml
@@ -0,0 +1,54 @@
1
+ # Generated once from the Python definitions (LinkML rebuild, phase 4); edit here.
2
+ id: https://w3id.org/katalyst/scaffolding/scaffolding
3
+ name: scaffolding
4
+ title: scaffolding scaffolding wing
5
+ version: 0.1.0
6
+ license: Proprietary
7
+ prefixes:
8
+ scaffolding: https://w3id.org/katalyst/scaffolding/
9
+ kc: https://w3id.org/katalyst/core/
10
+ linkml: https://w3id.org/linkml/
11
+ dcterms: http://purl.org/dc/terms/
12
+ default_prefix: scaffolding
13
+ default_range: string
14
+ imports:
15
+ - linkml:types
16
+ - katalyst_core
17
+ description: The scaffolding wing.
18
+ annotations:
19
+ wing: scaffolding
20
+ display_name: Scaffolding
21
+ describes_kind: wing
22
+ classes:
23
+ NodeWiring:
24
+ is_a: Node
25
+ class_uri: scaffolding:NodeWiring
26
+ description: 'A node wiring: a node_wiring node of the scaffolding wing.'
27
+ attributes:
28
+ description:
29
+ description: What this wiring adds to scaffolded nodes
30
+ slot_uri: dcterms:description
31
+ extends:
32
+ description: 'The templates it extends: targetKind and targetNames (''*'' for all)'
33
+ slot_uri: scaffolding:extends
34
+ range: AnyValue
35
+ provides_template:
36
+ description: Template directory whose files are added, relative to this document
37
+ slot_uri: scaffolding:providesTemplate
38
+ annotations:
39
+ kind: node_wiring
40
+ wing: scaffolding
41
+ label: Node Wiring
42
+ label_plural: Node Wirings
43
+ display_order: 900
44
+ directory_name: node_wiring
45
+ root: true
46
+ leaf: true
47
+ storage:
48
+ tag: storage
49
+ value:
50
+ root: .global/taxonomy
51
+ container: file
52
+ filename: identity
53
+ nest_under_parent: false
54
+ scaffold: null
@@ -0,0 +1,54 @@
1
+ # Generated once from the Python definitions (LinkML rebuild, phase 4); edit here.
2
+ id: https://w3id.org/katalyst/scaffolding/scaffolding
3
+ name: scaffolding
4
+ title: scaffolding scaffolding wing
5
+ version: 0.1.0
6
+ license: Proprietary
7
+ prefixes:
8
+ scaffolding: https://w3id.org/katalyst/scaffolding/
9
+ kc: https://w3id.org/katalyst/core/
10
+ linkml: https://w3id.org/linkml/
11
+ dcterms: http://purl.org/dc/terms/
12
+ default_prefix: scaffolding
13
+ default_range: string
14
+ imports:
15
+ - linkml:types
16
+ - katalyst_core
17
+ description: The scaffolding wing.
18
+ annotations:
19
+ wing: scaffolding
20
+ display_name: Scaffolding
21
+ describes_kind: wing
22
+ classes:
23
+ NodeWiring:
24
+ is_a: Node
25
+ class_uri: scaffolding:NodeWiring
26
+ description: 'A node wiring: a node_wiring node of the scaffolding wing.'
27
+ attributes:
28
+ description:
29
+ description: What this wiring adds to scaffolded nodes
30
+ slot_uri: dcterms:description
31
+ extends:
32
+ description: 'The templates it extends: targetKind and targetNames (''*'' for all)'
33
+ slot_uri: scaffolding:extends
34
+ range: AnyValue
35
+ provides_template:
36
+ description: Template directory whose files are added, relative to this document
37
+ slot_uri: scaffolding:providesTemplate
38
+ annotations:
39
+ kind: node_wiring
40
+ wing: scaffolding
41
+ label: Node Wiring
42
+ label_plural: Node Wirings
43
+ display_order: 900
44
+ directory_name: node_wiring
45
+ root: true
46
+ leaf: true
47
+ storage:
48
+ tag: storage
49
+ value:
50
+ root: .global/taxonomy
51
+ container: file
52
+ filename: identity
53
+ nest_under_parent: false
54
+ scaffold: null
@@ -0,0 +1,79 @@
1
+ """Scaffolding plugin — the template-bundle machinery scaffolding copies from.
2
+
3
+ Registers how template bundles are installed into, removed from, listed in and described
4
+ for a repository; how a fetched collection's bundle manifests are found; the probe
5
+ ``node_wiring`` documents check; the create hook that writes a scaffolded node's
6
+ wiring file; and ``/ext/scaffolding/variants``, the installed variants a form offers
7
+ (:mod:`katalyst_plugin_scaffolding.rest_routes`). ``kata tax add`` arrives over the ``katalyst_taxonomy.cli`` entry point.
8
+
9
+ The scaffold copy itself is not here. The runtime's scaffolding hook
10
+ (:mod:`katalyst_taxonomy.services.scaffolding_hook`) populates a new node's directory
11
+ when a scaffold variant resolves and the kind's template root holds a directory for it
12
+ (otherwise it is a no-op); the variants it copies are the templates this plugin
13
+ installed. There is no engine-level ``scaffoldable`` flag: where a node records its
14
+ variant is its kind's declaration (``ScaffoldPolicy.variant_annotation``, the field with
15
+ the scaffold-variant role).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib.metadata
21
+
22
+ from katalyst_taxonomy.plugins.protocol import PluginContext, PluginManifest
23
+
24
+
25
+ def _version() -> str:
26
+ """The installed distribution version, for :attr:`PluginManifest.version`.
27
+
28
+ No built-in plugin populated ``version`` before #219 — it was declared and
29
+ always empty, so ``kata config plugins`` could not tell you what you were
30
+ running. Read from installed metadata rather than a hardcoded literal so it
31
+ cannot drift from the package it describes.
32
+ """
33
+ try:
34
+ return importlib.metadata.version("katalyst-plugin-scaffolding")
35
+ except importlib.metadata.PackageNotFoundError: # pragma: no cover - source checkout
36
+ return ""
37
+
38
+
39
+ class ScaffoldingPlugin:
40
+ """The scaffolding plugin: template-bundle machinery and the node wiring create hook.
41
+
42
+ ``scaffold``: it installs the templates scaffolding copies; ``post_create``: its create
43
+ hook runs after a node is created.
44
+ """
45
+
46
+ @property
47
+ def name(self) -> str:
48
+ return "scaffolding"
49
+
50
+ @property
51
+ def manifest(self) -> PluginManifest:
52
+ return PluginManifest(
53
+ name="scaffolding",
54
+ capabilities=("scaffold", "post_create"),
55
+ description="Template bundles for scaffolding, and the node wiring create hook",
56
+ version=_version(),
57
+ )
58
+
59
+ def register(self, context: PluginContext) -> None:
60
+ """Register the template-bundle machinery and the create hook with *context*."""
61
+ from katalyst_plugin_scaffolding.rest_routes import ScaffoldingRestRoutes
62
+ from katalyst_plugin_scaffolding.templates.bundle_install import (
63
+ describe_bundle,
64
+ install_template_for_plugin,
65
+ installed_bundles,
66
+ )
67
+ from katalyst_plugin_scaffolding.templates.plugin import register_template_contributions
68
+ from katalyst_plugin_scaffolding.templates.template_remove import (
69
+ remove_template_for_plugin,
70
+ )
71
+
72
+ # No init hook: `kata tax init` installs what the user config declares
73
+ # (spec.templates), which a bundle's own init hook may add to.
74
+ register_template_contributions(context)
75
+ context.rest_routes.append(ScaffoldingRestRoutes())
76
+ context.template_installers.append(install_template_for_plugin)
77
+ context.template_removers.append(remove_template_for_plugin)
78
+ context.template_listers.append(installed_bundles)
79
+ context.template_describers.append(describe_bundle)
File without changes
@@ -0,0 +1,117 @@
1
+ # pyright: reportUnusedFunction=false
2
+ #
3
+ # The routes are nested functions registered by their decorators and never referenced by
4
+ # name; the REST app mounts the router this returns.
5
+ """``/ext/scaffolding`` — the installed scaffold variants, for a form that creates nodes.
6
+
7
+ ================================== ======================================================
8
+ Route Answers
9
+ ================================== ======================================================
10
+ ``GET /ext/scaffolding/variants`` every scaffold variant installed in the repository
11
+ (``?kind=`` one kind's; ``?holds=ROLE`` those whose
12
+ nodes hold ROLE): its bundle and version, its
13
+ declaration, what its nodes hold, its files, its
14
+ expansions and every token it renders with where the
15
+ value comes from
16
+ ================================== ======================================================
17
+
18
+ A read: nothing is written. ``backend`` names the repository, as every REST route that
19
+ reads one repository's files does. The answer is
20
+ :mod:`katalyst_taxonomy.services.scaffold_catalog`'s.
21
+ """
22
+
23
+ # No `from __future__ import annotations`: FastAPI reads the handlers' annotations.
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+
28
+ class ScaffoldingRestRoutes:
29
+ """Implements :class:`~katalyst_taxonomy.plugins.extensions.RestRoutes`."""
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return "scaffolding"
34
+
35
+ def router(self) -> Any:
36
+ from fastapi import APIRouter
37
+ from fastapi.responses import JSONResponse
38
+ from katalyst_taxonomy.config.installs import declared_config
39
+ from katalyst_taxonomy.services.scaffold_catalog import (
40
+ BundleFiles,
41
+ declared_tokens,
42
+ scaffold_variants,
43
+ )
44
+ from katalyst_taxonomy_mcp.response import ok
45
+ from katalyst_taxonomy_mcp.rest.repository import repository_or_refusal
46
+
47
+ from katalyst_plugin_scaffolding.templates.bundle_install import (
48
+ describe_bundle,
49
+ installed_bundles,
50
+ )
51
+
52
+ router = APIRouter()
53
+
54
+ def bundles(root: Path) -> list[BundleFiles]:
55
+ """The declared template bundles *root* has installed, with the files of each."""
56
+ config = declared_config()[0]
57
+ declared = config.spec.templates if config is not None else ()
58
+ have = set(installed_bundles(root, {t.name: t.source for t in declared}))
59
+ out: list[BundleFiles] = []
60
+ for template in declared:
61
+ if template.name not in have:
62
+ continue
63
+ described = describe_bundle(root, template.name, template.source) or {}
64
+ files = [str(f) for f in described.get("files") or ()]
65
+ out.append(BundleFiles(template.name, str(template.version or ""), files))
66
+ return out
67
+
68
+ @router.get("/variants", response_model=None)
69
+ def variants(
70
+ kind: str | None = None, holds: str | None = None, backend: str | None = None
71
+ ) -> Any:
72
+ """The variants installed in *backend*'s repository: of *kind*, holding *holds*."""
73
+ repository = repository_or_refusal(backend)
74
+ if isinstance(repository, JSONResponse):
75
+ return repository
76
+ root = repository.root
77
+ found = scaffold_variants(
78
+ root,
79
+ filesystem=_Files(),
80
+ bundles=bundles(root),
81
+ kind=kind or None,
82
+ holds=holds or None,
83
+ )
84
+ return ok(
85
+ {
86
+ "backend": repository.name,
87
+ "kind": kind,
88
+ "holds": holds,
89
+ "variants": [variant.to_dict() for variant in found],
90
+ # The kind's own scaffold tokens: fields only a template renders.
91
+ "tokens": declared_tokens(kind) if kind else [],
92
+ }
93
+ )
94
+
95
+ return router
96
+
97
+
98
+ class _Files:
99
+ """The repository's files, read — the ``FileReader`` the catalog reads through."""
100
+
101
+ def exists(self, path: Path) -> bool:
102
+ return path.exists()
103
+
104
+ def is_directory(self, path: Path) -> bool:
105
+ return path.is_dir()
106
+
107
+ def is_file(self, path: Path) -> bool:
108
+ return path.is_file()
109
+
110
+ def read_file(self, path: Path) -> str:
111
+ return path.read_text(encoding="utf-8")
112
+
113
+ def list_directory(self, path: Path) -> list[Path]:
114
+ return list(path.iterdir()) if path.is_dir() else []
115
+
116
+ def glob(self, path: Path, pattern: str) -> list[Path]:
117
+ return list(path.glob(pattern))
@@ -0,0 +1,75 @@
1
+ """Schema bundle for the scaffolding plugin: the ``node_wiring`` kind.
2
+
3
+ A ``node_wiring`` document is ordinary taxonomy content. It lives in a repository,
4
+ under ``.global/taxonomy/node_wiring/``, and adds files to scaffolded nodes: it names
5
+ the templates it extends and the template directory it provides, and it activates only
6
+ when the plugins it requires are installed.
7
+
8
+ Declaring it (in the LinkML module ``linkml/scaffolding.yaml``) makes it a registered kind like any other, validated and listed. The
9
+ runtime keeps no list of kinds to skip.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import cache
15
+
16
+ from katalyst_engine.bundle.protocol import BundleManifest
17
+ from katalyst_engine.core.identity import Identity
18
+ from katalyst_engine.core.version import Version
19
+ from katalyst_engine.schema.definition import (
20
+ NodeDefinition,
21
+ SchemaDefinition,
22
+ WingDefinition,
23
+ )
24
+ from katalyst_engine.schema.manager import SchemaManager
25
+ from katalyst_engine.toolkit.linkml import LinkMLDefinitions, installed_definitions
26
+
27
+ #: The bundle's entry-point name, as a profile's ``schemas`` list names it.
28
+ BUNDLE_NAME = "scaffolding-schema"
29
+ WING = "scaffolding"
30
+ KIND = "node_wiring"
31
+
32
+ #: The LinkML module this bundle builds its definitions from (``linkml/scaffolding.yaml``).
33
+ MODULES = ("scaffolding",)
34
+
35
+
36
+ @cache
37
+ def _definitions() -> LinkMLDefinitions:
38
+ return installed_definitions(MODULES)
39
+
40
+
41
+ NODE_WIRING: NodeDefinition = next(n for n in _definitions().nodes if n.describes_kind == KIND)
42
+ SCAFFOLDING_WING: WingDefinition = next(w for w in _definitions().wings if w.identity.name == WING)
43
+
44
+
45
+ class ScaffoldingSchemaBundle:
46
+ """Contributes ``node_wiring``, and the wing it lives in."""
47
+
48
+ @property
49
+ def manifest(self) -> BundleManifest:
50
+ return BundleManifest(
51
+ identity=Identity(kind="bundle", name=BUNDLE_NAME),
52
+ version=Version(major=0, minor=1),
53
+ modules=MODULES,
54
+ provided_kinds=frozenset({KIND}),
55
+ wing_names=frozenset({WING}),
56
+ description="Schema for the documents the scaffolding plugin reads",
57
+ )
58
+
59
+ def node_definitions(self) -> list[NodeDefinition]:
60
+ return [NODE_WIRING]
61
+
62
+ def wing_definitions(self) -> list[WingDefinition]:
63
+ return [SCAFFOLDING_WING]
64
+
65
+ def schema_definition(self) -> SchemaDefinition:
66
+ return SchemaDefinition(
67
+ identity=Identity(kind="schema", name=BUNDLE_NAME),
68
+ describes_kind="taxonomy",
69
+ api_version="v2alpha",
70
+ schema_version=Version(major=0, minor=1),
71
+ node_definitions=(NODE_WIRING.identity,),
72
+ )
73
+
74
+ def register(self, manager: SchemaManager) -> None:
75
+ manager.register_schema(self.schema_definition(), node_definitions=self.node_definitions())
@@ -0,0 +1,6 @@
1
+ """Template bundles, as a taxonomy plugin.
2
+
3
+ Installs, lists, describes and removes the template bundles a user config declares
4
+ (``spec.templates``) — every bundle the same way, from its manifest in its declared
5
+ source — and contributes the ``kata tax add`` command.
6
+ """
@@ -0,0 +1,238 @@
1
+ """Install forge template bundles into a taxonomy repository (the `kata tax add` engine).
2
+
3
+ A collection declares its layout in ``templates/manifest.json`` (``layout:
4
+ wing/kind/template``): a bundle is ``templates/_manifests/<name>.yaml`` listing the
5
+ templates it is made of, each of which is installed where its kind declares (see
6
+ :mod:`~katalyst_plugin_scaffolding.templates.composed_install`). The collection sits at
7
+ the root of a git or local source, or one or two package levels down in a wheel.
8
+
9
+ Every bundle is handled the same way, whatever kinds its templates are:
10
+
11
+ * installed by copying its templates (:func:`install_template_bundle`);
12
+ * installed *when* every path that copy writes is on disk (:func:`bundle_installed`);
13
+ * described by those paths and its templates' kinds (:func:`describe_bundle`);
14
+ * removed by deleting those paths (:mod:`~katalyst_plugin_scaffolding.templates.template_remove`).
15
+
16
+ Each reads the bundle's manifest in its declared source (a ``spec.templates`` entry). It
17
+ copies; it records nothing — the pinned source and version go on the template's
18
+ ``spec.templates`` entry in ``config.yaml`` (:mod:`katalyst_taxonomy.services.template_install`).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+ from collections.abc import Mapping
25
+ from pathlib import Path
26
+ from typing import Any, cast
27
+
28
+ import yaml
29
+ from katalyst_taxonomy.sources.bundle_sources import fetch_source_tree
30
+
31
+ from katalyst_plugin_scaffolding.templates.composed_install import (
32
+ BundleInstallResult,
33
+ bundle_targets,
34
+ composed_bundle_installed,
35
+ install_composed_bundle,
36
+ is_composed_manifest,
37
+ load_manifest,
38
+ )
39
+ from katalyst_plugin_scaffolding.templates.layout import LayoutSpec, TemplateRef, read_layout_spec
40
+
41
+
42
+ def collection_root(checkout_root: Path) -> Path:
43
+ """The directory holding ``templates/``: the root, or a package one or two levels down.
44
+
45
+ A git or local source has it at the root. A wheel's top level is the installed
46
+ package(s) plus ``*.dist-info`` / ``*.data``, so the collection sits at
47
+ ``<package>/templates/`` or ``<package>/<subdir>/templates/`` (for instance
48
+ ``<package>/collection/templates/``). Below the root, a ``templates/`` that declares
49
+ a collection (``templates/manifest.json`` or ``templates/_manifests/``) wins over one
50
+ that does not: a package may have a ``templates`` subpackage of its own beside its
51
+ collection. Falls back to *checkout_root* so the caller reports "bundle not found"
52
+ rather than an internal error.
53
+ """
54
+ if (checkout_root / "templates").is_dir():
55
+ return checkout_root
56
+ if not checkout_root.is_dir():
57
+ return checkout_root
58
+ level = [
59
+ c
60
+ for c in sorted(checkout_root.iterdir())
61
+ if c.is_dir()
62
+ and not c.name.endswith((".dist-info", ".data"))
63
+ and not c.name.startswith(".")
64
+ ]
65
+ candidates = [
66
+ *level,
67
+ *(c for parent in level for c in sorted(parent.iterdir()) if c.is_dir()),
68
+ ]
69
+ for found in (_declares_collection, _has_templates):
70
+ for candidate in candidates:
71
+ if found(candidate):
72
+ return candidate
73
+ return checkout_root
74
+
75
+
76
+ def _has_templates(directory: Path) -> bool:
77
+ return (directory / "templates").is_dir()
78
+
79
+
80
+ def _declares_collection(directory: Path) -> bool:
81
+ templates = directory / "templates"
82
+ return (templates / "manifest.json").is_file() or (templates / "_manifests").is_dir()
83
+
84
+
85
+ def install_template_bundle(
86
+ *,
87
+ repo_root: Path,
88
+ source_uri: str,
89
+ bundle_name: str,
90
+ ref: str = "",
91
+ checkout_root: Path | None = None,
92
+ ) -> BundleInstallResult:
93
+ """Fetch *source_uri* and install the named bundle into *repo_root*.
94
+
95
+ Returns a :class:`BundleInstallResult`; ``result.success`` is ``False`` with
96
+ a descriptive error if the bundle cannot be located.
97
+
98
+ *checkout_root*, when given, is an already-fetched tree for *source_uri* (see
99
+ :func:`~katalyst_taxonomy.sources.bundle_sources.fetch_bundle_collection`), so a
100
+ caller installing several bundles fetches — and clones — the source once.
101
+ """
102
+ # Issue #92: honour the documented contract — return an error result rather
103
+ # than letting raw exceptions from source fetching propagate.
104
+ if checkout_root is None:
105
+ try:
106
+ checkout_root = fetch_source_tree(source_uri, ref)
107
+ except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as exc:
108
+ return _failed(bundle_name, f"Failed to fetch source {source_uri!r}: {exc}")
109
+ checkout_root = collection_root(checkout_root)
110
+ try:
111
+ layout = read_layout_spec(checkout_root)
112
+ except ValueError as exc:
113
+ return _failed(bundle_name, str(exc))
114
+ if layout is None:
115
+ return _failed(
116
+ bundle_name,
117
+ f"Source {source_uri} declares no template collection "
118
+ "(looked for templates/manifest.json with layout 'wing/kind/template')",
119
+ )
120
+ manifest = layout.manifest_for(bundle_name)
121
+ try:
122
+ doc = load_manifest(manifest) if manifest.is_file() else {}
123
+ except yaml.YAMLError as exc:
124
+ return _failed(bundle_name, f"{manifest} could not be parsed: {exc}")
125
+ if not is_composed_manifest(doc):
126
+ return _failed(
127
+ bundle_name,
128
+ f"Bundle '{bundle_name}' not found in source {source_uri} "
129
+ f"(looked for {manifest.relative_to(checkout_root)} listing spec.templates)",
130
+ )
131
+ return install_composed_bundle(manifest=manifest, doc=doc, layout=layout, repo_root=repo_root)
132
+
133
+
134
+ def _failed(name: str, error: str) -> BundleInstallResult:
135
+ return BundleInstallResult(name=name, version="0.0.0", installed_targets=(), errors=(error,))
136
+
137
+
138
+ def _source_tree(source: str, *, fetch: bool = False) -> Path | None:
139
+ """The tree *source* names: the local path, the source cache, or (with *fetch*) fetched."""
140
+ from katalyst_taxonomy.sources.bundle_sources import local_source_root, parse_bundle_uri
141
+ from katalyst_taxonomy.sources.cache import lookup
142
+
143
+ try:
144
+ if source.strip().startswith(("git:", "github:", "pypi:")):
145
+ found = lookup(source)
146
+ if found is not None:
147
+ return found.path
148
+ return fetch_source_tree(source) if fetch else None
149
+ return local_source_root(parse_bundle_uri(source))
150
+ except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError):
151
+ return None
152
+
153
+
154
+ def layout_for(
155
+ source: str, cache: dict[str, LayoutSpec | None] | None = None, *, fetch: bool = False
156
+ ) -> LayoutSpec | None:
157
+ """The collection layout *source* holds, read once per *cache*; None if not at hand."""
158
+ cache = {} if cache is None else cache
159
+ if source not in cache:
160
+ tree = _source_tree(source, fetch=fetch)
161
+ try:
162
+ cache[source] = read_layout_spec(collection_root(tree)) if tree is not None else None
163
+ except ValueError:
164
+ cache[source] = None
165
+ return cache[source]
166
+
167
+
168
+ def bundle_installed(
169
+ repo_root: Path, name: str, source: str, cache: dict[str, LayoutSpec | None] | None = None
170
+ ) -> bool:
171
+ """Whether every path bundle *name* of *source* installs is on disk in *repo_root*."""
172
+ layout = layout_for(source, cache)
173
+ return layout is not None and composed_bundle_installed(
174
+ layout.manifest_for(name), layout, repo_root
175
+ )
176
+
177
+
178
+ def installed_bundles(repo_root: Path, declared: Mapping[str, str]) -> list[str]:
179
+ """The bundles of *declared* (name → source) that *repo_root* has installed.
180
+
181
+ The ``PluginContext.template_listers`` entry: the one answer to "which template
182
+ bundles are installed". A source that is not at hand reports nothing, and installing
183
+ it again is harmless.
184
+ """
185
+ cache: dict[str, LayoutSpec | None] = {}
186
+ return sorted(n for n, s in declared.items() if bundle_installed(repo_root, n, s, cache))
187
+
188
+
189
+ def describe_bundle(repo_root: Path, name: str, source: str) -> dict[str, Any] | None:
190
+ """What bundle *name* of *source* puts in *repo_root*: its node kinds and files.
191
+
192
+ A ``PluginContext.template_describers`` entry. None when the source is not at hand
193
+ or the manifest does not resolve.
194
+ """
195
+ layout = layout_for(source)
196
+ if layout is None:
197
+ return None
198
+ manifest = layout.manifest_for(name)
199
+ targets = bundle_targets(manifest, layout, repo_root)
200
+ if targets is None:
201
+ return None
202
+ kinds: dict[str, int] = {}
203
+ spec = cast(dict[str, Any], load_manifest(manifest).get("spec") or {})
204
+ for value in cast(list[object], spec.get("templates") or []):
205
+ kind = TemplateRef.parse(value).kind
206
+ kinds[kind] = kinds.get(kind, 0) + 1
207
+ files: list[str] = []
208
+ for target in targets:
209
+ try:
210
+ files.append(target.relative_to(repo_root).as_posix())
211
+ except ValueError:
212
+ files.append(str(target))
213
+ return {"kinds": kinds, "files": sorted(set(files))}
214
+
215
+
216
+ def install_template_for_plugin(
217
+ repo_root: Path,
218
+ source: str,
219
+ bundle: str,
220
+ *,
221
+ ref: str = "",
222
+ checkout_root: Path | None = None,
223
+ ) -> dict[str, Any]:
224
+ """:func:`install_template_bundle` as a ``PluginContext.template_installers`` entry."""
225
+ result = install_template_bundle(
226
+ repo_root=repo_root,
227
+ source_uri=source,
228
+ bundle_name=bundle,
229
+ ref=ref,
230
+ checkout_root=checkout_root,
231
+ )
232
+ return {
233
+ "name": result.name,
234
+ "version": result.version,
235
+ "success": result.success,
236
+ "installed_targets": list(result.installed_targets),
237
+ "errors": list(result.errors),
238
+ }