Coverage for scripts/init_skeleton.py: 100%

191 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 06:18 +0000

1#!/usr/bin/env python3 

2"""Finish a `uv init --lib` skeleton into a rhiza-shaped one — behind `/rhiza:skeleton`. 

3 

4`uv init --lib` gets a Python project 90% of the way there: `pyproject.toml`, 

5`src/<pkg>/__init__.py`, `README.md`, `.python-version`. This script closes the 

6gap between that and what a rhiza-managed repo needs, so `/rhiza:update`'s synced 

7gates have something to pass: 

8 

9 src/<pkg>/__init__.py replace uv's undocumented `hello()` placeholder with a 

10 package docstring (interrogate + coverage both fail on it) 

11 README.md uv creates it **empty**, and the template's 

12 test_readme_validation.py asserts it is non-empty 

13 [project].authors uv omits it entirely when git has no configured identity, 

14 and the template's pyproject gate requires a named author 

15 [project].description fill in uv's "Add your description here" placeholder 

16 [project.urls] Homepage + Repository — the template's .rhiza/tests/ 

17 test_pyproject.py requires both 

18 [dependency-groups] `test` (incl. pytest) and `lint` groups — likewise required 

19 

20It writes **no** ``classifiers`` — not a ``License ::`` trove classifier (PEP 639 

21replaced it with the SPDX ``license`` field, and `/rhiza:license` owns that), and 

22not the ``Programming Language :: Python :: X.Y`` entries either (`/rhiza:python-version` 

23owns those). This script never touches the ``classifiers`` key. 

24 

25Every edit is idempotent and additive: real code and hand-written metadata are 

26never overwritten. The placeholder `__init__.py` is only rewritten while it still 

27*is* uv's placeholder. Stdlib-only, so `/skeleton` can run it without the `rhiza` 

28CLI. Running `uv init --lib` itself is the caller's job — this script only finishes 

29the result, and reports what's missing when there's nothing to finish. 

30 

31Usage: 

32 uv run --python 3.12 --no-project python \ 

33 scripts/init_skeleton.py [TARGET] --owner OWNER --repo NAME \ 

34 [--host github|gitlab] [--description TEXT] [--json] 

35""" 

36 

37from __future__ import annotations 

38 

39import argparse 

40import json 

41import re 

42import shutil 

43import subprocess # nosec B404 

44import sys 

45from pathlib import Path 

46from typing import Any 

47 

48_HOSTS = {"github": "github.com", "gitlab": "gitlab.com"} 

49 

50# uv seeds this into `[project].description`; it is not a real description. 

51_UV_DESCRIPTION_PLACEHOLDER = "Add your description here" 

52 

53# Dependency groups the template's pyproject gate requires, with lower bounds. 

54_DEPENDENCY_GROUPS: dict[str, list[str]] = { 

55 "test": ["pytest>=8.0", "pytest-cov>=5.0"], 

56 "lint": ["ruff>=0.6"], 

57} 

58 

59 

60def _project_block(lines: list[str]) -> tuple[int, int]: 

61 """Return ``(header_idx, end_idx)`` bounding the ``[project]`` table body.""" 

62 header = next((i for i, line in enumerate(lines) if line.strip() == "[project]"), None) 

63 if header is None: 

64 raise ValueError("pyproject.toml has no [project] table") 

65 end = len(lines) 

66 for i in range(header + 1, len(lines)): 

67 if lines[i].lstrip().startswith("["): 

68 end = i 

69 break 

70 return header, end 

71 

72 

73def is_uv_placeholder_init(text: str) -> bool: 

74 """Is *text* still `uv init --lib`'s untouched `hello()` placeholder? 

75 

76 Conservative by design: anything the user has added (an import, a second 

77 function, a docstring of their own) makes this False, so real code is never 

78 rewritten. 

79 """ 

80 body = [line for line in text.splitlines() if line.strip()] 

81 return bool(body) and all( 

82 re.match(r'^def hello\(\) -> str:$|^\s+return "Hello from .*!"$', line) for line in body 

83 ) 

84 

85 

86def normalize_package_init(target: Path) -> list[str]: 

