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
« 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.
4Enforces a strict test/source parity so tests are easy to locate and no test
5drifts loose from what it covers:
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).
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.
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.
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``::
27 [tool.check_test_layout]
28 enforce = false
29 reason = "Tests are grouped by behaviour; coverage is enforced by pytest."
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.
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.
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"""
49from __future__ import annotations
51import argparse
52import ast
53import sys
54from collections.abc import Mapping
55from pathlib import Path
57from rhiza_hooks._repo import find_repo_root
58from rhiza_hooks._toml import load_toml
60_IGNORED = {"__init__.py", "conftest.py"}
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"}
68def _read_config(pyproject: Path) -> dict[str, object]:
69 """Return the ``[tool.check_test_layout]`` table from *pyproject* (empty if absent).
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.
74 Args:
75 pyproject: Path to the ``pyproject.toml`` to read.
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 {}
87def _exempt_dirs(config: Mapping[str, object]) -> set[str]:
88 """Return the exempt top-level test dirs: defaults plus any from *config*.
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.
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.
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
112def _exempt_files(config: Mapping[str, object]) -> set[str]:
113 """Return the exempt test files from *config* (none are exempt by default).
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.
119 As with :func:`_exempt_dirs`, a value of the wrong type is ignored rather
120 than raising.
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}
135def _top_level_classes(path: Path) -> set[str]:
136 """Return the names of top-level classes defined in *path*.
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.
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)}
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)
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 )
173def _forward_violations(src: Path, tests: Path) -> list[str]:
174 """Return violations where a source module or class has no mirrored test.
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
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.
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
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).
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 ]
230def main(argv: list[str] | None = None) -> int:
231 """Run the hook and return a process exit code.
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)
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")
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
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
280if __name__ == "__main__":
281 sys.exit(main())