Coverage for scripts/check_test_layout.py: 100%
114 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +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).
15``__init__.py`` and ``conftest.py`` are ignored on both sides, and the
16``tests/benchmarks/`` and ``tests/stress/`` trees are exempt entirely — those
17hold benchmarks and stress tests that need not mirror a source module. Test
18*functions* are unconstrained — the rules bind files and classes only.
20Repositories that deliberately organise tests by *behaviour* rather than 1:1
21mirroring (and guarantee per-module coverage another way, e.g. a 100% coverage
22gate) can opt out via a ``[tool.check_test_layout]`` table in ``pyproject.toml``::
24 [tool.check_test_layout]
25 enforce = false
26 reason = "Tests are grouped by behaviour; coverage is enforced by pytest."
28``enforce = false`` requires a non-empty ``reason`` so the deviation is always
29documented. The same table accepts ``exempt_dirs = [...]`` to extend the
30built-in benchmarks/stress exemptions when parity *is* enforced.
32Usage:
33 uv run --python 3.12 --no-project python \
34 scripts/check_test_layout.py [--src DIR] [--tests DIR] [--config FILE]
36Exits 0 when the layout is clean (or parity is intentionally not enforced),
371 (listing every violation) otherwise.
38"""
40from __future__ import annotations
42import argparse
43import ast
44import sys
45from collections.abc import Mapping
46from pathlib import Path
48try: # py3.11+
49 import tomllib
50except ModuleNotFoundError: # pragma: no cover - older interpreters
51 try:
52 import tomli as tomllib # type: ignore
53 except ModuleNotFoundError:
54 tomllib = None # type: ignore
56_IGNORED = {"__init__.py", "conftest.py"}
58# Top-level directories under the tests root that are exempt from parity by
59# default: they hold benchmarks / stress tests that need not mirror a source
60# module. A repo can extend this set via ``[tool.check_test_layout] exempt_dirs``.
61_DEFAULT_EXEMPT_DIRS = {"benchmarks", "stress"}
64def _coerce_scalar(raw: str) -> object:
65 """Coerce a TOML scalar/array literal to a Python value (fallback reader).
67 Handles the narrow subset the ``[tool.check_test_layout]`` table uses:
68 quoted strings, ``true``/``false`` booleans, and single-line arrays of
69 quoted strings. Everything else is returned as its stripped token.
70 """
71 raw = raw.strip()
72 if raw[:1] in {'"', "'"}:
73 end = raw.find(raw[0], 1)
74 return raw[1:end] if end != -1 else raw[1:]
75 if raw.startswith("["):
76 end = raw.find("]")
77 inner = raw[1 : end if end != -1 else len(raw)]
78 return [v for v in (_coerce_scalar(item) for item in inner.split(",")) if v != ""]
79 token = raw.split("#", 1)[0].strip()
80 if token == "true":
81 return True
82 if token == "false":
83 return False
84 return token
87def _parse_flat_section(text: str, header: str) -> dict[str, object]:
88 """Extract a single flat ``[header]`` table from TOML *text*.
90 A dependency-free fallback for interpreters without ``tomllib``/``tomli``
91 (the plugin runs under the ambient ``python3``, which may predate 3.11).
92 It recognises only the flat ``key = value`` table this checker reads.
93 """
94 want = f"[{header}]"
95 out: dict[str, object] = {}
96 in_section = False
97 for line in text.splitlines():
98 stripped = line.strip()
99 if not stripped or stripped.startswith("#"):
100 continue
101 if stripped.startswith("["):
102 in_section = stripped == want
103 continue
104 if not in_section or "=" not in stripped:
105 continue
106 key, _, value = stripped.partition("=")
107 out[key.strip()] = _coerce_scalar(value)
108 return out
111def _read_config(pyproject: Path) -> dict[str, object]:
112 """Return the ``[tool.check_test_layout]`` table from *pyproject* (empty if absent).
114 Prefers ``tomllib``/``tomli`` when importable; otherwise uses the flat-table
115 fallback so the opt-out is honoured regardless of interpreter version.
116 """
117 if not pyproject.is_file():
118 return {}
119 text = pyproject.read_text(encoding="utf-8")
120 if tomllib is not None:
121 try:
122 data = tomllib.loads(text)
123 except ValueError:
124 return {}
125 section = data.get("tool", {}).get("check_test_layout", {})
126 return section if isinstance(section, dict) else {}
127 return _parse_flat_section(text, "tool.check_test_layout")
130def _exempt_dirs(config: Mapping[str, object]) -> set[str]:
131 """Return the exempt top-level test dirs: defaults plus any from *config*."""
132 dirs = set(_DEFAULT_EXEMPT_DIRS)
133 extra = config.get("exempt_dirs")
134 if isinstance(extra, list):
135 dirs |= {str(d) for d in extra}
136 return dirs
139def _top_level_classes(path: Path) -> set[str]:
140 """Return the names of top-level classes defined in *path*."""
141 tree = ast.parse(path.read_text(), filename=str(path))
142 return {node.name for node in tree.body if isinstance(node, ast.ClassDef)}
145def _source_modules(src: Path) -> list[Path]:
146 """Return the source ``.py`` modules under *src* (ignoring dunder/conftest)."""
147 return sorted(p for p in src.rglob("*.py") if p.name not in _IGNORED)
150def _test_files(tests: Path, exempt: set[str] | None = None) -> list[Path]:
151 """Return the ``test_*.py`` files under *tests* (ignoring conftest/exempt dirs)."""
152 exempt = _DEFAULT_EXEMPT_DIRS if exempt is None else exempt
153 return sorted(
154 p
155 for p in tests.rglob("test_*.py")
156 if p.name not in _IGNORED and p.relative_to(tests).parts[0] not in exempt
157 )
160def check(src: Path, tests: Path, config: Mapping[str, object] | None = None) -> list[str]:
161 """Return a list of layout violations (empty when the layout is clean)."""
162 exempt = _exempt_dirs(config or {})
163 errors: list[str] = []
165 # Forward: every source module needs a mirrored test file + Test* classes.
166 for module in _source_modules(src):
167 rel = module.relative_to(src)
168 test_path = tests / rel.parent / f"test_{module.stem}.py"
169 if not test_path.exists():
170 errors.append(f"missing test file {test_path} for source module {module}")
171 continue
172 test_classes = _top_level_classes(test_path)
173 for cls in sorted(_top_level_classes(module)):
174 if f"Test{cls}" not in test_classes:
175 errors.append(f"missing class Test{cls} in {test_path} for class {cls} in {module}")
177 # Reverse: every test file/class must trace back to a source module/class.
178 for test_file in _test_files(tests, exempt):
179 rel = test_file.relative_to(tests)
180 source_name = test_file.stem[len("test_") :]
181 source_path = src / rel.parent / f"{source_name}.py"
182 if not source_path.exists():
183 errors.append(f"orphan test file {test_file} (no source module {source_path})")
184 continue
185 source_classes = _top_level_classes(source_path)
186 for cls in sorted(_top_level_classes(test_file)):
187 if cls.startswith("Test") and cls[len("Test") :] not in source_classes:
188 errors.append(
189 f"orphan test class {cls} in {test_file} "
190 f"(no class {cls[len('Test') :]} in {source_path})"
191 )
193 return errors
196def main(argv: list[str] | None = None) -> int:
197 """Entry point: check the layout and return an exit code."""
198 parser = argparse.ArgumentParser(description="Check test/source layout parity.")
199 parser.add_argument("--src", default="src", help="Source directory (default: src).")
200 parser.add_argument("--tests", default="tests", help="Tests directory (default: tests).")
201 parser.add_argument(
202 "--config",
203 default="pyproject.toml",
204 help="pyproject.toml providing [tool.check_test_layout] (default: pyproject.toml).",
205 )
206 args = parser.parse_args(argv)
208 config = _read_config(Path(args.config))
210 if not config.get("enforce", True):
211 reason = str(config.get("reason", "")).strip()
212 if not reason:
213 print(
214 "Test-layout check misconfigured: [tool.check_test_layout] enforce=false "
215 "requires a non-empty 'reason' documenting the intentional layout.",
216 file=sys.stderr,
217 )
218 return 1
219 print(f"Test layout OK: parity not enforced by request — {reason}")
220 return 0
222 errors = check(Path(args.src), Path(args.tests), config)
223 if errors:
224 print("Test-layout check failed:", file=sys.stderr)
225 for err in errors:
226 print(f" ✗ {err}", file=sys.stderr)
227 return 1
228 print("Test layout OK: tests mirror sources 1:1")
229 return 0
232if __name__ == "__main__":
233 raise SystemExit(main())