Signed-off-by: yenuo26 <410167048@qq.com> Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
352 lines
13 KiB
Python
352 lines
13 KiB
Python
#!/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()
|