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

94 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 05:51 +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 

54from collections.abc import Mapping 

55from pathlib import Path 

56 

57from rhiza_hooks._repo import find_repo_root 

58from rhiza_hooks._toml import load_toml 

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 data = load_toml(pyproject) 

81 if data is None: 

82 return {} 

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

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

85 

86 

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

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

89 

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

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

92 stress trees. 

93 

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

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

96 every commit in that repo. 

97 

98 >>> sorted(_exempt_dirs({})) 

99 ['benchmarks', 'stress'] 

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

101 ['benchmarks', 'meta', 'stress'] 

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

103 ['benchmarks', 'stress'] 

104 """ 

105 dirs = set(_DEFAULT_EXEMPT_DIRS) 

106 extra = config.get("exempt_dirs") 

107 if isinstance(extra, list): 

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

109 return dirs 

110 

111 

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

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

114 

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

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

117 elsewhere in the tree. 

118 

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

120 than raising. 

121 

122 >>> sorted(_exempt_files({})) 

123 [] 

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

125 ['test_rhiza_packaging.py'] 

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

127 [] 

128 """ 

129 entries = config.get("exempt_files") 

130 if not isinstance(entries, list): 

131 return set() 

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

133 

134 

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

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

137 

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

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

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

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

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

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

144 to the file. 

145 

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

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

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

149 ``test_top_level_classes`` instead. 

150 """ 

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

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

153 

154 

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

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

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

158 

159 

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

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

162 exempt = _DEFAULT_EXEMPT_DIRS if exempt is None else exempt 

163 exempt_files = exempt_files or set() 

164 return sorted( 

165 p 

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

167 if p.name not in _IGNORED 

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

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

170 ) 

171 

172 

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

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

175 

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

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

178 """ 

179 errors: list[str] = [] 

180 for module in _source_modules(src): 

181 rel = module.relative_to(src) 

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

183 if not test_path.exists(): 

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

185 continue 

186 test_classes = _top_level_classes(test_path) 

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

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

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

190 return errors 

191 

192 

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

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

195 

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

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

198 """ 

199 errors: list[str] = [] 

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

201 rel = test_file.relative_to(tests) 

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

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

204 if not source_path.exists(): 

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

206 continue 

207 source_classes = _top_level_classes(source_path) 

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

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

210 errors.append( 

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

212 ) 

213 return errors 

214 

215 

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

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

218 

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

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

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

222 """ 

223 config = config or {} 

224 return [ 

225 *_forward_violations(src, tests), 

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

227 ] 

228 

229 

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

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

232 

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

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

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

236 from a subdirectory by hand. 

237 """ 

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

239 parser.add_argument( 

240 "filenames", 

241 nargs="*", 

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

243 ) 

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

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

246 parser.add_argument( 

247 "--config", 

248 default=None, 

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

250 ) 

251 args = parser.parse_args(argv) 

252 

253 repo_root = find_repo_root() 

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

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

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

257 

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

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

260 if not reason: 

261 print( 

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

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

264 file=sys.stderr, 

265 ) 

266 return 1 

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

268 return 0 

269 

270 errors = check(src, tests, config) 

271 if errors: 

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

273 for err in errors: 

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

275 return 1 

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

277 return 0 

278 

279 

280if __name__ == "__main__": 

281 sys.exit(main())