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
« 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.
3Three altitudes, matching how tasks are actually written:
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:
10 ```python
11 from footman.testing import recording
12 from tasks import lint
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 ```
203. **CLI-level.** `Runner.invoke` drives argv → exit code → output →
21 structured results, entirely in-process:
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 ```
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"""
34from __future__ import annotations
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
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
52__all__ = [
53 "InvokeResult",
54 "Result",
55 "Runner",
56 "TaskResult",
57 "recording",
58 "use_context",
59]
62@contextlib.contextmanager
63def recording(**overrides: Any) -> Iterator[list[Result]]:
64 """Capture the commands a block would `run()` — silently, not executing.
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
79@dataclass
80class InvokeResult:
81 """Everything one `Runner.invoke` produced.
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."""
87 exit_code: int
88 stdout: str
89 stderr: str
90 results: list[TaskResult] = field(default_factory=list)
92 @property
93 def ok(self) -> bool:
94 return self.exit_code == 0
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
116class Runner:
117 """Drive a footman CLI in-process, capturing output and results.
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 """
124 def __init__(self, app: App | None = None) -> None:
125 self.app = app if app is not None else App()
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.
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)