Coverage for src/edwh/release_backend.py: 66%

135 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-13 17:03 +0200

1""" 

2Which release tool owns a project, and everything edwh asks of vommit. 

3 

4Detection is edwh's own because it has to answer "should I offer to install 

5vommit?" before vommit exists; every other call in here goes to vommit. Those 

6imports are deferred on purpose -- the extra is optional, so importing at module 

7level would make edwh itself unimportable without it. 

8""" 

9 

10import tomllib 

11import typing as t 

12from contextlib import contextmanager 

13from importlib.metadata import PackageNotFoundError, requires 

14from pathlib import Path 

15 

16import keyring 

17import keyring.errors 

18import tomlkit 

19from ewok import Context 

20from packaging.requirements import InvalidRequirement, Requirement 

21from termcolor import cprint 

22 

23# Where each tool keeps its configuration. 

24PSR_KEY = ("tool", "semantic_release") 

25VOMMIT_KEY = ("tool", "vommit") 

26# Our own opt-out, alongside [tool.edwh.lint]. 

27PIN_KEY = ("tool", "edwh", "release") 

28 

29# edwh's PyPI token, as `plugin.authenticate` has always stored it. 

30EDWH_KEYRING_SERVICE = "edwh" 

31EDWH_KEYRING_USERNAME = "pypi" 

32 

33# Only used when edwh's metadata cannot be read, i.e. an uninstalled source tree. 

34VOMMIT_FALLBACK_SPECIFIER = ">=0.1.1,<1" 

35 

36PYPROJECT = Path("pyproject.toml") 

37 

38type Backend = t.Literal["vommit", "psr", "none"] 

39 

40 

41def _load(pyproject: Path) -> dict[str, t.Any]: 

42 """ 

43 The pyproject as a plain dict, or empty when it is missing or unreadable. 

44 

45 Unreadable counts as empty on purpose: a broken pyproject is a problem for 

46 the build backend to report, not a reason for `plugin.release` to traceback 

47 before it has said anything useful. 

48 """ 

49 if not pyproject.exists(): 

50 return {} 

51 

52 try: 

53 return tomllib.loads(pyproject.read_text()) 

54 except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError): 

55 return {} 

56 

57 

58def _nested(document: t.Mapping[str, t.Any], path: t.Sequence[str]) -> t.Any: 

59 """ 

60 Follow a dotted key path, returning None as soon as it stops being a table. 

61 """ 

62 current: t.Any = document 

63 for key in path: 

64 if not isinstance(current, dict): 

65 return None 

66 current = current.get(key) 

67 

68 return current 

69 

70 

71def has_psr_config(pyproject: Path = PYPROJECT) -> bool: 

72 """ 

73 Whether this project still carries a [tool.semantic_release] table. 

74 """ 

75 return isinstance(_nested(_load(pyproject), PSR_KEY), dict) 

76 

77 

78def has_vommit_config(pyproject: Path = PYPROJECT) -> bool: 

79 """ 

80 Whether this project carries a [tool.vommit] table. 

81 

82 Deliberately not `vommit.config.Config.has_pyproject_config`: this question 

83 gets asked while vommit may not be importable yet. 

84 """ 

85 return isinstance(_nested(_load(pyproject), VOMMIT_KEY), dict) 

86 

87 

88def pinned_backend(pyproject: Path = PYPROJECT) -> str | None: 

89 """ 

90 The backend this project has been pinned to, if any. 

91 

92 [tool.edwh.release] 

93 backend = "psr" 

94 """ 

95 pin = _nested(_load(pyproject), PIN_KEY) 

96 if not isinstance(pin, dict): 

97 return None 

98 

99 backend = pin.get("backend") 

100 return backend if isinstance(backend, str) else None 

101 

102 

103def detect_backend(pyproject: Path = PYPROJECT) -> Backend: 

