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

1#!/usr/bin/env python3 

2"""Does the template define the profile a pointer is about to name? Ask it, don't assume. 

3 

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: 

8 

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*. 

13 

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. 

17 

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. 

24 

25Only that one file is fetched (a sparse, blobless clone), so the check costs a fraction 

26of a sync. 

27 

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] 

33 

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""" 

42 

43from __future__ import annotations 

44 

45import argparse 

46import json 

47import shutil 

48import subprocess # nosec B404 

49import sys 

50import tempfile 

51from pathlib import Path 

52from typing import Any 

53 

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 

60 

61EXIT_OK = 0 

62EXIT_MISSING = 1 

63EXIT_UNREADABLE = 2 

64 

65 

66def _reason(exc: Exception) -> str: 

67 """Render *exc* as one line a user can act on. 

68 

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) 

81 

82 

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. 

87 

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) 

113 

114 

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 

141 

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 

148 

149 

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 

172 

173 

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) 

197 

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"]) 

212 

213 

214if __name__ == "__main__": 

215 raise SystemExit(main())