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
« 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.
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"""
10import tomllib
11import typing as t
12from contextlib import contextmanager
13from importlib.metadata import PackageNotFoundError, requires
14from pathlib import Path
16import keyring
17import keyring.errors
18import tomlkit
19from ewok import Context
20from packaging.requirements import InvalidRequirement, Requirement
21from termcolor import cprint
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")
29# edwh's PyPI token, as `plugin.authenticate` has always stored it.
30EDWH_KEYRING_SERVICE = "edwh"
31EDWH_KEYRING_USERNAME = "pypi"
33# Only used when edwh's metadata cannot be read, i.e. an uninstalled source tree.
34VOMMIT_FALLBACK_SPECIFIER = ">=0.1.1,<1"
36PYPROJECT = Path("pyproject.toml")
38type Backend = t.Literal["vommit", "psr", "none"]
41def _load(pyproject: Path) -> dict[str, t.Any]:
42 """
43 The pyproject as a plain dict, or empty when it is missing or unreadable.
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 {}
52 try:
53 return tomllib.loads(pyproject.read_text())
54 except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
55 return {}
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)
68 return current
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)
78def has_vommit_config(pyproject: Path = PYPROJECT) -> bool:
79 """
80 Whether this project carries a [tool.vommit] table.
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)
88def pinned_backend(pyproject: Path = PYPROJECT) -> str | None:
89 """
90 The backend this project has been pinned to, if any.
92 [tool.edwh.release]
93 backend = "psr"
94 """
95 pin = _nested(_load(pyproject), PIN_KEY)
96 if not isinstance(pin, dict):
97 return None
99 backend = pin.get("backend")
100 return backend if isinstance(backend, str) else None
103def detect_backend(pyproject: Path = PYPROJECT) -> Backend:
104 """
105 Which tool should release this project.
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"
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"
122 return "psr" if has_psr_config(pyproject) else "none"
125@contextmanager
126def _edit(pyproject: Path) -> t.Iterator[tomlkit.TOMLDocument]:
127 """
128 Edit a pyproject in place, keeping its comments and formatting.
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()
135 yield document
137 pyproject.write_text(tomlkit.dumps(document))
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]
150 return current
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
161class VommitTasks(t.Protocol):
162 """
163 The vommit tasks edwh calls, and the arguments it calls them with.
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 """
170 def setup(self, c: Context, /, *, project_dir: str) -> None: ... 170 ↛ anywhereline 170 didn't jump anywhere: it always raised an exception.
172 def migrate(self, c: Context, /, *, project_dir: str) -> None: ... 172 ↛ anywhereline 172 didn't jump anywhere: it always raised an exception.
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: ...
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: ...
200def vommit_tasks() -> VommitTasks | None:
201 """
202 vommit's tasks, or None when the `edwh[vommit]` extra isn't installed.
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
213 return t.cast(VommitTasks, tasks)
216def vommit_configured(pyproject: Path = PYPROJECT) -> bool:
217 """
218 Whether vommit ended up with a config here, according to vommit.
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
225 return Config.has_pyproject_config(pyproject)
228def vommit_specifier() -> str:
229 """
230 The version range the `vommit` extra declares.
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
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
247 if parsed.name == "vommit" and parsed.marker and parsed.marker.evaluate({"extra": "vommit"}):
248 return str(parsed.specifier)
250 return VOMMIT_FALLBACK_SPECIFIER
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.
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
266 try:
267 check_format(token)
268 except VommitError as complaint:
269 return str(complaint)
271 return None
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
284def store_vommit_pypi_token(token: str) -> bool:
285 """
286 Put a token where vommit looks for it, without hardcoding where that is.
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
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
304 return True
307def copy_pypi_token() -> bool:
308 """
309 Copy edwh's PyPI token into vommit's keyring, leaving edwh's entry alone.
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
317 if not ensure_keyring_unlocked():
318 cprint("Keyring unavailable; run `vommit authenticate` to store your PyPI token.", "yellow")
319 return False
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
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
330 cprint("Copied your PyPI token to vommit's keyring (edwh's copy is untouched).", "green")
331 return True