"""HTML report generation for pytest-routes."""
from __future__ import annotations
import html
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pytest_routes.reporting.metrics import RunMetrics
from pytest_routes.reporting.route_coverage import CoverageMetrics
def _jinja2_available() -> bool:
"""Check if Jinja2 is available."""
try:
import jinja2 # noqa: F401
return True
except ImportError:
return False
[docs]
@dataclass
class ReportConfig:
"""Configuration for HTML report generation.
Attributes:
output_path: Path to write the HTML report.
title: Title for the report.
include_charts: Whether to include charts.
include_details: Whether to include detailed route info.
theme: Color theme ('light' or 'dark').
"""
output_path: Path | str = "pytest-routes-report.html"
title: str = "pytest-routes Test Report"
include_charts: bool = True
include_details: bool = True
theme: str = "light"
def __post_init__(self) -> None:
if isinstance(self.output_path, str):
self.output_path = Path(self.output_path)
_REPORT_TEMPLATE = """<!DOCTYPE html>
<html lang="en" data-theme="{{ theme }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<style>
:root {
--bg-color: #ffffff;
--text-color: #1a1a1a;
--card-bg: #f8f9fa;
--border-color: #dee2e6;
--success-color: #28a745;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #17a2b8;
--primary-color: #dc2626;
}
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
--card-bg: #2d2d2d;
--border-color: #404040;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
padding: 2rem;
}
.container { max-width: 1200px; margin: 0 auto; }
header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--primary-color);
}
h1 { font-size: 2rem; color: var(--primary-color); }
.meta { color: #666; font-size: 0.9rem; margin-top: 0.5rem; }
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--border-color);
}
.card h3 { font-size: 0.9rem; color: #666; text-transform: uppercase; }
.card .value {
font-size: 2rem;
font-weight: 700;
margin-top: 0.5rem;
}
.card.success .value { color: var(--success-color); }
.card.danger .value { color: var(--danger-color); }
.card.info .value { color: var(--info-color); }
.progress-bar {
height: 8px;
background: var(--border-color);
border-radius: 4px;
overflow: hidden;
margin-top: 1rem;
}
.progress-bar .fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th { background: var(--card-bg); font-weight: 600; }
tr:hover { background: var(--card-bg); }
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.status.passed { background: #d4edda; color: #155724; }
.status.failed { background: #f8d7da; color: #721c24; }
.status.skipped { background: #fff3cd; color: #856404; }
[data-theme="dark"] .status.passed { background: #1e4620; color: #a3d9a5; }
[data-theme="dark"] .status.failed { background: #4a1a1a; color: #f5a5a5; }
[data-theme="dark"] .status.skipped { background: #4a3f1a; color: #f5d9a5; }
.method {
font-family: monospace;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
}
.method.GET { background: #61affe; color: white; }
.method.POST { background: #49cc90; color: white; }
.method.PUT { background: #fca130; color: white; }
.method.DELETE { background: #f93e3e; color: white; }
.method.PATCH { background: #50e3c2; color: white; }
.path { font-family: monospace; }
.timing { font-family: monospace; color: #666; }
section { margin-bottom: 2rem; }
section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.chart-container {
display: flex;
justify-content: center;
gap: 2rem;
margin: 1rem 0;
}
.pie-chart {
width: 150px;
height: 150px;
border-radius: 50%;
position: relative;
}
.pie-legend {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
}
.pie-legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pie-legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
footer {
text-align: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
color: #666;
font-size: 0.85rem;
}
footer a { color: var(--primary-color); text-decoration: none; }
.collapsible { cursor: pointer; }
.collapsible:after { content: ' ▼'; font-size: 0.7em; }
.collapsible.active:after { content: ' ▲'; }
.details { display: none; padding: 1rem; background: var(--card-bg); }
.details.show { display: block; }
.errors { color: var(--danger-color); font-family: monospace; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>{{ title }}</h1>
<p class="meta">Generated on {{ generated_at }} | Duration: {{ duration }}s</p>
</header>
<section class="summary">
<div class="card {% if passed_routes == total_routes %}success{% elif failed_routes > 0 %}danger{% else %}info{% endif %}">
<h3>Pass Rate</h3>
<div class="value">{{ pass_rate }}%</div>
<div class="progress-bar">
<div class="fill" style="width: {{ pass_rate }}%"></div>
</div>
</div>
<div class="card success">
<h3>Passed Routes</h3>
<div class="value">{{ passed_routes }}</div>
</div>
<div class="card {% if failed_routes > 0 %}danger{% endif %}">
<h3>Failed Routes</h3>
<div class="value">{{ failed_routes }}</div>
</div>
<div class="card info">
<h3>Total Requests</h3>
<div class="value">{{ total_requests }}</div>
</div>
</section>
{% if coverage %}
<section>
<h2>Coverage Metrics</h2>
<div class="summary">
<div class="card">
<h3>Route Coverage</h3>
<div class="value">{{ coverage.coverage_percentage }}%</div>
<div class="progress-bar">
<div class="fill" style="width: {{ coverage.coverage_percentage }}%"></div>
</div>
</div>
<div class="card">
<h3>Tested Routes</h3>
<div class="value">{{ coverage.tested_routes }} / {{ coverage.total_routes }}</div>
</div>
<div class="card">
<h3>Avg Coverage Score</h3>
<div class="value">{{ coverage.average_coverage_score }}</div>
</div>
</div>
{% if coverage.untested_routes %}
<details>
<summary style="cursor: pointer; color: var(--warning-color);">
{{ coverage.untested_routes|length }} untested routes
</summary>
<ul style="margin-top: 0.5rem; padding-left: 1.5rem;">
{% for route in coverage.untested_routes %}
<li class="path">{{ route }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endif %}
<section>
<h2>Route Results</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Status</th>
<th>Requests</th>
<th>Avg Time</th>
<th>Success Rate</th>
</tr>
</thead>
<tbody>
{% for route in routes %}
<tr>
<td><span class="method {{ route.method }}">{{ route.method }}</span></td>
<td class="path">{{ route.route_path }}</td>
<td><span class="status {% if route.passed %}passed{% else %}failed{% endif %}">
{% if route.passed %}PASSED{% else %}FAILED{% endif %}
</span></td>
<td>{{ route.total_requests }}</td>
<td class="timing">{{ route.avg_time_ms }}ms</td>
<td>{{ route.success_rate }}%</td>
</tr>
{% if route.errors %}
<tr>
<td colspan="6" class="errors">
{% for error in route.errors[:3] %}
<div>{{ error }}</div>
{% endfor %}
{% if route.errors|length > 3 %}
<div>... and {{ route.errors|length - 3 }} more errors</div>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</section>
<footer>
<p>Generated by <a href="https://github.com/JacobCoffee/pytest-routes">pytest-routes</a></p>
</footer>
</div>
</body>
</html>
"""
_SIMPLE_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{ font-family: sans-serif; padding: 2rem; max-width: 1200px; margin: 0 auto; }}
h1 {{ color: #dc2626; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 1rem; }}
th, td {{ padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #f5f5f5; }}
.passed {{ color: green; }}
.failed {{ color: red; }}
.summary {{ display: flex; gap: 2rem; margin: 1rem 0; }}
.stat {{ padding: 1rem; background: #f5f5f5; border-radius: 8px; }}
.stat-value {{ font-size: 2rem; font-weight: bold; }}
</style>
</head>
<body>
<h1>{title}</h1>
<p>Generated on {generated_at} | Duration: {duration}s</p>
<div class="summary">
<div class="stat"><div class="stat-value">{pass_rate}%</div>Pass Rate</div>
<div class="stat"><div class="stat-value passed">{passed_routes}</div>Passed</div>
<div class="stat"><div class="stat-value failed">{failed_routes}</div>Failed</div>
<div class="stat"><div class="stat-value">{total_requests}</div>Requests</div>
</div>
<table>
<tr><th>Method</th><th>Path</th><th>Status</th><th>Requests</th><th>Avg Time</th></tr>
{rows}
</table>
<p style="margin-top: 2rem; color: #666;">
Generated by <a href="https://github.com/JacobCoffee/pytest-routes">pytest-routes</a>
</p>
</body>
</html>
"""
[docs]
class HTMLReportGenerator:
"""Generate HTML reports for pytest-routes test results."""
[docs]
def __init__(self, config: ReportConfig | None = None) -> None:
"""Initialize the report generator.
Args:
config: Report configuration options.
"""
self.config = config or ReportConfig()
self._use_jinja = _jinja2_available()
[docs]
def generate(
self,
metrics: RunMetrics,
coverage: CoverageMetrics | None = None,
) -> str:
"""Generate HTML report.
Args:
metrics: Test metrics to include.
coverage: Optional coverage metrics.
Returns:
HTML content as a string.
"""
if self._use_jinja:
return self._generate_with_jinja(metrics, coverage)
return self._generate_simple(metrics, coverage)
[docs]
def write(
self,
metrics: RunMetrics,
coverage: CoverageMetrics | None = None,
) -> Path:
"""Generate and write HTML report to file.
Args:
metrics: Test metrics to include.
coverage: Optional coverage metrics.
Returns:
Path to the written file.
"""
content = self.generate(metrics, coverage)
output_path = Path(self.config.output_path)
output_path.write_text(content, encoding="utf-8")
return output_path
def _generate_with_jinja(
self,
metrics: RunMetrics,
coverage: CoverageMetrics | None,
) -> str:
"""Generate report using Jinja2 templates."""
from jinja2 import Environment
env = Environment(autoescape=True)
template = env.from_string(_REPORT_TEMPLATE)
routes = sorted(
[rm.to_dict() for rm in metrics.routes.values()],
key=lambda r: (not r["passed"], r["route_path"]),
)
context = {
"title": self.config.title,
"theme": self.config.theme,
"generated_at": datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"duration": round(metrics.duration_seconds, 2),
"pass_rate": round(metrics.pass_rate, 1),
"passed_routes": metrics.passed_routes,
"failed_routes": metrics.failed_routes,
"total_routes": metrics.total_routes,
"total_requests": metrics.total_requests,
"routes": routes,
"coverage": coverage.to_dict() if coverage else None,
}
return template.render(**context)
def _generate_simple(
self,
metrics: RunMetrics,
coverage: CoverageMetrics | None,
) -> str:
"""Generate simple HTML report without Jinja2."""
routes = sorted(
metrics.routes.values(),
key=lambda r: (not r.passed, r.route_path),
)
rows = []
for rm in routes:
status_class = "passed" if rm.passed else "failed"
status_text = "PASSED" if rm.passed else "FAILED"
row = (
f"<tr><td>{html.escape(rm.method)}</td>"
f"<td>{html.escape(rm.route_path)}</td>"
f'<td class="{status_class}">{status_text}</td>'
f"<td>{rm.total_requests}</td>"
f"<td>{round(rm.avg_time_ms, 2)}ms</td></tr>"
)
rows.append(row)
return _SIMPLE_TEMPLATE.format(
title=html.escape(self.config.title),
generated_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
duration=round(metrics.duration_seconds, 2),
pass_rate=round(metrics.pass_rate, 1),
passed_routes=metrics.passed_routes,
failed_routes=metrics.failed_routes,
total_requests=metrics.total_requests,
rows="\n".join(rows),
)
[docs]
def to_json(
self,
metrics: RunMetrics,
coverage: CoverageMetrics | None = None,
) -> str:
"""Export metrics as JSON.
Args:
metrics: Test metrics to export.
coverage: Optional coverage metrics.
Returns:
JSON string.
"""
data: dict[str, Any] = {
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
"metrics": metrics.to_dict(),
}
if coverage:
data["coverage"] = coverage.to_dict()
return json.dumps(data, indent=2)
[docs]
def write_json(
self,
metrics: RunMetrics,
coverage: CoverageMetrics | None = None,
output_path: Path | str | None = None,
) -> Path:
"""Write metrics as JSON file.
Args:
metrics: Test metrics to export.
coverage: Optional coverage metrics.
output_path: Path to write to (defaults to report path with .json).
Returns:
Path to the written file.
"""
if output_path is None:
output_path = Path(self.config.output_path).with_suffix(".json")
elif isinstance(output_path, str):
output_path = Path(output_path)
content = self.to_json(metrics, coverage)
output_path.write_text(content, encoding="utf-8")
return output_path