testsAndMisc/scripts/pytest_changed_packages.py
Krzysztof kuhy Rudnicki dee307700e Fix pytest OOM: wrap each package in 3GB cgroup sub-scope
systemd-run --scope -p MemoryMax=3G per pytest subprocess so each
package gets its own memory cap, freed completely before the next.
Also use shutil.which + pathlib per ruff rules.
2026-04-12 21:27:24 +02:00

114 lines
3.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run pytest only for python_pkg subpackages that have changed files.
Used as a pre-commit hook entry point. Receives staged file paths as
arguments, determines which ``python_pkg/<subpackage>/`` directories are
affected, and runs pytest scoped to just those subpackages.
If a file outside any subpackage is changed (e.g. ``python_pkg/conftest.py``),
all tests are run as a fallback.
"""
from __future__ import annotations
from pathlib import Path, PurePosixPath
import shutil
import subprocess
import sys
_MIN_SUBPACKAGE_DEPTH = 2
_MEMORY_CAP = "3G"
def _affected_packages(files: list[str]) -> set[str] | None:
"""Return subpackage names touched by *files*, or ``None`` for all.
Returns ``None`` when a root-level ``python_pkg/`` file is modified,
meaning every test should run.
"""
packages: set[str] = set()
for path in files:
parts = PurePosixPath(path).parts
if len(parts) < _MIN_SUBPACKAGE_DEPTH or parts[0] != "python_pkg":
continue
if len(parts) == _MIN_SUBPACKAGE_DEPTH:
# Root-level file like python_pkg/conftest.py - run everything.
return None
packages.add(parts[1])
return packages
def _build_pytest_command(packages: set[str] | None) -> list[str]:
"""Build the pytest invocation for the given *packages*."""
base = [
sys.executable,
"-m",
"pytest",
"--cov-branch",
"--cov-report=term-missing",
"--cov-fail-under=100",
"-q",
]
if packages is None or not packages:
# Fallback: run everything.
return [*base, "--cov=python_pkg"]
# Override addopts from pyproject.toml to remove the global --cov=python_pkg
# that would widen coverage measurement to the entire tree.
cmd = [
*base,
"-o",
"addopts=-v --strict-markers --strict-config -ra",
]
for pkg in sorted(packages):
cmd.extend(["--cov", f"python_pkg/{pkg}"])
for pkg in sorted(packages):
test_dir = f"python_pkg/{pkg}/tests"
cmd.append(test_dir)
return cmd
def main() -> int:
"""Entry point."""
files = sys.argv[1:]
if not files:
return 0
packages = _affected_packages(files)
# When many packages are affected, run each one in a separate subprocess
# to avoid accumulating memory across all test suites (OOM prevention).
if packages is None:
# Discover all subpackages that have a tests/ directory.
packages = {
entry.name
for entry in Path("python_pkg").iterdir()
if (entry / "tests").is_dir()
}
if not packages:
return 0
# Run each package in its own subprocess so memory is freed between runs.
# Wrap in systemd-run cgroup to hard-cap memory per package.
use_cgroup = shutil.which("systemd-run") is not None
for pkg in sorted(packages):
cmd = _build_pytest_command({pkg})
if use_cgroup:
cmd = [
"systemd-run",
"--user",
"--scope",
"-p",
f"MemoryMax={_MEMORY_CAP}",
*cmd,
]
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
return result.returncode
return 0
if __name__ == "__main__":
raise SystemExit(main())