Coverage for src/footman/testing.py: 100%

77 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Test your tasks — an in-process CLI runner and a silent recording context. 

2 

3Three altitudes, matching how tasks are actually written: 

4 

51. **Plain calls.** `@task` returns your function untouched, so a task body 

6 is unit-testable by just calling it: `lint(fix=True)`. 

72. **Recording.** `recording()` captures the commands a block *would* run — 

8 silently, without executing anything — so a test can assert on them: 

9 

10 ```python 

11 from footman.testing import recording 

12 from tasks import lint 

13 

14 def test_lint_fix_passes_the_flag(): 

15 with recording() as steps: 

16 lint(fix=True) 

17 assert steps[0].command == "ruff check . --fix" 

18 ``` 

19 

203. **CLI-level.** `Runner.invoke` drives argv → exit code → output → 

21 structured results, entirely in-process: 

22 

23 ```python 

24 result = Runner().invoke("--dry-run release 1.2.0 --push") 

25 assert result.ok 

26 assert result.results[0].task == "release" 

27 ``` 

28 

29Everything here is stdlib-only — the zero-dependency promise holds. The 

30pytest fixtures in `footman.pytest_plugin` are thin shims over this module, 

31so non-pytest users get the same power. 

32""" 

33 

34from __future__ import annotations 

35 

36import contextlib 

37import io 

38import os 

39import shlex 

40import tempfile 

41from collections.abc import Generator, Mapping 

42from dataclasses import dataclass, field 

43from pathlib import Path 

44from typing import Any 

45 

46from footman import _app, context 

47from footman._executor import TaskResult 

48from footman.app import App 

49from footman.context import Context, Result, use_context 

50from footman.registry import Group 

51 

52__all__ = [ 

53 "InvokeResult", 

54 "Result", 

55 "Runner", 

56 "TaskResult", 

57 "recording", 

58 "use_context", 

59] 

60 

61 

62@contextlib.contextmanager 

63def recording( 

64 *, answers: Mapping[Any, Any] | None = None, **overrides: Any 

65) -> Generator[list[Result]]: 

66 """Capture the commands a block would `run()` — silently, not executing. 

67 

68 Yields the live step list; each `run()` call inside the block — hosted 

69 toolroom calls included — appends a `Result` instead of executing, and 

70 answers with a blank success. Keyword overrides go to the underlying 

71 `Context` (e.g. `env={...}`). 

72 

73 `answers=` scripts the answers instead. Keys are command prefixes — 

74 `"uv tool list"`, or a tuple of tokens — matched against the recorded 

75 command (`Result.command`), longest match first. Values: a `str` is 

76 stdout with exit 0, an `int` is an exit code, a `Result` sets code and 

77 both streams, an exception instance is *raised* by the call (a missing 

78 binary), and a list answers consecutive matches in order and refuses by 

79 name when it runs dry. Unmatched calls keep the blank success. A 

80 non-zero answer takes the real failing lane — returned under 

81 `nofail=True`, raised as `RunFailed` otherwise — and a matched call is 

82 answered even when it opted out of the record with `recorded=False`. 

83 A recorded step also keeps the `env` and `cwd` it would have run with. 

84 

85 toolroom's `answers()` is the same table applied one layer down, at the 

86 bridge's own seam; nested inside a recording, it wins and the record 

87 sees nothing. 

88 """ 

89 # Build the kwargs dict so `overrides` can win over the dry_run/quiet 

90 # defaults — passing them as positional defaults made `recording(quiet=False)` 

91 # raise "got multiple values for keyword argument" (F51). 

92 # One merged dict, then one splat — a caller's override may replace the 

93 # defaults (a direct `Context(dry_run=True, **overrides)` would raise on 

94 # a duplicate keyword instead). 

95 merged: dict[str, Any] = {"dry_run": True, "quiet": True, **overrides} 

96 if answers is not None: 

97 merged["answers"] = context._Answers(answers) 

98 ctx = Context(**merged) 

99 with use_context(ctx): 

100 yield ctx.steps 

101 

102 

103@dataclass 

104class InvokeResult: 

105 """Everything one `Runner.invoke` produced. 

106 

107 Named apart from the run-step `Result` (`footman.Result`, what `run()` 

108 returns): this is the outcome of a whole CLI invocation — its exit code, 

109 captured streams, and per-task `TaskResult`s.""" 

110 

111 exit_code: int 

112 stdout: str 

113 stderr: str 

114 results: list[TaskResult] = field(default_factory=list) 

115 

116 @property 

117 def ok(self) -> bool: 

118 return self.exit_code == 0 

119 

120 

121@contextlib.contextmanager 

122def _isolated(cwd: Path | None) -> Generator[None]: 

123 """A throwaway completion cache (and optional cwd) for one invocation. 

124 

125 **The brand and its locations are restored too.** A real entry point runs 

126 one brand and deliberately never puts the module globals back — the 

127 process *is* that CLI, and `run` may not even return (the uv handoff 

128 re-execs). A test process is the one place that isn't true: it drives 

129 many brands in a row, and the next one must not read the last one's 

