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

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. 

6 

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

13 

14import importlib 

15import io 

16import sys 

17import types 

18import typing as t 

19from contextlib import chdir, contextmanager 

20from pathlib import Path 

21 

22import ewok 

23import pytest 

24 

25from src.edwh.local_tasks import plugin 

26 

27# What the `project` fixture hands back: write this pyproject, get its directory. 

28type MakeProject = t.Callable[[str], Path] 

29 

30 

31class Answers(t.Protocol): 

32 """ 

33 What the `answers` fixture hands back. 

34 

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

38 

39 def __call__(self, *keystrokes: str) -> t.ContextManager[None]: ... 39 ↛ exitline 39 didn't return from function '__call__' because

40 

41 

42VOMMIT = """[project] 

43name = "demo" 

44version = "1.0.0" 

45 

46[tool.vommit] 

47prerelease_token = "beta" 

48""" 

49 

50BARE = """[project] 

51name = "demo" 

52version = "1.0.0" 

53""" 

54 

55 

56class RecordingContext(ewok.Context): 

57 """ 

58 A Context that writes commands down instead of running them. 

59 

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

65 

66 def __init__(self) -> None: 

67 super().__init__(host="localhost") 

68 self.commands: list[str] = [] 

69 

70 def run(self, command: str, **_: t.Any) -> None: # type: ignore[override] 

71 self.commands.append(command) 

72 return None 

73 

74 

75@pytest.fixture 

76def ctx() -> RecordingContext: 

77 return RecordingContext() 

78 

79 

80@pytest.fixture 

81def project(tmp_path: Path) -> MakeProject: 

82 """Build a throwaway project to run a task inside.""" 

83 

84 def make(content: str) -> Path: 

85 (tmp_path / "pyproject.toml").write_text(content) 

86 return tmp_path 

87 

88 return make 

89 

90 

91@pytest.fixture 

92def no_vommit() -> t.Iterator[None]: 

93 """ 

94 An interpreter where the optional `vommit` extra is not installed. 

95 

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

100 

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 

108 

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] 

113 

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) 

122 

123 

124class Unattended(io.StringIO): 

125 """ 

126 A stdin with nobody behind it: not a terminal, and loud when read. 

127 

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

132 

133 def isatty(self) -> bool: 

134 return False 

135 

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

138 

139 

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 

147 

148 

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 

154 

155 

156@pytest.fixture 

157def answers() -> Answers: 

158 """ 

159 Type answers into the prompts, as somebody at a terminal would. 

160 

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

164 

165 def typed(*keystrokes: str) -> t.ContextManager[None]: 

166 return _stdin(io.StringIO("".join(f"{key}\n" for key in keystrokes))) 

167 

168 return typed 

169 

170 

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 

182 

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

188 

189 

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) 

198 

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

201 

202 

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 

214 

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" 

219 

220 

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

227 

228 with chdir(project(VOMMIT)): 

229 assert plugin._resolve_backend(ctx) == "vommit" 

230 

231 assert not ctx.commands 

232 

233 

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. 

238 

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) 

244 

245 assert not ctx.commands, f"reached the shell anyway: {ctx.commands}" 

246 

247 

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

253 

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) 

258 

259 

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) 

269 

270 assert not ctx["result"], f"leaked {ctx['result']!r} into the shared result"