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
« 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`.
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:
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
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.
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.
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"""
37from __future__ import annotations
39import argparse
40import json
41import re
42import shutil
43import subprocess # nosec B404
44import sys
45from pathlib import Path
46from typing import Any
48_HOSTS = {"github": "github.com", "gitlab": "gitlab.com"}
50# uv seeds this into `[project].description`; it is not a real description.
51_UV_DESCRIPTION_PLACEHOLDER = "Add your description here"
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}
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
73def is_uv_placeholder_init(text: str) -> bool:
74 """Is *text* still `uv init --lib`'s untouched `hello()` placeholder?
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 )
86def normalize_package_init(target: Path) -> list[str]:
87 """Rewrite any placeholder `src/<pkg>/__init__.py` to a package docstring.
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
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.
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.
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
127def git_identity(target: Path) -> tuple[str | None, str | None]:
128 """Return ``(name, email)`` from git config in *target*, or ``(None, None)``.
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
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
146 return read("user.name"), read("user.email")
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)``.
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}]"
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)
173 new_text = "\n".join(lines)
174 if text.endswith("\n"):
175 new_text += "\n"
176 return new_text, True
179def set_description(text: str, description: str) -> tuple[str, bool]:
180 """Set ``[project].description``, replacing uv's placeholder or inserting it.
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
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
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])
225def set_project_urls(text: str, homepage: str, repository: str) -> tuple[str, bool]:
226 """Ensure ``[project.urls]`` declares Homepage and Repository.
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
253def set_dependency_groups(text: str) -> tuple[str, bool]:
254 """Ensure ``[dependency-groups]`` declares the required ``test`` and ``lint`` groups.
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
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] = []
298 modified.extend(normalize_package_init(target))
299 if modified:
300 notes.append("normalised uv's placeholder hello() to a package docstring")
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")
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}
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}
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")
345 notes.append("license + classifiers are /rhiza:license and /rhiza:python-version's job")
347 return {"modified": modified, "changes": changes, "notes": notes, "ok": True}
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)
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 )
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
387if __name__ == "__main__":
388 raise SystemExit(main())