duckguard 3.1.0__py3-none-any.whl → 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- duckguard/__init__.py +1 -1
- duckguard/ai/__init__.py +33 -0
- duckguard/ai/config.py +201 -0
- duckguard/ai/explainer.py +109 -0
- duckguard/ai/fixer.py +105 -0
- duckguard/ai/natural_language.py +119 -0
- duckguard/ai/rules_generator.py +121 -0
- duckguard/checks/conditional.py +4 -3
- duckguard/cli/main.py +156 -4
- duckguard/core/column.py +15 -5
- duckguard/py.typed +0 -0
- duckguard/reports/html_reporter.py +522 -37
- duckguard/reports/pdf_reporter.py +33 -5
- duckguard/semantic/detector.py +18 -7
- {duckguard-3.1.0.dist-info → duckguard-3.2.0.dist-info}/METADATA +98 -25
- {duckguard-3.1.0.dist-info → duckguard-3.2.0.dist-info}/RECORD +20 -12
- duckguard-3.2.0.dist-info/licenses/LICENSE +190 -0
- duckguard-3.2.0.dist-info/licenses/NOTICE +7 -0
- duckguard-3.1.0.dist-info/licenses/LICENSE +0 -55
- {duckguard-3.1.0.dist-info → duckguard-3.2.0.dist-info}/WHEEL +0 -0
- {duckguard-3.1.0.dist-info → duckguard-3.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""HTML report generation for DuckGuard.
|
|
2
2
|
|
|
3
3
|
Generates beautiful, standalone HTML reports from validation results.
|
|
4
|
+
Features: dark mode, collapsible sections, sortable tables, search,
|
|
5
|
+
trend charts, and dataset metadata — all in a single self-contained file.
|
|
4
6
|
"""
|
|
5
7
|
|
|
6
8
|
from __future__ import annotations
|
|
@@ -11,7 +13,7 @@ from pathlib import Path
|
|
|
11
13
|
from typing import TYPE_CHECKING, Any
|
|
12
14
|
|
|
13
15
|
if TYPE_CHECKING:
|
|
14
|
-
from duckguard.history.storage import StoredRun
|
|
16
|
+
from duckguard.history.storage import StoredRun, TrendDataPoint
|
|
15
17
|
from duckguard.rules.executor import ExecutionResult
|
|
16
18
|
|
|
17
19
|
|
|
@@ -28,6 +30,9 @@ class ReportConfig:
|
|
|
28
30
|
include_trends: Include trend charts (requires history)
|
|
29
31
|
custom_css: Custom CSS to include
|
|
30
32
|
logo_url: URL or data URI for logo
|
|
33
|
+
dark_mode: Theme mode — "auto" (OS preference), "light", or "dark"
|
|
34
|
+
trend_days: Number of days of history for trend charts
|
|
35
|
+
include_metadata: Show row count, column count, and duration in header
|
|
31
36
|
"""
|
|
32
37
|
|
|
33
38
|
title: str = "DuckGuard Data Quality Report"
|
|
@@ -38,11 +43,14 @@ class ReportConfig:
|
|
|
38
43
|
include_trends: bool = False
|
|
39
44
|
custom_css: str | None = None
|
|
40
45
|
logo_url: str | None = None
|
|
46
|
+
dark_mode: str = "auto"
|
|
47
|
+
trend_days: int = 30
|
|
48
|
+
include_metadata: bool = True
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
# Embedded HTML template (no external dependencies for basic reports)
|
|
44
52
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
45
|
-
<html lang="en">
|
|
53
|
+
<html lang="en"{% if dark_mode == 'dark' %} data-theme="dark"{% elif dark_mode == 'light' %} data-theme="light"{% endif %}>
|
|
46
54
|
<head>
|
|
47
55
|
<meta charset="UTF-8">
|
|
48
56
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
@@ -59,6 +67,30 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
59
67
|
--color-text: #111827;
|
|
60
68
|
--color-text-secondary: #6b7280;
|
|
61
69
|
}
|
|
70
|
+
[data-theme="dark"] {
|
|
71
|
+
--color-pass: #34d399;
|
|
72
|
+
--color-fail: #f87171;
|
|
73
|
+
--color-warn: #fbbf24;
|
|
74
|
+
--color-info: #9ca3af;
|
|
75
|
+
--color-bg: #111827;
|
|
76
|
+
--color-card: #1f2937;
|
|
77
|
+
--color-border: #374151;
|
|
78
|
+
--color-text: #f9fafb;
|
|
79
|
+
--color-text-secondary: #9ca3af;
|
|
80
|
+
}
|
|
81
|
+
@media (prefers-color-scheme: dark) {
|
|
82
|
+
:root:not([data-theme="light"]) {
|
|
83
|
+
--color-pass: #34d399;
|
|
84
|
+
--color-fail: #f87171;
|
|
85
|
+
--color-warn: #fbbf24;
|
|
86
|
+
--color-info: #9ca3af;
|
|
87
|
+
--color-bg: #111827;
|
|
88
|
+
--color-card: #1f2937;
|
|
89
|
+
--color-border: #374151;
|
|
90
|
+
--color-text: #f9fafb;
|
|
91
|
+
--color-text-secondary: #9ca3af;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
62
94
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
63
95
|
body {
|
|
64
96
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
@@ -76,8 +108,11 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
76
108
|
padding-bottom: 1rem;
|
|
77
109
|
border-bottom: 2px solid var(--color-border);
|
|
78
110
|
}
|
|
111
|
+
.header-left { display: flex; align-items: center; gap: 1rem; }
|
|
112
|
+
.header-logo { max-height: 48px; max-width: 200px; object-fit: contain; }
|
|
79
113
|
.header h1 { font-size: 1.75rem; font-weight: 600; }
|
|
80
114
|
.header .meta { color: var(--color-text-secondary); font-size: 0.875rem; }
|
|
115
|
+
.header-right { display: flex; align-items: center; gap: 0.75rem; }
|
|
81
116
|
.status-badge {
|
|
82
117
|
display: inline-flex;
|
|
83
118
|
align-items: center;
|
|
@@ -88,6 +123,26 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
88
123
|
}
|
|
89
124
|
.status-pass { background: #d1fae5; color: #065f46; }
|
|
90
125
|
.status-fail { background: #fee2e2; color: #991b1b; }
|
|
126
|
+
[data-theme="dark"] .status-pass { background: #064e3b; color: #34d399; }
|
|
127
|
+
[data-theme="dark"] .status-fail { background: #7f1d1d; color: #f87171; }
|
|
128
|
+
@media (prefers-color-scheme: dark) {
|
|
129
|
+
:root:not([data-theme="light"]) .status-pass { background: #064e3b; color: #34d399; }
|
|
130
|
+
:root:not([data-theme="light"]) .status-fail { background: #7f1d1d; color: #f87171; }
|
|
131
|
+
}
|
|
132
|
+
.theme-toggle {
|
|
133
|
+
background: none; border: 1px solid var(--color-border);
|
|
134
|
+
border-radius: 0.375rem; padding: 0.5rem;
|
|
135
|
+
cursor: pointer; color: var(--color-text-secondary);
|
|
136
|
+
display: flex; align-items: center;
|
|
137
|
+
}
|
|
138
|
+
.theme-toggle:hover { background: var(--color-bg); }
|
|
139
|
+
.icon-moon { display: none; }
|
|
140
|
+
[data-theme="dark"] .icon-sun { display: none; }
|
|
141
|
+
[data-theme="dark"] .icon-moon { display: block; }
|
|
142
|
+
@media (prefers-color-scheme: dark) {
|
|
143
|
+
:root:not([data-theme="light"]) .icon-sun { display: none; }
|
|
144
|
+
:root:not([data-theme="light"]) .icon-moon { display: block; }
|
|
145
|
+
}
|
|
91
146
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
92
147
|
.card {
|
|
93
148
|
background: var(--color-card);
|
|
@@ -101,11 +156,20 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
101
156
|
.card-value.fail { color: var(--color-fail); }
|
|
102
157
|
.card-value.warn { color: var(--color-warn); }
|
|
103
158
|
.section { background: var(--color-card); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
159
|
+
details.section > summary { cursor: pointer; user-select: none; list-style: none; }
|
|
160
|
+
details.section > summary::-webkit-details-marker { display: none; }
|
|
104
161
|
.section-title { font-size: 1.125rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
|
|
105
162
|
.section-title .icon { width: 1.25rem; height: 1.25rem; }
|
|
163
|
+
.collapse-hint { font-size: 0.75rem; color: var(--color-text-secondary); margin-left: auto; }
|
|
164
|
+
details.section[open] .collapse-hint::after { content: '[-]'; }
|
|
165
|
+
details.section:not([open]) .collapse-hint::after { content: '[+]'; }
|
|
166
|
+
details.section:not([open]) .section-title { margin-bottom: 0; }
|
|
106
167
|
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
|
107
168
|
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border); }
|
|
108
|
-
th { font-weight: 600; color: var(--color-text-secondary); background: var(--color-bg); }
|
|
169
|
+
th { font-weight: 600; color: var(--color-text-secondary); background: var(--color-bg); cursor: pointer; user-select: none; }
|
|
170
|
+
th:hover { color: var(--color-text); }
|
|
171
|
+
th.sort-asc::after { content: ' \\25B2'; font-size: 0.65rem; }
|
|
172
|
+
th.sort-desc::after { content: ' \\25BC'; font-size: 0.65rem; }
|
|
109
173
|
tr:hover { background: var(--color-bg); }
|
|
110
174
|
.status-icon { display: inline-flex; align-items: center; gap: 0.25rem; }
|
|
111
175
|
.status-icon.pass { color: var(--color-pass); }
|
|
@@ -119,11 +183,45 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
119
183
|
.failed-rows { margin-top: 0.5rem; padding: 0.75rem; background: #fef2f2; border-radius: 0.375rem; font-size: 0.8rem; }
|
|
120
184
|
.failed-rows-title { font-weight: 600; color: #991b1b; margin-bottom: 0.25rem; }
|
|
121
185
|
.failed-rows code { background: #fee2e2; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-family: monospace; }
|
|
186
|
+
[data-theme="dark"] .failed-rows { background: #1c1917; }
|
|
187
|
+
[data-theme="dark"] .failed-rows-title { color: #f87171; }
|
|
188
|
+
[data-theme="dark"] .failed-rows code { background: #292524; color: #fca5a5; }
|
|
189
|
+
@media (prefers-color-scheme: dark) {
|
|
190
|
+
:root:not([data-theme="light"]) .failed-rows { background: #1c1917; }
|
|
191
|
+
:root:not([data-theme="light"]) .failed-rows-title { color: #f87171; }
|
|
192
|
+
:root:not([data-theme="light"]) .failed-rows code { background: #292524; color: #fca5a5; }
|
|
193
|
+
}
|
|
194
|
+
.search-bar { margin-bottom: 0.75rem; }
|
|
195
|
+
.search-input {
|
|
196
|
+
width: 100%; padding: 0.5rem 0.75rem;
|
|
197
|
+
border: 1px solid var(--color-border); border-radius: 0.375rem;
|
|
198
|
+
background: var(--color-bg); color: var(--color-text);
|
|
199
|
+
font-size: 0.875rem; font-family: inherit;
|
|
200
|
+
}
|
|
201
|
+
.search-input:focus { outline: 2px solid var(--color-pass); outline-offset: -1px; }
|
|
202
|
+
.search-input::placeholder { color: var(--color-text-secondary); }
|
|
203
|
+
.trend-chart { width: 100%; overflow-x: auto; }
|
|
204
|
+
.trend-chart svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
|
|
122
205
|
.footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--color-border); text-align: center; color: var(--color-text-secondary); font-size: 0.75rem; }
|
|
123
206
|
.footer a { color: inherit; text-decoration: none; }
|
|
124
207
|
@media print {
|
|
125
208
|
body { padding: 0; }
|
|
209
|
+
:root, [data-theme="dark"] {
|
|
210
|
+
--color-pass: #10b981; --color-fail: #ef4444; --color-warn: #f59e0b;
|
|
211
|
+
--color-info: #6b7280; --color-bg: #f9fafb; --color-card: #ffffff;
|
|
212
|
+
--color-border: #e5e7eb; --color-text: #111827; --color-text-secondary: #6b7280;
|
|
213
|
+
}
|
|
214
|
+
.status-pass { background: #d1fae5 !important; color: #065f46 !important; }
|
|
215
|
+
.status-fail { background: #fee2e2 !important; color: #991b1b !important; }
|
|
216
|
+
.failed-rows { background: #fef2f2 !important; }
|
|
217
|
+
.failed-rows-title { color: #991b1b !important; }
|
|
218
|
+
.failed-rows code { background: #fee2e2 !important; color: inherit !important; }
|
|
126
219
|
.section { break-inside: avoid; }
|
|
220
|
+
details.section { display: block; }
|
|
221
|
+
details.section > summary { pointer-events: none; }
|
|
222
|
+
.collapse-hint { display: none; }
|
|
223
|
+
.theme-toggle { display: none; }
|
|
224
|
+
.search-bar { display: none; }
|
|
127
225
|
}
|
|
128
226
|
{{ custom_css }}
|
|
129
227
|
</style>
|
|
@@ -131,15 +229,34 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
131
229
|
<body>
|
|
132
230
|
<div class="container">
|
|
133
231
|
<div class="header">
|
|
134
|
-
<div>
|
|
135
|
-
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
232
|
+
<div class="header-left">
|
|
233
|
+
{% if logo_url %}
|
|
234
|
+
<img src="{{ logo_url }}" alt="Logo" class="header-logo">
|
|
235
|
+
{% endif %}
|
|
236
|
+
<div>
|
|
237
|
+
<h1>{{ title }}</h1>
|
|
238
|
+
<div class="meta">
|
|
239
|
+
Source: <strong>{{ source }}</strong> |
|
|
240
|
+
Generated: {{ generated_at }}
|
|
241
|
+
{% if include_metadata %}
|
|
242
|
+
<br>
|
|
243
|
+
{% if row_count is not none %}Rows: <strong>{{ "{:,}".format(row_count) }}</strong>{% endif %}
|
|
244
|
+
{% if row_count is not none and column_count is not none %} | {% endif %}
|
|
245
|
+
{% if column_count is not none %}Columns: <strong>{{ column_count }}</strong>{% endif %}
|
|
246
|
+
{% if execution_duration and (row_count is not none or column_count is not none) %} | {% endif %}
|
|
247
|
+
{% if execution_duration %}Duration: <strong>{{ execution_duration }}</strong>{% endif %}
|
|
248
|
+
{% endif %}
|
|
249
|
+
</div>
|
|
139
250
|
</div>
|
|
140
251
|
</div>
|
|
141
|
-
<div class="
|
|
142
|
-
|
|
252
|
+
<div class="header-right">
|
|
253
|
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode" aria-label="Toggle dark mode">
|
|
254
|
+
<svg class="icon-sun" width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
|
255
|
+
<svg class="icon-moon" width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
|
|
256
|
+
</button>
|
|
257
|
+
<div class="status-badge {{ 'status-pass' if passed else 'status-fail' }}">
|
|
258
|
+
{{ ('✓ PASSED' if passed else '✗ FAILED')|safe }}
|
|
259
|
+
</div>
|
|
143
260
|
</div>
|
|
144
261
|
</div>
|
|
145
262
|
|
|
@@ -187,13 +304,29 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
187
304
|
</div>
|
|
188
305
|
{% endif %}
|
|
189
306
|
|
|
190
|
-
{% if
|
|
307
|
+
{% if include_trends and trend_chart_svg %}
|
|
191
308
|
<div class="section">
|
|
192
|
-
<div class="section-title"
|
|
309
|
+
<div class="section-title">
|
|
310
|
+
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
|
|
311
|
+
Quality Trend ({{ trend_data|length }} data points)
|
|
312
|
+
</div>
|
|
313
|
+
<div class="trend-chart">
|
|
314
|
+
{{ trend_chart_svg|safe }}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
{% endif %}
|
|
318
|
+
|
|
319
|
+
{% if failures %}
|
|
320
|
+
<details class="section" open>
|
|
321
|
+
<summary class="section-title" style="color: var(--color-fail);">
|
|
193
322
|
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
194
323
|
Failures ({{ failures|length }})
|
|
324
|
+
<span class="collapse-hint"></span>
|
|
325
|
+
</summary>
|
|
326
|
+
<div class="search-bar">
|
|
327
|
+
<input type="text" class="search-input" data-table="failures-table" placeholder="Search failures..." aria-label="Filter failure rows">
|
|
195
328
|
</div>
|
|
196
|
-
<table>
|
|
329
|
+
<table id="failures-table">
|
|
197
330
|
<thead>
|
|
198
331
|
<tr>
|
|
199
332
|
<th>Check</th>
|
|
@@ -206,7 +339,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
206
339
|
<tbody>
|
|
207
340
|
{% for f in failures %}
|
|
208
341
|
<tr>
|
|
209
|
-
<td><span class="status-icon fail"
|
|
342
|
+
<td><span class="status-icon fail">✗</span> {{ f.check.type.value }}</td>
|
|
210
343
|
<td>{{ f.column or '-' }}</td>
|
|
211
344
|
<td>{{ f.message }}</td>
|
|
212
345
|
<td><code>{{ f.actual_value }}</code></td>
|
|
@@ -227,16 +360,17 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
227
360
|
{% endfor %}
|
|
228
361
|
</tbody>
|
|
229
362
|
</table>
|
|
230
|
-
</
|
|
363
|
+
</details>
|
|
231
364
|
{% endif %}
|
|
232
365
|
|
|
233
366
|
{% if warnings %}
|
|
234
|
-
<
|
|
235
|
-
<
|
|
367
|
+
<details class="section" open>
|
|
368
|
+
<summary class="section-title" style="color: var(--color-warn);">
|
|
236
369
|
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
|
237
370
|
Warnings ({{ warnings|length }})
|
|
238
|
-
|
|
239
|
-
|
|
371
|
+
<span class="collapse-hint"></span>
|
|
372
|
+
</summary>
|
|
373
|
+
<table id="warnings-table">
|
|
240
374
|
<thead>
|
|
241
375
|
<tr>
|
|
242
376
|
<th>Check</th>
|
|
@@ -248,7 +382,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
248
382
|
<tbody>
|
|
249
383
|
{% for w in warnings %}
|
|
250
384
|
<tr>
|
|
251
|
-
<td><span class="status-icon warn"
|
|
385
|
+
<td><span class="status-icon warn">⚠</span> {{ w.check.type.value }}</td>
|
|
252
386
|
<td>{{ w.column or '-' }}</td>
|
|
253
387
|
<td>{{ w.message }}</td>
|
|
254
388
|
<td><code>{{ w.actual_value }}</code></td>
|
|
@@ -256,16 +390,17 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
256
390
|
{% endfor %}
|
|
257
391
|
</tbody>
|
|
258
392
|
</table>
|
|
259
|
-
</
|
|
393
|
+
</details>
|
|
260
394
|
{% endif %}
|
|
261
395
|
|
|
262
396
|
{% if include_passed and passed_results %}
|
|
263
|
-
<
|
|
264
|
-
<
|
|
397
|
+
<details class="section">
|
|
398
|
+
<summary class="section-title" style="color: var(--color-pass);">
|
|
265
399
|
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
266
400
|
Passed Checks ({{ passed_results|length }})
|
|
267
|
-
|
|
268
|
-
|
|
401
|
+
<span class="collapse-hint"></span>
|
|
402
|
+
</summary>
|
|
403
|
+
<table id="passed-table">
|
|
269
404
|
<thead>
|
|
270
405
|
<tr>
|
|
271
406
|
<th>Check</th>
|
|
@@ -276,14 +411,14 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
276
411
|
<tbody>
|
|
277
412
|
{% for p in passed_results %}
|
|
278
413
|
<tr>
|
|
279
|
-
<td><span class="status-icon pass"
|
|
414
|
+
<td><span class="status-icon pass">✓</span> {{ p.check.type.value }}</td>
|
|
280
415
|
<td>{{ p.column or '-' }}</td>
|
|
281
416
|
<td>{{ p.message }}</td>
|
|
282
417
|
</tr>
|
|
283
418
|
{% endfor %}
|
|
284
419
|
</tbody>
|
|
285
420
|
</table>
|
|
286
|
-
</
|
|
421
|
+
</details>
|
|
287
422
|
{% endif %}
|
|
288
423
|
|
|
289
424
|
<div class="footer">
|
|
@@ -291,6 +426,107 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
291
426
|
Data quality that just works
|
|
292
427
|
</div>
|
|
293
428
|
</div>
|
|
429
|
+
<script>
|
|
430
|
+
(function() {
|
|
431
|
+
'use strict';
|
|
432
|
+
function toggleTheme() {
|
|
433
|
+
var html = document.documentElement;
|
|
434
|
+
var current = html.getAttribute('data-theme');
|
|
435
|
+
if (current === 'dark') {
|
|
436
|
+
html.setAttribute('data-theme', 'light');
|
|
437
|
+
} else {
|
|
438
|
+
html.setAttribute('data-theme', 'dark');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
window.toggleTheme = toggleTheme;
|
|
442
|
+
|
|
443
|
+
function makeSortable(table) {
|
|
444
|
+
var headers = table.querySelectorAll('th');
|
|
445
|
+
for (var i = 0; i < headers.length; i++) {
|
|
446
|
+
(function(index) {
|
|
447
|
+
headers[index].addEventListener('click', function() {
|
|
448
|
+
sortTable(table, index, this);
|
|
449
|
+
});
|
|
450
|
+
})(i);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function sortTable(table, colIndex, header) {
|
|
455
|
+
var tbody = table.querySelector('tbody');
|
|
456
|
+
if (!tbody) return;
|
|
457
|
+
var rows = [];
|
|
458
|
+
var children = tbody.querySelectorAll('tr');
|
|
459
|
+
for (var i = 0; i < children.length; i++) { rows.push(children[i]); }
|
|
460
|
+
var dataRows = [];
|
|
461
|
+
var detailMap = {};
|
|
462
|
+
for (var j = 0; j < rows.length; j++) {
|
|
463
|
+
if (rows[j].querySelector('td[colspan]')) {
|
|
464
|
+
if (dataRows.length > 0) {
|
|
465
|
+
detailMap[dataRows.length - 1] = rows[j];
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
dataRows.push(rows[j]);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
var ascending = !header.classList.contains('sort-asc');
|
|
472
|
+
var allHeaders = table.querySelectorAll('th');
|
|
473
|
+
for (var h = 0; h < allHeaders.length; h++) {
|
|
474
|
+
allHeaders[h].classList.remove('sort-asc', 'sort-desc');
|
|
475
|
+
}
|
|
476
|
+
dataRows.sort(function(a, b) {
|
|
477
|
+
var aText = a.cells[colIndex] ? a.cells[colIndex].textContent.trim() : '';
|
|
478
|
+
var bText = b.cells[colIndex] ? b.cells[colIndex].textContent.trim() : '';
|
|
479
|
+
var aNum = parseFloat(aText);
|
|
480
|
+
var bNum = parseFloat(bText);
|
|
481
|
+
if (!isNaN(aNum) && !isNaN(bNum)) {
|
|
482
|
+
return ascending ? aNum - bNum : bNum - aNum;
|
|
483
|
+
}
|
|
484
|
+
return ascending ? aText.localeCompare(bText) : bText.localeCompare(aText);
|
|
485
|
+
});
|
|
486
|
+
header.classList.add(ascending ? 'sort-asc' : 'sort-desc');
|
|
487
|
+
while (tbody.firstChild) { tbody.removeChild(tbody.firstChild); }
|
|
488
|
+
for (var k = 0; k < dataRows.length; k++) {
|
|
489
|
+
tbody.appendChild(dataRows[k]);
|
|
490
|
+
if (detailMap[k]) { tbody.appendChild(detailMap[k]); }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function setupSearch() {
|
|
495
|
+
var inputs = document.querySelectorAll('.search-input');
|
|
496
|
+
for (var i = 0; i < inputs.length; i++) {
|
|
497
|
+
(function(input) {
|
|
498
|
+
var tableId = input.getAttribute('data-table');
|
|
499
|
+
var table = document.getElementById(tableId);
|
|
500
|
+
if (!table) return;
|
|
501
|
+
input.addEventListener('input', function() {
|
|
502
|
+
filterTable(table, input.value.toLowerCase());
|
|
503
|
+
});
|
|
504
|
+
})(inputs[i]);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function filterTable(table, query) {
|
|
509
|
+
var tbody = table.querySelector('tbody');
|
|
510
|
+
if (!tbody) return;
|
|
511
|
+
var rows = tbody.querySelectorAll('tr');
|
|
512
|
+
for (var i = 0; i < rows.length; i++) {
|
|
513
|
+
var row = rows[i];
|
|
514
|
+
if (row.querySelector('td[colspan]')) continue;
|
|
515
|
+
var text = row.textContent.toLowerCase();
|
|
516
|
+
var visible = !query || text.indexOf(query) >= 0;
|
|
517
|
+
row.style.display = visible ? '' : 'none';
|
|
518
|
+
var next = row.nextElementSibling;
|
|
519
|
+
if (next && next.querySelector('td[colspan]')) {
|
|
520
|
+
next.style.display = visible ? '' : 'none';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
var tables = document.querySelectorAll('table');
|
|
526
|
+
for (var t = 0; t < tables.length; t++) { makeSortable(tables[t]); }
|
|
527
|
+
setupSearch();
|
|
528
|
+
})();
|
|
529
|
+
</script>
|
|
294
530
|
</body>
|
|
295
531
|
</html>
|
|
296
532
|
"""
|
|
@@ -300,7 +536,8 @@ class HTMLReporter:
|
|
|
300
536
|
"""Generates HTML reports from DuckGuard validation results.
|
|
301
537
|
|
|
302
538
|
Creates beautiful, standalone HTML reports that can be shared
|
|
303
|
-
or viewed in any browser.
|
|
539
|
+
or viewed in any browser. Supports dark mode, collapsible sections,
|
|
540
|
+
sortable tables, search, and quality trend charts.
|
|
304
541
|
|
|
305
542
|
Usage:
|
|
306
543
|
from duckguard.reports import HTMLReporter
|
|
@@ -329,6 +566,9 @@ class HTMLReporter:
|
|
|
329
566
|
output_path: str | Path,
|
|
330
567
|
*,
|
|
331
568
|
history: list[StoredRun] | None = None,
|
|
569
|
+
trend_data: list[TrendDataPoint] | None = None,
|
|
570
|
+
row_count: int | None = None,
|
|
571
|
+
column_count: int | None = None,
|
|
332
572
|
) -> Path:
|
|
333
573
|
"""Generate an HTML report.
|
|
334
574
|
|
|
@@ -336,6 +576,9 @@ class HTMLReporter:
|
|
|
336
576
|
result: ExecutionResult to report on
|
|
337
577
|
output_path: Path to write HTML file
|
|
338
578
|
history: Optional historical results for trends
|
|
579
|
+
trend_data: Optional trend data points for chart rendering
|
|
580
|
+
row_count: Optional dataset row count for metadata display
|
|
581
|
+
column_count: Optional dataset column count for metadata display
|
|
339
582
|
|
|
340
583
|
Returns:
|
|
341
584
|
Path to generated report
|
|
@@ -347,7 +590,9 @@ class HTMLReporter:
|
|
|
347
590
|
from jinja2 import BaseLoader, Environment
|
|
348
591
|
except ImportError:
|
|
349
592
|
# Fall back to basic string formatting if jinja2 not available
|
|
350
|
-
return self._generate_basic(
|
|
593
|
+
return self._generate_basic(
|
|
594
|
+
result, output_path, row_count=row_count, column_count=column_count
|
|
595
|
+
)
|
|
351
596
|
|
|
352
597
|
output_path = Path(output_path)
|
|
353
598
|
|
|
@@ -356,7 +601,13 @@ class HTMLReporter:
|
|
|
356
601
|
template = env.from_string(HTML_TEMPLATE)
|
|
357
602
|
|
|
358
603
|
# Build context
|
|
359
|
-
context = self._build_context(
|
|
604
|
+
context = self._build_context(
|
|
605
|
+
result,
|
|
606
|
+
history,
|
|
607
|
+
row_count=row_count,
|
|
608
|
+
column_count=column_count,
|
|
609
|
+
trend_data=trend_data,
|
|
610
|
+
)
|
|
360
611
|
|
|
361
612
|
# Render and write
|
|
362
613
|
html = template.render(**context)
|
|
@@ -368,12 +619,17 @@ class HTMLReporter:
|
|
|
368
619
|
self,
|
|
369
620
|
result: ExecutionResult,
|
|
370
621
|
output_path: str | Path,
|
|
622
|
+
*,
|
|
623
|
+
row_count: int | None = None,
|
|
624
|
+
column_count: int | None = None,
|
|
371
625
|
) -> Path:
|
|
372
626
|
"""Generate a basic HTML report without Jinja2.
|
|
373
627
|
|
|
374
628
|
Args:
|
|
375
629
|
result: ExecutionResult to report on
|
|
376
630
|
output_path: Path to write HTML file
|
|
631
|
+
row_count: Optional dataset row count
|
|
632
|
+
column_count: Optional dataset column count
|
|
377
633
|
|
|
378
634
|
Returns:
|
|
379
635
|
Path to generated report
|
|
@@ -385,11 +641,21 @@ class HTMLReporter:
|
|
|
385
641
|
status_class = "status-pass" if result.passed else "status-fail"
|
|
386
642
|
grade = self._score_to_grade(result.quality_score)
|
|
387
643
|
|
|
644
|
+
# Build metadata line
|
|
645
|
+
metadata_parts: list[str] = []
|
|
646
|
+
if row_count is not None:
|
|
647
|
+
metadata_parts.append(f"Rows: {row_count:,}")
|
|
648
|
+
if column_count is not None:
|
|
649
|
+
metadata_parts.append(f"Columns: {column_count}")
|
|
650
|
+
metadata_html = ""
|
|
651
|
+
if metadata_parts and self.config.include_metadata:
|
|
652
|
+
metadata_html = f"<br>{' | '.join(metadata_parts)}"
|
|
653
|
+
|
|
388
654
|
failures_html = ""
|
|
389
655
|
for f in result.get_failures():
|
|
390
656
|
failures_html += f"""
|
|
391
657
|
<tr>
|
|
392
|
-
<td
|
|
658
|
+
<td>✗ {f.check.type.value}</td>
|
|
393
659
|
<td>{f.column or '-'}</td>
|
|
394
660
|
<td>{f.message}</td>
|
|
395
661
|
</tr>
|
|
@@ -418,7 +684,7 @@ class HTMLReporter:
|
|
|
418
684
|
<div class="header">
|
|
419
685
|
<div>
|
|
420
686
|
<h1>{self.config.title}</h1>
|
|
421
|
-
<p>Source: {result.source} | Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
|
|
687
|
+
<p>Source: {result.source} | Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}{metadata_html}</p>
|
|
422
688
|
</div>
|
|
423
689
|
<span class="{status_class}">{status}</span>
|
|
424
690
|
</div>
|
|
@@ -454,8 +720,17 @@ class HTMLReporter:
|
|
|
454
720
|
self,
|
|
455
721
|
result: ExecutionResult,
|
|
456
722
|
history: list[StoredRun] | None = None,
|
|
723
|
+
*,
|
|
724
|
+
row_count: int | None = None,
|
|
725
|
+
column_count: int | None = None,
|
|
726
|
+
trend_data: list[TrendDataPoint] | None = None,
|
|
457
727
|
) -> dict[str, Any]:
|
|
458
728
|
"""Build template context from result."""
|
|
729
|
+
trend_dicts = self._serialize_trend_data(trend_data) if trend_data else None
|
|
730
|
+
trend_svg = ""
|
|
731
|
+
if self.config.include_trends and trend_dicts:
|
|
732
|
+
trend_svg = self._generate_trend_svg(trend_dicts)
|
|
733
|
+
|
|
459
734
|
return {
|
|
460
735
|
"title": self.config.title,
|
|
461
736
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
@@ -469,16 +744,24 @@ class HTMLReporter:
|
|
|
469
744
|
"warning_count": result.warning_count,
|
|
470
745
|
"failures": result.get_failures(),
|
|
471
746
|
"warnings": result.get_warnings(),
|
|
472
|
-
"passed_results":
|
|
473
|
-
|
|
474
|
-
|
|
747
|
+
"passed_results": (
|
|
748
|
+
[r for r in result.results if r.passed] if self.config.include_passed else []
|
|
749
|
+
),
|
|
475
750
|
"include_passed": self.config.include_passed,
|
|
476
751
|
"include_charts": self.config.include_charts,
|
|
477
752
|
"include_failed_rows": self.config.include_failed_rows,
|
|
478
753
|
"max_failed_rows": self.config.max_failed_rows,
|
|
479
|
-
"include_trends": self.config.include_trends and
|
|
754
|
+
"include_trends": self.config.include_trends and bool(trend_dicts),
|
|
755
|
+
"trend_data": trend_dicts or [],
|
|
756
|
+
"trend_chart_svg": trend_svg,
|
|
480
757
|
"history": history,
|
|
481
758
|
"custom_css": self.config.custom_css or "",
|
|
759
|
+
"logo_url": self.config.logo_url or "",
|
|
760
|
+
"dark_mode": self.config.dark_mode,
|
|
761
|
+
"include_metadata": self.config.include_metadata,
|
|
762
|
+
"row_count": row_count,
|
|
763
|
+
"column_count": column_count,
|
|
764
|
+
"execution_duration": self._calculate_duration(result.started_at, result.finished_at),
|
|
482
765
|
}
|
|
483
766
|
|
|
484
767
|
def _score_to_grade(self, score: float) -> str:
|
|
@@ -493,10 +776,201 @@ class HTMLReporter:
|
|
|
493
776
|
return "D"
|
|
494
777
|
return "F"
|
|
495
778
|
|
|
779
|
+
def _calculate_duration(
|
|
780
|
+
self, started_at: datetime | None, finished_at: datetime | None
|
|
781
|
+
) -> str | None:
|
|
782
|
+
"""Format execution duration as a human-readable string.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
started_at: Validation start time
|
|
786
|
+
finished_at: Validation end time
|
|
787
|
+
|
|
788
|
+
Returns:
|
|
789
|
+
Formatted duration string, or None if timing unavailable
|
|
790
|
+
"""
|
|
791
|
+
if not started_at or not finished_at:
|
|
792
|
+
return None
|
|
793
|
+
delta = finished_at - started_at
|
|
794
|
+
seconds = delta.total_seconds()
|
|
795
|
+
if seconds < 1:
|
|
796
|
+
return f"{seconds * 1000:.0f}ms"
|
|
797
|
+
if seconds < 60:
|
|
798
|
+
return f"{seconds:.1f}s"
|
|
799
|
+
minutes = seconds / 60
|
|
800
|
+
return f"{minutes:.1f}m"
|
|
801
|
+
|
|
802
|
+
def _serialize_trend_data(self, trend_data: list[TrendDataPoint]) -> list[dict[str, Any]]:
|
|
803
|
+
"""Convert TrendDataPoint list to template-friendly dicts.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
trend_data: List of TrendDataPoint objects
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
List of dicts with date, avg_score, min_score, max_score, run_count
|
|
810
|
+
"""
|
|
811
|
+
return [
|
|
812
|
+
{
|
|
813
|
+
"date": tp.date,
|
|
814
|
+
"avg_score": tp.avg_score,
|
|
815
|
+
"min_score": tp.min_score,
|
|
816
|
+
"max_score": tp.max_score,
|
|
817
|
+
"run_count": tp.run_count,
|
|
818
|
+
}
|
|
819
|
+
for tp in trend_data
|
|
820
|
+
]
|
|
821
|
+
|
|
822
|
+
def _generate_trend_svg(
|
|
823
|
+
self,
|
|
824
|
+
trend_data: list[dict[str, Any]],
|
|
825
|
+
width: int = 700,
|
|
826
|
+
height: int = 200,
|
|
827
|
+
) -> str:
|
|
828
|
+
"""Generate an inline SVG line chart for quality score trends.
|
|
829
|
+
|
|
830
|
+
The chart includes a line for avg_score, a shaded min/max band,
|
|
831
|
+
gridlines, date labels, and data point tooltips.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
trend_data: List of trend data dicts
|
|
835
|
+
width: SVG width in pixels
|
|
836
|
+
height: SVG height in pixels
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
SVG markup string
|
|
840
|
+
"""
|
|
841
|
+
if not trend_data:
|
|
842
|
+
return ""
|
|
843
|
+
|
|
844
|
+
pad_top = 20
|
|
845
|
+
pad_right = 20
|
|
846
|
+
pad_bottom = 35
|
|
847
|
+
pad_left = 40
|
|
848
|
+
plot_w = width - pad_left - pad_right
|
|
849
|
+
plot_h = height - pad_top - pad_bottom
|
|
850
|
+
|
|
851
|
+
n = len(trend_data)
|
|
852
|
+
x_step = plot_w / max(n - 1, 1)
|
|
853
|
+
x_positions = [pad_left + i * x_step for i in range(n)]
|
|
854
|
+
|
|
855
|
+
def y_for_score(score: float) -> float:
|
|
856
|
+
return pad_top + plot_h * (1 - score / 100)
|
|
857
|
+
|
|
858
|
+
# Determine line color from latest score
|
|
859
|
+
latest_score = trend_data[-1]["avg_score"]
|
|
860
|
+
if latest_score >= 80:
|
|
861
|
+
line_color = "#10b981"
|
|
862
|
+
band_color = "#10b981"
|
|
863
|
+
elif latest_score >= 60:
|
|
864
|
+
line_color = "#f59e0b"
|
|
865
|
+
band_color = "#f59e0b"
|
|
866
|
+
else:
|
|
867
|
+
line_color = "#ef4444"
|
|
868
|
+
band_color = "#ef4444"
|
|
869
|
+
|
|
870
|
+
parts: list[str] = []
|
|
871
|
+
parts.append(
|
|
872
|
+
f'<svg viewBox="0 0 {width} {height}" '
|
|
873
|
+
f'xmlns="http://www.w3.org/2000/svg" role="img" '
|
|
874
|
+
f'aria-label="Quality score trend chart">'
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
# Background
|
|
878
|
+
parts.append(
|
|
879
|
+
f'<rect x="0" y="0" width="{width}" height="{height}" ' f'fill="none" rx="8"/>'
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# Gridlines at 0, 25, 50, 75, 100
|
|
883
|
+
for val in [0, 25, 50, 75, 100]:
|
|
884
|
+
gy = y_for_score(val)
|
|
885
|
+
parts.append(
|
|
886
|
+
f'<line x1="{pad_left}" y1="{gy:.1f}" x2="{width - pad_right}" '
|
|
887
|
+
f'y2="{gy:.1f}" stroke="#e5e7eb" stroke-width="1" '
|
|
888
|
+
f'stroke-dasharray="4"/>'
|
|
889
|
+
)
|
|
890
|
+
parts.append(
|
|
891
|
+
f'<text x="{pad_left - 5}" y="{gy + 4:.1f}" '
|
|
892
|
+
f'text-anchor="end" fill="#9ca3af" font-size="11">{val}</text>'
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# Min/Max band (polygon)
|
|
896
|
+
if n > 1:
|
|
897
|
+
band_points_top = " ".join(
|
|
898
|
+
f"{x_positions[i]:.1f},{y_for_score(trend_data[i]['max_score']):.1f}"
|
|
899
|
+
for i in range(n)
|
|
900
|
+
)
|
|
901
|
+
band_points_bottom = " ".join(
|
|
902
|
+
f"{x_positions[i]:.1f},{y_for_score(trend_data[i]['min_score']):.1f}"
|
|
903
|
+
for i in range(n - 1, -1, -1)
|
|
904
|
+
)
|
|
905
|
+
parts.append(
|
|
906
|
+
f'<polygon points="{band_points_top} {band_points_bottom}" '
|
|
907
|
+
f'fill="{band_color}" opacity="0.1"/>'
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
# Average score line
|
|
911
|
+
line_points = " ".join(
|
|
912
|
+
f"{x_positions[i]:.1f},{y_for_score(trend_data[i]['avg_score']):.1f}" for i in range(n)
|
|
913
|
+
)
|
|
914
|
+
parts.append(
|
|
915
|
+
f'<polyline points="{line_points}" fill="none" '
|
|
916
|
+
f'stroke="{line_color}" stroke-width="2.5" '
|
|
917
|
+
f'stroke-linecap="round" stroke-linejoin="round"/>'
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Data points with tooltips
|
|
921
|
+
for i in range(n):
|
|
922
|
+
cx = x_positions[i]
|
|
923
|
+
cy = y_for_score(trend_data[i]["avg_score"])
|
|
924
|
+
score = trend_data[i]["avg_score"]
|
|
925
|
+
date = trend_data[i]["date"]
|
|
926
|
+
parts.append(
|
|
927
|
+
f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="4" '
|
|
928
|
+
f'fill="{line_color}" stroke="white" stroke-width="2">'
|
|
929
|
+
f"<title>{date}: {score:.1f}%</title></circle>"
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# X-axis date labels (sample to avoid overlap)
|
|
933
|
+
max_labels = max(1, plot_w // 80)
|
|
934
|
+
step = max(1, n // max_labels)
|
|
935
|
+
for i in range(0, n, step):
|
|
936
|
+
date_label = trend_data[i]["date"]
|
|
937
|
+
# Shorten date: "2026-01-15" -> "Jan 15"
|
|
938
|
+
try:
|
|
939
|
+
dt = datetime.strptime(date_label, "%Y-%m-%d")
|
|
940
|
+
date_label = dt.strftime("%b %d")
|
|
941
|
+
except (ValueError, TypeError):
|
|
942
|
+
pass
|
|
943
|
+
parts.append(
|
|
944
|
+
f'<text x="{x_positions[i]:.1f}" y="{height - 5}" '
|
|
945
|
+
f'text-anchor="middle" fill="#9ca3af" font-size="11">'
|
|
946
|
+
f"{date_label}</text>"
|
|
947
|
+
)
|
|
948
|
+
# Always show last label if not already shown
|
|
949
|
+
if (n - 1) % step != 0 and n > 1:
|
|
950
|
+
date_label = trend_data[-1]["date"]
|
|
951
|
+
try:
|
|
952
|
+
dt = datetime.strptime(date_label, "%Y-%m-%d")
|
|
953
|
+
date_label = dt.strftime("%b %d")
|
|
954
|
+
except (ValueError, TypeError):
|
|
955
|
+
pass
|
|
956
|
+
parts.append(
|
|
957
|
+
f'<text x="{x_positions[-1]:.1f}" y="{height - 5}" '
|
|
958
|
+
f'text-anchor="middle" fill="#9ca3af" font-size="11">'
|
|
959
|
+
f"{date_label}</text>"
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
parts.append("</svg>")
|
|
963
|
+
return "\n".join(parts)
|
|
964
|
+
|
|
496
965
|
|
|
497
966
|
def generate_html_report(
|
|
498
967
|
result: ExecutionResult,
|
|
499
968
|
output_path: str | Path,
|
|
969
|
+
*,
|
|
970
|
+
history: list[StoredRun] | None = None,
|
|
971
|
+
trend_data: list[TrendDataPoint] | None = None,
|
|
972
|
+
row_count: int | None = None,
|
|
973
|
+
column_count: int | None = None,
|
|
500
974
|
**kwargs: Any,
|
|
501
975
|
) -> Path:
|
|
502
976
|
"""Convenience function to generate HTML report.
|
|
@@ -504,6 +978,10 @@ def generate_html_report(
|
|
|
504
978
|
Args:
|
|
505
979
|
result: ExecutionResult to report on
|
|
506
980
|
output_path: Path to write HTML file
|
|
981
|
+
history: Optional historical results for trends
|
|
982
|
+
trend_data: Optional trend data points for chart rendering
|
|
983
|
+
row_count: Optional dataset row count
|
|
984
|
+
column_count: Optional dataset column count
|
|
507
985
|
**kwargs: Additional ReportConfig options
|
|
508
986
|
|
|
509
987
|
Returns:
|
|
@@ -511,4 +989,11 @@ def generate_html_report(
|
|
|
511
989
|
"""
|
|
512
990
|
config = ReportConfig(**kwargs) if kwargs else None
|
|
513
991
|
reporter = HTMLReporter(config=config)
|
|
514
|
-
return reporter.generate(
|
|
992
|
+
return reporter.generate(
|
|
993
|
+
result,
|
|
994
|
+
output_path,
|
|
995
|
+
history=history,
|
|
996
|
+
trend_data=trend_data,
|
|
997
|
+
row_count=row_count,
|
|
998
|
+
column_count=column_count,
|
|
999
|
+
)
|