- Organized and grouped import statements for better readability in health.py, metadata.py, releases.py, scan.py, search.py, version.py, viewer.py, and registry.py. - Updated type hints to use the new union syntax (e.g., str | None) for better clarity in scan.py, settings.py, tool_detect.py, and result.py. - Enhanced the handling of optional types in various functions to ensure more robust type checking. - Removed unnecessary blank lines and improved formatting for consistency. - Adjusted the logic in several test files to ensure compatibility with the updated import structure and type hints. (See <attachments> above for file contents. You may not need to search or read the file again.)
486 lines
16 KiB
Python
486 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as _dt
|
|
import html
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
_CSS = r"""
|
|
:root {
|
|
--bg: #0f1115;
|
|
--panel: #151923;
|
|
--panel-2: #10141d;
|
|
--text: #e7eaf0;
|
|
--muted: rgba(231,234,240,0.68);
|
|
--border: rgba(231,234,240,0.10);
|
|
--accent: #5fb3ff;
|
|
--ok: #39d98a;
|
|
--fail: #ff5c5c;
|
|
--skip: #f6c177;
|
|
--shadow: 0 18px 45px rgba(0,0,0,0.35);
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
margin: 0;
|
|
font-family: var(--sans);
|
|
background: radial-gradient(1200px 600px at 20% -20%, rgba(95,179,255,0.20), transparent 60%),
|
|
radial-gradient(900px 550px at 100% 0%, rgba(57,217,138,0.14), transparent 55%),
|
|
var(--bg);
|
|
color: var(--text);
|
|
}
|
|
|
|
a { color: var(--accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
|
|
.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 18px 60px; }
|
|
.top {
|
|
display: flex; gap: 14px; align-items: baseline; justify-content: space-between;
|
|
padding: 16px 18px; border: 1px solid var(--border); border-radius: 14px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.title { font-size: 18px; font-weight: 700; letter-spacing: 0.2px; }
|
|
.meta { color: var(--muted); font-size: 12px; }
|
|
.chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; justify-content: flex-end; }
|
|
.chip {
|
|
display: inline-flex; gap: 8px; align-items: center;
|
|
padding: 7px 10px; border-radius: 999px;
|
|
border: 1px solid var(--border);
|
|
background: rgba(255,255,255,0.03);
|
|
font-size: 12px; color: var(--muted);
|
|
}
|
|
.dot { width: 9px; height: 9px; border-radius: 999px; background: var(--muted); box-shadow: 0 0 0 3px rgba(255,255,255,0.04); }
|
|
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(57,217,138,0.14); }
|
|
.dot.fail { background: var(--fail); box-shadow: 0 0 0 3px rgba(255,92,92,0.14); }
|
|
.dot.skip { background: var(--skip); box-shadow: 0 0 0 3px rgba(246,193,119,0.14); }
|
|
|
|
.grid { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 14px; }
|
|
.panel {
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
background: rgba(21,25,35,0.92);
|
|
box-shadow: var(--shadow);
|
|
overflow: hidden;
|
|
}
|
|
.panel-h {
|
|
display: flex; gap: 10px; align-items: center; justify-content: space-between;
|
|
padding: 12px 14px; border-bottom: 1px solid var(--border);
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01));
|
|
}
|
|
.panel-h .left { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
|
.suite { font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.small { color: var(--muted); font-size: 12px; }
|
|
.panel-b { padding: 10px 12px 12px; }
|
|
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { padding: 9px 10px; vertical-align: top; }
|
|
th { text-align: left; font-size: 12px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); }
|
|
tr + tr td { border-top: 1px solid rgba(231,234,240,0.07); }
|
|
.name { font-weight: 600; color: var(--text); }
|
|
.cls { font-family: var(--mono); font-size: 12px; color: rgba(231,234,240,0.78); }
|
|
.time { font-family: var(--mono); font-size: 12px; color: rgba(231,234,240,0.72); text-align: right; white-space: nowrap; }
|
|
.status { font-size: 12px; font-weight: 700; letter-spacing: 0.2px; }
|
|
.status.ok { color: var(--ok); }
|
|
.status.fail { color: var(--fail); }
|
|
.status.skip { color: var(--skip); }
|
|
|
|
details { margin-top: 8px; }
|
|
summary {
|
|
cursor: pointer;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
user-select: none;
|
|
}
|
|
.pre {
|
|
margin-top: 8px;
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(231,234,240,0.10);
|
|
background: rgba(16,20,29,0.85);
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: 14px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
|
|
def _as_int(value: str | None, default: int = 0) -> int:
|
|
try:
|
|
return int(float(value or 0))
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _as_float(value: str | None, default: float = 0.0) -> float:
|
|
try:
|
|
return float(value or 0.0)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _now_local_str() -> str:
|
|
try:
|
|
return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _parse_junit(xml_path: Path) -> dict:
|
|
tree = ET.parse(xml_path)
|
|
root = tree.getroot()
|
|
|
|
# JUnit can be either <testsuite> or <testsuites>.
|
|
if root.tag == "testsuites":
|
|
suites = list(root.findall("testsuite"))
|
|
root_totals = {
|
|
"tests": _as_int(root.get("tests")),
|
|
"failures": _as_int(root.get("failures")),
|
|
"errors": _as_int(root.get("errors")),
|
|
"skipped": _as_int(root.get("skipped")),
|
|
"time": _as_float(root.get("time")),
|
|
}
|
|
else:
|
|
suites = [root] if root.tag == "testsuite" else list(root.findall(".//testsuite"))
|
|
root_totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0}
|
|
|
|
# Some generators (including pytest) may omit totals on <testsuites>.
|
|
# Always compute totals from child suites; only trust root totals if they look valid.
|
|
computed = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0}
|
|
for s in suites:
|
|
computed["tests"] += _as_int(s.get("tests"))
|
|
computed["failures"] += _as_int(s.get("failures"))
|
|
computed["errors"] += _as_int(s.get("errors"))
|
|
computed["skipped"] += _as_int(s.get("skipped"))
|
|
computed["time"] += _as_float(s.get("time"))
|
|
|
|
# If root totals are all zeros but suites have real counts, use computed.
|
|
if sum(int(root_totals.get(k, 0) or 0) for k in ("tests", "failures", "errors", "skipped")) == 0 and computed["tests"] > 0:
|
|
totals = computed
|
|
else:
|
|
totals = root_totals
|
|
|
|
parsed_suites: list[dict] = []
|
|
for suite in suites:
|
|
suite_name = suite.get("name") or "testsuite"
|
|
suite_time = _as_float(suite.get("time"))
|
|
suite_tests = _as_int(suite.get("tests"))
|
|
suite_failures = _as_int(suite.get("failures"))
|
|
suite_errors = _as_int(suite.get("errors"))
|
|
suite_skipped = _as_int(suite.get("skipped"))
|
|
|
|
cases: list[dict] = []
|
|
for tc in suite.findall(".//testcase"):
|
|
name = tc.get("name") or ""
|
|
classname = tc.get("classname") or ""
|
|
t = _as_float(tc.get("time"))
|
|
|
|
status = "ok"
|
|
detail = ""
|
|
failure = tc.find("failure")
|
|
error = tc.find("error")
|
|
skipped = tc.find("skipped")
|
|
if skipped is not None:
|
|
status = "skip"
|
|
detail = skipped.get("message") or (skipped.text or "")
|
|
elif failure is not None:
|
|
status = "fail"
|
|
detail = (failure.get("message") or "") + ("\n" if (failure.text or "").strip() else "") + (failure.text or "")
|
|
elif error is not None:
|
|
status = "fail"
|
|
detail = (error.get("message") or "") + ("\n" if (error.text or "").strip() else "") + (error.text or "")
|
|
|
|
cases.append(
|
|
{
|
|
"name": name,
|
|
"classname": classname,
|
|
"time": t,
|
|
"status": status,
|
|
"detail": detail.strip(),
|
|
}
|
|
)
|
|
|
|
parsed_suites.append(
|
|
{
|
|
"name": suite_name,
|
|
"tests": suite_tests,
|
|
"failures": suite_failures,
|
|
"errors": suite_errors,
|
|
"skipped": suite_skipped,
|
|
"time": suite_time,
|
|
"cases": cases,
|
|
}
|
|
)
|
|
|
|
return {"totals": totals, "suites": parsed_suites}
|
|
|
|
|
|
def _escape(s: str) -> str:
|
|
return html.escape(s or "", quote=False)
|
|
|
|
|
|
def _render_html(title: str, xml_path: Path, data: dict) -> str:
|
|
totals = data.get("totals") or {}
|
|
suites = data.get("suites") or []
|
|
failures_total = int(totals.get("failures", 0) or 0) + int(totals.get("errors", 0) or 0)
|
|
status = "ok" if failures_total == 0 else "fail"
|
|
if status == "ok" and int(totals.get("skipped", 0) or 0) > 0:
|
|
status = "skip"
|
|
|
|
def chip(label: str, value: str, kind: str) -> str:
|
|
return f'<span class="chip"><span class="dot {kind}"></span><span>{_escape(label)}:</span><b style="color: var(--text)">{_escape(value)}</b></span>'
|
|
|
|
overall_label = "PASSED" if status == "ok" else ("SKIPPED" if status == "skip" else "FAILED")
|
|
chips = "\n".join(
|
|
[
|
|
chip("Overall", overall_label, status),
|
|
chip("Tests", str(int(totals.get("tests", 0) or 0)), "ok" if status != "fail" else "fail"),
|
|
chip("Failures", str(int(failures_total)), "fail" if failures_total else "ok"),
|
|
chip("Skipped", str(int(totals.get("skipped", 0) or 0)), "skip" if int(totals.get("skipped", 0) or 0) else "ok"),
|
|
chip("Time", f"{float(totals.get('time', 0.0) or 0.0):.2f}s", "ok" if status != "fail" else "fail"),
|
|
]
|
|
)
|
|
|
|
rendered_suites: list[str] = []
|
|
for suite in suites:
|
|
suite_name = suite.get("name") or "testsuite"
|
|
cases = suite.get("cases") or []
|
|
suite_fail = int(suite.get("failures", 0) or 0) + int(suite.get("errors", 0) or 0)
|
|
suite_skip = int(suite.get("skipped", 0) or 0)
|
|
suite_status = "fail" if suite_fail else ("skip" if suite_skip else "ok")
|
|
|
|
rows: list[str] = []
|
|
for c in cases:
|
|
st = c.get("status") or "ok"
|
|
status_label = "PASSED" if st == "ok" else ("SKIPPED" if st == "skip" else "FAILED")
|
|
detail = c.get("detail") or ""
|
|
detail_html = ""
|
|
if detail:
|
|
detail_html = f"""
|
|
<details>
|
|
<summary>Details</summary>
|
|
<div class="pre">{_escape(detail)}</div>
|
|
</details>
|
|
"""
|
|
rows.append(
|
|
f"""
|
|
<tr>
|
|
<td>
|
|
<div class="name">{_escape(c.get("name") or "")}</div>
|
|
<div class="cls">{_escape(c.get("classname") or "")}</div>
|
|
{detail_html}
|
|
</td>
|
|
<td class="time">{float(c.get("time") or 0.0):.3f}s</td>
|
|
<td class="time"><span class="status {st}">{status_label}</span></td>
|
|
</tr>
|
|
"""
|
|
)
|
|
|
|
rendered_suites.append(
|
|
f"""
|
|
<section class="panel">
|
|
<div class="panel-h">
|
|
<div class="left">
|
|
<span class="dot {suite_status}"></span>
|
|
<div style="min-width:0">
|
|
<div class="suite">{_escape(suite_name)}</div>
|
|
<div class="small">{int(suite.get("tests", 0) or 0)} tests • {suite_fail} failing • {suite_skip} skipped • {float(suite.get("time", 0.0) or 0.0):.2f}s</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="panel-b">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Test Case</th>
|
|
<th style="width: 90px; text-align:right;">Time</th>
|
|
<th style="width: 96px; text-align:right;">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{''.join(rows)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
"""
|
|
)
|
|
|
|
xml_name = xml_path.name
|
|
xml_mtime = ""
|
|
try:
|
|
xml_mtime = _dt.datetime.fromtimestamp(xml_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
pass
|
|
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{_escape(title)}</title>
|
|
<style>{_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="top">
|
|
<div>
|
|
<div class="title">{_escape(title)}</div>
|
|
<div class="meta">Source: <span style="font-family: var(--mono)">{_escape(xml_name)}</span> • Generated: {_escape(_now_local_str())} • XML mtime: {_escape(xml_mtime)}</div>
|
|
</div>
|
|
<div class="chips">
|
|
{chips}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
{''.join(rendered_suites) if rendered_suites else '<div class="panel"><div class="panel-b">No suites found.</div></div>'}
|
|
</div>
|
|
|
|
<div class="footer">Majoor Assets Manager • Pytest JUnit report</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def _write_index(index_dir: Path) -> None:
|
|
try:
|
|
index_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
return
|
|
|
|
items: list[tuple[float, Path]] = []
|
|
try:
|
|
for p in index_dir.glob("*.html"):
|
|
if p.name.lower() == "index.html":
|
|
continue
|
|
try:
|
|
items.append((p.stat().st_mtime, p))
|
|
except Exception:
|
|
items.append((0.0, p))
|
|
except Exception:
|
|
return
|
|
|
|
items.sort(key=lambda t: t[0], reverse=True)
|
|
|
|
rows: list[str] = []
|
|
for mtime, p in items[:200]:
|
|
ts = ""
|
|
try:
|
|
ts = _dt.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
pass
|
|
rows.append(
|
|
f'<tr><td><a href="{html.escape(p.name)}">{html.escape(p.name)}</a></td><td class="time">{html.escape(ts)}</td></tr>'
|
|
)
|
|
|
|
page = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Pytest Reports</title>
|
|
<style>{_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="top">
|
|
<div>
|
|
<div class="title">Pytest Reports</div>
|
|
<div class="meta">Folder: <span style="font-family: var(--mono)">{html.escape(str(index_dir))}</span></div>
|
|
</div>
|
|
<div class="chips">
|
|
<span class="chip"><span class="dot ok"></span><span>Count:</span><b style="color: var(--text)">{len(items)}</b></span>
|
|
</div>
|
|
</div>
|
|
<div class="grid">
|
|
<section class="panel">
|
|
<div class="panel-h">
|
|
<div class="left">
|
|
<span class="dot ok"></span>
|
|
<div class="suite">Latest HTML reports</div>
|
|
</div>
|
|
</div>
|
|
<div class="panel-b">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Report</th><th style="width: 190px; text-align:right;">Modified</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{''.join(rows) if rows else '<tr><td colspan="2" class="small">No reports found.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<div class="footer">Majoor Assets Manager • Pytest reports index</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
try:
|
|
(index_dir / "index.html").write_text(page, encoding="utf-8")
|
|
except Exception:
|
|
return
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
p = argparse.ArgumentParser(description="Convert a JUnit XML report to a styled HTML report.")
|
|
p.add_argument("xml", type=str, help="Input JUnit XML path")
|
|
p.add_argument("html", type=str, help="Output HTML path")
|
|
p.add_argument("--title", type=str, default="Pytest Report", help="HTML title")
|
|
p.add_argument("--index-dir", type=str, default="", help="If set, also write index.html in this folder")
|
|
args = p.parse_args(argv)
|
|
|
|
xml_path = Path(args.xml)
|
|
html_path = Path(args.html)
|
|
|
|
if not xml_path.exists():
|
|
print(f"[junit_to_html] Input XML not found: {xml_path}", file=sys.stderr)
|
|
return 2
|
|
|
|
try:
|
|
data = _parse_junit(xml_path)
|
|
except Exception as exc:
|
|
print(f"[junit_to_html] Failed to parse XML: {exc}", file=sys.stderr)
|
|
return 3
|
|
|
|
try:
|
|
html_path.parent.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
page = _render_html(args.title, xml_path, data)
|
|
try:
|
|
html_path.write_text(page, encoding="utf-8")
|
|
except Exception as exc:
|
|
print(f"[junit_to_html] Failed to write HTML: {exc}", file=sys.stderr)
|
|
return 4
|
|
|
|
if args.index_dir:
|
|
try:
|
|
_write_index(Path(args.index_dir))
|
|
except Exception:
|
|
pass
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|