104 """ 

105 Which tool should release this project. 

106 

107 A present [tool.vommit] wins over a pin, so migrating by hand is honoured 

108 without also having to remove the opt-out. Otherwise a pin is final -- that 

109 is the whole point of answering "never" -- and only then does a psr config 

110 lead to the migration offer. 

111 """ 

112 if has_vommit_config(pyproject): 

113 return "vommit" 

114 

115 match pinned_backend(pyproject): 

116 case "vommit": 

117 # pinned to vommit but not configured for it yet: setup, not psr 

118 return "none" 

119 case "psr": 

120 return "psr" 

121 

122 return "psr" if has_psr_config(pyproject) else "none" 

123 

124 

125@contextmanager 

126def _edit(pyproject: Path) -> t.Iterator[tomlkit.TOMLDocument]: 

127 """ 

128 Edit a pyproject in place, keeping its comments and formatting. 

129 

130 Written back on a clean exit only, so a failure part-way leaves the file 

131 as it was rather than half-updated. 

132 """ 

133 document = tomlkit.parse(pyproject.read_text()) if pyproject.exists() else tomlkit.document() 

134 

135 yield document 

136 

137 pyproject.write_text(tomlkit.dumps(document)) 

138 

139 

140def _table(document: tomlkit.TOMLDocument, path: t.Sequence[str]) -> t.Any: 

141 """ 

142 Reach (creating as needed) the table at a dotted key path. 

143 """ 

144 current: t.Any = document 

145 for key in path: 

146 if key not in current: 

147 current[key] = tomlkit.table() 

148 current = current[key] 

149 

150 return current 

151 

152 

153def pin_backend(backend: Backend, pyproject: Path = PYPROJECT) -> None: 

154 """ 

155 Record the release backend in the project, so we stop asking. 

156 """ 

157 with _edit(pyproject) as document: 

158 _table(document, PIN_KEY)["backend"] = backend 

159 

160 

161class VommitTasks(t.Protocol): 

162 """ 

163 The vommit tasks edwh calls, and the arguments it calls them with. 

164 

165 Spelled out rather than typed as a module, so a wrong keyword is a type 

166 error here instead of an AttributeError during someone's release. 

167 `tests/test_release_backend.py` checks the real signatures still match. 

168 """ 

169 

170 def setup(self, c: Context, /, *, project_dir: str) -> None: ... 170 ↛ anywhereline 170 didn't jump anywhere: it always raised an exception.

171 

172 def migrate(self, c: Context, /, *, project_dir: str) -> None: ... 172 ↛ anywhereline 172 didn't jump anywhere: it always raised an exception.

173 

174 def bump( 174 ↛ anywhereline 174 didn't jump anywhere: it always raised an exception.

175 self, 

176 c: Context, 

177 /, 

178 *, 

179 major: bool = False, 

180 minor: bool = False, 

181 patch: bool = False, 

182 prerelease: bool = False, 

183 noop: bool = False, 

184 ) -> str | None: ... 

185 

186 def release( 186 ↛ exitline 186 didn't return from function 'release' because

187 self, 

188 c: Context, 

189 /, 

190 *, 

191 major: bool = False, 

192 minor: bool = False, 

193 patch: bool = False, 

194 prerelease: bool = False, 

195 noop: bool = False, 

196 yes: bool = False, 

197 ) -> str | None: ... 

198 

199 

200def vommit_tasks() -> VommitTasks | None: 

201 """ 

202 vommit's tasks, or None when the `edwh[vommit]` extra isn't installed. 

203 

204 They take a Context and are callable in-process, which is why vommit is a 

205 dependency rather than a tool we shell out to: `bump` hands back the new 

206 version instead of us regex-scraping it out of another process' stderr. 

207 """ 

208 try: 

209 from vommit import tasks 

210 except ImportError: 

211 return None 

212 

213 return t.cast(VommitTasks, tasks) 

214 

215 

216def vommit_configured(pyproject: Path = PYPROJECT) -> bool: 

