-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathtest_node_delta.py
More file actions
executable file
·190 lines (161 loc) · 6.05 KB
/
Copy pathtest_node_delta.py
File metadata and controls
executable file
·190 lines (161 loc) · 6.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#!/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()