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

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 

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. 

24 

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

26question has a different answer in each language. 

27""" 

28 

29from __future__ import annotations 

30 

31import json 

32import re 

33import sys 

34from pathlib import Path 

35from typing import Any 

36 

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 

40 

41_PYPROJECT = "pyproject.toml" 

42 

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

44_UV_DESCRIPTION_PLACEHOLDER = "Add your description here" 

45 

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} 

52 

53 

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

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

56 

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 ) 

65 

66 

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

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

69 

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 

81 

82 

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

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

85 

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 ) 

97 

98 

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

101 

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 ) 

118 

119 

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

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

122 

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) 

132 

133 

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

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

136 

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) 

147 

148 

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

159 

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 

179 

180 

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

195 

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

199 

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} 

206 

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} 

224 

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

231 

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

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