87 """Rewrite any placeholder `src/<pkg>/__init__.py` to a package docstring. 

88 

89 Returns the relative paths modified (empty when there's nothing to normalise). 

90 """ 

91 modified: list[str] = [] 

92 src = target / "src" 

93 if not src.is_dir(): 

94 return modified 

95 for init in sorted(src.glob("*/__init__.py")): 

96 if is_uv_placeholder_init(init.read_text()): 

97 init.write_text(f'"""{init.parent.name} package."""\n') 

98 modified.append(str(init.relative_to(target))) 

99 return modified 

100 

101 

102def seed_readme(target: Path, *, repo: str, description: str | None) -> bool: 

103 """Give an empty `README.md` a title and description; return whether it was written. 

104 

105 `uv init --lib` creates `README.md` **empty** — zero bytes. The template's 

106 `.rhiza/tests/test_readme_validation.py` asserts ``len(content) > 0``, so a repo 

107 built by the documented `/init` chain failed `make rhiza-test` before it had done 

108 anything wrong. Closing that gap is exactly this script's remit. 

109 

110 Only an empty (or whitespace-only) file is written. `/rhiza:docs` owns the real 

111 README and must never find its work overwritten — this is a stub to clear the gate, 

112 not a document. Nothing is created if `README.md` is absent, since its absence is a 

113 different failure the template reports separately. 

114 """ 

115 readme = target / "README.md" 

116 if not readme.is_file() or readme.read_text().strip(): 

117 return False 

118 body = f"# {repo}\n" 

119 if description: 

120 body += f"\n{description}\n" 

121 # No fenced code blocks: the same template test executes any it finds. 

122 body += "\nRun `/rhiza:docs` to write this properly.\n" 

123 readme.write_text(body) 

124 return True 

125 

126 

127def git_identity(target: Path) -> tuple[str | None, str | None]: 

128 """Return ``(name, email)`` from git config in *target*, or ``(None, None)``. 

129 

130 This is where `uv init` gets the authors entry it writes — and when git has no 

131 identity configured it writes **no `authors` key at all**, which the template's 

132 pyproject gate requires. So the same source is consulted here to fill the gap. 

133 """ 

134 git = shutil.which("git") 

135 if git is None: # pragma: no cover - git is present everywhere this runs 

136 return None, None 

137 

138 def read(key: str) -> str | None: 

139 result = subprocess.run( # nosec B603 

140 [git, "config", "--get", key], cwd=str(target), capture_output=True, text=True, 

141 check=False, 

142 ) # fmt: skip 

143 value = result.stdout.strip() 

144 return value or None 

145 

146 return read("user.name"), read("user.email") 

147 

148 

149def set_authors(text: str, *, name: str, email: str | None) -> tuple[str, bool]: 

150 """Ensure ``[project].authors`` names at least one author; return ``(text, changed)``. 

151 

152 `uv init --lib` omits the key entirely when git has no configured identity, and an 

153 author already written by hand is never touched. Two of the template's 

154 `.rhiza/tests/test_pyproject.py` assertions depend on this — the key existing, and 

155 its first entry having a non-empty ``name``. 

156 """ 

157 lines = text.splitlines() 

158 header, end = _project_block(lines) 

159 entry = f'{{ name = "{name}"' + (f', email = "{email}"' if email else "") + " }" 

160 new_line = f"authors = [{entry}]" 

161 

162 for i in range(header + 1, end): 

163 if not re.match(r"^\s*authors\s*=", lines[i]): 

164 continue 

165 # An empty inline list is uv's placeholder; anything else is the user's. 

166 if re.match(r"^\s*authors\s*=\s*\[\s*\]\s*$", lines[i]): 

167 lines[i] = new_line 

168 break 

169 return text, False 

170 else: 

171 lines.insert(header + 1, new_line) 

172 

173 new_text = "\n".join(lines) 

174 if text.endswith("\n"): 

175 new_text += "\n" 

176 return new_text, True 

177 

178 

179def set_description(text: str, description: str) -> tuple[str, bool]: 