217 """ 

218 Whether vommit ended up with a config here, according to vommit. 

219 

220 Unlike `has_vommit_config`, this needs vommit installed -- it is for after 

221 the migrator has run, when it is. 

222 """ 

223 from vommit.config import Config 

224 

225 return Config.has_pyproject_config(pyproject) 

226 

227 

228def vommit_specifier() -> str: 

229 """ 

230 The version range the `vommit` extra declares. 

231 

232 Read from edwh's metadata rather than written down twice, so widening the 

233 extra cannot leave the install prompt behind on the old range. The fallback 

234 covers a source tree that was never installed, whose metadata is absent. 

235 """ 

236 try: 

237 declared = requires("edwh") or () 

238 except PackageNotFoundError: 

239 return VOMMIT_FALLBACK_SPECIFIER 

240 

241 for requirement in declared: 241 ↛ 250line 241 didn't jump to line 250 because the loop on line 241 didn't complete

242 try: 

243 parsed = Requirement(requirement) 

244 except InvalidRequirement: 

245 continue 

246 

247 if parsed.name == "vommit" and parsed.marker and parsed.marker.evaluate({"extra": "vommit"}): 

248 return str(parsed.specifier) 

249 

250 return VOMMIT_FALLBACK_SPECIFIER 

251 

252 

253def vommit_token_complaint(token: str) -> str | None: 

254 """ 

255 vommit's verdict on a token's format, or None when it has nothing to say. 

256 

257 Delegated rather than re-checked here: vommit already knows what a usable 

258 token looks like, and its message says more than "should start with pypi-". 

259 """ 

260 try: 

261 from vommit.auth import check_format 

262 from vommit.errors import VommitError 

263 except ImportError: 

264 return None 

265 

266 try: 

267 check_format(token) 

268 except VommitError as complaint: 

269 return str(complaint) 

270 

271 return None 

272 

273 

274def edwh_pypi_token() -> str | None: 

275 """ 

276 The PyPI token `plugin.authenticate` stored, or None when there is none. 

277 """ 

278 try: 

279 return keyring.get_password(EDWH_KEYRING_SERVICE, EDWH_KEYRING_USERNAME) 

280 except keyring.errors.KeyringError: 

281 return None 

282 

283 

284def store_vommit_pypi_token(token: str) -> bool: 

285 """ 

286 Put a token where vommit looks for it, without hardcoding where that is. 

287 

288 `TokenStore` defaults to vommit's own service and username, so the two 

289 packages cannot drift apart on the name, and its keyring errors arrive 

290 already translated. 

291 """ 

292 try: 

293 from vommit.auth import TokenStore 

294 from vommit.errors import VommitError 

295 except ImportError: 

296 return False 

297 

298 try: 

299 TokenStore().store(token) 

300 except VommitError: 

301 # a keyring vommit cannot write to is not a reason to abort a release 

302 return False 

303 

304 return True 

305 

306 

307def copy_pypi_token() -> bool: 

308 """ 

309 Copy edwh's PyPI token into vommit's keyring, leaving edwh's entry alone. 

310 

311 Keeping both means a half-finished migration can still release the old way. 

312 Returns whether vommit now has a token to publish with; a False is a nudge 

313 towards `vommit authenticate`, never a failure worth stopping for. 

314 """ 

315 from .tasks import ensure_keyring_unlocked 

316 

317 if not ensure_keyring_unlocked(): 

318 cprint("Keyring unavailable; run `vommit authenticate` to store your PyPI token.", "yellow") 

319 return False 

320 

321 token = edwh_pypi_token() 

322 if not token: 

323 cprint("No PyPI token in edwh's keyring; run `vommit authenticate` when you release.", "blue") 

324 return False 

325 

326 if not store_vommit_pypi_token(token): 

327 cprint("Could not write to vommit's keyring; run `vommit authenticate` instead.", "yellow") 

328 return False 

329 

330 cprint("Copied your PyPI token to vommit's keyring (edwh's copy is untouched).", "green") 

331 return True