130 world. `_paths` was already restored here; `_app._brand` was not, so 

131 whichever `Runner(App(...))` ran first in a pytest-xdist worker silently 

132 decided what every later test saw — three macOS jobs failed on that, and 

133 only three, because the workers happened to order things differently. 

134 """ 

135 from footman import _app, _paths 

136 

137 with tempfile.TemporaryDirectory(prefix="footman-test-") as tmp: 

138 old = os.environ.get("XDG_CACHE_HOME") 

139 os.environ["XDG_CACHE_HOME"] = tmp 

140 where = _paths.child_args() 

141 brand = _app._brand 

142 try: 

143 if cwd is not None: 

144 with contextlib.chdir(cwd): 

145 yield 

146 else: 

147 yield 

148 finally: 

149 _app._brand = brand 

150 _paths.configure_child(*where) 

151 if old is None: 

152 os.environ.pop("XDG_CACHE_HOME", None) 

153 else: 

154 os.environ["XDG_CACHE_HOME"] = old 

155 

156 

157class Runner: 

158 """Drive a footman CLI in-process, capturing output and results. 

159 

160 Pass a branded `App` to test a custom CLI (`Runner(App(prog="acme"))`) — 

161 error prefixes, `--version`, and hints then use that brand, exactly as 

162 they would for real users. 

163 """ 

164 

165 app: App 

166 

167 def __init__(self, app: App | None = None) -> None: 

168 self.app = app if app is not None else App() 

169 

170 def invoke( 

171 self, 

172 args: str | list[str], 

173 *, 

174 tasks: Path | Group | None = None, 

175 cwd: Path | None = None, 

176 stdin: str | bytes | None = None, 

177 answers: Mapping[Any, Any] | None = None, 

178 ) -> InvokeResult: 

179 """Run one command line and return everything it produced. 

180 

181 `args` is a string (shlex-split) or an argv list. `tasks` overrides 

182 discovery: a `Path` routes through `--tasks-file`, a `Group` skips 

183 discovery entirely (an in-memory tree, no files needed). Without it, 

184 the normal `tasks.py` cascade from `cwd` applies. `stdin` is what a 

185 pipe would have delivered — a `str` is encoded UTF-8 — and its 

186 absence means "a terminal": the invocation never reads the test 

187 harness's real stream, so `stdin`-bound parameters see exactly what 

188 the test says and nothing else. `answers` scripts the world the 

189 invocation rehearses in — the same table `recording(answers=…)` 

190 takes — and implies `--dry-run`, so the CLI runs end to end against 

191 scripted commands. Never raises on task failure — the code is in 

192 the `Result`; `KeyboardInterrupt` passes through. 

193 """ 

194 argv = shlex.split(args) if isinstance(args, str) else list(args) 

195 if answers is not None and not any(a in ("--dry-run", "-n") for a in argv): 

196 argv = ["--dry-run", *argv] 

197 out, err = io.StringIO(), io.StringIO() 

198 collected: list[TaskResult] = [] 

199 payload = stdin.encode("utf-8") if isinstance(stdin, str) else stdin 

200 previous = context._inject_stdin(payload) 

201 table = context._inject_answers( 

202 context._Answers(answers) if answers is not None else None 

203 ) 

204 try: 

205 return self._invoke(argv, tasks, cwd, out, err, collected) 

206 finally: 

207 context._inject_answers(table) 

208 context._restore_stdin_payload(previous) 

209 

210 def _invoke( 

211 self, 

212 argv: list[str], 

213 tasks: Path | Group | None, 

214 cwd: Path | None, 

215 out: io.StringIO, 

216 err: io.StringIO, 

217 collected: list[TaskResult], 

218 ) -> InvokeResult: 

219 with ( 

220 _isolated(cwd), 

221 contextlib.redirect_stdout(out), 

222 contextlib.redirect_stderr(err), 

223 ): 

224 # `App.run` is bypassed below, so point the locations at this 

225 # brand's world here — the same call a real entry point makes 

226 # before anything reads a path. 

227 self.app.brand.install() 

228 if isinstance(tasks, Group): 

229 # One shared surface with the real CLI (help/version/list/tree/ 

230 # json all honoured) — no drifting Group-mode re-implementation. 

231 code = _app.run_group( 

232 tasks, argv, brand=self.app.brand, collect=collected 

233 ) 

234 else: 

235 if tasks is not None: 

236 argv = [f"--tasks-file={tasks}", *argv] 

237 # `_run`, not `run`: bypass the CLI's KeyboardInterrupt->130 

238 # wrapper so Ctrl-C reaches pytest (the invoke docstring's 

239 # contract). The wrapper stays for real CLI entry. 

240 # `handoff=False`: the uv re-exec must never fire from an 

241 # embedded invocation — it would execvp the HOST process 

242 # (pytest; under pytest-xdist the worker whose stdio is the 

243 # test-protocol channel, which the exec kills outright). 

244 code = _app._run( 

245 argv, brand=self.app.brand, collect=collected, handoff=False 

246 ) 

247 return InvokeResult(code, out.getvalue(), err.getvalue(), collected)