Coverage for src/rhiza_hooks/check_test_layout.py: 100%

98 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 06:14 +0000

1#!/usr/bin/env python3 

2"""Check that the test layout mirrors the source layout. 

3 

4Enforces a strict test/source parity so tests are easy to locate and no test 

5drifts loose from what it covers: 

6 

7 * every source module ``<src>/…/xyz.py`` has a test file 

8 ``<tests>/…/test_xyz.py`` (nested packages are mirrored); 

9 * every top-level ``class A`` in a source module has a matching ``TestA`` 

10 class in that test file; 

11 * no test file lacks a corresponding source module (no orphan test files); 

12 * no ``Test*`` class lacks a corresponding source class (no orphan test 

13 classes). 

14 

15The reverse direction is the one that pays for itself: a renamed or retired 

16module leaves its tests behind, and those tests keep passing against nothing. 

17 

18``__init__.py`` and ``conftest.py`` are ignored on both sides, and the 

19``tests/benchmarks/`` and ``tests/stress/`` trees are exempt entirely — those 

20hold benchmarks and stress tests that need not mirror a source module. Test 

21*functions* are unconstrained — the rules bind files and classes only. 

22 

23Repositories that deliberately organise tests by *behaviour* rather than 1:1 

24mirroring (and guarantee per-module coverage another way, e.g. a 100% coverage 

25gate) can opt out via a ``[tool.check_test_layout]`` table in ``pyproject.toml``:: 

26 

27 [tool.check_test_layout] 

28 enforce = false 

29 reason = "Tests are grouped by behaviour; coverage is enforced by pytest." 

30 

31``enforce = false`` requires a non-empty ``reason`` so the deviation is always 

32documented — an undocumented opt-out is indistinguishable from neglect. The same 

33table accepts ``exempt_dirs = [...]`` to extend the built-in benchmarks/stress 

34exemptions when parity *is* enforced, and ``exempt_files = [...]`` to exempt 

35individual test files by their path relative to the tests root — for a single 

36loose file, ``exempt_dirs`` cannot express it, since its first path component is 

37the file itself and exempting that reads as a directory that does not exist. 

38 

39The configuration lives in ``pyproject.toml`` rather than under ``.rhiza/`` 

40deliberately: the layout it describes is a property of the Python project, not of 

41the template that syncs its infrastructure, and a repo that is not rhiza-managed 

42must still be able to configure this hook. 

43 

44Exit codes: 

45 0 - the layout is clean, or parity is intentionally not enforced 

46 1 - violations were found (every one is listed), or the opt-out is misconfigured 

47""" 

48 

49from __future__ import annotations 

50 

51import argparse 

52import ast 

53import sys 

54import tomllib 

55from collections.abc import Mapping 

56from pathlib import Path 

57 

58from rhiza_hooks._repo import find_repo_root 

59 

60_IGNORED = {"__init__.py", "conftest.py"} 

61 

62# Top-level directories under the tests root that are exempt from parity by 

63# default: they hold benchmarks / stress tests that need not mirror a source 

64# module. A repo can extend this set via ``[tool.check_test_layout] exempt_dirs``. 

65_DEFAULT_EXEMPT_DIRS = {"benchmarks", "stress"} 

66 

67 

68def _read_config(pyproject: Path) -> dict[str, object]: 

69 """Return the ``[tool.check_test_layout]`` table from *pyproject* (empty if absent). 

70 

71 A missing, malformed or unreadable pyproject.toml is somebody else's error to 

72 report — the same lenient stance the other hooks in this package take. 

73 

74 Args: 

75 pyproject: Path to the ``pyproject.toml`` to read. 

76 

77 Returns: 

78 The configuration table, or an empty dict when it cannot be read. 

79 """ 

80 if not pyproject.is_file(): 

81 return {} 

82 try: 

83 with pyproject.open("rb") as handle: 

84 data = tomllib.load(handle) 

85 except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError): 

86 # tomllib decodes the stream itself, so invalid UTF-8 surfaces as 

87 # UnicodeDecodeError rather than a TOML error. 

88 return {} 

89 section = data.get("tool", {}).get("check_test_layout", {}) 

90 return section if isinstance(section, dict) else {} 

91 

92 

93def _exempt_dirs(config: Mapping[str, object]) -> set[str]: 

