Coverage for plugin/scripts/_skeleton_python.py: 100%
77 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"""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
20It writes **no** ``classifiers`` — not a ``License ::`` trove classifier (PEP 639
21replaced it with the SPDX ``license`` field, and `/rhiza:license` owns that), and not the
22``Programming Language :: Python :: X.Y`` entries either (`/rhiza:python-version` owns
23those). Nothing here touches the ``classifiers`` key.
25The version location — `[tool.bumpversion]` — is `_skeleton_version`'s, since the same
26question has a different answer in each language.
27"""
29from __future__ import annotations
31import json
32import re
33import sys
34from pathlib import Path
35from typing import Any
37sys.path.insert(0, str(Path(__file__).resolve().parent))
38import _skeleton_common as common # noqa: E402
39from _rhiza_toml import merge_table, set_key # noqa: E402
41_PYPROJECT = "pyproject.toml"
43# uv seeds this into `[project].description`; it is not a real description.
44_UV_DESCRIPTION_PLACEHOLDER = "Add your description here"
46# Dependency groups the template's pyproject gate requires, with lower bounds. `lint`
47# was here until the gate dropped its required-group check (rhiza #1484): the template
48# provisions every linter through prek/uvx, so nothing ever resolved that group.
49_DEPENDENCY_GROUPS: dict[str, list[str]] = {
50 "test": ["pytest>=8.0", "pytest-cov>=5.0"],
51}
54def is_uv_placeholder_init(text: str) -> bool:
55 """Is *text* still `uv init --lib`'s untouched `hello()` placeholder?
57 Conservative by design: anything the user has added (an import, a second
58 function, a docstring of their own) makes this False, so real code is never
59 rewritten.
60 """
61 body = [line for line in text.splitlines() if line.strip()]
62 return bool(body) and all(
63 re.match(r'^def hello\(\) -> str:$|^\s+return "Hello from .*!"$', line) for line in body
64 )
67def normalize_package_init(target: Path) -> list[str]:
68 """Rewrite any placeholder `src/<pkg>/__init__.py` to a package docstring.
70 Returns the relative paths modified (empty when there's nothing to normalise).
71 """
72 modified: list[str] = []
73 src = target / "src"
74 if not src.is_dir():
75 return modified
76 for init in sorted(src.glob("*/__init__.py")):
77 if is_uv_placeholder_init(init.read_text(encoding="utf-8")):
78 init.write_text(f'"""{init.parent.name} package."""\n', encoding="utf-8")
79 modified.append(init.relative_to(target).as_posix())
80 return modified
83def set_description(text: str, description: str) -> tuple[str, bool]:
84 """Set ``[project].description``, replacing uv's placeholder or inserting it.
86 A description the user has already written is left alone. Returns
87 ``(new_text, changed)``.
88 """
89 return set_key(
90 text,
91 "project",
92 "description",
93 json.dumps(description),
94 filename=_PYPROJECT,
95 replaceable=lambda value: _UV_DESCRIPTION_PLACEHOLDER in value,
96 )
99def set_authors(text: str, *, name: str, email: str | None) -> tuple[str, bool]:
100 """Ensure ``[project].authors`` names at least one author; return ``(text, changed)``.
102 `uv init --lib` omits the key entirely when git has no configured identity, and an
103 author already written by hand is never touched. Two of the template's
104 `.rhiza/tests/test_pyproject.py` assertions depend on this — the key existing, and
105 its first entry having a non-empty ``name``.
106 """
107 entry = f'{{ name = "{name}"' + (f', email = "{email}"' if email else "") + " }"
108 return set_key(
109 text,
110 "project",
111 "authors",
112 f"[{entry}]",
113 filename=_PYPROJECT,
114 # An empty inline list is uv's placeholder; anything else — including the `[` that
115 # opens uv's own multi-line array — is the user's.
116 replaceable=lambda value: re.fullmatch(r"\[\s*\]", value.strip()) is not None,
117 )
120def set_project_urls(text: str, homepage: str, repository: str) -> tuple[str, bool]:
121 """Ensure ``[project.urls]`` declares Homepage and Repository.
123 Existing entries win — only missing keys are added. Returns ``(new_text, changed)``.
124 """
125 new_text, added = merge_table(
126 text,
127 "project.urls",
128 {"Homepage": json.dumps(homepage), "Repository": json.dumps(repository)},
129 filename=_PYPROJECT,
130 )
131 return new_text, bool(added)
134def set_dependency_groups(text: str) -> tuple[str, bool]:
135 """Ensure ``[dependency-groups]`` declares the required ``test`` group.
137 Existing groups are left exactly as they are — this only adds absent ones, each
138 with lower-bounded requirements. Returns ``(new_text, changed)``.
139 """
140 new_text, added = merge_table(
141 text,
142 "dependency-groups",
143 {name: json.dumps(deps) for name, deps in _DEPENDENCY_GROUPS.items()},
144 filename=_PYPROJECT,
145 )
146 return new_text, bool(added)
149def apply_pyproject(
150 text: str,
151 changes: list[str],
152 *,
153 url: str,
154 description: str | None,
155 author_name: str,
156 author_email: str | None,
157) -> str:
158 """Apply every `[project]` edit to *text*, recording each in *changes*.
160 *changes* is appended to in place rather than returned, so that a
161 :class:`ValueError` from a later edit still leaves the caller holding the keys the
162 earlier ones wrote. Reporting "we changed nothing" after a partial edit would be a
163 lie about the file on disk.
164 """
165 if description:
166 text, changed = set_description(text, description)
167 if changed:
168 changes.append("description")
169 text, changed = set_project_urls(text, url, url)
170 if changed:
171 changes.append("project.urls")
172 text, changed = set_dependency_groups(text)
173 if changed:
174 changes.append("dependency-groups")
175 text, changed = set_authors(text, name=author_name, email=author_email)
176 if changed:
177 changes.append("authors")
178 return text
181def finish_python(
182 target: Path,
183 *,
184 owner: str,
185 repo: str,
186 domain: str,
187 description: str | None,
188 modified: list[str],
189 notes: list[str],
190) -> dict[str, Any]:
191 """Finish a `uv init --lib` skeleton; return a summary dict."""
192 modified.extend(normalize_package_init(target))
193 if modified:
194 notes.append("normalised uv's placeholder hello() to a package docstring")
196 if common.seed_readme(target, repo=repo, description=description):
197 modified.append("README.md")
198 notes.append("seeded the empty README.md uv left behind — /rhiza:docs owns the real one")
200 manifest = target / _PYPROJECT
201 # `is_file`, not `exists`: a directory named pyproject.toml would pass the gate here
202 # and then be read as an absent manifest by every helper downstream.
203 if not manifest.is_file():
204 notes.append("pyproject.toml absent — run `uv init --lib` first")
205 return {"modified": modified, "changes": [], "notes": notes, "ok": False}
207 changes: list[str] = []
208 original = manifest.read_text(encoding="utf-8")
209 identity_name, identity_email = common.git_identity(target)
210 try:
211 text = apply_pyproject(
212 original,
213 changes,
214 url=common.host_url(domain, owner, repo),
215 description=description,
216 # Falls back to the owner: the gate needs a non-empty name, and the owner is
217 # the best fact available when the machine has no git identity at all.
218 author_name=identity_name or owner,
219 author_email=identity_email,
220 )
221 except ValueError as exc:
222 notes.append(f"pyproject.toml: {exc}")
223 return {"modified": modified, "changes": changes, "notes": notes, "ok": False}
225 if text != original:
226 manifest.write_text(text, encoding="utf-8")
227 modified.append(_PYPROJECT)
228 notes.append("pyproject.toml: " + ", ".join(changes))
229 else:
230 notes.append("pyproject.toml already rhiza-shaped")
232 notes.append("license + classifiers are /rhiza:license and /rhiza:python-version's job")
233 return {"modified": modified, "changes": changes, "notes": notes, "ok": True}