# /// script # dependencies = [ # "aiohttp", # ] # /// """ Script to visualize cross-version test results for MLflow autologging and models. This script fetches scheduled workflow run results from GitHub Actions and generates a markdown table showing the test status for different package versions across different dates. Usage: uv run dev/xtest_viz.py # Fetch last 14 days from mlflow/dev uv run dev/xtest_viz.py --days 30 # Fetch last 30 days uv run dev/xtest_viz.py --repo mlflow/mlflow # Use different repo Example output (truncated for brevity): | Name | 2024-01-15 | 2024-01-14 | 2024-01-13 | |----------------------------------------|------------|------------|------------| | test1 (sklearn, 1.3.1, autologging...) | [✅](link) | [✅](link) | [❌](link) | | test1 (pytorch, 2.1.0, models...) | [✅](link) | [⚠️](link) | [✅](link) | | test2 (xgboost, 2.0.0, autologging...) | [❌](link) | [✅](link) | — | Where: ✅ = success ❌ = failure ⚠️ = cancelled ❓ = unknown status — = no data """ import argparse import asyncio import json import os import re import sys from dataclasses import asdict, dataclass from datetime import datetime, timedelta from pathlib import Path from typing import Any, cast import aiohttp @dataclass class JobResult: name: str conclusion: str date: str started_at: str completed_at: str failed_step: str | None html_url: str logs_url: str class XTestViz: def __init__(self, github_token: str | None = None, repo: str = "mlflow/dev"): self.github_token = github_token or os.environ.get("GH_TOKEN") self.repo = repo self.per_page = 30 self.headers: dict[str, str] = {} if self.github_token: self.headers["Authorization"] = f"token {self.github_token}" self.headers["Accept"] = "application/vnd.github.v3+json" def status_to_emoji(self, status: str) -> str | None: """Convert job status to emoji representation. Returns None for skipped status to indicate it should be filtered out. """ match status: case "success": return "✅" case "failure": return "❌" case "cancelled": return "⚠️" case "skipped": return None case _: return "❓" def parse_job_name(self, job_name: str) -> str: """Extract string inside parentheses from job name. Examples: - "test1 (sklearn / autologging / 1.3.1)" -> "sklearn / autologging / 1.3.1" - "test2 (pytorch / models / 2.1.0)" -> "pytorch / models / 2.1.0" Returns: str: Content inside parentheses, or original name if no parentheses found """ # Pattern to match: anything (content) pattern = r"\(([^)]+)\)" if match := re.search(pattern, job_name.strip()): return match.group(1).strip() return job_name async def _make_request( self, session: aiohttp.ClientSession, url: str, params: dict[str, str] | None = None, ) -> dict[str, Any]: """Make an async HTTP GET request and return JSON response.""" async with session.get(url, headers=self.headers, params=params) as response: response.raise_for_status() return cast(dict[str, Any], await response.json()) async def get_workflow_runs( self, session: aiohttp.ClientSession, days_back: int = 30 ) -> list[dict[str, Any]]: """Fetch cross-version test workflow runs from the last N days.""" since_date = (datetime.now() - timedelta(days=days_back)).isoformat() print(f"Fetching scheduled workflow runs from last {days_back} days...", file=sys.stderr) all_runs: list[dict[str, Any]] = [] page = 1 while True: params = { "per_page": str(self.per_page), "page": str(page), "created": f">={since_date}", "status": "completed", "event": "schedule", } url = f"https://api.github.com/repos/{self.repo}/actions/workflows/cross-version-tests.yml/runs" data = await self._make_request(session, url, params=params) runs = data.get("workflow_runs", []) if not runs: break all_runs.extend(runs) print(f" Fetched page {page} ({len(runs)} runs)", file=sys.stderr) if len(runs) < self.per_page: break page += 1 print(f"Found {len(all_runs)} scheduled workflow runs total", file=sys.stderr) return all_runs async def get_workflow_jobs( self, session: aiohttp.ClientSession, run_id: int ) -> list[dict[str, Any]]: """Get jobs for a specific workflow run.""" all_jobs: list[dict[str, Any]] = [] page = 1 while True: params = {"per_page": str(self.per_page), "page": str(page)} url = f"https://api.github.com/repos/{self.repo}/actions/runs/{run_id}/jobs" data = await self._make_request(session, url, params=params) jobs = data.get("jobs", []) if not jobs: break all_jobs.extend(jobs) if len(jobs) < self.per_page: break page += 1 return all_jobs async def _fetch_run_jobs( self, session: aiohttp.ClientSession, run: dict[str, Any] ) -> list[JobResult]: """Fetch jobs for a single workflow run.""" run_id = run["id"] run_date = datetime.fromisoformat(run["created_at"].replace("Z", "+00:00")).strftime( "%m/%d" ) jobs = await self.get_workflow_jobs(session, run_id) data_rows = [] for job in jobs: conclusion = job["conclusion"] if self.status_to_emoji(conclusion) is None: # Skip this job continue failed_step = next( (s["name"] for s in job.get("steps", []) if s.get("conclusion") == "failure"), None, ) data_rows.append( JobResult( name=self.parse_job_name(job["name"]), conclusion=conclusion, date=run_date, started_at=job["started_at"], completed_at=job["completed_at"], failed_step=failed_step, html_url=job["html_url"], logs_url=f"{job['url']}/logs", ) ) return data_rows async def fetch_all_jobs(self, days_back: int = 30) -> list[JobResult]: """Fetch all jobs from workflow runs in the specified time period.""" async with aiohttp.ClientSession() as session: workflow_runs = await self.get_workflow_runs(session, days_back) if not workflow_runs: return [] print( f"Fetching jobs for {len(workflow_runs)} workflow runs concurrently...", file=sys.stderr, ) tasks = [self._fetch_run_jobs(session, run) for run in workflow_runs] results = await asyncio.gather(*tasks, return_exceptions=True) data_rows: list[JobResult] = [] for i, result in enumerate(results, 1): if isinstance(result, BaseException): print(f" Error fetching jobs for run {i}: {result}", file=sys.stderr) else: data_rows.extend(result) print( f" Completed {i}/{len(workflow_runs)} ({len(result)} jobs)", file=sys.stderr, ) return data_rows def _pivot_job_results( self, data_rows: list[JobResult] ) -> tuple[dict[str, dict[str, str]], list[str], list[str]]: """Pivot job results data into a format suitable for table rendering. Args: data_rows: List of job results to pivot Returns: Tuple of (pivot_data, sorted_dates, sorted_names) where: - pivot_data: Dictionary mapping name -> date -> status - sorted_dates: List of dates sorted in reverse chronological order - sorted_names: List of test names sorted alphabetically """ pivot_data: dict[str, dict[str, str]] = {} all_dates: set[str] = set() for row in data_rows: if row.name not in pivot_data: pivot_data[row.name] = {} # Use first occurrence for each name-date combination if row.date not in pivot_data[row.name]: emoji = self.status_to_emoji(row.conclusion) pivot_data[row.name][row.date] = f"[{emoji}]({row.html_url})" all_dates.add(row.date) # Sort dates in reverse order (newest first) sorted_dates = sorted(all_dates, reverse=True) # Sort names alphabetically sorted_names = sorted(pivot_data.keys()) return pivot_data, sorted_dates, sorted_names def _build_markdown_table( self, pivot_data: dict[str, dict[str, str]], sorted_dates: list[str], sorted_names: list[str], ) -> str: """Build a markdown table from pivoted data. Args: pivot_data: Dictionary mapping name -> date -> status sorted_dates: List of dates (columns) in desired order sorted_names: List of test names (rows) in desired order Returns: Markdown-formatted table as a string """ headers = ["Name"] + sorted_dates # Calculate column widths col_widths = [len(h) for h in headers] for name in sorted_names: col_widths[0] = max(col_widths[0], len(name)) for i, date in enumerate(sorted_dates, 1): value = pivot_data[name].get(date, "—") col_widths[i] = max(col_widths[i], len(value)) # Build table rows lines = [] # Header row header_row = "| " + " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + " |" lines.append(header_row) # Separator row separator = "| " + " | ".join("-" * w for w in col_widths) + " |" lines.append(separator) # Data rows for name in sorted_names: row_values = [name.ljust(col_widths[0])] for i, date in enumerate(sorted_dates, 1): value = pivot_data[name].get(date, "—") row_values.append(value.ljust(col_widths[i])) lines.append("| " + " | ".join(row_values) + " |") return "\n".join(lines) def filter_latest_not_success(self, data_rows: list[JobResult]) -> list[JobResult]: """Keep only jobs whose latest run for each name was not a success.""" latest_per_name: dict[str, JobResult] = {} for r in data_rows: cur = latest_per_name.get(r.name) if cur is None or r.started_at > cur.started_at: latest_per_name[r.name] = r keep = {name for name, r in latest_per_name.items() if r.conclusion != "success"} return [r for r in data_rows if r.name in keep] def render_results_table(self, data_rows: list[JobResult]) -> str: """Render job data as a markdown table.""" if not data_rows: return "No test jobs found." pivot_data, sorted_dates, sorted_names = self._pivot_job_results(data_rows) return self._build_markdown_table(pivot_data, sorted_dates, sorted_names) def render_json(self, data_rows: list[JobResult]) -> str: return json.dumps([asdict(r) for r in data_rows], indent=2) async def main() -> None: parser = argparse.ArgumentParser(description="Visualize MLflow cross-version test results") parser.add_argument( "--days", type=int, default=14, help="Number of days back to fetch results (default: 14)" ) parser.add_argument( "--repo", default="mlflow/dev", help="GitHub repository in owner/repo format (default: mlflow/dev)", ) parser.add_argument("--token", help="GitHub token (default: use GH_TOKEN env var)") parser.add_argument( "--json-output", help="If set, also write JSON results to this path.", ) args = parser.parse_args() token = args.token or os.environ.get("GH_TOKEN") if not token: print( "Warning: No GitHub token provided. API requests may be rate-limited.", file=sys.stderr ) print("Set GH_TOKEN environment variable or use --token option.", file=sys.stderr) visualizer = XTestViz(github_token=token, repo=args.repo) raw_rows = await visualizer.fetch_all_jobs(args.days) data_rows = visualizer.filter_latest_not_success(raw_rows) if args.json_output: Path(args.json_output).write_text(visualizer.render_json(data_rows)) if not data_rows: if raw_rows: print("All latest cross-version tests succeeded.") else: print("No workflow runs found in the specified time period.") else: print(visualizer.render_results_table(data_rows)) if __name__ == "__main__": asyncio.run(main())