94 """Return the exempt top-level test dirs: defaults plus any from *config*. 

95 

96 Config *extends* the built-ins rather than replacing them, so a repo that 

97 exempts one extra directory does not silently re-arm the benchmarks and 

98 stress trees. 

99 

100 A value of the wrong type is ignored rather than raising: a typo in a 

101 consumer's ``pyproject.toml`` degrades to the defaults instead of breaking 

102 every commit in that repo. 

103 

104 >>> sorted(_exempt_dirs({})) 

105 ['benchmarks', 'stress'] 

106 >>> sorted(_exempt_dirs({"exempt_dirs": ["meta"]})) 

107 ['benchmarks', 'meta', 'stress'] 

108 >>> sorted(_exempt_dirs({"exempt_dirs": "meta"})) # a string, not a list 

109 ['benchmarks', 'stress'] 

110 """ 

111 dirs = set(_DEFAULT_EXEMPT_DIRS) 

112 extra = config.get("exempt_dirs") 

113 if isinstance(extra, list): 

114 dirs |= {str(d) for d in extra} 

115 return dirs 

116 

117 

118def _exempt_files(config: Mapping[str, object]) -> set[str]: 

119 """Return the exempt test files from *config* (none are exempt by default). 

120 

121 Entries are paths relative to the tests root (POSIX separators), not bare 

122 names, so exempting one file cannot silently exempt a same-named file 

123 elsewhere in the tree. 

124 

125 As with :func:`_exempt_dirs`, a value of the wrong type is ignored rather 

126 than raising. 

127 

128 >>> sorted(_exempt_files({})) 

129 [] 

130 >>> sorted(_exempt_files({"exempt_files": ["test_rhiza_packaging.py"]})) 

131 ['test_rhiza_packaging.py'] 

132 >>> sorted(_exempt_files({"exempt_files": "test_rhiza_packaging.py"})) 

133 [] 

134 """ 

135 entries = config.get("exempt_files") 

136 if not isinstance(entries, list): 

137 return set() 

138 return {str(f) for f in entries} 

139 

140 

141def _top_level_classes(path: Path) -> set[str]: 

142 """Return the names of top-level classes defined in *path*. 

143 

144 The source is handed to :func:`ast.parse` as *bytes*, not text. Decoding it here 

145 would mean choosing an encoding, and the platform default is the wrong choice — 

146 on Windows that is cp1252, where any test file containing an em dash or an 

147 accented word raises ``UnicodeDecodeError`` and takes the whole check down. 

148 ``ast.parse`` decodes bytes per PEP 263, honouring a ``# -*- coding: -*-`` cookie 

149 and defaulting to UTF-8, which is exactly the rule the interpreter itself applies 

150 to the file. 

151 

152 Deliberately carries no ``>>>`` example: it needs a file on disk, so the example 

153 would be three lines of ``tempfile`` setup around one assertion. The behaviour 

154 worth pinning — that nested classes and functions are excluded — is covered by 

155 ``test_top_level_classes`` instead. 

156 """ 

157 tree = ast.parse(path.read_bytes(), filename=str(path)) 

158 return {node.name for node in tree.body if isinstance(node, ast.ClassDef)} 

159 

160 

161def _source_modules(src: Path) -> list[Path]: 

162 """Return the source ``.py`` modules under *src* (ignoring dunder/conftest).""" 

163 return sorted(p for p in src.rglob("*.py") if p.name not in _IGNORED) 

164 

165 

166def _test_files(tests: Path, exempt: set[str] | None = None, exempt_files: set[str] | None = None) -> list[Path]: 

167 """Return the ``test_*.py`` files under *tests* (ignoring conftest/exempt dirs and files).""" 

168 exempt = _DEFAULT_EXEMPT_DIRS if exempt is None else exempt 

169 exempt_files = exempt_files or set() 

170 return sorted( 

171 p 

172 for p in tests.rglob("test_*.py") 

173 if p.name not in _IGNORED 

174 and p.relative_to(tests).parts[0] not in exempt 

175 and p.relative_to(tests).as_posix() not in exempt_files 

176 ) 

177 

178 

179def _forward_violations(src: Path, tests: Path) -> list[str]: 

180 """Return violations where a source module or class has no mirrored test. 

181 

182 The forward direction takes no exemptions: ``exempt_dirs``/``exempt_files`` 

183 name paths under the *tests* root, and this pass is keyed by source module. 

184 """ 

185 errors: list[str] = [] 

186 for module in _source_modules(src): 

187 rel = module.relative_to(src) 

