From c501c2d5128e1eafe376277878e1e5b1e0bb47fd Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:08:11 +0800 Subject: [PATCH] [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> --- .buildkite/test-nightly.yml | 12 +- .../nightly/buildkite_testcase_statistics.py | 798 ++++++++++++++++++ tools/nightly/send_nightly_email.py | 351 ++++++++ tools/nightly/send_nightly_perf_email.py | 234 ----- 4 files changed, 1157 insertions(+), 238 deletions(-) create mode 100644 tools/nightly/buildkite_testcase_statistics.py create mode 100644 tools/nightly/send_nightly_email.py delete mode 100644 tools/nightly/send_nightly_perf_email.py diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 013640988..7c8c4a89e 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -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" diff --git a/tools/nightly/buildkite_testcase_statistics.py b/tools/nightly/buildkite_testcase_statistics.py new file mode 100644 index 000000000..312bd6b6b --- /dev/null +++ b/tools/nightly/buildkite_testcase_statistics.py @@ -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'{esc(pipeline_name)}' if badge else esc(pipeline_name) + pipeline_summary_rows.append( + f'{badge_span}{pipeline_count}' + ) + + # 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 = "
".join(esc(f) for f in test_files) + elif marker_targets: + # Display full marker / run-level arguments when no explicit test files. + files_html = "
".join(esc(m) for m in marker_targets) + else: + files_html = "No explicit tests or markers" + badge = pipeline_badges.get(pipeline_name, "") + badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) + summary_rows.append( + f'' + f'{badge_span}{esc(label)}{count}' + f'{files_html}' + ) + + # Detail rows grouped by Pipeline, then by Test Suite (two-level collapsible) + case_details_thead = """ + + Pipeline + Test Suite + Test File + No. + Test Name + Status + Description + """ + # 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'{esc(pipeline_name)}' if badge else esc(pipeline_name) + suite_summary = f"{esc(label)} ({count} cases)" + file_sections: list[str] = [] + if not names: + suite_rows = [ + f'' + f"{badge_span}{esc(label)}" + f'(marker-only / no collection)0' + f'No collection or only -m/--run-level' + f'' + ] + file_sections.append( + '
' + "(marker-only / no collection)" + f'
{case_details_thead}' + f"{''.join(suite_rows)}
" + ) + 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", "
") if desc else "" + status_html = 'Skipped' if is_skipped else "—" + suite_rows.append( + f'' + f'{badge_span}{esc(label)}{esc(file_name)}' + f'{i}' + f'{esc(name)}' + f'{status_html}{desc_html}' + ) + file_sections.append( + f'
' + f"{esc(file_name)} ({len(file_groups[file_name])} cases)" + f'
{case_details_thead}' + f"{''.join(suite_rows)}
" + ) + 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'{esc(pipeline_name)}' if badge else esc(pipeline_name) + pipeline_summary = f"{badge_span} ({pipeline_total} cases)" + inner_html = "".join( + f'
{suite_summary}' + f'
{body}
' + for _, _, suite_summary, body in suites + ) + detail_sections_html_parts.append( + f'
{pipeline_summary}' + f'
{inner_html}
' + ) + # 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'
{suite_summary}' + f'
{body}
' + for _, _, suite_summary, body in suites + ) + detail_sections_html_parts.append( + f'
{pipeline_summary}' + f'
{inner_html}
' + ) + detail_sections_html = "".join(detail_sections_html_parts) + + html_content = f""" + + + + + Buildkite Test Case Statistics + + + +
+

Buildkite Pytest Case Statistics

+

Test Suites: {len(all_stats)} · Total cases (sum of steps): {total} ·

+ +
+

Pipeline Summary

+
+ + + + + + {"".join(pipeline_summary_rows)} + +
PipelineTotal Cases
+
+
+ +
+

Summary by Test Suite

+
+ + + + + + {"".join(summary_rows)} + +
PipelineTest SuiteCasesTest Files
+
+
+ +
+

Case Details

+

Expand by Pipeline, then by Test Suite, then by Test File.

+
+ + + Filter applies to the Test File fold sections below. +
+ {detail_sections_html} +
+
+ ↑ Top + + + +""" + 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() diff --git a/tools/nightly/send_nightly_email.py b/tools/nightly/send_nightly_email.py new file mode 100644 index 000000000..723a5c3d5 --- /dev/null +++ b/tools/nightly/send_nightly_email.py @@ -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() diff --git a/tools/nightly/send_nightly_perf_email.py b/tools/nightly/send_nightly_perf_email.py deleted file mode 100644 index 9b4bf7ece..000000000 --- a/tools/nightly/send_nightly_perf_email.py +++ /dev/null @@ -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()