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.
@@ -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
- <h1>{{ title }}</h1>
136
- <div class="meta">
137
- Source: <strong>{{ source }}</strong> |
138
- Generated: {{ generated_at }}
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="status-badge {{ 'status-pass' if passed else 'status-fail' }}">
142
- {{ '✓ PASSED' if passed else '✗ FAILED' }}
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
+ {{ ('&#10003; PASSED' if passed else '&#10007; 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 failures %}
307
+ {% if include_trends and trend_chart_svg %}
191
308
  <div class="section">
192
- <div class="section-title" style="color: var(--color-fail);">
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">✗</span> {{ f.check.type.value }}</td>
342
+ <td><span class="status-icon fail">&#10007;</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
- </div>
363
+ </details>
231
364
  {% endif %}
232
365
 
233
366
  {% if warnings %}
234
- <div class="section">
235
- <div class="section-title" style="color: var(--color-warn);">
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
- </div>
239
- <table>
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">⚠</span> {{ w.check.type.value }}</td>
385
+ <td><span class="status-icon warn">&#9888;</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
- </div>
393
+ </details>
260
394
  {% endif %}
261
395
 
262
396
  {% if include_passed and passed_results %}
263
- <div class="section">
264
- <div class="section-title" style="color: var(--color-pass);">
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
- </div>
268
- <table>
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">✓</span> {{ p.check.type.value }}</td>
414
+ <td><span class="status-icon pass">&#10003;</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
- </div>
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(result, output_path)
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(result, history)
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>✗ {f.check.type.value}</td>
658
+ <td>&#10007; {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": [r for r in result.results if r.passed]
473
- if self.config.include_passed
474
- else [],
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 history,
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(result, output_path)
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
+ )