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

1"""Pytest fixtures for testing tasks — auto-loaded when footman is installed. 

2 

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. 

7 

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

14 

15from __future__ import annotations 

16 

17import textwrap 

18from typing import TYPE_CHECKING 

19 

20import pytest 

21 

22if TYPE_CHECKING: 

23 from collections.abc import Callable, Iterator 

24 from pathlib import Path 

25 

26 from footman.context import Result 

27 from footman.testing import Runner 

28 

29__all__ = ["fm", "fm_project", "fm_record"] 

30 

31 

32@pytest.fixture 

33def fm() -> Runner: 

34 """A `Runner` for the project the test process runs in.""" 

35 from footman.testing import Runner 

36 

37 return Runner() 

38 

39 

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. 

45 

46 ```python 

47 def test_release(fm_project): 

48 fm = fm_project(''' 

49 from footman import task, run 

50 

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

57 

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 

63 

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

73 

74 return make 

75 

76 

77@pytest.fixture 

78def fm_record() -> Iterator[list[Result]]: 

79 """Recorded steps for the whole test: task code runs, commands don't. 

80 

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 

89 

90 with recording() as steps: 

91 yield steps 

92 

93 

94def pytest_configure(config: pytest.Config) -> None: 

95 """Join a profiled footman run, when there is one to join. 

96 

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 

103 

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

112 

113 

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`. 

117 

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

122 

123 def __init__(self, sink: str) -> None: 

124 self._sink = sink 

125 self._events: list[dict[str, object]] = [] 

126 self._tracks: dict[str, int] = {} 

127 

128 def _tid(self, track: str) -> int: 

129 return self._tracks.setdefault(track, len(self._tracks) + 1) 

130 

131 def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: 

132 import os 

133 

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 ) 

150 

151 def pytest_sessionfinish(self) -> None: 

152 import json 

153 import os 

154 import sys 

155 

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)