Coverage for src/footman/pytest_plugin.py: 100%
59 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Pytest fixtures for testing tasks — auto-loaded when footman is installed.
3Registered through the `pytest11` entry point, so `pip install footman`
4alongside pytest is all it takes; there is nothing to enable. Each fixture is
5a thin shim over `footman.testing` — non-pytest users get the same power from
6that module directly.
8Two deliberate laziness rules: this module is only ever imported by pytest
9itself (footman's runtime stays zero-dependency), and it imports nothing from
10footman at module level — pytest loads entry-point plugins before coverage
11tools start measuring, so an eager import here would make every downstream
12project's coverage of footman-adjacent code look worse than it is.
13"""
15from __future__ import annotations
17import textwrap
18from typing import TYPE_CHECKING
20import pytest
22if TYPE_CHECKING:
23 from collections.abc import Callable, Iterator
24 from pathlib import Path
26 from footman.context import Result
27 from footman.testing import Runner
29__all__ = ["fm", "fm_project", "fm_record"]
32@pytest.fixture
33def fm() -> Runner:
34 """A `Runner` for the project the test process runs in."""
35 from footman.testing import Runner
37 return Runner()
40@pytest.fixture
41def fm_project(
42 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
43) -> Callable[..., Runner]:
44 """Factory: scaffold an isolated project from tasks-file source.
46 ```python
47 def test_release(fm_project):
48 fm = fm_project('''
49 from footman import task, run
51 @task
52 def release(version: str):
53 run(f"git tag v{version}")
54 ''')
55 assert fm.invoke("--dry-run release 1.2.0").ok
56 ```
58 Writes a minimal `pyproject.toml` plus the tasks file into `tmp_path`,
59 chdirs there for the test, and returns a `Runner`. Pass `name=` to use a
60 non-default tasks filename (wired up via `[tool.footman] tasks`).
61 """
62 from footman.testing import Runner
64 def make(source: str, *, name: str = "tasks.py") -> Runner:
65 config = "" if name == "tasks.py" else f"\n[tool.footman]\ntasks = '{name}'\n"
66 (tmp_path / "pyproject.toml").write_text(
67 f'[project]\nname = "test"\nversion = "0"\n{config}',
68 encoding="utf-8",
69 )
70 (tmp_path / name).write_text(textwrap.dedent(source), encoding="utf-8")
71 monkeypatch.chdir(tmp_path)
72 return Runner()
74 return make
77@pytest.fixture
78def fm_record() -> Iterator[list[Result]]:
79 """Recorded steps for the whole test: task code runs, commands don't.
81 ```python
82 def test_lint_fix(fm_record):
83 from tasks import lint
84 lint(fix=True)
85 assert fm_record[0].command == "ruff check . --fix"
86 ```
87 """
88 from footman.testing import recording
90 with recording() as steps:
91 yield steps
94def pytest_configure(config: pytest.Config) -> None:
95 """Join a profiled footman run, when there is one to join.
97 A profiled `fm` exports `FM_PROFILE_DIR` to every task's children; a
98 pytest that inherits it drops a Chrome-trace fragment of its own tests
99 there, and the profile embeds it — every test's setup/call/teardown on
100 the run's timeline, xdist workers as named tracks. Unset — every pytest
101 that is not a profiled run's child — this is one dict read."""
102 import os
104 sink = os.environ.get("FM_PROFILE_DIR")
105 if sink and not hasattr(config, "workerinput"):
106 # `workerinput` marks an xdist worker process — there the recorder
107 # stays unregistered, because the controller replays every worker's
108 # report and records it once. The env var alone cannot say which
109 # side this is: a pytest *spawned by a test* inherits the outer
110 # suite's variables, and it is a fresh controller, not a worker.
111 config.pluginmanager.register(_TraceRecorder(sink), "footman-profile-trace")
114class _TraceRecorder:
115 """Per-test timings as a Chrome-trace fragment, per the profile plugin's
116 drop-directory convention: `ts` in epoch microseconds, our own `pid`.
118 Registered on controllers only (see `pytest_configure`), so each report
119 is recorded exactly once — locally on a plain run, replayed-from-worker
120 under xdist, on a track named for the worker it ran on.
121 """
123 def __init__(self, sink: str) -> None:
124 self._sink = sink
125 self._events: list[dict[str, object]] = []
126 self._tracks: dict[str, int] = {}
128 def _tid(self, track: str) -> int:
129 return self._tracks.setdefault(track, len(self._tracks) + 1)
131 def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
132 import os
134 start = getattr(report, "start", None)
135 stop = getattr(report, "stop", None)
136 if not isinstance(start, float) or not isinstance(stop, float):
137 return
138 track = getattr(report, "worker_id", None)
139 self._events.append(
140 {
141 "ph": "X",
142 "cat": f"test.{report.when}",
143 "name": report.nodeid,
144 "pid": os.getpid(),
145 "tid": self._tid(track or "pytest"),
146 "ts": start * 1e6,
147 "dur": round((stop - start) * 1e6, 1),
148 }
149 )
151 def pytest_sessionfinish(self) -> None:
152 import json
153 import os
154 import sys
156 if not self._events:
157 return
158 pid = os.getpid()
159 metadata: list[dict[str, object]] = [
160 {"ph": "M", "name": "process_name", "pid": pid, "args": {"name": "pytest"}}
161 ]
162 for track, tid in self._tracks.items():
163 metadata.append(
164 {
165 "ph": "M",
166 "name": "thread_name",
167 "pid": pid,
168 "tid": tid,
169 "args": {"name": track},
170 }
171 )
172 try:
173 with open(
174 os.path.join(self._sink, f"pytest-{pid}.json"), "w", encoding="utf-8"
175 ) as sink:
176 json.dump({"traceEvents": metadata + self._events}, sink)
177 except OSError as exc: # a broken drop never fails the suite it rode in
178 print(f"footman profile fragment not written: {exc}", file=sys.stderr)