188 test_path = tests / rel.parent / f"test_{module.stem}.py" 

189 if not test_path.exists(): 

190 errors.append(f"missing test file {test_path} for source module {module}") 

191 continue 

192 test_classes = _top_level_classes(test_path) 

193 for cls in sorted(_top_level_classes(module)): 

194 if f"Test{cls}" not in test_classes: 

195 errors.append(f"missing class Test{cls} in {test_path} for class {cls} in {module}") 

196 return errors 

197 

198 

199def _orphan_violations(src: Path, tests: Path, exempt: set[str], exempt_files: set[str]) -> list[str]: 

200 """Return violations where a test file or ``Test*`` class has no source counterpart. 

201 

202 This is the direction that catches a renamed or retired module whose tests 

203 linger and keep passing, and the one the exemptions apply to. 

204 """ 

205 errors: list[str] = [] 

206 for test_file in _test_files(tests, exempt, exempt_files): 

207 rel = test_file.relative_to(tests) 

208 source_name = test_file.stem[len("test_") :] 

209 source_path = src / rel.parent / f"{source_name}.py" 

210 if not source_path.exists(): 

211 errors.append(f"orphan test file {test_file} (no source module {source_path})") 

212 continue 

213 source_classes = _top_level_classes(source_path) 

214 for cls in sorted(_top_level_classes(test_file)): 

215 if cls.startswith("Test") and cls[len("Test") :] not in source_classes: 

216 errors.append( 

217 f"orphan test class {cls} in {test_file} (no class {cls[len('Test') :]} in {source_path})" 

218 ) 

219 return errors 

220 

221 

222def check(src: Path, tests: Path, config: Mapping[str, object] | None = None) -> list[str]: 

223 """Return a list of layout violations (empty when the layout is clean). 

224 

225 The two directions are independent passes — see :func:`_forward_violations` 

226 and :func:`_orphan_violations` — and this is their concatenation, forward 

227 first so a missing test file is reported before its knock-on orphans. 

228 """ 

229 config = config or {} 

230 return [ 

231 *_forward_violations(src, tests), 

232 *_orphan_violations(src, tests, _exempt_dirs(config), _exempt_files(config)), 

233 ] 

234 

235 

236def main(argv: list[str] | None = None) -> int: 

237 """Run the hook and return a process exit code. 

238 

239 ``--src``/``--tests``/``--config`` are resolved against the current working 

240 directory (pre-commit runs hooks from the repository root); the defaults are 

241 anchored to the repository root itself, so the hook also behaves when invoked 

242 from a subdirectory by hand. 

243 """ 

244 parser = argparse.ArgumentParser(description="Check test/source layout parity.") 

245 parser.add_argument( 

246 "filenames", 

247 nargs="*", 

248 help="Filenames (ignored: parity is a property of the whole tree, not of one file)", 

249 ) 

250 parser.add_argument("--src", default=None, help="Source directory (default: <repo root>/src).") 

251 parser.add_argument("--tests", default=None, help="Tests directory (default: <repo root>/tests).") 

252 parser.add_argument( 

253 "--config", 

254 default=None, 

255 help="pyproject.toml providing [tool.check_test_layout] (default: <repo root>/pyproject.toml).", 

256 ) 

257 args = parser.parse_args(argv) 

258 

259 repo_root = find_repo_root() 

260 src = Path(args.src) if args.src else repo_root / "src" 

261 tests = Path(args.tests) if args.tests else repo_root / "tests" 

262 config = _read_config(Path(args.config) if args.config else repo_root / "pyproject.toml") 

263 

264 if not config.get("enforce", True): 

265 reason = str(config.get("reason", "")).strip() 

266 if not reason: 

267 print( 

268 "Test-layout check misconfigured: [tool.check_test_layout] enforce=false " 

269 "requires a non-empty 'reason' documenting the intentional layout.", 

270 file=sys.stderr, 

271 ) 

272 return 1 

273 print(f"Test layout OK: parity not enforced by request — {reason}") 

274 return 0 

275 

276 errors = check(src, tests, config) 

277 if errors: 

278 print("Test-layout check failed:", file=sys.stderr) 

279 for err in errors: 

280 print(f"{err}", file=sys.stderr) 

281 return 1 

282 print("Test layout OK: tests mirror sources 1:1") 

283 return 0 

284 

285 

286if __name__ == "__main__": # pragma: no mutate 

287 sys.exit(main())