TileOPs-Metax/scripts/test_node_delta.py

191 lines
6.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Compare test-node counts between the current branch and main.
Usage
-----
python scripts/test_node_delta.py # auto-detect changed test files
python scripts/test_node_delta.py tests/ops/test_foo.py tests/ops/test_bar.py
The script always exits 0 (non-blocking). Output is a human-readable table
showing per-file and total node deltas, suitable for pasting into a PR
description.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
BASE_BRANCH = "main"
def _collect_node_count(test_file: str, *, ref: str | None = None) -> int | None:
"""Return the number of pytest nodes in *test_file*.
When *ref* is given the file content is read from that git ref and
written to a temporary file so ``pytest --collect-only`` can process it.
Returns ``None`` when the file does not exist at the given ref.
"""
if ref is not None:
blob = f"{ref}:{test_file}"
result = subprocess.run(
["git", "show", blob],
capture_output=True,
)
if result.returncode != 0:
return None # file does not exist at ref
content = result.stdout
# Write temp file in the same directory as the original so that
# conftest.py, relative imports, and PYTHONPATH all resolve correctly.
# Prefix must NOT start with '.' — pytest treats dotted names as
# package paths and fails with ModuleNotFoundError.
original = Path(test_file)
suffix = original.suffix
parent = original.parent
parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
suffix=suffix, prefix=f"_delta_{original.stem}_", dir=parent, delete=False
) as tmp:
tmp.write(content)
tmp.flush()
target = tmp.name
else:
if not Path(test_file).exists():
return None
target = test_file
try:
result = subprocess.run(
[sys.executable, "-m", "pytest", target, "--collect-only", "-q", "--no-header"],
capture_output=True,
text=True,
timeout=120,
)
finally:
# Clean up temp file regardless of success/failure
if ref is not None:
Path(target).unlink(missing_ok=True)
if result.returncode != 0:
# Collection failed (syntax error, missing dep, etc.)
print(f" warning: pytest collection failed for {test_file}", file=sys.stderr)
return 0
# Parse the summary line: "N tests collected" or "no tests collected"
for line in result.stdout.splitlines()[::-1]:
line = line.strip()
if "no tests" in line and "collected" in line:
return 0
if "collected" in line:
parts = line.split()
for i, token in enumerate(parts):
if token in ("test", "tests") and i > 0:
try:
return int(parts[i - 1])
except ValueError:
continue
# Also match "N tests collected in Xs"
if token == "collected" and i > 0:
try:
return int(parts[i - 1])
except ValueError:
continue
return 0
def _changed_test_files(base: str) -> list[str]:
"""Return test files changed between HEAD and *base*."""
result = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=ACMR", base, "--", "tests/"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return [f for f in result.stdout.strip().splitlines() if f.endswith(".py")]
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"files",
nargs="*",
help="Test files to check (default: auto-detect from git diff against main)",
)
parser.add_argument(
"--base",
default=BASE_BRANCH,
help=f"Base branch for comparison (default: {BASE_BRANCH})",
)
args = parser.parse_args(argv)
files = args.files or _changed_test_files(args.base)
if not files:
print("No changed test files detected.")
return
rows: list[tuple[str, int | None, int, int | None]] = []
total_base = 0
total_head = 0
for f in sorted(files):
base_count = _collect_node_count(f, ref=args.base)
head_count = _collect_node_count(f)
if head_count is None:
continue # file does not exist on HEAD (deleted)
delta: int | None
if base_count is None:
delta = None # new file — no baseline
else:
delta = head_count - base_count
total_base += base_count
total_head += head_count
rows.append((f, base_count, head_count, delta))
if not rows:
print("No test files to report.")
return
# Print table
col_file = max(len(r[0]) for r in rows)
col_file = max(col_file, 4) # "File"
header = f"{'File':<{col_file}} {'Base':>6} {'HEAD':>6} {'Delta':>7}"
sep = "-" * len(header)
print(header)
print(sep)
for path, base_count, head_count, delta in rows:
base_str = "new" if base_count is None else str(base_count)
if delta is None:
delta_str = "(new)"
elif delta > 0:
delta_str = f"+{delta}"
elif delta == 0:
delta_str = "0"
else:
delta_str = str(delta)
print(f"{path:<{col_file}} {base_str:>6} {head_count:>6} {delta_str:>7}")
# Totals
total_delta = total_head - total_base
print(sep)
sign = "+" if total_delta > 0 else ""
print(f"{'TOTAL':<{col_file}} {total_base:>6} {total_head:>6} {sign + str(total_delta):>7}")
if total_base > 0:
pct = (total_delta / total_base) * 100
print(f"\nGrowth: {pct:+.1f}%")
elif total_head > 0:
print(f"\nAll {total_head} nodes are from new files.")
if __name__ == "__main__":
main()