Spaces:
Running
Running
File size: 11,716 Bytes
d999bba | 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 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | #!/usr/bin/env python3
"""Update README.md and AGENTS.md with live test/benchmark counts.
This script keeps two pieces of project documentation in sync with reality:
1. **README.md** β replaces the comment after `uv run pytest tests/ -v`
and `uv run pytest benchmarks/ -v -m benchmark` with the live pytest
summary line ("N passed in T.Ts").
2. **AGENTS.md** β the "Test Inventory" table. For each `tests/test_*.py`
row, replaces the count in the "Tests" column with the live count from
`pytest --collect-only`. Adds new test files as new rows (with scope
marked "NEW β needs scope description" so the human can fill in the
hand-written scope). Preserves the hand-written "Scope" column for
existing rows. Updates the "Total" row.
Non-blocking: if uv/pytest aren't available (e.g., CI, fresh checkout),
the script exits 0 and leaves both files unchanged.
"""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
REPO = Path(__file__).resolve().parent.parent
README = REPO / "README.md"
AGENTS = REPO / "AGENTS.md"
def _find_uv() -> Optional[str]:
"""Locate the uv binary, trying venv-first, then PATH."""
candidates = [
REPO / ".venv" / "bin" / "uv",
Path.home() / ".local" / "bin" / "uv",
shutil.which("uv"),
]
for c in candidates:
if c and Path(str(c)).is_file():
return str(c)
return None
def _pytest(uv: str, target: str, extra_args: Optional[list] = None) -> Optional[str]:
"""Run pytest via uv, returning stdout or None on failure."""
cmd = [uv, "run", "pytest", target, "-q"] + (extra_args or [])
env = {**os.environ, "PATH": os.environ.get("PATH", "")}
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
cwd=REPO,
env=env,
)
return result.stdout
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
def extract_summary(output: str) -> str:
"""Pull the count + time from pytest output, or empty string.
Supports two output formats:
* Actual run: ``2434 passed in 35.96s`` (or with skips/failures)
* Collect-only: ``2434 tests collected in 35.96s``
The summary is used for the README's ``# N passed in T.Ts`` comment.
We return the full match (e.g. ``2434 passed in 35.96s``) so the
README comment looks identical to a real run.
Why both: the per-file counts are best collected via
``--collect-only`` (faster, no test execution), but the README
expects a "passed" summary line for consistency with what
contributors see when they run ``uv run pytest tests/ -q``.
"""
# Actual run: "N passed in T.Ts" (may also include "N skipped")
m = re.search(r"(\d+)\s+passed.*?in [\d.]+[sm]", output)
if m:
return m.group(0)
# Collect-only: "N tests collected in T.Ts"
m = re.search(r"(\d+)\s+tests?\s+collected\s+in [\d.]+[sm]", output)
if m:
count = m.group(1)
time = re.search(r"in ([\d.]+[sm])", m.group(0)).group(1)
return f"{count} collected in {time}"
return ""
def collect_per_file_counts(output: str) -> dict[str, int]:
"""Parse pytest --collect-only output to per-file test counts.
Returns a dict mapping `tests/test_foo.py` to its test count. Files
with 0 tests are omitted. The total can be computed as sum(values()).
"""
counts: dict[str, int] = {}
for line in output.splitlines():
if "::" in line:
path = line.split("::")[0].strip()
if path.endswith(".py") and path.startswith("tests/"):
counts[path] = counts.get(path, 0) + 1
return counts
# ββ README.md sync ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def sync_readme(test_summary: str, bench_summary: str) -> bool:
"""Update README.md comment lines with live counts. Returns True if changed."""
if not test_summary and not bench_summary:
return False
text = README.read_text()
changed = False
if test_summary:
new_text = re.sub(
r"uv run pytest tests/ -v # .*$",
f"uv run pytest tests/ -v # {test_summary}",
text,
flags=re.MULTILINE,
)
if new_text != text:
text = new_text
changed = True
if bench_summary:
new_text = re.sub(
r"uv run pytest benchmarks/ -v -m benchmark # .*$",
f"uv run pytest benchmarks/ -v -m benchmark # {bench_summary}",
text,
flags=re.MULTILINE,
)
if new_text != text:
text = new_text
changed = True
if changed:
README.write_text(text)
return changed
# ββ AGENTS.md test inventory sync βββββββββββββββββββββββββββββββββββββ
# Pattern that matches one row of the inventory table.
# Group 1: test file path (with backticks)
# Group 2: current count (number)
# Group 3: scope (rest of line, after the second `|`)
_TABLE_ROW_RE = re.compile(
r"^\| `(?P<path>tests/test_[^`]+\.py)` \| (?P<count>\d+) \| (?P<scope>.+) \|$",
re.MULTILINE,
)
# Pattern that matches the Total row (different column for the total).
_TOTAL_ROW_RE = re.compile(
r"^\| \*\*Total\*\* \| \*\*(?P<count>\d+)\*\* \| (?P<scope>.+) \|$",
re.MULTILINE,
)
# Marker for new test files (no hand-written scope yet).
_NEW_FILE_MARKER = "**NEW β needs scope description**"
def sync_agents_inventory(per_file: dict[str, int]) -> bool:
"""Update the AGENTS.md test inventory table with live per-file counts.
Strategy:
1. Parse the existing table into a dict of (path, count, scope).
2. Update counts for files that still exist.
3. Add new rows for files that don't appear in the table yet.
New rows get a marker scope; the human should fill in the real scope.
4. Update the Total row.
5. Sort the table rows by file path for stable diffs.
Files that no longer exist (count is 0 from the live run) are kept
with their existing count, marked with a warning in scope. The human
can decide to delete them. This avoids silent data loss.
"""
text = AGENTS.read_text()
# ββ Find the table ββ
table_start = text.find("## Test Inventory")
if table_start == -1:
print("sync-agents-inventory: '## Test Inventory' heading not found", file=sys.stderr)
return False
# The table ends at the next "## " heading (or EOF).
after_table = text.find("\n## ", table_start + len("## Test Inventory"))
if after_table == -1:
after_table = len(text)
table_block = text[table_start:after_table]
# ββ Parse existing rows ββ
existing: dict[str, tuple[int, str]] = {}
total_match: Optional[re.Match] = None
for line in table_block.splitlines():
m = _TABLE_ROW_RE.match(line)
if m:
existing[m.group("path")] = (int(m.group("count")), m.group("scope").strip())
continue
m = _TOTAL_ROW_RE.match(line)
if m:
total_match = m
if total_match is None:
print("sync-agents-inventory: Total row not found", file=sys.stderr)
return False
# ββ Update counts ββ
new_total = sum(per_file.values())
updated: dict[str, tuple[int, str]] = {}
for path, (old_count, scope) in existing.items():
if path in per_file:
new_count = per_file[path]
else:
# File no longer exists; keep it with the old count and add a
# warning marker so the human notices.
new_count = old_count
if "REMOVED" not in scope and "(removed)" not in scope:
scope = f"{scope} *(file removed β verify)*"
updated[path] = (new_count, scope)
# ββ Add new files ββ
new_files_added: list[str] = []
for path, count in per_file.items():
if path not in updated:
updated[path] = (count, _NEW_FILE_MARKER)
new_files_added.append(path)
# ββ Rebuild the table rows ββ
sorted_paths = sorted(updated.keys())
new_rows = [
"| File | Tests | Scope |",
"|------|-------|-------|",
]
for path in sorted_paths:
count, scope = updated[path]
new_rows.append(f"| `{path}` | {count} | {scope} |")
new_rows.append(
f"| **Total** | **{new_total}** | Regenerate before trusting: `uv run pytest tests/ --collect-only -q` |"
)
# ββ Splice the new table into the file ββ
pre = text[:table_start]
# Preserve everything up to and including the table header line and the
# "|----|-------|-------|" separator that follows it.
header_end = table_block.find("\n", table_block.find("\n|------"))
# The header_end is at the end of the separator line. We want to keep
# "## Test Inventory\n\n| File | Tests | Scope |\n|------|...|\n" intact.
# Find the position of the separator line.
sep_start = table_block.find("|------|-------|-------|")
if sep_start == -1:
print("sync-agents-inventory: separator line not found", file=sys.stderr)
return False
sep_end = table_block.find("\n", sep_start)
if sep_end == -1:
sep_end = len(table_block)
prefix_block = table_block[: sep_end + 1] # includes the trailing \n
post = text[after_table:]
new_block = prefix_block + "\n".join(new_rows[2:]) + "\n" # skip header+sep, add rows
new_text = pre + new_block + post
if new_text != text:
AGENTS.write_text(new_text)
return new_text != text or bool(new_files_added)
# ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def main() -> int:
uv = _find_uv()
if uv is None:
print("sync-readme-stats: uv not found β skipping all syncs", file=sys.stderr)
return 0
# Collect everything in one pytest run (faster than multiple invocations).
# Both calls use --collect-only so we don't actually run any tests or
# benchmarks. The per-file counts are still accurate.
test_output = _pytest(uv, "tests/", ["--collect-only", "--no-header"])
bench_output = _pytest(uv, "benchmarks/", ["--collect-only", "-m", "benchmark", "--no-header"])
if test_output is None and bench_output is None:
print("sync-readme-stats: pytest not available β skipping all syncs", file=sys.stderr)
return 0
test_summary = extract_summary(test_output) if test_output else ""
bench_summary = extract_summary(bench_output) if bench_output else ""
if not test_summary and not bench_summary:
print("sync-readme-stats: no test/benchmark output β skipping all syncs", file=sys.stderr)
return 0
per_file = collect_per_file_counts(test_output) if test_output else {}
readme_changed = sync_readme(test_summary, bench_summary)
agents_changed = sync_agents_inventory(per_file)
if readme_changed or agents_changed:
new_total = sum(per_file.values())
print(
f"README synced: tests={test_summary}, benchmarks={bench_summary}; "
f"AGENTS inventory synced: {len(per_file)} files, {new_total} tests"
)
else:
print("sync-readme-stats: nothing to update")
return 0
if __name__ == "__main__":
sys.exit(main())
|