Coverage for tests/test_release_routing.py: 97%
111 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# SPDX-FileCopyrightText: 2023-present Remco Boerma <remco.b@educationwarehouse.nl>
2#
3# SPDX-License-Identifier: MIT
4"""
5How plugin.release and plugin.bump choose, and refuse, a release backend.
7Routing depends on three things the project file cannot answer: whether vommit
8is importable, what the user replies, and what a shell-out would do. Each is
9stood in for at its own boundary -- the import system, stdin, `Context.run` --
10so what runs here is the real routing code, not a version of it with its
11collaborators swapped out.
12"""
14import importlib
15import io
16import sys
17import types
18import typing as t
19from contextlib import chdir, contextmanager
20from pathlib import Path
22import ewok
23import pytest
25from src.edwh.local_tasks import plugin
27# What the `project` fixture hands back: write this pyproject, get its directory.
28type MakeProject = t.Callable[[str], Path]
31class Answers(t.Protocol):
32 """
33 What the `answers` fixture hands back.
35 A Protocol rather than a Callable alias because the keystrokes are
36 variadic, which `Callable[...]` can only spell as "any arguments at all".
37 """
39 def __call__(self, *keystrokes: str) -> t.ContextManager[None]: ... 39 ↛ exitline 39 didn't return from function '__call__' because
42VOMMIT = """[project]
43name = "demo"
44version = "1.0.0"
46[tool.vommit]
47prerelease_token = "beta"
48"""
50BARE = """[project]
51name = "demo"
52version = "1.0.0"
53"""
56class RecordingContext(ewok.Context):
57 """
58 A Context that writes commands down instead of running them.
60 Every side effect on this path leaves through `Context.run`: installing
61 vommit, invoking python-semantic-release, publishing. So an empty
62 `commands` is the proof that none of it happened -- and the real Context
63 would SSH to localhost to find that out.
64 """
66 def __init__(self) -> None:
67 super().__init__(host="localhost")
68 self.commands: list[str] = []
70 def run(self, command: str, **_: t.Any) -> None: # type: ignore[override]
71 self.commands.append(command)
72 return None
75@pytest.fixture
76def ctx() -> RecordingContext:
77 return RecordingContext()
80@pytest.fixture
81def project(tmp_path: Path) -> MakeProject:
82 """Build a throwaway project to run a task inside."""
84 def make(content: str) -> Path:
85 (tmp_path / "pyproject.toml").write_text(content)
86 return tmp_path
88 return make
91@pytest.fixture
92def no_vommit() -> t.Iterator[None]:
93 """
94 An interpreter where the optional `vommit` extra is not installed.
96 Blocked at the import system rather than at `vommit_tasks`, because the
97 ImportError is the thing under test: [tool.vommit] in a pyproject outlives
98 the install that wrote it, so the routing has to survive a real one.
99 """
101 class Blocked:
102 def find_spec(
103 self, fullname: str, _path: t.Sequence[str] | None = None, _target: types.ModuleType | None = None
104 ) -> None:
105 if fullname.split(".")[0] == "vommit": 105 ↛ 107line 105 didn't jump to line 107 because the condition on line 105 was always true
106 raise ImportError("No module named 'vommit'")
107 return None
109 # an already-imported vommit would never reach the finder
110 imported = {name: module for name, module in sys.modules.items() if name.split(".")[0] == "vommit"}
111 for name in imported:
112 del sys.modules[name]
114 finder = Blocked()
115 sys.meta_path.insert(0, finder)
116 importlib.invalidate_caches()
117 try:
118 yield
119 finally:
120 sys.meta_path.remove(finder)
121 sys.modules.update(imported)
124class Unattended(io.StringIO):
125 """
126 A stdin with nobody behind it: not a terminal, and loud when read.
128 pytest only hides the real stdin while it captures, so under `-s` the
129 terminal is still attached, `_can_ask` sees a tty, and any test that
130 reaches a prompt sits there waiting for a human. This fails instead.
131 """
133 def isatty(self) -> bool:
134 return False
136 def readline(self, *_args: t.Any) -> str: # type: ignore[override] # what input() calls
137 raise AssertionError("a prompt was reached that this test does not answer")
140@contextmanager
141def _stdin(replacement: io.StringIO) -> t.Iterator[None]:
142 original, sys.stdin = sys.stdin, replacement
143 try:
144 yield
145 finally:
146 sys.stdin = original
149@pytest.fixture(autouse=True)
150def unattended() -> t.Iterator[None]:
151 """Nobody is at the keyboard for any test in here, `-s` or not."""
152 with _stdin(Unattended()):
153 yield
156@pytest.fixture
157def answers() -> Answers:
158 """
159 Type answers into the prompts, as somebody at a terminal would.
161 Only the prompts a test names: anything else it is asked fails it, because
162 `unattended` is still what the answers run out into.
163 """
165 def typed(*keystrokes: str) -> t.ContextManager[None]:
166 return _stdin(io.StringIO("".join(f"{key}\n" for key in keystrokes)))
168 return typed
171@pytest.mark.usefixtures("no_vommit")
172def test_configured_for_vommit_but_not_installed_offers_the_install(
173 project: MakeProject, ctx: RecordingContext, answers: Answers, capsys: pytest.CaptureFixture[str]
174) -> None:
175 """
176 vommit's config outlives its install -- a migrated project cloned onto a
177 second machine has [tool.vommit] and no vommit. That has to reach the
178 install offer, not an assertion.
179 """
180 with chdir(project(VOMMIT)), answers("n"):
181 assert plugin._resolve_backend(ctx) is None
183 printed = capsys.readouterr().out
184 assert "configured for vommit, but vommit is not installed" in printed, "the diagnosis was never printed"
185 assert "Install vommit" in printed, "no install was offered"
186 assert plugin.vommit_specifier() in printed, "the offer did not name the supported range"
187 assert not ctx.commands, f"declining still ran {ctx.commands}"
190@pytest.mark.usefixtures("no_vommit")
191def test_declining_that_install_does_not_release(project: MakeProject, ctx: RecordingContext, answers: Answers) -> None:
192 """Refusing the install must stop, not fall back to the deprecated path."""
193 with chdir(project(VOMMIT)), answers("n", "n"):
194 plugin.release(ctx, noop=True, pull=False)
195 # ewok hands back its own empty result rather than None, so this asserts
196 # no version came out
197 assert not plugin.bump(ctx, noop=True)
199 # semantic-release, uv build and uv publish would all show up here
200 assert not ctx.commands, f"reached the shell anyway: {ctx.commands}"
203@pytest.mark.usefixtures("no_vommit")
204def test_accepting_the_install_installs_the_declared_spec(
205 project: MakeProject, ctx: RecordingContext, answers: Answers, capsys: pytest.CaptureFixture[str]
206) -> None:
207 """
208 Accepting installs into edwh's own environment -- that is what activates
209 vommit's `edwh` entry point -- and then stops, because this interpreter
210 cannot import what a subprocess just installed.
211 """
212 with chdir(project(VOMMIT)), answers("y"):
213 assert plugin._resolve_backend(ctx) is None
215 assert len(ctx.commands) == 1, f"expected one install, got {ctx.commands}"
216 assert " install " in ctx.commands[0], f"{ctx.commands[0]} is not an install"
217 assert plugin._vommit_spec() in ctx.commands[0], "installed something other than the pinned spec"
218 assert "run this command again" in capsys.readouterr().out, "no way out was offered"
221def test_installed_vommit_resolves_without_asking(project: MakeProject, ctx: RecordingContext) -> None:
222 """
223 The ordinary case: config and install agree, so nothing is asked and
224 nothing is run -- reading stdin at all would fail this test.
225 """
226 pytest.importorskip("vommit.tasks")
228 with chdir(project(VOMMIT)):
229 assert plugin._resolve_backend(ctx) == "vommit"
231 assert not ctx.commands
234def test_bump_without_any_backend_does_not_reach_psr(project: MakeProject, ctx: RecordingContext) -> None:
235 """
236 `release` already refused this; `bump` used to install psr and run it
237 against a project that has no psr config.
239 Nothing is asked either: `_can_ask` checks for a terminal before offering
240 the vommit setup, and `unattended` is not one.
241 """
242 with chdir(project(BARE)):
243 assert not plugin.bump(ctx, noop=True)
245 assert not ctx.commands, f"reached the shell anyway: {ctx.commands}"
248def test_install_specifier_carries_the_declared_bound() -> None:
249 """
250 The install prompt must not be able to pull a vommit edwh does not support.
251 """
252 specifier = plugin.vommit_specifier()
254 assert specifier, "no version bound at all"
255 assert "<1" in specifier.replace(" ", "")
256 assert plugin._vommit_spec().startswith("vommit")
257 assert plugin._vommit_spec().endswith(specifier)
260@pytest.mark.usefixtures("no_vommit")
261def test_require_vommit_task_keeps_its_result_to_itself(ctx: RecordingContext, answers: Answers) -> None:
262 """
263 ewok writes a task's return value into the shared ctx["result"], which the
264 enclosing task then returns as its own. `ensure_vommit` is a plain function
265 for that reason; the task wrapper must not reintroduce the leak.
266 """
267 with answers("n"):
268 plugin.require_vommit(ctx)
270 assert not ctx["result"], f"leaked {ctx['result']!r} into the shared result"