[Test] Nightly Buildkite Pytest Test Case Statistics And Send HTML Report By Email (#1674)

Signed-off-by: yenuo26 <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
This commit is contained in:
wangyu authored and GitHub committed 2026-03-12 12:08:11 +08:00
1 parent 5d28b6a40c
commit c501c2d512
4 files changed
+1157 -238

No files matched your search

+8 -4
View File
@@ -119,7 +119,7 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: ":full_moon: Omni Model Perf Test with H100"
- label: ":full_moon: Omni Model Perf Test & Test Case Statistics"
key: nightly-performance
timeout_in_minutes: 180
depends_on: upload-nightly-pipeline
@@ -129,6 +129,8 @@ steps:
- export BENCHMARK_DIR=tests
- pytest -s -v tests/perf/scripts/run_benchmark.py
- buildkite-agent artifact upload "tests/*.json"
- python tools/nightly/buildkite_testcase_statistics.py -o tests/buildkite_testcase_statistics.html
- buildkite-agent artifact upload "tests/*.html"
agents:
queue: "mithril-h100-pool"
plugins:
@@ -158,17 +160,19 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":email: Nightly Perf Collection & Email"
- label: ":email: Nightly Collection & Email"
key: nightly-perf-distribution
depends_on: nightly-performance
depends_on:
- nightly-performance
if: build.env("NIGHTLY") == "1"
commands:
- pip install openpyxl
- export DEFAULT_INPUT_DIR=tests
- export DEFAULT_OUTPUT_DIR=tests
- buildkite-agent artifact download "tests/*.json" . --step nightly-performance
- buildkite-agent artifact download "tests/*.html" . --step nightly-performance
- python tools/nightly/generate_nightly_perf_excel.py
- python tools/nightly/send_nightly_perf_email.py
- python tools/nightly/send_nightly_email.py --report-file "tests/*.xlsx, tests/*.html"
- buildkite-agent artifact upload "tests/*.xlsx"
agents:
queue: "cpu_queue_premerge"
@@ -0,0 +1,798 @@
#!/usr/bin/env python3
"""
Parse pytest commands from Buildkite test-ready.yml, test-merge.yml, test-nightly.yml;
collect test cases (including parametrized) via pytest --collect-only -q and produce an HTML report.
Usage (run from repo root):
python scripts/buildkite_testcase_statistics.py -o buildkite_testcase_statistics.html
Requires: PyYAML (pip install pyyaml)
"""
from __future__ import annotations
import argparse
import ast
import html
import re
import subprocess
import sys
from pathlib import Path
import yaml
# Repo root (parent of the directory containing this script)
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
BUILDKITE_DIR = REPO_ROOT / ".buildkite"
PIPELINE_FILES = ["test-ready.yml", "test-merge.yml", "test-nightly.yml"]
def load_yaml(path: Path) -> dict:
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
PYTEST_CMD_RE = re.compile(r"(?:timeout\s+\S+\s+)?(?:python3? -m\s+)?pytest\s+[^\n&|;]*")
def normalize_commands(step: dict) -> list[str]:
raw = step.get("commands") or []
if isinstance(raw, str):
raw = [raw]
text = "\n".join((c.strip() if isinstance(c, str) else "") for c in raw if c)
text = text.replace("$$", "$")
result = []
for m in PYTEST_CMD_RE.finditer(text):
line = m.group(0).strip()
line_start = text.rfind("\n", 0, m.start()) + 1
before_match = text[line_start : m.start()]
if before_match.lstrip().startswith("#"):
continue
if line:
result.append(line)
return result
def extract_pytest_targets_from_line(line: str) -> list[str]:
"""
Parse pytest test targets (file path or marker expression) from a single command line.
Returns e.g. ['tests/xxx.py'].
When there is no explicit test file but only -m/--run-level, returns
['-m expr'] or ['-m expr --run-level level'] with the full marker args.
"""
if "pytest" not in line:
return []
# Strip leading timeout and anything before pytest
idx = line.find("pytest")
if idx == -1:
return []
rest = line[idx:].strip()
if not rest.startswith("pytest"):
return []
# Simple split on whitespace (no handling of quoted spaces)
parts = re.split(r"\s+", rest, maxsplit=1)
args = parts[1].strip() if len(parts) > 1 else ""
if not args:
return []
targets = []
# Match tests/.../*.py
for m in re.finditer(r"tests/[^\s'\"#]+\.py", args):
path = m.group(0)
if path not in targets:
targets.append(path)
if not targets:
# May be -m/--run-level only; record full marker arguments instead of a generic marker.
extra = _parse_extra_args_from_line(line)
if extra:
# Join into a single string, e.g. "-m core and cpu --run-level nightly"
targets.append(" ".join(extra))
return targets
def get_pytest_targets_from_step(step: dict) -> list[tuple[str, str]]:
"""
Extract all pytest-related targets from a step.
Returns [(target, raw_line), ...] where target is tests/xxx.py or '(marker/run-level)'.
"""
lines = normalize_commands(step)
out = []
seen = set()
for line in lines:
for t in extract_pytest_targets_from_line(line):
if t not in seen:
seen.add(t)
out.append((t, line))
return out
def _parse_extra_args_from_line(raw_line: str) -> list[str]:
"""Extract -m and --run-level args from a raw pytest command line. Strips extra spaces and quotes.
Correctly handles quoted expressions with spaces, e.g. -m 'core_model and cpu'.
"""
extra: list[str] = []
# -m: match -m followed by '...', "..." or a single token (so -m expression is one argv)
m = re.search(r"-m\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line)
if m:
value = (m.group(1) or m.group(2) or m.group(3) or "").strip()
if value:
extra.extend(["-m", value])
r = re.search(r"--run-level\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line)
if r:
value = (r.group(1) or r.group(2) or r.group(3) or "").strip()
if value:
extra.extend(["--run-level", value])
return extra
def _parse_collect_only_stdout(stdout: str, *, raise_on_empty: bool = True, stderr: str = "") -> list[str]:
"""
Parse pytest --collect-only -q stdout into a list of node ids.
Normalizes \\r\\n and \\r to \\n before splitting.
If raise_on_empty and no node ids found, raises RuntimeError; otherwise returns [].
"""
raw = (stdout or "").replace("\r\n", "\n").replace("\r", "\n")
out: list[str] = []
for line in raw.splitlines():
line = line.strip()
if not line or "::" not in line or "test_" not in line:
continue
out.append(line)
if not out and raise_on_empty:
raise RuntimeError(
f"Failed to parse pytest --collect-only output: no node ids found.\nstdout:\n{stdout}\n\nstderr:\n{stderr}"
)
return out
def _resolve_pytest_target(target: str, repo_root: Path, extra: list[str], raw_line: str) -> tuple[str, str, int]:
"""
Resolve target + repo_root + extra to (path_arg, fallback_path, timeout).
Raises FileNotFoundError/RuntimeError on invalid config.
"""
if target.endswith(".py"):
path = (repo_root / target).resolve()
if not path.exists():
raise FileNotFoundError(f"Pytest target path does not exist: {path}")
return str(path), target.replace("\\", "/"), 60
if not extra:
raise RuntimeError(f"Failed to parse -m/--run-level from pytest line: {raw_line!r}")
if not (repo_root / "tests").exists():
raise FileNotFoundError("tests/ directory not found under repo root")
return "tests/", "tests/", 120
def collect_test_names(target: str, repo_root: Path, raw_line: str) -> list[str]:
"""
Run pytest --collect-only -q for a target and return test node id list (incl. parametrized).
target: path like tests/foo.py or marker string (e.g. "-m expr"). raw_line used for -m/--run-level.
"""
extra = _parse_extra_args_from_line(raw_line)
path_arg, _fallback, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line)
cmd_quiet = [sys.executable, "-m", "pytest", path_arg, "--collect-only", "-q"]
cmd_quiet.extend(extra)
result = subprocess.run(
cmd_quiet,
cwd=repo_root,
capture_output=True,
text=True,
timeout=timeout_quiet,
)
if result.returncode != 0:
raise RuntimeError(f"pytest --collect-only failed for {path_arg} with args {extra}: {result.stderr.strip()}")
print(f"pytest --collect-only success for {path_arg} with args {extra}: {result.stdout.strip()}")
return _parse_collect_only_stdout(result.stdout or "", stderr=result.stderr or "")
def _extra_with_skip_marker(extra: list[str]) -> list[str]:
"""Build extra args that add -m skip: if extra has -m expr then use 'expr and skip', else -m skip."""
out: list[str] = []
i = 0
has_m = False
while i < len(extra):
if extra[i] == "-m" and i + 1 < len(extra):
out.extend(["-m", extra[i + 1] + " and skip"])
has_m = True
i += 2
elif extra[i] == "--run-level" and i + 1 < len(extra):
out.extend(["--run-level", extra[i + 1]])
i += 2
else:
i += 1
if not has_m:
out = ["-m", "skip"] + out
return out
def get_skip_status(target: str, raw_line: str, repo_root: Path) -> set[str]:
"""
Use pytest -m skip --collect-only -q to collect tests marked with skip; return their node ids.
Does not run tests; only collects. Supports both pytest xxx.py and pytest -m "marker".
"""
extra = _parse_extra_args_from_line(raw_line)
try:
path_arg, _, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line)
except (FileNotFoundError, RuntimeError):
return set()
extra_skip = _extra_with_skip_marker(extra)
cmd = [sys.executable, "-m", "pytest", path_arg, "--collect-only", "-q"]
cmd.extend(extra_skip)
try:
result = subprocess.run(
cmd,
cwd=repo_root,
capture_output=True,
text=True,
timeout=timeout_quiet,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return set()
if result.returncode != 0:
return set()
ids = _parse_collect_only_stdout(result.stdout or "", raise_on_empty=False)
return set(ids)
def get_docstring_for_node_id(repo_root: Path, node_id: str) -> str:
"""
Parse the docstring of the test_ function/method from source by pytest node id.
Supports path::test_func, path::TestClass::test_method, path::test_func[param], etc.
"""
if not node_id or "::" not in node_id or node_id.startswith("["):
return ""
parts = node_id.split("::")
# Path accepts forward slashes; no platform-specific replacement needed
path = (repo_root / parts[0]).resolve()
if not path.exists():
return ""
# Strip parametrize suffix, e.g. test_foo[param] -> test_foo
func_part = parts[-1]
if "[" in func_part:
func_part = func_part.split("[", 1)[0]
try:
source = path.read_text(encoding="utf-8", errors="replace")
tree = ast.parse(source)
except Exception:
return ""
# parts: [file, maybe_class, func] or [file, func]
if len(parts) == 2:
# path::test_func
target_func = func_part
target_class = None
else:
# path::TestClass::test_method
target_class = parts[1]
target_func = func_part
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == target_func and target_class is None:
doc = ast.get_docstring(node)
return (doc or "").strip()
if isinstance(node, ast.ClassDef) and node.name == target_class:
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == target_func:
doc = ast.get_docstring(item)
return (doc or "").strip()
break
return ""
def get_test_file_for_node_id(node_id: str) -> str:
"""Extract test file path from a pytest node id."""
if not node_id:
return ""
if "::" in node_id:
return node_id.split("::", 1)[0]
if node_id.endswith(".py"):
return node_id
return "(marker-only / no collection)"
def write_html(all_stats: list[tuple], out_path: Path, total: int, repo_root: Path | None = None) -> None:
"""Write stats to HTML with styling and responsive tables."""
root = repo_root if repo_root is not None else REPO_ROOT
pipeline_badges = {
"test-ready": "ready",
"test-merge": "merge",
"test-nightly": "nightly",
}
def esc(s: str) -> str:
return html.escape(str(s))
def tr_class(i: int) -> str:
return "odd" if i % 2 == 0 else "even"
pipeline_totals: dict[str, int] = {}
for item in all_stats:
_, pipeline_name, _, _, _, _, count, _, _, _ = item
pipeline_totals[pipeline_name] = pipeline_totals.get(pipeline_name, 0) + count
pipeline_summary_rows = []
for idx, (pipeline_name, pipeline_count) in enumerate(sorted(pipeline_totals.items())):
badge = pipeline_badges.get(pipeline_name, "")
badge_span = f'<span class="badge {badge}">{esc(pipeline_name)}</span>' if badge else esc(pipeline_name)
pipeline_summary_rows.append(
f'<tr class="{tr_class(idx)}"><td>{badge_span}</td><td class="num">{pipeline_count}</td></tr>'
)
# Summary rows by module (item: ..., names, docstrings, skipped_ids)
summary_rows = []
for i, item in enumerate(all_stats):
_, pipeline_name, label, test_files, _, marker_targets, count, _, _, _ = item
if test_files:
files_html = "<br>".join(esc(f) for f in test_files)
elif marker_targets:
# Display full marker / run-level arguments when no explicit test files.
files_html = "<br>".join(esc(m) for m in marker_targets)
else:
files_html = "<em>No explicit tests or markers</em>"
badge = pipeline_badges.get(pipeline_name, "")
badge_span = f'<span class="badge {badge}">{esc(pipeline_name)}</span>' if badge else esc(pipeline_name)
summary_rows.append(
f'<tr class="{tr_class(i)}">'
f'<td>{badge_span}</td><td>{esc(label)}</td><td class="num">{count}</td>'
f'<td class="files">{files_html}</td></tr>'
)
# Detail rows grouped by Pipeline, then by Test Suite (two-level collapsible)
case_details_thead = """
<tr>
<th>Pipeline</th>
<th>Test Suite</th>
<th>Test File</th>
<th>No.</th>
<th>Test Name</th>
<th>Status</th>
<th>Description</th>
</tr>"""
# Per-item: (pipeline_name, label, count, badge_span, suite_rows_html)
pipeline_suites: dict[str, list[tuple[str, int, str, str]]] = {} # pipeline -> [(label, count, summary, body)]
for item in all_stats:
_, pipeline_name, label, _, _, _, count, names, docstrings, skipped_ids = item
badge = pipeline_badges.get(pipeline_name, "")
badge_span = f'<span class="badge {badge}">{esc(pipeline_name)}</span>' if badge else esc(pipeline_name)
suite_summary = f"{esc(label)} ({count} cases)"
file_sections: list[str] = []
if not names:
suite_rows = [
f'<tr class="{tr_class(0)}">'
f"<td>{badge_span}</td><td>{esc(label)}</td>"
f'<td class="files"><em>(marker-only / no collection)</em></td><td class="num">0</td>'
f'<td class="name"><em>No collection or only -m/--run-level</em></td>'
f'<td class="status"></td><td class="desc"></td></tr>'
]
file_sections.append(
'<details class="file-details" open>'
"<summary>(marker-only / no collection)</summary>"
f'<div class="table-wrap"><table><thead>{case_details_thead}</thead>'
f"<tbody>{''.join(suite_rows)}</tbody></table></div></details>"
)
else:
file_groups: dict[str, list[tuple[str, str, bool]]] = {}
for idx, name in enumerate(names):
desc = (docstrings[idx] if idx < len(docstrings) else "") or ""
if not desc and "::" in name and not name.startswith("["):
desc = get_docstring_for_node_id(root, name)
file_key = get_test_file_for_node_id(name)
file_groups.setdefault(file_key, []).append((name, desc, name in skipped_ids))
for file_name in sorted(file_groups.keys()):
suite_rows = []
for i, (name, desc, is_skipped) in enumerate(file_groups[file_name], 1):
desc_html = esc(desc).replace("\n", "<br>") if desc else ""
status_html = '<span class="badge skip">Skipped</span>' if is_skipped else "—"
suite_rows.append(
f'<tr class="{tr_class(i - 1)}">'
f'<td>{badge_span}</td><td>{esc(label)}</td><td class="files">{esc(file_name)}</td>'
f'<td class="num">{i}</td>'
f'<td class="name"><code>{esc(name)}</code></td>'
f'<td class="status">{status_html}</td><td class="desc">{desc_html}</td></tr>'
)
file_sections.append(
f'<details class="file-details" data-test-file="{esc(file_name.lower())}">'
f"<summary>{esc(file_name)} ({len(file_groups[file_name])} cases)</summary>"
f'<div class="table-wrap"><table><thead>{case_details_thead}</thead>'
f"<tbody>{''.join(suite_rows)}</tbody></table></div></details>"
)
body = "".join(file_sections)
if pipeline_name not in pipeline_suites:
pipeline_suites[pipeline_name] = []
pipeline_suites[pipeline_name].append((label, count, suite_summary, body))
# Build two-level HTML: Pipeline (outer) -> Test Suite (inner) -> table
detail_sections_html_parts = []
for pipeline_name in sorted(pipeline_badges.keys()):
if pipeline_name not in pipeline_suites:
continue
suites = pipeline_suites[pipeline_name]
pipeline_total = sum(c for _, c, _, _ in suites)
badge = pipeline_badges.get(pipeline_name, "")
badge_span = f'<span class="badge {badge}">{esc(pipeline_name)}</span>' if badge else esc(pipeline_name)
pipeline_summary = f"{badge_span} ({pipeline_total} cases)"
inner_html = "".join(
f'<details class="suite-details"><summary>{suite_summary}</summary>'
f'<div class="case-files-inner">{body}</div></details>'
for _, _, suite_summary, body in suites
)
detail_sections_html_parts.append(
f'<details class="pipeline-details"><summary>{pipeline_summary}</summary>'
f'<div class="case-details-inner">{inner_html}</div></details>'
)
# Include pipelines that might be in all_stats but not in pipeline_badges (e.g. custom names)
for pipeline_name in sorted(pipeline_suites.keys()):
if pipeline_name in pipeline_badges:
continue
suites = pipeline_suites[pipeline_name]
pipeline_total = sum(c for _, c, _, _ in suites)
pipeline_summary = f"{esc(pipeline_name)} ({pipeline_total} cases)"
inner_html = "".join(
f'<details class="suite-details"><summary>{suite_summary}</summary>'
f'<div class="case-files-inner">{body}</div></details>'
for _, _, suite_summary, body in suites
)
detail_sections_html_parts.append(
f'<details class="pipeline-details"><summary>{pipeline_summary}</summary>'
f'<div class="case-details-inner">{inner_html}</div></details>'
)
detail_sections_html = "".join(detail_sections_html_parts)
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Buildkite Test Case Statistics</title>
<style>
:root {{
--bg: #0f1419;
--card: #1a2332;
--border: #2d3a4f;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--ready: #3fb950;
--merge: #a371f7;
--nightly: #f0883e;
}}
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
margin: 0;
padding: 2rem;
}}
.container {{ max-width: 1200px; margin: 0 auto; }}
h1 {{
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}}
.meta {{ color: var(--muted); font-size: 0.9rem; margin-bottom: 2rem; }}
section {{
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}}
section h2 {{
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--accent);
}}
.filter-bar {{
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
}}
.filter-bar label {{
color: var(--muted);
font-size: 0.9rem;
}}
.filter-input {{
min-width: 320px;
max-width: 520px;
width: 100%;
padding: 0.7rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.04);
color: var(--text);
outline: none;
}}
.filter-input:focus {{
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.18);
}}
.filter-hint {{
color: var(--muted);
font-size: 0.82rem;
}}
.table-wrap {{ overflow-x: auto; }}
table {{
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}}
th {{
text-align: left;
padding: 0.65rem 0.85rem;
background: rgba(88, 166, 255, 0.12);
color: var(--accent);
font-weight: 600;
border: 1px solid var(--border);
}}
th:first-child {{ border-radius: 8px 0 0 0; }}
th:last-child {{ border-radius: 0 8px 0 0; }}
td {{
padding: 0.6rem 0.85rem;
border: 1px solid var(--border);
vertical-align: top;
}}
tr.odd td {{ background: rgba(255,255,255,0.02); }}
tr.even td {{ background: transparent; }}
tr:hover td {{ background: rgba(88, 166, 255, 0.06); }}
td.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
td.files {{ font-size: 0.85rem; color: var(--muted); }}
td.name code {{ font-size: 0.8rem; background: rgba(0,0,0,0.3); padding: 0.15em 0.4em; border-radius: 4px; }}
td.desc {{ font-size: 0.85rem; color: var(--muted); max-width: 320px; }}
.badge {{
display: inline-block;
padding: 0.2em 0.5em;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
}}
.badge.ready {{ background: rgba(63, 185, 80, 0.2); color: var(--ready); }}
.badge.merge {{ background: rgba(163, 113, 247, 0.2); color: var(--merge); }}
.badge.nightly {{ background: rgba(240, 136, 62, 0.2); color: var(--nightly); }}
.badge.skip {{ background: rgba(139, 148, 158, 0.25); color: var(--muted); }}
td.status {{ color: var(--muted); font-size: 0.85rem; }}
.case-details details {{
margin-bottom: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}}
.case-details details summary {{
padding: 0.65rem 0.85rem;
cursor: pointer;
font-weight: 500;
list-style: none;
user-select: none;
}}
.case-details details summary::-webkit-details-marker {{ display: none; }}
.case-details details summary::before {{
content: "▶ ";
font-size: 0.7rem;
color: var(--muted);
margin-right: 0.35rem;
}}
.case-details details[open] summary::before {{ content: "▼ "; }}
.case-details details summary:hover {{ background: rgba(88, 166, 255, 0.06); }}
.case-details details .table-wrap {{ margin: 0; border-top: 1px solid var(--border); }}
.case-details details table {{ margin: 0; }}
.case-details details th {{ border-top: 0; }}
.case-details .pipeline-details {{ margin-bottom: 0.5rem; }}
.case-details .pipeline-details .case-details-inner {{ padding-left: 0.5rem; margin-top: 0.25rem; }}
.case-details .suite-details {{ margin-bottom: 0.35rem; margin-left: 0.5rem; }}
.case-details .suite-details summary::before {{
content: "▶ "; font-size: 0.7rem; color: var(--muted); margin-right: 0.35rem;
}}
.case-details .suite-details[open] summary::before {{ content: "▼ "; }}
.case-details .case-files-inner {{ padding-left: 0.5rem; margin-top: 0.35rem; }}
.case-details .file-details {{
margin-bottom: 0.35rem;
margin-left: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}}
.case-details .file-details summary {{
padding: 0.55rem 0.8rem;
cursor: pointer;
user-select: none;
background: rgba(255,255,255,0.015);
}}
.case-details .file-details summary::-webkit-details-marker {{ display: none; }}
.case-details .file-details summary::before {{
content: "▶ ";
font-size: 0.7rem;
color: var(--muted);
margin-right: 0.35rem;
}}
.case-details .file-details[open] summary::before {{ content: "▼ "; }}
.back-to-top {{
position: fixed;
right: 24px;
bottom: 24px;
display: inline-block;
padding: 0.7rem 0.9rem;
border-radius: 999px;
background: rgba(88, 166, 255, 0.18);
border: 1px solid var(--border);
color: var(--text);
text-decoration: none;
font-size: 0.9rem;
backdrop-filter: blur(6px);
}}
.back-to-top:hover {{ background: rgba(88, 166, 255, 0.28); }}
</style>
</head>
<body id="top">
<div class="container">
<h1>Buildkite Pytest Case Statistics</h1>
<p class="meta">Test Suites: {len(all_stats)} · Total cases (sum of steps): {total} · </p>
<section>
<h2>Pipeline Summary</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>Pipeline</th><th>Total Cases</th></tr>
</thead>
<tbody>
{"".join(pipeline_summary_rows)}
</tbody>
</table>
</div>
</section>
<section>
<h2>Summary by Test Suite</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>Pipeline</th><th>Test Suite</th><th>Cases</th><th>Test Files</th></tr>
</thead>
<tbody>
{"".join(summary_rows)}
</tbody>
</table>
</div>
</section>
<section class="case-details">
<h2>Case Details</h2>
<p class="meta">Expand by Pipeline, then by Test Suite, then by Test File.</p>
<div class="filter-bar">
<label for="test-file-filter">Filter by Test File</label>
<input
id="test-file-filter"
class="filter-input"
type="text"
placeholder="Type part of a test file path, e.g. tests/e2e/online_serving"
/>
<span class="filter-hint">Filter applies to the Test File fold sections below.</span>
</div>
{detail_sections_html}
</section>
</div>
<a href="#top" class="back-to-top" aria-label="Back to top">↑ Top</a>
<script>
(() => {{
const input = document.getElementById("test-file-filter");
if (!input) return;
const applyFilter = () => {{
const query = input.value.trim().toLowerCase();
document.querySelectorAll(".pipeline-details").forEach((pipeline) => {{
let pipelineVisible = false;
pipeline.querySelectorAll(".suite-details").forEach((suite) => {{
let suiteVisible = false;
suite.querySelectorAll(".file-details").forEach((fileDetail) => {{
const fileName = (fileDetail.dataset.testFile || fileDetail.textContent || "").toLowerCase();
const matched = !query || fileName.includes(query);
fileDetail.style.display = matched ? "" : "none";
if (matched) suiteVisible = true;
}});
suite.style.display = suiteVisible ? "" : "none";
if (suiteVisible) pipelineVisible = true;
}});
pipeline.style.display = pipelineVisible ? "" : "none";
}});
}};
input.addEventListener("input", applyFilter);
applyFilter();
}})();
</script>
</body>
</html>
"""
out_path.write_text(html_content, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Count pytest cases from Buildkite pipelines")
parser.add_argument(
"--buildkite-dir",
type=Path,
default=BUILDKITE_DIR,
help="Path to .buildkite directory",
)
default_out = REPO_ROOT / "buildkite_testcase_statistics.html"
parser.add_argument(
"-o",
"--output",
type=Path,
default=default_out,
help=f"Output HTML file path (default: {default_out})",
)
args = parser.parse_args()
all_stats = []
for filename in PIPELINE_FILES:
yml_path = args.buildkite_dir / filename
if not yml_path.exists():
print(f"Skipped (not found): {yml_path}", file=sys.stderr)
continue
data = load_yaml(yml_path)
steps = data.get("steps") or []
pipeline_name = filename.replace(".yml", "")
for step in steps:
if not isinstance(step, dict):
continue
label = step.get("label") or "(no label)"
# Skip commented-out steps (usually no label or commands)
if not step.get("commands"):
continue
targets = get_pytest_targets_from_step(step)
if not targets:
continue
module_key = f"{pipeline_name} / {label}"
# test_files: only real .py files; marker-only steps will have an empty list.
test_files = [t for t, _ in targets if t.endswith(".py")]
# marker_targets: synthetic marker targets (e.g. "-m expr", "-m expr --run-level level").
marker_targets = [t for t, _ in targets if not t.endswith(".py")]
# marker_only: all targets are marker expressions (no explicit .py files).
marker_only = bool(targets) and all(not t.endswith(".py") for t, _ in targets)
names = []
skipped_ids: set[str] = set()
for target, raw in targets:
collected = collect_test_names(target, REPO_ROOT, raw)
skipped_ids.update(get_skip_status(target, raw, REPO_ROOT))
names.extend(collected)
count = len(names) - len(skipped_ids)
docstrings = [
get_docstring_for_node_id(REPO_ROOT, n) if "::" in n and not n.startswith("[") else "" for n in names
]
all_stats.append(
(
module_key,
pipeline_name,
label,
test_files,
marker_only,
marker_targets,
count,
names,
docstrings,
skipped_ids,
)
)
out_path = args.output.resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
total = sum(item[6] for item in all_stats)
write_html(all_stats, out_path, total, REPO_ROOT)
print(f"Written: {out_path}")
print(f"Test Suites: {len(all_stats)}, total cases (sum of steps): {total}")
if __name__ == "__main__":
main()
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""
Send the nightly report by email to DAILY_EMAIL_LIST.
Reads SMTP and recipient config from environment variables. Use --dry-run to
validate config and print subject/body without sending.
"""
from __future__ import annotations
import argparse
import logging
import mimetypes
import os
import smtplib
import sys
from datetime import date
from email.header import Header
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
LOGGER = logging.getLogger(__name__)
# Env keys for SMTP and recipients.
ENV_SMTP_HOST = "SMTP_HOST"
ENV_SMTP_PORT = "SMTP_PORT"
ENV_SMTP_USERNAME = "SMTP_USERNAME"
ENV_SMTP_PASSWORD = "SMTP_PASSWORD"
ENV_DAILY_EMAIL_LIST = "DAILY_EMAIL_LIST"
ENV_EMAIL_SENDER = "EMAIL_SENDER"
ENV_EMAIL_SUBJECT_PREFIX = "EMAIL_SUBJECT_PREFIX"
ENV_BUILD_URL = "BUILDKITE_BUILD_URL"
ENV_COMMIT = "BUILDKITE_COMMIT"
DEFAULT_OUTPUT_DIR = os.getenv("DEFAULT_OUTPUT_DIR")
# Do not attach file if size >= this (bytes); body will suggest downloading from build URL.
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
SMTP_RETRIES = 3
SMTP_RETRY_DELAY_SEC = 5
def _strip_quoted(s: str) -> str:
"""Remove one layer of surrounding quote characters from env values.
Handles both ASCII quotes ('", ''') and common Unicode quotes (“ ” ‘ ’).
"""
if not s:
return s
s = s.strip()
if not s:
return s
quotes = {'"', "'", "“", "”", "‘", "’"}
if len(s) >= 2 and s[0] in quotes and s[-1] in quotes:
s = s[1:-1].strip()
return s
def _get_required_env() -> dict[str, str]:
"""Read required env vars; raise SystemExit with clear message if any missing."""
required = {
ENV_SMTP_HOST: os.environ.get(ENV_SMTP_HOST),
ENV_SMTP_PORT: os.environ.get(ENV_SMTP_PORT),
ENV_SMTP_USERNAME: os.environ.get(ENV_SMTP_USERNAME),
ENV_SMTP_PASSWORD: os.environ.get(ENV_SMTP_PASSWORD),
ENV_DAILY_EMAIL_LIST: os.environ.get(ENV_DAILY_EMAIL_LIST),
}
missing = [k for k, v in required.items() if not (v and str(v).strip())]
if missing:
raise SystemExit(f"Missing required env vars: {', '.join(missing)}. Set them (e.g. in Buildkite secrets).")
return {k: _strip_quoted(str(v)) for k, v in required.items()}
def _get_latest_file(path: str) -> str | list[str]:
"""
Resolve report file patterns into concrete files.
- If path is a directory: return all files in that directory (list).
- If path contains wildcards (e.g. *.xlsx, *.html): expand using glob and
return all matching files (list).
- If path is a plain filename or file path: treat as (dir, filename),
search that directory for files with the same basename and return the
latest modified one (str).
Note: higher-level callers will turn a single str into [str] when they
want to attach multiple files.
"""
import glob
# Support comma-/semicolon-separated list: e.g. "*.xlsx, *.html; reports/foo.xlsx"
parts = [p.strip() for p in path.split(";") for p in p.split(",") if p and p.strip()]
if len(parts) > 1:
files: list[str] = []
for part in parts:
files_part = _get_latest_file(part)
if isinstance(files_part, str):
files.append(files_part)
else:
files.extend(files_part)
if not files:
raise SystemExit(f"No files resolved from list: {path}")
return sorted(set(files))
# Single expression after splitting
path = parts[0] if parts else path
# Directory: return all files inside
if os.path.isdir(path):
files = [os.path.join(path, f) for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
if not files:
raise SystemExit(f"No files found in {path}")
return sorted(files)
# Wildcard pattern: expand via glob
if any(ch in path for ch in ("*", "?", "[")) and not os.path.exists(path):
matches = [p for p in glob.glob(path) if os.path.isfile(p)]
if not matches:
raise SystemExit(f"No files match pattern {path!r}")
return sorted(matches)
# Plain filename or explicit file path: search its directory for same basename
parent = os.path.dirname(path) or "."
base = os.path.basename(path)
if not base:
raise SystemExit(f"Invalid path (no filename): {path}")
if not os.path.isdir(parent):
raise SystemExit(f"Directory not found: {parent}")
candidates = [
p
for p in (os.path.join(parent, f) for f in os.listdir(parent))
if os.path.basename(p) == base and os.path.isfile(p)
]
if not candidates:
raise SystemExit(f"No file named {base!r} found in {parent}")
return max(candidates, key=os.path.getmtime)
def _recipients_list(comma_separated: str) -> list[str]:
"""Parse DAILY_EMAIL_LIST into a list of addresses."""
return [_strip_quoted(a.strip()) for a in comma_separated.split(",") if a and a.strip()]
def _build_body(
date_str: str,
commit_sha: str | None,
build_url: str | None,
attachment_skipped: bool,
) -> str:
"""Plain-text body with build metadata; note when attachment was skipped."""
lines = [
f"Nightly report date: {date_str}",
"",
f"Commit: {commit_sha or 'N/A'}",
f"Build: {build_url or 'N/A'}",
"",
]
if attachment_skipped:
lines.append(
"Some report file(s) were too large to attach. Please download from the build artifacts (Build URL above)."
)
lines.append("")
return "\n".join(lines)
def _build_subject(prefix: str | None, date_str: str) -> str:
"""Subject line: optional prefix + date."""
base = f"Nightly Report {date_str}"
if prefix and prefix.strip():
return f"{prefix.strip()} {base}"
return base
def _mime_type_and_subtype(file_path: str) -> tuple[str, str]:
"""Return (maintype, subtype) for the file; fallback to application/octet-stream."""
full_type, _ = mimetypes.guess_type(file_path, strict=False)
if not full_type:
return "application", "octet-stream"
parts = full_type.split("/", 1)
if len(parts) != 2:
return "application", "octet-stream"
return parts[0], parts[1]
def _attach_file(msg: MIMEMultipart, file_path: str, max_bytes: int) -> bool:
"""
Attach one file to msg with correct MIME type. Return True if attached, False if skipped (e.g. size).
"""
if not os.path.isfile(file_path):
return False
size = os.path.getsize(file_path)
if size <= 0 or size >= max_bytes:
return False
maintype, subtype = _mime_type_and_subtype(file_path)
name = os.path.basename(file_path)
try:
if maintype == "text":
with open(file_path, encoding="utf-8", errors="replace") as f:
part = MIMEText(f.read(), subtype, "utf-8")
else:
with open(file_path, "rb") as f:
part = MIMEApplication(f.read(), _subtype=subtype)
part.add_header("Content-Disposition", "attachment", filename=name)
msg.attach(part)
return True
except OSError:
return False
def _send_mail(
report_files: list[str],
date_str: str,
dry_run: bool,
) -> None:
"""Load config, build message (attach each file with correct MIME type), and send (or dry-run)."""
cfg = _get_required_env()
recipients = _recipients_list(cfg[ENV_DAILY_EMAIL_LIST])
if not recipients:
raise SystemExit("DAILY_EMAIL_LIST is empty after parsing.")
commit_sha = os.environ.get(ENV_COMMIT)
build_url = os.environ.get(ENV_BUILD_URL)
# Allow EMAIL_SENDER / EMAIL_SUBJECT_PREFIX to be quoted; strip quotes if present.
raw_sender = os.environ.get(ENV_EMAIL_SENDER)
sender = _strip_quoted(raw_sender) if raw_sender else cfg[ENV_SMTP_USERNAME]
raw_prefix = os.environ.get(ENV_EMAIL_SUBJECT_PREFIX)
prefix = _strip_quoted(raw_prefix) if raw_prefix else None
attachment_skipped = any(os.path.isfile(p) and os.path.getsize(p) >= MAX_ATTACHMENT_BYTES for p in report_files)
body = _build_body(
commit_sha=commit_sha, build_url=build_url, date_str=date_str, attachment_skipped=attachment_skipped
)
subject = _build_subject(prefix=prefix, date_str=date_str)
msg = MIMEMultipart()
msg["Subject"] = Header(subject, "utf-8")
msg["From"] = sender
msg["To"] = ", ".join(recipients)
msg.attach(MIMEText(body, "plain", "utf-8"))
attached_count = 0
for file_path in report_files:
if os.path.isfile(file_path) and os.path.getsize(file_path) >= MAX_ATTACHMENT_BYTES:
attachment_skipped = True
else:
if _attach_file(msg, file_path, MAX_ATTACHMENT_BYTES):
attached_count += 1
elif os.path.isfile(file_path):
attachment_skipped = True
if dry_run:
LOGGER.info("dry-run: not sending mail")
print("To:", recipients, file=sys.stderr)
print("Subject:", subject, file=sys.stderr)
print("Attachments:", attached_count, "(skipped large:", attachment_skipped, ")", file=sys.stderr)
print("Body preview:", body[:300] + ("..." if len(body) > 300 else ""), file=sys.stderr)
return
host = cfg[ENV_SMTP_HOST]
port = int(cfg[ENV_SMTP_PORT], 10)
use_ssl = port == 465
last_err: Exception | None = None
for attempt in range(SMTP_RETRIES):
try:
if use_ssl:
with smtplib.SMTP_SSL(host, port=port, timeout=30) as smtp:
smtp.login(cfg[ENV_SMTP_USERNAME], cfg[ENV_SMTP_PASSWORD])
smtp.sendmail(sender, recipients, msg.as_string())
else:
with smtplib.SMTP(host, port=port, timeout=30) as smtp:
smtp.starttls()
smtp.login(cfg[ENV_SMTP_USERNAME], cfg[ENV_SMTP_PASSWORD])
smtp.sendmail(sender, recipients, msg.as_string())
LOGGER.info("sent nightly email to %d recipient(s)", len(recipients))
return
except Exception as e:
last_err = e
LOGGER.warning("SMTP attempt %d/%d failed: %s", attempt + 1, SMTP_RETRIES, e)
if attempt < SMTP_RETRIES - 1:
import time
time.sleep(SMTP_RETRY_DELAY_SEC)
hint = ""
if last_err and "getaddrinfo" in str(last_err).lower():
hint = f" (check SMTP_HOST={host!r} is correct and reachable, e.g. nslookup {host})"
raise SystemExit(f"Failed to send email after {SMTP_RETRIES} attempts.{hint}") from last_err
def _vllm_omni_root() -> str:
"""Resolve vllm-omni repo root: directory that contains a 'tests' subdir (and usually 'tools')."""
path = os.path.dirname(os.path.abspath(__file__))
while path and path != os.path.dirname(path):
if os.path.isdir(os.path.join(path, "tests")):
return path
path = os.path.dirname(path)
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
def _default_output_dir() -> str:
"""Default: vllm-omni root / DEFAULT_OUTPUT_DIR (where nightly report files live)."""
root = _vllm_omni_root()
subdir = DEFAULT_OUTPUT_DIR if (DEFAULT_OUTPUT_DIR and DEFAULT_OUTPUT_DIR.strip()) else "."
return os.path.join(root, subdir)
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Send nightly report by email (config from env).",
)
parser.add_argument(
"--report-file",
type=str,
default=_default_output_dir(),
help="Folder/file path to the sent email file; default is DEFAULT_OUTPUT_DIR.",
)
parser.add_argument(
"--date",
type=str,
default=None,
help="Date string for subject/body (default: inferred from report filename).",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print recipient, subject, and body; do not send.",
)
return parser.parse_args()
def main() -> None:
"""Entrypoint."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
args = parse_args()
result = _get_latest_file(args.report_file)
report_files = result if isinstance(result, list) else [result]
report_files = [p for p in report_files if os.path.isfile(p)]
if not report_files:
raise SystemExit("No report file(s) found.")
date_str = args.date or date.today().strftime("%Y-%m-%d")
_send_mail(report_files=report_files, date_str=date_str, dry_run=args.dry_run)
if __name__ == "__main__":
main()
-234
View File
@@ -1,234 +0,0 @@
#!/usr/bin/env python3
"""
Send the nightly performance Excel report by email to DAILY_EMAIL_LIST.
Reads SMTP and recipient config from environment variables. Use --dry-run to
validate config and print subject/body without sending.
"""
from __future__ import annotations
import argparse
import logging
import os
import smtplib
import sys
from email.header import Header
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
LOGGER = logging.getLogger(__name__)
# Env keys for SMTP and recipients.
ENV_SMTP_HOST = "SMTP_HOST"
ENV_SMTP_PORT = "SMTP_PORT"
ENV_SMTP_USERNAME = "SMTP_USERNAME"
ENV_SMTP_PASSWORD = "SMTP_PASSWORD"
ENV_DAILY_EMAIL_LIST = "DAILY_EMAIL_LIST"
ENV_EMAIL_SENDER = "EMAIL_SENDER"
ENV_EMAIL_SUBJECT_PREFIX = "EMAIL_SUBJECT_PREFIX"
ENV_BUILD_URL = "BUILDKITE_BUILD_URL"
ENV_COMMIT = "BUILDKITE_COMMIT"
DEFAULT_OUTPUT_DIR = os.getenv("DEFAULT_OUTPUT_DIR")
# Do not attach Excel if size >= this (bytes); body will suggest downloading from build URL.
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
SMTP_RETRIES = 3
SMTP_RETRY_DELAY_SEC = 5
def _get_required_env() -> dict[str, str]:
"""Read required env vars; raise SystemExit with clear message if any missing."""
required = {
ENV_SMTP_HOST: os.environ.get(ENV_SMTP_HOST),
ENV_SMTP_PORT: os.environ.get(ENV_SMTP_PORT),
ENV_SMTP_USERNAME: os.environ.get(ENV_SMTP_USERNAME),
ENV_SMTP_PASSWORD: os.environ.get(ENV_SMTP_PASSWORD),
ENV_DAILY_EMAIL_LIST: os.environ.get(ENV_DAILY_EMAIL_LIST),
}
missing = [k for k, v in required.items() if not (v and str(v).strip())]
if missing:
raise SystemExit(f"Missing required env vars: {', '.join(missing)}. Set them (e.g. in Buildkite secrets).")
return {k: str(v).strip() for k, v in required.items()}
def _get_latest_file(folder_path: str) -> str:
"""Get the latest modified file from the folder path."""
files = [os.path.join(folder_path, f) for f in os.listdir(folder_path) if f.endswith(".xlsx")]
if not files:
raise SystemExit(f"No Excel files found in {folder_path}")
return max(files, key=os.path.getmtime)
def _recipients_list(comma_separated: str) -> list[str]:
"""Parse DAILY_EMAIL_LIST into a list of addresses."""
return [a.strip() for a in comma_separated.split(",") if a.strip()]
def _build_body(
date_str: str,
commit_sha: str | None,
build_url: str | None,
attachment_skipped: bool,
) -> str:
"""Plain-text body with build metadata; note when attachment was skipped."""
lines = [
f"Nightly performance report date: {date_str}",
"",
f"Commit: {commit_sha or 'N/A'}",
f"Build: {build_url or 'N/A'}",
"",
]
if attachment_skipped:
lines.append(
"Report file was too large to attach. Please download the Excel from the build artifacts (Build URL above)."
)
lines.append("")
return "\n".join(lines)
def _build_subject(prefix: str | None, date_str: str) -> str:
"""Subject line: optional prefix + date."""
base = f"Nightly Perf {date_str}"
if prefix and prefix.strip():
return f"{prefix.strip()} {base}"
return base
def _send_mail(
report_file: str,
date_str: str,
dry_run: bool,
) -> None:
"""Load config, build message, and send (or dry-run)."""
cfg = _get_required_env()
recipients = _recipients_list(cfg[ENV_DAILY_EMAIL_LIST])
if not recipients:
raise SystemExit("DAILY_EMAIL_LIST is empty after parsing.")
commit_sha = os.environ.get(ENV_COMMIT)
build_url = os.environ.get(ENV_BUILD_URL)
sender = os.environ.get(ENV_EMAIL_SENDER) or cfg[ENV_SMTP_USERNAME]
prefix = os.environ.get(ENV_EMAIL_SUBJECT_PREFIX)
size = os.path.getsize(report_file) if os.path.isfile(report_file) else 0
attach_excel = size < MAX_ATTACHMENT_BYTES and size > 0
attachment_skipped = os.path.isfile(report_file) and not attach_excel
body = _build_body(
commit_sha=commit_sha, build_url=build_url, date_str=date_str, attachment_skipped=attachment_skipped
)
subject = _build_subject(prefix=prefix, date_str=date_str)
msg = MIMEMultipart()
msg["Subject"] = Header(subject, "utf-8")
msg["From"] = sender
msg["To"] = ", ".join(recipients)
msg.attach(MIMEText(body, "plain", "utf-8"))
if attach_excel:
with open(report_file, "rb") as f:
part = MIMEApplication(f.read(), _subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet")
part.add_header("Content-Disposition", "attachment", filename=os.path.basename(report_file))
msg.attach(part)
if dry_run:
LOGGER.info("dry-run: not sending mail")
print("To:", recipients, file=sys.stderr)
print("Subject:", subject, file=sys.stderr)
print("Attachment:", "yes" if attach_excel else "no (size limit)", file=sys.stderr)
print("Body preview:", body[:300] + ("..." if len(body) > 300 else ""), file=sys.stderr)
return
port = int(cfg[ENV_SMTP_PORT], 10)
last_err: Exception | None = None
for attempt in range(SMTP_RETRIES):
try:
with smtplib.SMTP(cfg[ENV_SMTP_HOST], port=port, timeout=30) as smtp:
smtp.starttls()
smtp.login(cfg[ENV_SMTP_USERNAME], cfg[ENV_SMTP_PASSWORD])
smtp.sendmail(sender, recipients, msg.as_string())
LOGGER.info("sent nightly perf email to %d recipient(s)", len(recipients))
return
except Exception as e:
last_err = e
LOGGER.warning("SMTP attempt %d/%d failed: %s", attempt + 1, SMTP_RETRIES, e)
if attempt < SMTP_RETRIES - 1:
import time
time.sleep(SMTP_RETRY_DELAY_SEC)
raise SystemExit(f"Failed to send email after {SMTP_RETRIES} attempts.") from last_err
def _date_from_filename(path: str) -> str:
"""Try to derive a date string from report filename (e.g. nightly_perf_20260211-020704.xlsx -> 2026-02-11)."""
base = os.path.splitext(os.path.basename(path))[0]
if base.startswith("nightly_perf_") and len(base) > 13:
raw = base.replace("nightly_perf_", "")[:8]
if len(raw) == 8 and raw.isdigit():
return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
return base or "unknown"
def _vllm_omni_root() -> str:
"""Resolve vllm-omni repo root: directory that contains a 'tests' subdir (and usually 'tools')."""
path = os.path.dirname(os.path.abspath(__file__))
while path and path != os.path.dirname(path):
if os.path.isdir(os.path.join(path, "tests")):
return path
path = os.path.dirname(path)
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
def _default_output_dir() -> str:
"""Default: vllm-omni root / DEFAULT_OUTPUT_DIR (where performance .xlsx files live)."""
root = _vllm_omni_root()
return os.path.join(root, DEFAULT_OUTPUT_DIR)
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Send nightly performance Excel report by email (config from env).",
)
parser.add_argument(
"--report-file",
type=str,
default=_default_output_dir(),
help="Folder/file path to the nightly_perf_*.xlsx file; default is DEFAULT_OUTPUT_DIR.",
)
parser.add_argument(
"--date",
type=str,
default=None,
help="Date string for subject/body (default: inferred from report filename).",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print recipient, subject, and body; do not send.",
)
return parser.parse_args()
def main() -> None:
"""Entrypoint."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
args = parse_args()
report_file = _get_latest_file(args.report_file) if os.path.isdir(args.report_file) else args.report_file
if not os.path.isfile(report_file):
raise SystemExit(f"Report file not found: {report_file}")
date_str = args.date or _date_from_filename(report_file)
_send_mail(report_file=report_file, date_str=date_str, dry_run=args.dry_run)
if __name__ == "__main__":
main()