Coverage for plugin/scripts/_skeleton_python.py: 100%
94 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
1#!/usr/bin/env python3
2"""Finish what `uv init --lib` leaves out of a Python package.
4`uv init --lib` gets a project 90% of the way there: `pyproject.toml`,
5`src/<pkg>/__init__.py`, `README.md`, `.python-version`. This module closes the gap
6between that and what a rhiza-managed repo needs, so `/rhiza:update`'s synced gates have
7something 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] a `test` group (incl. pytest) — likewise required
19 mkdocs.yml the synced `rhiza_book.yml` builds the book on every push, and
20 `rhiza-task book` skips without a root `mkdocs.yml` — so the
21 upload of the `_book/` it never wrote turns the first sync red
23It writes **no** ``classifiers`` — not a ``License ::`` trove classifier (PEP 639
24replaced it with the SPDX ``license`` field, and `/rhiza:license` owns that), and not the
25``Programming Language :: Python :: X.Y`` entries either (`/rhiza:python-version` owns
26those). Nothing here touches the ``classifiers`` key.
28The version location — `[tool.bumpversion]` — is `_skeleton_version`'s, since the same
29question has a different answer in each language.
30"""
32from __future__ import annotations
34import json
35import re
36import sys
37from pathlib import Path
38from typing import Any
40sys.path.insert(0, str(Path(__file__).resolve().parent))
41import _skeleton_common as common # noqa: E402
42from _rhiza_toml import merge_table, set_key # noqa: E402
44_PYPROJECT = "pyproject.toml"
45_MKDOCS = "mkdocs.yml"
47# uv seeds this into `[project].description`; it is not a real description.
48_UV_DESCRIPTION_PLACEHOLDER = "Add your description here"
50# Dependency groups the template's pyproject gate requires, with lower bounds. `lint`
51# was here until the gate dropped its required-group check (rhiza #1484): the template
52# provisions every linter through prek/uvx, so nothing ever resolved that group.
53_DEPENDENCY_GROUPS: dict[str, list[str]] = {
54 "test": ["pytest>=8.0", "pytest-cov>=5.0"],
55}
58def is_uv_placeholder_init(text: str) -> bool:
59 """Is *text* still `uv init --lib`'s untouched `hello()` placeholder?
61 Conservative by design: anything the user has added (an import, a second
62 function, a docstring of their own) makes this False, so real code is never
63 rewritten.
64 """
65 body = [line for line in text.splitlines() if line.strip()]
66 return bool(body) and all(
67 re.match(r'^def hello\(\) -> str:$|^\s+return "Hello from .*!"$', line) for line in body
68 )
71def normalize_package_init(target: Path) -> list[str]:
72 """Rewrite any placeholder `src/<pkg>/__init__.py` to a package docstring.
74 Returns the relative paths modified (empty when there's nothing to normalise).
75 """
76 modified: list[str] = []
77 src = target / "src"
78 if not src.is_dir():
79 return modified
80 for init in sorted(src.glob("*/__init__.py")):
81 if is_uv_placeholder_init(init.read_text(encoding="utf-8")):
82 init.write_text(f'"""{init.parent.name} package."""\n', encoding="utf-8")
83 modified.append(init.relative_to(target).as_posix())
84 return modified
87def set_description(text: str, description: str) -> tuple[str, bool]:
88 """Set ``[project].description``, replacing uv's placeholder or inserting it.
90 A description the user has already written is left alone. Returns
91 ``(new_text, changed)``.
92 """
93 return set_key(
94 text,
95 "project",
96 "description",
97 json.dumps(description),
98 filename=_PYPROJECT,
99 replaceable=lambda value: _UV_DESCRIPTION_PLACEHOLDER in value,
100 )
103def set_authors(text: str, *, name: str, email: str | None) -> tuple[str, bool]:
104 """Ensure ``[project].authors`` names at least one author; return ``(text, changed)``.
106 `uv init --lib` omits the key entirely when git has no configured identity, and an
107 author already written by hand is never touched. Two of the template's
108 `.rhiza/tests/test_pyproject.py` assertions depend on this — the key existing, and
109 its first entry having a non-empty ``name``.
110 """
111 entry = f'{{ name = "{name}"' + (f', email = "{email}"' if email else "") + " }"
112 return set_key(
113 text,
114 "project",
115 "authors",
116 f"[{entry}]",
117 filename=_PYPROJECT,
118 # An empty inline list is uv's placeholder; anything else — including the `[` that
119 # opens uv's own multi-line array — is the user's.
120 replaceable=lambda value: re.fullmatch(r"\[\s*\]", value.strip()) is not None,
121 )
124def set_project_urls(text: str, homepage: str, repository: str) -> tuple[str, bool]:
125 """Ensure ``[project.urls]`` declares Homepage and Repository.
127 Existing entries win — only missing keys are added. Returns ``(new_text, changed)``.
128 """
129 new_text, added = merge_table(
130 text,
131 "project.urls",
132 {"Homepage": json.dumps(homepage), "Repository": json.dumps(repository)},
133 filename=_PYPROJECT,
134 )
135 return new_text, bool(added)
138def set_dependency_groups(text: str) -> tuple[str, bool]:
139 """Ensure ``[dependency-groups]`` declares the required ``test`` group.
141 Existing groups are left exactly as they are — this only adds absent ones, each
142 with lower-bounded requirements. Returns ``(new_text, changed)``.
143 """
144 new_text, added = merge_table(
145 text,
146 "dependency-groups",
147 {name: json.dumps(deps) for name, deps in _DEPENDENCY_GROUPS.items()},
148 filename=_PYPROJECT,
149 )
150 return new_text, bool(added)
153def render_mkdocs(*, repo: str, description: str | None, url: str, slug: str) -> str:
154 """Render the minimal root `mkdocs.yml` that inherits the synced base.
156 Every value goes through :func:`json.dumps`: a JSON string is a valid YAML
157 double-quoted scalar, so a description with a colon or a quote cannot break the file.
159 >>> print(render_mkdocs(repo="lasso", description="Sparse: fast.",
160 ... url="https://github.com/acme/lasso", slug="acme/lasso"), end="")
161 INHERIT: docs/mkdocs-base.yml
162 <BLANKLINE>
163 site_name: "lasso"
164 site_description: "Sparse: fast."
165 repo_url: "https://github.com/acme/lasso"
166 repo_name: "acme/lasso"
168 There is deliberately **no** ``nav``. The pages it would name (``docs/index.md`` and
169 friends) are template-owned and arrive with the first sync, so hardcoding them here
170 couples `/init` to upstream's filenames, and a rename would publish a 404 that
171 ``book-nav`` then fails on. Without a ``nav`` the build lists whatever is under
172 ``docs/``, and ``book-nav`` skips. `/rhiza:docs` writes the curated one later.
173 """
174 lines = ["INHERIT: docs/mkdocs-base.yml", "", f"site_name: {json.dumps(repo)}"]
175 if description:
176 lines.append(f"site_description: {json.dumps(description, ensure_ascii=False)}")
177 lines += [f"repo_url: {json.dumps(url)}", f"repo_name: {json.dumps(slug)}"]
178 return "\n".join(lines) + "\n"
181def seed_mkdocs(target: Path, *, repo: str, description: str | None, url: str, slug: str) -> bool:
182 """Write a root `mkdocs.yml` when there is none; return whether it was written.
184 An existing file is the user's — or `/rhiza:docs`'s — and is never touched.
185 ``docs/mkdocs-base.yml`` is not checked for: it is template-owned and does not exist
186 until the first `/rhiza:update`, which is exactly the sync this file is written for.
187 """
188 mkdocs = target / _MKDOCS
189 if mkdocs.exists():
190 return False
191 mkdocs.write_text(
192 render_mkdocs(repo=repo, description=description, url=url, slug=slug), encoding="utf-8"
193 )
194 return True
197def apply_pyproject(
198 text: str,
199 changes: list[str],
200 *,
201 url: str,
202 description: str | None,
203 author_name: str,
204 author_email: str | None,
205) -> str:
206 """Apply every `[project]` edit to *text*, recording each in *changes*.
208 *changes* is appended to in place rather than returned, so that a
209 :class:`ValueError` from a later edit still leaves the caller holding the keys the
210 earlier ones wrote. Reporting "we changed nothing" after a partial edit would be a
211 lie about the file on disk.
212 """
213 if description:
214 text, changed = set_description(text, description)
215 if changed:
216 changes.append("description")
217 text, changed = set_project_urls(text, url, url)
218 if changed:
219 changes.append("project.urls")
220 text, changed = set_dependency_groups(text)
221 if changed:
222 changes.append("dependency-groups")
223 text, changed = set_authors(text, name=author_name, email=author_email)
224 if changed:
225 changes.append("authors")
226 return text
229def finish_python(
230 target: Path,
231 *,
232 owner: str,
233 repo: str,
234 domain: str,
235 description: str | None,
236 modified: list[str],
237 notes: list[str],
238) -> dict[str, Any]:
239 """Finish a `uv init --lib` skeleton; return a summary dict."""
240 modified.extend(normalize_package_init(target))
241 if modified:
242 notes.append("normalised uv's placeholder hello() to a package docstring")
244 if common.seed_readme(target, repo=repo, description=description):
245 modified.append("README.md")
246 notes.append("seeded the empty README.md uv left behind — /rhiza:docs owns the real one")
248 manifest = target / _PYPROJECT
249 # `is_file`, not `exists`: a directory named pyproject.toml would pass the gate here
250 # and then be read as an absent manifest by every helper downstream.
251 if not manifest.is_file():
252 notes.append("pyproject.toml absent — run `uv init --lib` first")
253 return {"modified": modified, "changes": [], "notes": notes, "ok": False}
255 changes: list[str] = []
256 original = manifest.read_text(encoding="utf-8")
257 identity_name, identity_email = common.git_identity(target)
258 url = common.host_url(domain, owner, repo)
259 try:
260 text = apply_pyproject(
261 original,
262 changes,
263 url=url,
264 description=description,
265 # Falls back to the owner: the gate needs a non-empty name, and the owner is
266 # the best fact available when the machine has no git identity at all.
267 author_name=identity_name or owner,
268 author_email=identity_email,
269 )
270 except ValueError as exc:
271 notes.append(f"pyproject.toml: {exc}")
272 return {"modified": modified, "changes": changes, "notes": notes, "ok": False}
274 if text != original:
275 manifest.write_text(text, encoding="utf-8")
276 modified.append(_PYPROJECT)
277 notes.append("pyproject.toml: " + ", ".join(changes))
278 else:
279 notes.append("pyproject.toml already rhiza-shaped")
281 if seed_mkdocs(target, repo=repo, description=description, url=url, slug=f"{owner}/{repo}"):
282 modified.append(_MKDOCS)
283 notes.append("wrote a minimal mkdocs.yml for the synced book — /rhiza:docs owns the nav")
285 notes.append("license + classifiers are /rhiza:license and /rhiza:python-version's job")
286 return {"modified": modified, "changes": changes, "notes": notes, "ok": True}