Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Copyright (c) 2026 TiaC Systems
# SPDX-License-Identifier: Apache-2.0
"""Render a pytest --junitxml file as one self-contained HTML page.
Stdlib only, deliberately — the same no-new-dependency rule as driving
coverage.py directly (pyproject.toml [tool.coverage.*]). check.sh calls
this after EVERY pytest invocation, including failed ones (the status is
captured and re-raised after rendering): a red run is precisely when a
browsable report is worth having.
Usage: junit_html.py <junit.xml> <out.html>
Layout: suite summary up top; failures and errors first, each expandable
to the full captured message; then every module with its own pass/fail
counts and per-test rows. Companion to timing_report.py, which reads the
same files for wall-time diffing.
"""
from __future__ import annotations
import html
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
_CSS = """
body { font: 14px/1.5 system-ui, sans-serif; margin: 2rem auto;
max-width: 60rem; padding: 0 1rem; }
h1 { font-size: 1.3rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
table { border-collapse: collapse; width: 100%; }
td, th { text-align: left; padding: .15rem .6rem .15rem 0;
border-bottom: 1px solid #8884; }
td.t { text-align: right; font-variant-numeric: tabular-nums; }
.badge { display: inline-block; min-width: 4.5em; text-align: center;
border-radius: .3em; padding: 0 .4em; font-size: .85em; }
.pass { background: #2e7d3222; color: #2e7d32; }
.fail { background: #c6282822; color: #c62828; }
.skip { background: #f9a82522; color: #9a6700; }
summary { cursor: pointer; margin: .3rem 0; }
pre { background: #8881; padding: .6rem; overflow-x: auto; font-size: .85em; }
@media (prefers-color-scheme: dark) {
body { background: #1c1c1c; color: #ddd; }
.pass { color: #7bc67e; } .fail { color: #ef9a9a; } .skip { color: #e0b856; }
}
"""
def _status(tc: ET.Element) -> tuple[str, ET.Element | None]:
for kind in ("error", "failure", "skipped"):
node = tc.find(kind)
if node is not None:
return ("skip" if kind == "skipped" else "fail", node)
return "pass", None
def _testcases(root: ET.Element) -> list[ET.Element]:
return list(root.iter("testcase"))
def render(xml_path: Path) -> str:
root = ET.parse(xml_path).getroot()
suites = root.findall("testsuite") if root.tag == "testsuites" else [root]
cases = _testcases(root)
counts = {"pass": 0, "fail": 0, "skip": 0}
rows_by_module: dict[str, list[str]] = {}
failures: list[str] = []
total_time = 0.0
for tc in cases:
status, detail = _status(tc)
counts[status] += 1
module = (tc.get("classname") or "?").rsplit(".", 1)[-1]
name = tc.get("name") or "?"
time = float(tc.get("time") or 0.0)
total_time += time
badge = {"pass": "passed", "fail": "FAILED", "skip": "skipped"}[status]
rows_by_module.setdefault(module, []).append(
f"<tr><td><span class='badge {status}'>{badge}</span></td>"
f"<td>{html.escape(name)}</td><td class='t'>{time:.2f}s</td></tr>"
)
if status == "fail" and detail is not None:
text = html.escape((detail.get("message") or "") + "\n" + (detail.text or ""))
failures.append(
f"<details><summary><span class='badge fail'>FAILED</span> "
f"{html.escape(module)}::{html.escape(name)}</summary>"
f"<pre>{text}</pre></details>"
)
suite_name = ", ".join(s.get("name") or "?" for s in suites)
stamp = ", ".join(filter(None, (s.get("timestamp") for s in suites)))
parts = [
"<!DOCTYPE html><html><head><meta charset='utf-8'>",
f"<title>{html.escape(xml_path.name)}</title>",
f"<style>{_CSS}</style></head><body>",
f"<h1>{html.escape(xml_path.name)} — {html.escape(suite_name)}</h1>",
f"<p><span class='badge pass'>{counts['pass']} passed</span> "
f"<span class='badge fail'>{counts['fail']} failed</span> "
f"<span class='badge skip'>{counts['skip']} skipped</span> "
f"— {total_time:.1f}s test time"
f"{' — ' + html.escape(stamp) if stamp else ''}</p>",
]
if failures:
parts.append(f"<h2>Failures ({len(failures)})</h2>")
parts += failures
for module in sorted(rows_by_module):
rows = rows_by_module[module]
parts.append(
f"<h2>{html.escape(module)} ({len(rows)})</h2><table>" + "".join(rows) + "</table>"
)
parts.append("</body></html>")
return "".join(parts)
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: junit_html.py <junit.xml> <out.html>", file=sys.stderr)
return 2
xml_path, out_path = Path(argv[0]), Path(argv[1])
out_path.write_text(render(xml_path))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
55022eb50This file is pinned to the selected release. Download the original to open it in your design tool.