Coverage for plugin/scripts/check_template_profile.py: 100%
80 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Does the template define the profile a pointer is about to name? Ask it, don't assume.
4`.rhiza/template.yml` names a template repository, a ref, and a profile. The first two
5are checked before `/rhiza:init` writes the pointer — `git ls-remote` proves the repo is
6reachable, and the ref comes from the template's own release list. The third was not
7checked at all, and it is the one that has been wrong twice:
9* `rust-github-project` was written into the pointer for a Rust repo. The template has
10 never defined it (commit `7021d43`).
11* `rust-local` replaced it. That profile exists on `jebel-quant/rhiza`'s default branch
12 and in **no release** — and `/init` pins the latest *release*.
14Both fail the same way: `/init` succeeds, its PR merges, and the *first* `/rhiza:update`
15dies with "Profile 'rust-local' was not found". The cost lands on the user, one step
16removed from the mistake, in the command that did nothing wrong.
18The fix is not a table of which profile each template defines at each ref — that is the
19per-language table `language_profile.py` deliberately refuses to keep, for the same
20reason: it describes repositories this plugin does not own and cannot see. Profiles vary
21by template, by fork, and by ref, so they are **discovered**, exactly as `make` targets
22are discovered by `check_make_targets.py`. This script reads the one file that knows —
23the template's `.rhiza/template-bundles.yml`, at the ref in question — and reports.
25Only that one file is fetched (a sparse, blobless clone), so the check costs a fraction
26of a sync.
28Usage:
29 uv run --python 3.12 --no-project python \
30 scripts/check_template_profile.py PROFILE [PROFILE ...] \
31 --template-repo owner/repo --ref REF \
32 [--template-host github|gitlab] [--bundles-path PATH] [--json]
34Exit codes:
35 0 every requested profile is defined at that ref
36 1 at least one is not — the pointer would be unsatisfiable; **our** mistake to fix
37 2 the template could not be read (network, unknown ref, no bundles file) — nothing
38 was learned about the profile, so a caller should warn and continue rather than
39 treat it as a missing profile. The two have different owners and different fixes,
40 which is why they are different exit codes.
41"""
43from __future__ import annotations
45import argparse
46import json
47import shutil
48import subprocess # nosec B404
49import sys
50import tempfile
51from pathlib import Path
52from typing import Any
54sys.path.insert(0, str(Path(__file__).resolve().parent))
55from _rhiza_bundles import Bundles # noqa: E402
56from _rhiza_common import SyncError # noqa: E402
57from _rhiza_git import GitContext, clone # noqa: E402
58from _rhiza_template import Template # noqa: E402
59from _rhiza_yaml import load_yaml # noqa: E402
61EXIT_OK = 0
62EXIT_MISSING = 1
63EXIT_UNREADABLE = 2
66def _reason(exc: Exception) -> str:
67 """Render *exc* as one line a user can act on.
69 A failed clone raises ``CalledProcessError``, whose ``str()`` is the whole argv —
70 a temp path and a token-length URL, with git's own explanation ("Remote branch v9.9.9
71 not found", "could not resolve host") nowhere in it. That explanation is on stderr,
72 so prefer it.
73 """
74 stderr = getattr(exc, "stderr", None)
75 if stderr:
76 text = stderr.decode(errors="replace") if isinstance(stderr, bytes) else str(stderr)
77 lines = [line.strip() for line in text.splitlines() if line.strip()]
78 if lines:
79 return lines[-1]
80 return str(exc)
83def available_profiles(
84 repository: str, ref: str, *, host: str = "github", bundles_path: str | None = None
85) -> list[str]:
86 """Return the profile names *repository* defines at *ref*, sorted.
88 Raises:
89 SyncError: If the template could not be cloned, or its bundles file is absent
90 or unparseable — i.e. nothing could be learned, as opposed to learning that
91 a profile is missing.
92 """
93 template = (
94 Template(repository=repository, ref=ref, host=host, bundles_path=bundles_path)
95 if bundles_path
96 else Template(repository=repository, ref=ref, host=host)
97 )
98 work_dir = Path(tempfile.mkdtemp())
99 try:
100 clone(GitContext.default(), template.git_url, work_dir, [template.bundles_path], branch=ref)
101 bundles_file = work_dir / template.bundles_path
102 if not bundles_file.is_file():
103 raise SyncError(
104 f"{repository}@{ref} has no {template.bundles_path} — "
105 "it may not be a rhiza template, or the path is configured elsewhere"
106 )
107 config = load_yaml(bundles_file)
108 except (subprocess.CalledProcessError, OSError, RuntimeError, ValueError) as exc:
109 raise SyncError(f"could not read {repository}@{ref}: {_reason(exc)}") from exc
110 finally:
111 shutil.rmtree(work_dir, ignore_errors=True)
112 return sorted(Bundles.from_config(config).profiles)
115def check(
116 repository: str,
117 ref: str,
118 profiles: list[str],
119 *,
120 host: str = "github",
121 bundles_path: str | None = None,
122) -> dict[str, Any]:
123 """Report which of *profiles* the template defines at *ref*; return a summary dict."""
124 summary: dict[str, Any] = {
125 "repository": repository,
126 "ref": ref,
127 "host": host,
128 "requested": profiles,
129 "defined": [],
130 "missing": [],
131 "available": [],
132 "error": None,
133 "exit_code": EXIT_OK,
134 }
135 try:
136 available = available_profiles(repository, ref, host=host, bundles_path=bundles_path)
137 except SyncError as exc:
138 summary["error"] = str(exc)
139 summary["exit_code"] = EXIT_UNREADABLE
140 return summary
142 summary["available"] = available
143 summary["defined"] = [p for p in profiles if p in available]
144 summary["missing"] = [p for p in profiles if p not in available]
145 if summary["missing"]:
146 summary["exit_code"] = EXIT_MISSING
147 return summary
150def _report(summary: dict[str, Any]) -> list[str]:
151 """Render the human-readable lines for *summary*, most important first."""
152 where = f"{summary['repository']}@{summary['ref']}"
153 if summary["exit_code"] == EXIT_UNREADABLE:
154 return [
155 f"unknown {summary['error']}",
156 " Nothing was learned about the profile — warn and continue; "
157 "this is a network or template-side problem, not a wrong pointer.",
158 ]
159 lines = [f"defined {where} defines {p}" for p in summary["defined"]]
160 for profile in summary["missing"]:
161 lines.append(f"MISSING {where} does not define {profile}")
162 if summary["missing"]:
163 lines.append(
164 f" available profiles: {', '.join(summary['available']) or 'none'}"
165 )
166 lines.append(
167 " A pointer naming it would sync fine at /init and fail at the "
168 "first /rhiza:update. Pin a ref that defines it, or pick a profile that "
169 "exists."
170 )
171 return lines
174def main(argv: list[str] | None = None) -> int:
175 """Entry point: check the profiles against the template and return an exit code."""
176 parser = argparse.ArgumentParser(
177 description="Check that a template defines the profiles a pointer would name.",
178 )
179 parser.add_argument("profiles", nargs="+", help="Profile name(s) to look for.")
180 parser.add_argument(
181 "--template-repo", required=True, help="Template repository, as owner/repo or a URL."
182 )
183 parser.add_argument("--ref", required=True, help="Template branch or tag to read.")
184 parser.add_argument(
185 "--template-host",
186 choices=("github", "gitlab"),
187 default="github",
188 help="Where the TEMPLATE lives; sets the clone URL (default: github).",
189 )
190 parser.add_argument(
191 "--bundles-path", help="Override the template's bundles file path (rarely needed)."
192 )
193 parser.add_argument(
194 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
195 )
196 args = parser.parse_args(argv)
198 summary = check(
199 args.template_repo,
200 args.ref,
201 list(args.profiles),
202 host=args.template_host,
203 bundles_path=args.bundles_path,
204 )
205 if args.json_output:
206 print(json.dumps(summary, indent=2))
207 else:
208 stream = sys.stdout if summary["exit_code"] == EXIT_OK else sys.stderr
209 for line in _report(summary):
210 print(line, file=stream)
211 return int(summary["exit_code"])
214if __name__ == "__main__":
215 raise SystemExit(main())