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

57 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +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 Iterator 

42from dataclasses import dataclass, field 

43from pathlib import Path 

44from typing import Any 

45 

46from footman import _app 

47from footman.app import App 

48from footman.context import Context, Result, use_context 

49from footman.executor import TaskResult 

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(**overrides: Any) -> Iterator[list[Result]]: 

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

65 

66 Yields the live step list; each `run()`/`tools.*` call inside the block 

67 appends a `Result` instead of executing. In-process callables passed to 

68 `run()` are skipped too — that is the point, but worth knowing. Keyword 

69 overrides go to the underlying `Context` (e.g. `env={...}`). 

70 """ 

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

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

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

74 ctx = Context(**{"dry_run": True, "quiet": True, **overrides}) 

75 with use_context(ctx): 

76 yield ctx.steps 

77 

78 

79@dataclass 

80class InvokeResult: 

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

82 

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

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

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

86 

87 exit_code: int 

88 stdout: str 

89 stderr: str 

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

91 

92 @property 

93 def ok(self) -> bool: 

94 return self.exit_code == 0 

95 

96 

97@contextlib.contextmanager 

98def _isolated(cwd: Path | None) -> Iterator[None]: 

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

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

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

102 os.environ["XDG_CACHE_HOME"] = tmp 

103 try: 

104 if cwd is not None: 

105 with contextlib.chdir(cwd): 

106 yield 

107 else: 

108 yield 

109 finally: 

110 if old is None: 110 ↛ 113line 110 didn't jump to line 113 because the condition on line 110 was always true

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

112 else: 

113 os.environ["XDG_CACHE_HOME"] = old 

114 

115 

116class Runner: 

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

118 

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

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

121 they would for real users. 

122 """ 

123 

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

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

126 

127 def invoke( 

128 self, 

129 args: str | list[str], 

130 *, 

131 tasks: Path | Group | None = None, 

132 cwd: Path | None = None, 

133 ) -> InvokeResult: 

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

135 

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

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

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

139 the normal `tasks.py` cascade from `cwd` applies. Never raises on 

140 task failure — the code is in the `Result`; `KeyboardInterrupt` 

141 passes through. 

142 """ 

143 argv = shlex.split(args) if isinstance(args, str) else [str(a) for a in args] 

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

145 collected: list[TaskResult] = [] 

146 with ( 

147 _isolated(cwd), 

148 contextlib.redirect_stdout(out), 

149 contextlib.redirect_stderr(err), 

150 ): 

151 if isinstance(tasks, Group): 

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

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

154 code = _app.run_group( 

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

156 ) 

157 else: 

158 if tasks is not None: 

159 argv = ["--tasks-file", str(tasks), *argv] 

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

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

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

163 code = _app._run(argv, brand=self.app.brand, collect=collected) 

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