180 """Set ``[project].description``, replacing uv's placeholder or inserting it. 

181 

182 A description the user has already written is left alone. Returns 

183 ``(new_text, changed)``. 

184 """ 

185 lines = text.splitlines() 

186 header, end = _project_block(lines) 

187 pattern = re.compile(r"^\s*description\s*=\s*(.*)$") 

188 new_line = f'description = "{description}"' 

189 for i in range(header + 1, end): 

190 match = pattern.match(lines[i]) 

191 if not match: 

192 continue 

193 if _UV_DESCRIPTION_PLACEHOLDER not in match.group(1): 

194 return text, False # a real description — hands off 

195 lines[i] = new_line 

196 break 

197 else: 

198 lines.insert(header + 1, new_line) 

199 new_text = "\n".join(lines) 

200 if text.endswith("\n"): 

201 new_text += "\n" 

202 return new_text, True 

203 

204 

205def _table_span(lines: list[str], name: str) -> tuple[int, int] | None: 

206 """Return ``(header_idx, end_idx)`` of a top-level ``[name]`` table, or None.""" 

207 header = next((i for i, line in enumerate(lines) if line.strip() == f"[{name}]"), None) 

208 if header is None: 

209 return None 

210 end = len(lines) 

211 for i in range(header + 1, len(lines)): 

212 if lines[i].lstrip().startswith("["): 

213 end = i 

214 break 

215 return header, end 

216 

217 

218def _append_table(lines: list[str], header: str, body: list[str]) -> None: 

219 """Append a ``[header]`` table with *body* lines to the end of the document.""" 

220 while lines and not lines[-1].strip(): 

221 lines.pop() 

222 lines.extend(["", header, *body]) 

223 

224 

225def set_project_urls(text: str, homepage: str, repository: str) -> tuple[str, bool]: 

226 """Ensure ``[project.urls]`` declares Homepage and Repository. 

227 

228 Existing entries win — only missing keys are added. Returns 

229 ``(new_text, changed)``. 

230 """ 

231 lines = text.splitlines() 

232 wanted = {"Homepage": homepage, "Repository": repository} 

233 span = _table_span(lines, "project.urls") 

234 if span is None: 

235 _append_table(lines, "[project.urls]", [f'{k} = "{v}"' for k, v in wanted.items()]) 

236 changed = True 

237 else: 

238 header, end = span 

239 present = { 

240 match.group(1) 

241 for line in lines[header + 1 : end] 

242 if (match := re.match(r"^\s*([A-Za-z-]+)\s*=", line)) 

243 } 

244 missing = [f'{k} = "{v}"' for k, v in wanted.items() if k not in present] 

245 lines[end:end] = missing 

246 changed = bool(missing) 

247 new_text = "\n".join(lines) 

248 if text.endswith("\n"): 

249 new_text += "\n" 

250 return new_text, changed 

251 

252 

253def set_dependency_groups(text: str) -> tuple[str, bool]: 

254 """Ensure ``[dependency-groups]`` declares the required ``test`` and ``lint`` groups. 

255 

256 Existing groups are left exactly as they are — this only adds absent ones, each 

257 with lower-bounded requirements. Returns ``(new_text, changed)``. 

258 """ 

259 lines = text.splitlines() 

260 span = _table_span(lines, "dependency-groups") 

261 if span is None: 

262 body = [f"{name} = {json.dumps(deps)}" for name, deps in _DEPENDENCY_GROUPS.items()] 

263 _append_table(lines, "[dependency-groups]", body) 

264 changed = True 

265 else: 

266 header, end = span 

267 present = { 

268 match.group(1) 

269 for line in lines[header + 1 : end] 

270 if (match := re.match(r"^\s*([A-Za-z0-9_-]+)\s*=", line)) 

271 } 

272 missing = [ 

273 f"{name} = {json.dumps(deps)}" 

274 for name, deps in _DEPENDENCY_GROUPS.items() 

275 if name not in present 

276 ] 

277 lines[end:end] = missing 

278 changed = bool(missing) 

279 new_text = "\n".join(lines) 

280 if text.endswith("\n"): 

281 new_text += "\n" 

282 return new_text, changed 

283 

