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
« 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.
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
54import tomllib
55from collections.abc import Mapping
56from pathlib import Path
58from rhiza_hooks._repo import find_repo_root
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 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 {}
93def _exempt_dirs(config: Mapping[str, object]) -> set[str]:
94 """Return the exempt top-level test dirs: defaults plus any from *config*.
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.
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.
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
118def _exempt_files(config: Mapping[str, object]) -> set[str]:
119 """Return the exempt test files from *config* (none are exempt by default).
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.
125 As with :func:`_exempt_dirs`, a value of the wrong type is ignored rather
126 than raising.
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}
141def _top_level_classes(path: Path) -> set[str]:
142 """Return the names of top-level classes defined in *path*.
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.
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)}
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)
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 )
179def _forward_violations(src: Path, tests: Path) -> list[str]:
180 """Return violations where a source module or class has no mirrored test.
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
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.
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
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).
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 ]
236def main(argv: list[str] | None = None) -> int:
237 """Run the hook and return a process exit code.
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)
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")
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
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
286if __name__ == "__main__": # pragma: no mutate
287 sys.exit(main())