CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
362 lines
12 KiB
Python
362 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright Advanced Micro Devices, Inc.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""Parse JUnit XML test reports and write a GitHub Actions step summary.
|
|
|
|
Two modes of operation:
|
|
|
|
1. **Single shard** (default) — scans one test-reports directory and writes a
|
|
per-shard summary. Called from each matrix job.
|
|
|
|
2. **Combined** (``--combined-dir``) — scans a directory that contains
|
|
multiple downloaded artifact directories named
|
|
``test-reports-<config>-<shard>-<num_shards>``. Produces one unified
|
|
table with a *Config* and *Shard* column so all failures are visible in
|
|
one place. Called from a post-matrix summary job.
|
|
|
|
Usage:
|
|
# Per-shard (inside matrix job):
|
|
python summarize_test_results.py
|
|
|
|
# Combined (summary job after all shards):
|
|
python summarize_test_results.py --combined-dir artifacts/
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
|
|
def parse_junit_xml(xml_path: Path) -> list[dict]:
|
|
"""Extract failed/errored test cases from a JUnit XML file."""
|
|
results = []
|
|
try:
|
|
tree = ET.parse(xml_path)
|
|
except ET.ParseError:
|
|
return results
|
|
|
|
root = tree.getroot()
|
|
|
|
testsuites = root.findall(".//testsuite")
|
|
if root.tag == "testsuite":
|
|
testsuites = [root]
|
|
elif root.tag == "testsuites":
|
|
testsuites = root.findall("testsuite")
|
|
|
|
for suite in testsuites:
|
|
for testcase in suite.findall("testcase"):
|
|
classname = testcase.get("classname", "")
|
|
name = testcase.get("name", "")
|
|
time_s = testcase.get("time", "")
|
|
|
|
failure = testcase.find("failure")
|
|
error = testcase.find("error")
|
|
|
|
if failure is not None:
|
|
status = "FAILED"
|
|
message = (failure.get("message") or "")[:200]
|
|
elif error is not None:
|
|
status = "ERROR"
|
|
message = (error.get("message") or "")[:200]
|
|
else:
|
|
continue
|
|
|
|
results.append(
|
|
{
|
|
"file": classname,
|
|
"class": (
|
|
classname.rsplit(".", 1)[-1] if "." in classname else classname
|
|
),
|
|
"test": name,
|
|
"status": status,
|
|
"message": message,
|
|
"time": time_s,
|
|
}
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
def collect_results(reports_dir: Path) -> list[dict]:
|
|
"""Walk a single test-reports directory and collect all failures."""
|
|
all_failures = []
|
|
for xml_file in sorted(reports_dir.rglob("*.xml")):
|
|
failures = parse_junit_xml(xml_file)
|
|
for f in failures:
|
|
f["report_file"] = str(xml_file.relative_to(reports_dir))
|
|
all_failures.extend(failures)
|
|
return all_failures
|
|
|
|
|
|
def derive_test_file(report_path: str) -> str:
|
|
"""Derive the PyTorch test file name from the report path.
|
|
|
|
e.g. 'python-pytest/distributions.test_distributions/...'
|
|
-> 'distributions/test_distributions'
|
|
"""
|
|
parts = report_path.split("/")
|
|
if len(parts) >= 2:
|
|
test_dir_name = parts[1] if parts[0].startswith("python") else parts[0]
|
|
return test_dir_name.replace(".", "/")
|
|
return report_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-shard summary (called from each matrix job)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def write_shard_summary(
|
|
failures: list[dict],
|
|
test_config: str,
|
|
shard: str,
|
|
num_shards: str,
|
|
amdgpu_family: str,
|
|
summary_file: str,
|
|
) -> None:
|
|
"""Write a per-shard markdown summary."""
|
|
lines = []
|
|
|
|
shard_label = f"shard {shard}/{num_shards}" if shard and num_shards else ""
|
|
heading_parts = [p for p in [test_config, shard_label, amdgpu_family] if p]
|
|
heading_suffix = " — " + " | ".join(heading_parts) if heading_parts else ""
|
|
|
|
if not failures:
|
|
lines.append(f"### All tests passed{heading_suffix} :white_check_mark:")
|
|
lines.append("")
|
|
else:
|
|
seen = set()
|
|
rows = []
|
|
for f in failures:
|
|
test_file = derive_test_file(f["report_file"])
|
|
key = (test_file, f["class"], f["test"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
msg = f["message"].replace("|", "\\|").replace("\n", " ")[:100]
|
|
rows.append(
|
|
f"| {test_file} | {f['class']} | {f['test']} "
|
|
f"| {f['status']} | {msg} |"
|
|
)
|
|
|
|
lines.append(f"### {len(seen)} failed{heading_suffix}")
|
|
lines.append("")
|
|
lines.append("| Test File | Test Class | Test Name | Status | Error |")
|
|
lines.append("|-----------|-----------|-----------|--------|-------|")
|
|
lines.extend(rows)
|
|
lines.append("")
|
|
|
|
_emit(lines, summary_file)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Combined summary (called from the post-matrix summary job)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ARTIFACT_RE = re.compile(r"test-reports-(?P<config>[^-]+)-(?P<shard>\d+)-(?P<num>\d+)")
|
|
|
|
|
|
def _build_report_header(
|
|
torch_version: str,
|
|
python_version: str,
|
|
amdgpu_family: str,
|
|
package_index_url: str,
|
|
pytorch_git_ref: str,
|
|
) -> list[str]:
|
|
"""Build the PyTorch Test Report header lines."""
|
|
pytorch_repo_org = "pytorch" if pytorch_git_ref == "nightly" else "ROCm"
|
|
pytorch_web_url = f"https://github.com/{pytorch_repo_org}/pytorch"
|
|
index_url = f"{package_index_url}/{amdgpu_family}/"
|
|
|
|
lines = [
|
|
"## PyTorch Test Report",
|
|
"",
|
|
f"* Torch version: `{torch_version}`",
|
|
f"* Python version: `{python_version}`",
|
|
f"* GPU family: `{amdgpu_family}`",
|
|
f"* Package index: {index_url}",
|
|
f"* PyTorch source code: {pytorch_web_url}/tree/{pytorch_git_ref}",
|
|
"",
|
|
"To reproduce, see [Running/testing PyTorch]"
|
|
"(https://github.com/ROCm/TheRock/tree/main/external-builds/pytorch"
|
|
"#runningtesting-pytorch) and setup with:",
|
|
"",
|
|
"```bash",
|
|
"# Fetch pytorch source files, including tests:",
|
|
f"git clone --branch {pytorch_git_ref} {pytorch_web_url}.git",
|
|
"",
|
|
"# Install torch and test requirements",
|
|
f"pip install --index-url={index_url} torch=={torch_version}",
|
|
"pip install -r pytorch/.ci/docker/requirements-ci.txt",
|
|
"```",
|
|
"",
|
|
]
|
|
return lines
|
|
|
|
|
|
def write_combined_summary(
|
|
combined_dir: Path,
|
|
amdgpu_family: str,
|
|
summary_file: str,
|
|
torch_version: str = "",
|
|
python_version: str = "",
|
|
package_index_url: str = "",
|
|
pytorch_git_ref: str = "",
|
|
) -> None:
|
|
"""Scan all artifact directories and write one combined table."""
|
|
lines: list[str] = []
|
|
|
|
if torch_version and amdgpu_family:
|
|
lines.extend(
|
|
_build_report_header(
|
|
torch_version,
|
|
python_version,
|
|
amdgpu_family,
|
|
package_index_url,
|
|
pytorch_git_ref,
|
|
)
|
|
)
|
|
|
|
rows: list[tuple[str, int, str]] = []
|
|
shard_dirs = sorted(combined_dir.iterdir())
|
|
|
|
for artifact_dir in shard_dirs:
|
|
if not artifact_dir.is_dir():
|
|
continue
|
|
m = _ARTIFACT_RE.match(artifact_dir.name)
|
|
if not m:
|
|
continue
|
|
config = m.group("config")
|
|
shard = m.group("shard")
|
|
num = m.group("num")
|
|
shard_label = f"{shard}/{num}"
|
|
|
|
failures = collect_results(artifact_dir)
|
|
seen = set()
|
|
for f in failures:
|
|
test_file = derive_test_file(f["report_file"])
|
|
key = (config, shard_label, test_file, f["class"], f["test"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
msg = f["message"].replace("|", "\\|").replace("\n", " ")[:100]
|
|
rows.append(
|
|
(
|
|
config,
|
|
int(shard),
|
|
f"| {config} | {shard_label} | {test_file} "
|
|
f"| {f['class']} | {f['test']} | {f['status']} | {msg} |",
|
|
)
|
|
)
|
|
|
|
if not rows:
|
|
lines.append(f"### All tests passed — {amdgpu_family} :white_check_mark:")
|
|
lines.append("")
|
|
else:
|
|
rows.sort(key=lambda r: (r[0], r[1]))
|
|
lines.append(f"### {len(rows)} failures across all shards — {amdgpu_family}")
|
|
lines.append("")
|
|
lines.append(
|
|
"| Config | Shard | Test File | Test Class "
|
|
"| Test Name | Status | Error |"
|
|
)
|
|
lines.append(
|
|
"|--------|-------|-----------|-----------|" "-----------|--------|-------|"
|
|
)
|
|
lines.extend(row for _, _, row in rows)
|
|
lines.append("")
|
|
|
|
_emit(lines, summary_file)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _emit(lines: list[str], summary_file: str) -> None:
|
|
summary = "\n".join(lines) + "\n"
|
|
if summary_file:
|
|
with open(summary_file, "a") as fh:
|
|
fh.write(summary)
|
|
print(summary)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--reports-dir",
|
|
type=Path,
|
|
default=Path("external-builds/pytorch/pytorch/test/test-reports"),
|
|
help="Path to a single shard's JUnit XML test-reports directory",
|
|
)
|
|
parser.add_argument(
|
|
"--combined-dir",
|
|
type=Path,
|
|
default=None,
|
|
help="Path to a directory of downloaded artifact dirs "
|
|
"(test-reports-<config>-<shard>-<num>). Enables combined mode.",
|
|
)
|
|
parser.add_argument(
|
|
"--test-config",
|
|
default=os.getenv("TEST_CONFIG", "unknown"),
|
|
)
|
|
parser.add_argument(
|
|
"--amdgpu-family",
|
|
default=os.getenv("AMDGPU_FAMILY", "unknown"),
|
|
)
|
|
parser.add_argument(
|
|
"--torch-version",
|
|
default=os.getenv("TORCH_VERSION", ""),
|
|
)
|
|
parser.add_argument(
|
|
"--python-version",
|
|
default=os.getenv("PYTHON_VERSION", ""),
|
|
)
|
|
parser.add_argument(
|
|
"--package-index-url",
|
|
default=os.getenv("PACKAGE_INDEX_URL", ""),
|
|
)
|
|
parser.add_argument(
|
|
"--pytorch-git-ref",
|
|
default=os.getenv("PYTORCH_GIT_REF", ""),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
summary_file = os.getenv("GITHUB_STEP_SUMMARY", "")
|
|
|
|
if args.combined_dir:
|
|
if not args.combined_dir.is_dir():
|
|
print(f"Combined directory not found: {args.combined_dir}")
|
|
return 0
|
|
write_combined_summary(
|
|
args.combined_dir,
|
|
args.amdgpu_family,
|
|
summary_file,
|
|
torch_version=args.torch_version,
|
|
python_version=args.python_version,
|
|
package_index_url=args.package_index_url,
|
|
pytorch_git_ref=args.pytorch_git_ref,
|
|
)
|
|
return 0
|
|
|
|
if not args.reports_dir.is_dir():
|
|
print(f"Reports directory not found: {args.reports_dir}")
|
|
return 0
|
|
|
|
shard = os.getenv("SHARD_NUMBER", "")
|
|
num_shards = os.getenv("NUM_TEST_SHARDS", "")
|
|
failures = collect_results(args.reports_dir)
|
|
write_shard_summary(
|
|
failures, args.test_config, shard, num_shards, args.amdgpu_family, summary_file
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|