284 

285def finish_skeleton( 

286 target: Path, 

287 *, 

288 owner: str, 

289 repo: str, 

290 host: str, 

291 description: str | None, 

292) -> dict[str, Any]: 

293 """Finish the `uv init --lib` skeleton at *target*; return a summary dict.""" 

294 modified: list[str] = [] 

295 notes: list[str] = [] 

296 changes: list[str] = [] 

297 

298 modified.extend(normalize_package_init(target)) 

299 if modified: 

300 notes.append("normalised uv's placeholder hello() to a package docstring") 

301 

302 if seed_readme(target, repo=repo, description=description): 

303 modified.append("README.md") 

304 notes.append("seeded the empty README.md uv left behind — /rhiza:docs owns the real one") 

305 

306 pyproject = target / "pyproject.toml" 

307 if not pyproject.exists(): 

308 notes.append("pyproject.toml absent — run `uv init --lib` first") 

309 return {"modified": modified, "changes": changes, "notes": notes, "ok": False} 

310 

311 host_domain = _HOSTS.get(host, _HOSTS["github"]) 

312 text = original = pyproject.read_text() 

313 try: 

314 if description: 

315 text, changed = set_description(text, description) 

316 if changed: 

317 changes.append("description") 

318 text, changed = set_project_urls( 

319 text, 

320 homepage=f"https://{host_domain}/{owner}/{repo}", 

321 repository=f"https://{host_domain}/{owner}/{repo}", 

322 ) 

323 if changed: 

324 changes.append("project.urls") 

325 text, changed = set_dependency_groups(text) 

326 if changed: 

327 changes.append("dependency-groups") 

328 identity_name, identity_email = git_identity(target) 

329 # Falls back to the owner: the gate needs a non-empty name, and the owner is the 

330 # best fact available when the machine has no git identity at all. 

331 text, changed = set_authors(text, name=identity_name or owner, email=identity_email) 

332 if changed: 

333 changes.append("authors") 

334 except ValueError as exc: 

335 notes.append(f"pyproject.toml: {exc}") 

336 return {"modified": modified, "changes": changes, "notes": notes, "ok": False} 

337 

338 if text != original: 

339 pyproject.write_text(text) 

340 modified.append("pyproject.toml") 

341 notes.append("pyproject.toml: " + ", ".join(changes)) 

342 else: 

343 notes.append("pyproject.toml already rhiza-shaped") 

344 

345 notes.append("license + classifiers are /rhiza:license and /rhiza:python-version's job") 

346 

347 return {"modified": modified, "changes": changes, "notes": notes, "ok": True} 

348 

349 

350def main(argv: list[str] | None = None) -> int: 

351 """Entry point: parse args, finish the skeleton, return an exit code.""" 

352 parser = argparse.ArgumentParser( 

353 description="Finish a `uv init --lib` skeleton into a rhiza-shaped one.", 

354 ) 

355 parser.add_argument( 

356 "target", nargs="?", default=".", help="Repository root (default: current directory)." 

357 ) 

358 parser.add_argument("--owner", required=True, help="GitHub/GitLab owner or org.") 

359 parser.add_argument("--repo", required=True, help="Repository name (for the project URLs).") 

360 parser.add_argument( 

361 "--host", choices=("github", "gitlab"), default="github", help="Git hosting platform." 

362 ) 

363 parser.add_argument("--description", help="Project description (replaces uv's placeholder).") 

364 parser.add_argument( 

365 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

366 ) 

367 args = parser.parse_args(argv) 

368 

369 summary = finish_skeleton( 

370 Path(args.target).resolve(), 

371 owner=args.owner, 

372 repo=args.repo, 

373 host=args.host, 

374 description=args.description, 

375 ) 

376 

377 if args.json_output: 

378 print(json.dumps(summary, indent=2)) 

379 else: 

380 for path in summary["modified"]: 

381 print(f"modified {path}") 

382 for note in summary["notes"]: 

383 print(f"note {note}", file=sys.stderr) 

384 return 0 if summary["ok"] else 1 

385 

386 

387if __name__ == "__main__": 

388 raise SystemExit(main())