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

1#!/usr/bin/env python3 

2"""Finish what `uv init --lib` leaves out of a Python package. 

3 

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: 

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

22 

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. 

27 

28The version location — `[tool.bumpversion]` — is `_skeleton_version`'s, since the same 

29question has a different answer in each language. 

30""" 

31 

32from __future__ import annotations 

33 

34import json 

35import re 

36import sys 

37from pathlib import Path 

38from typing import Any 

39 

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 

43 

44_PYPROJECT = "pyproject.toml" 

45_MKDOCS = "mkdocs.yml" 

46 

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

48_UV_DESCRIPTION_PLACEHOLDER = "Add your description here" 

49 

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} 

56 

57 

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

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

60 

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 ) 

69 

70 

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

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

73 

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 

85 

86 

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

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

89 

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 ) 

101 

102 

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)``. 

105 

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 ) 

122 

123 

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

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

126 

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) 

136 

137 

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

139 """Ensure ``[dependency-groups]`` declares the required ``test`` group. 

140 

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) 

151 

152 

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. 

155 

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. 

158 

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" 

167 

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" 

179 

180 

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. 

183 

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 

195 

196 

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

207 

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 

227 

228 

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

243 

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

247 

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} 

254 

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} 

273 

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

280 

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

284 

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

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