duckguard 2.0.0__py3-none-any.whl → 2.3.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.
Files changed (71) hide show
  1. duckguard/__init__.py +55 -28
  2. duckguard/anomaly/__init__.py +29 -1
  3. duckguard/anomaly/baselines.py +294 -0
  4. duckguard/anomaly/detector.py +1 -5
  5. duckguard/anomaly/methods.py +17 -5
  6. duckguard/anomaly/ml_methods.py +724 -0
  7. duckguard/cli/main.py +561 -56
  8. duckguard/connectors/__init__.py +2 -2
  9. duckguard/connectors/bigquery.py +1 -1
  10. duckguard/connectors/databricks.py +1 -1
  11. duckguard/connectors/factory.py +2 -3
  12. duckguard/connectors/files.py +1 -1
  13. duckguard/connectors/kafka.py +2 -2
  14. duckguard/connectors/mongodb.py +1 -1
  15. duckguard/connectors/mysql.py +1 -1
  16. duckguard/connectors/oracle.py +1 -1
  17. duckguard/connectors/postgres.py +1 -2
  18. duckguard/connectors/redshift.py +1 -1
  19. duckguard/connectors/snowflake.py +1 -2
  20. duckguard/connectors/sqlite.py +1 -1
  21. duckguard/connectors/sqlserver.py +10 -13
  22. duckguard/contracts/__init__.py +6 -6
  23. duckguard/contracts/diff.py +1 -1
  24. duckguard/contracts/generator.py +5 -6
  25. duckguard/contracts/loader.py +4 -4
  26. duckguard/contracts/validator.py +3 -4
  27. duckguard/core/__init__.py +3 -3
  28. duckguard/core/column.py +588 -5
  29. duckguard/core/dataset.py +708 -3
  30. duckguard/core/result.py +328 -1
  31. duckguard/core/scoring.py +1 -2
  32. duckguard/errors.py +362 -0
  33. duckguard/freshness/__init__.py +33 -0
  34. duckguard/freshness/monitor.py +429 -0
  35. duckguard/history/__init__.py +44 -0
  36. duckguard/history/schema.py +301 -0
  37. duckguard/history/storage.py +479 -0
  38. duckguard/history/trends.py +348 -0
  39. duckguard/integrations/__init__.py +31 -0
  40. duckguard/integrations/airflow.py +387 -0
  41. duckguard/integrations/dbt.py +458 -0
  42. duckguard/notifications/__init__.py +61 -0
  43. duckguard/notifications/email.py +508 -0
  44. duckguard/notifications/formatter.py +118 -0
  45. duckguard/notifications/notifiers.py +357 -0
  46. duckguard/profiler/auto_profile.py +3 -3
  47. duckguard/pytest_plugin/__init__.py +1 -1
  48. duckguard/pytest_plugin/plugin.py +1 -1
  49. duckguard/reporting/console.py +2 -2
  50. duckguard/reports/__init__.py +42 -0
  51. duckguard/reports/html_reporter.py +514 -0
  52. duckguard/reports/pdf_reporter.py +114 -0
  53. duckguard/rules/__init__.py +3 -3
  54. duckguard/rules/executor.py +3 -4
  55. duckguard/rules/generator.py +8 -5
  56. duckguard/rules/loader.py +5 -5
  57. duckguard/rules/schema.py +23 -0
  58. duckguard/schema_history/__init__.py +40 -0
  59. duckguard/schema_history/analyzer.py +414 -0
  60. duckguard/schema_history/tracker.py +288 -0
  61. duckguard/semantic/__init__.py +1 -1
  62. duckguard/semantic/analyzer.py +0 -2
  63. duckguard/semantic/detector.py +17 -1
  64. duckguard/semantic/validators.py +2 -1
  65. duckguard-2.3.0.dist-info/METADATA +953 -0
  66. duckguard-2.3.0.dist-info/RECORD +77 -0
  67. duckguard-2.0.0.dist-info/METADATA +0 -221
  68. duckguard-2.0.0.dist-info/RECORD +0 -55
  69. {duckguard-2.0.0.dist-info → duckguard-2.3.0.dist-info}/WHEEL +0 -0
  70. {duckguard-2.0.0.dist-info → duckguard-2.3.0.dist-info}/entry_points.txt +0 -0
  71. {duckguard-2.0.0.dist-info → duckguard-2.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,508 @@
1
+ """Email notification provider for DuckGuard.
2
+
3
+ Provides SMTP-based email notifications for data quality alerts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import smtplib
11
+ import ssl
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from email.mime.multipart import MIMEMultipart
15
+ from email.mime.text import MIMEText
16
+ from typing import Any
17
+
18
+ from duckguard.notifications.notifiers import BaseNotifier, NotificationConfig, NotificationError
19
+ from duckguard.rules.executor import ExecutionResult
20
+
21
+
22
+ @dataclass
23
+ class EmailConfig:
24
+ """Email configuration.
25
+
26
+ Attributes:
27
+ smtp_host: SMTP server hostname
28
+ smtp_port: SMTP server port (default: 587 for TLS)
29
+ smtp_user: SMTP username for authentication
30
+ smtp_password: SMTP password for authentication
31
+ from_address: Email address to send from
32
+ to_addresses: List of recipient email addresses
33
+ use_tls: Whether to use TLS encryption (default: True)
34
+ use_ssl: Whether to use SSL (default: False, use for port 465)
35
+ subject_prefix: Prefix for email subjects
36
+ """
37
+
38
+ smtp_host: str
39
+ smtp_port: int = 587
40
+ smtp_user: str | None = None
41
+ smtp_password: str | None = None
42
+ from_address: str | None = None
43
+ to_addresses: list[str] = field(default_factory=list)
44
+ use_tls: bool = True
45
+ use_ssl: bool = False
46
+ subject_prefix: str = "[DuckGuard]"
47
+
48
+
49
+ class EmailNotifier(BaseNotifier):
50
+ """Email notification provider via SMTP.
51
+
52
+ Sends HTML-formatted email notifications when data quality checks
53
+ fail or meet other notification conditions.
54
+
55
+ Usage:
56
+ from duckguard.notifications import EmailNotifier
57
+
58
+ # Direct configuration
59
+ notifier = EmailNotifier(
60
+ smtp_host="smtp.gmail.com",
61
+ smtp_port=587,
62
+ smtp_user="alerts@company.com",
63
+ smtp_password="app_password",
64
+ from_address="alerts@company.com",
65
+ to_addresses=["team@company.com", "oncall@company.com"],
66
+ )
67
+
68
+ # Or via environment variable
69
+ # DUCKGUARD_EMAIL_CONFIG='{"smtp_host": "smtp.gmail.com", ...}'
70
+ notifier = EmailNotifier()
71
+
72
+ result = execute_rules(rules, dataset)
73
+ notifier.send_results(result)
74
+
75
+ Environment Variable Format (DUCKGUARD_EMAIL_CONFIG):
76
+ {
77
+ "smtp_host": "smtp.gmail.com",
78
+ "smtp_port": 587,
79
+ "smtp_user": "user@gmail.com",
80
+ "smtp_password": "app_password",
81
+ "from_address": "alerts@company.com",
82
+ "to_addresses": ["team@company.com"]
83
+ }
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ smtp_host: str | None = None,
89
+ smtp_port: int = 587,
90
+ smtp_user: str | None = None,
91
+ smtp_password: str | None = None,
92
+ from_address: str | None = None,
93
+ to_addresses: list[str] | None = None,
94
+ use_tls: bool = True,
95
+ use_ssl: bool = False,
96
+ subject_prefix: str = "[DuckGuard]",
97
+ config: NotificationConfig | None = None,
98
+ ):
99
+ """Initialize email notifier.
100
+
101
+ Args:
102
+ smtp_host: SMTP server hostname
103
+ smtp_port: SMTP server port
104
+ smtp_user: SMTP username
105
+ smtp_password: SMTP password
106
+ from_address: Sender email address
107
+ to_addresses: List of recipient addresses
108
+ use_tls: Use STARTTLS
109
+ use_ssl: Use SSL (for port 465)
110
+ subject_prefix: Subject line prefix
111
+ config: Notification configuration
112
+ """
113
+ self.config = config or NotificationConfig()
114
+
115
+ # Try to load from environment if not provided
116
+ if smtp_host is None:
117
+ env_config = self._load_config_from_env()
118
+ if env_config:
119
+ self.email_config = env_config
120
+ else:
121
+ raise ValueError(
122
+ "Email configuration required. Set DUCKGUARD_EMAIL_CONFIG environment variable "
123
+ "or pass smtp_host parameter."
124
+ )
125
+ else:
126
+ self.email_config = EmailConfig(
127
+ smtp_host=smtp_host,
128
+ smtp_port=smtp_port,
129
+ smtp_user=smtp_user,
130
+ smtp_password=smtp_password,
131
+ from_address=from_address or smtp_user,
132
+ to_addresses=to_addresses or [],
133
+ use_tls=use_tls,
134
+ use_ssl=use_ssl,
135
+ subject_prefix=subject_prefix,
136
+ )
137
+
138
+ if not self.email_config.to_addresses:
139
+ raise ValueError("At least one recipient address (to_addresses) is required")
140
+
141
+ # Set webhook_url to a placeholder (not used for email)
142
+ self.webhook_url = "email://smtp"
143
+
144
+ @property
145
+ def _env_var_name(self) -> str:
146
+ return "DUCKGUARD_EMAIL_CONFIG"
147
+
148
+ def _load_config_from_env(self) -> EmailConfig | None:
149
+ """Load configuration from environment variable."""
150
+ env_value = os.environ.get(self._env_var_name)
151
+ if not env_value:
152
+ return None
153
+
154
+ try:
155
+ data = json.loads(env_value)
156
+ return EmailConfig(
157
+ smtp_host=data["smtp_host"],
158
+ smtp_port=data.get("smtp_port", 587),
159
+ smtp_user=data.get("smtp_user"),
160
+ smtp_password=data.get("smtp_password"),
161
+ from_address=data.get("from_address", data.get("smtp_user")),
162
+ to_addresses=data.get("to_addresses", []),
163
+ use_tls=data.get("use_tls", True),
164
+ use_ssl=data.get("use_ssl", False),
165
+ subject_prefix=data.get("subject_prefix", "[DuckGuard]"),
166
+ )
167
+ except (json.JSONDecodeError, KeyError) as e:
168
+ raise ValueError(f"Invalid {self._env_var_name} format: {e}") from e
169
+
170
+ def _format_message(self, result: ExecutionResult) -> dict[str, Any]:
171
+ """Format the result as email subject and body.
172
+
173
+ Returns:
174
+ Dict with 'subject' and 'html_body' keys
175
+ """
176
+ status = "PASSED" if result.passed else "FAILED"
177
+ subject = f"{self.email_config.subject_prefix} Validation {status}: {result.source}"
178
+
179
+ html_body = self._generate_html_body(result)
180
+
181
+ return {
182
+ "subject": subject,
183
+ "html_body": html_body,
184
+ "text_body": self._generate_text_body(result),
185
+ }
186
+
187
+ def _generate_html_body(self, result: ExecutionResult) -> str:
188
+ """Generate HTML email body."""
189
+ status_color = "#28a745" if result.passed else "#dc3545"
190
+ status_text = "PASSED" if result.passed else "FAILED"
191
+
192
+ # Get failures and warnings
193
+ failures = result.get_failures()
194
+ warnings = result.get_warnings()
195
+
196
+ html = f"""
197
+ <!DOCTYPE html>
198
+ <html>
199
+ <head>
200
+ <meta charset="utf-8">
201
+ <style>
202
+ body {{
203
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
204
+ line-height: 1.6;
205
+ color: #333;
206
+ max-width: 600px;
207
+ margin: 0 auto;
208
+ padding: 20px;
209
+ }}
210
+ .header {{
211
+ background: {status_color};
212
+ color: white;
213
+ padding: 20px;
214
+ border-radius: 8px 8px 0 0;
215
+ text-align: center;
216
+ }}
217
+ .header h1 {{
218
+ margin: 0;
219
+ font-size: 24px;
220
+ }}
221
+ .content {{
222
+ background: #f8f9fa;
223
+ padding: 20px;
224
+ border: 1px solid #dee2e6;
225
+ border-top: none;
226
+ border-radius: 0 0 8px 8px;
227
+ }}
228
+ .stats {{
229
+ display: flex;
230
+ justify-content: space-around;
231
+ margin: 20px 0;
232
+ flex-wrap: wrap;
233
+ }}
234
+ .stat {{
235
+ text-align: center;
236
+ padding: 10px 20px;
237
+ background: white;
238
+ border-radius: 8px;
239
+ margin: 5px;
240
+ min-width: 100px;
241
+ }}
242
+ .stat-value {{
243
+ font-size: 24px;
244
+ font-weight: bold;
245
+ color: #333;
246
+ }}
247
+ .stat-label {{
248
+ font-size: 12px;
249
+ color: #666;
250
+ text-transform: uppercase;
251
+ }}
252
+ .section {{
253
+ margin: 20px 0;
254
+ }}
255
+ .section h3 {{
256
+ color: #333;
257
+ border-bottom: 2px solid #dee2e6;
258
+ padding-bottom: 5px;
259
+ }}
260
+ .failure {{
261
+ background: #fff3f3;
262
+ border-left: 4px solid #dc3545;
263
+ padding: 10px 15px;
264
+ margin: 10px 0;
265
+ border-radius: 0 4px 4px 0;
266
+ }}
267
+ .warning {{
268
+ background: #fff8e6;
269
+ border-left: 4px solid #ffc107;
270
+ padding: 10px 15px;
271
+ margin: 10px 0;
272
+ border-radius: 0 4px 4px 0;
273
+ }}
274
+ .column-name {{
275
+ font-weight: bold;
276
+ color: #495057;
277
+ }}
278
+ .footer {{
279
+ text-align: center;
280
+ margin-top: 20px;
281
+ font-size: 12px;
282
+ color: #6c757d;
283
+ }}
284
+ .score-badge {{
285
+ display: inline-block;
286
+ padding: 5px 15px;
287
+ border-radius: 20px;
288
+ font-weight: bold;
289
+ margin-top: 10px;
290
+ }}
291
+ .score-a {{ background: #28a745; color: white; }}
292
+ .score-b {{ background: #5cb85c; color: white; }}
293
+ .score-c {{ background: #ffc107; color: black; }}
294
+ .score-d {{ background: #fd7e14; color: white; }}
295
+ .score-f {{ background: #dc3545; color: white; }}
296
+ </style>
297
+ </head>
298
+ <body>
299
+ <div class="header">
300
+ <h1>DuckGuard Validation {status_text}</h1>
301
+ <p style="margin: 5px 0; opacity: 0.9;">{result.source}</p>
302
+ </div>
303
+
304
+ <div class="content">
305
+ <div class="stats">
306
+ <div class="stat">
307
+ <div class="stat-value">{result.quality_score:.1f}%</div>
308
+ <div class="stat-label">Quality Score</div>
309
+ </div>
310
+ <div class="stat">
311
+ <div class="stat-value">{result.passed_count}/{result.total_checks}</div>
312
+ <div class="stat-label">Checks Passed</div>
313
+ </div>
314
+ <div class="stat">
315
+ <div class="stat-value">{result.failed_count}</div>
316
+ <div class="stat-label">Failures</div>
317
+ </div>
318
+ <div class="stat">
319
+ <div class="stat-value">{result.warning_count}</div>
320
+ <div class="stat-label">Warnings</div>
321
+ </div>
322
+ </div>
323
+ """
324
+
325
+ # Add failures section
326
+ if failures:
327
+ html += """
328
+ <div class="section">
329
+ <h3>Failures</h3>
330
+ """
331
+ for f in failures[:self.config.max_failures_shown]:
332
+ col_name = f"[{f.column}]" if f.column else "[table]"
333
+ html += f"""
334
+ <div class="failure">
335
+ <span class="column-name">{col_name}</span> {f.message}
336
+ </div>
337
+ """
338
+ remaining = len(failures) - self.config.max_failures_shown
339
+ if remaining > 0:
340
+ html += f"""
341
+ <p style="color: #6c757d; font-style: italic;">...and {remaining} more failures</p>
342
+ """
343
+ html += " </div>\n"
344
+
345
+ # Add warnings section
346
+ if warnings and self.config.on_warning:
347
+ html += """
348
+ <div class="section">
349
+ <h3>Warnings</h3>
350
+ """
351
+ for w in warnings[:self.config.max_failures_shown]:
352
+ col_name = f"[{w.column}]" if w.column else "[table]"
353
+ html += f"""
354
+ <div class="warning">
355
+ <span class="column-name">{col_name}</span> {w.message}
356
+ </div>
357
+ """
358
+ remaining = len(warnings) - self.config.max_failures_shown
359
+ if remaining > 0:
360
+ html += f"""
361
+ <p style="color: #6c757d; font-style: italic;">...and {remaining} more warnings</p>
362
+ """
363
+ html += " </div>\n"
364
+
365
+ html += f"""
366
+ </div>
367
+
368
+ <div class="footer">
369
+ <p>Generated by DuckGuard at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
370
+ </div>
371
+ </body>
372
+ </html>
373
+ """
374
+ return html
375
+
376
+ def _generate_text_body(self, result: ExecutionResult) -> str:
377
+ """Generate plain text email body."""
378
+ status = "PASSED" if result.passed else "FAILED"
379
+ lines = [
380
+ f"DuckGuard Validation {status}",
381
+ f"Source: {result.source}",
382
+ f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
383
+ "",
384
+ f"Quality Score: {result.quality_score:.1f}%",
385
+ f"Checks: {result.passed_count}/{result.total_checks} passed",
386
+ f"Failures: {result.failed_count}",
387
+ f"Warnings: {result.warning_count}",
388
+ "",
389
+ ]
390
+
391
+ failures = result.get_failures()
392
+ if failures:
393
+ lines.append("FAILURES:")
394
+ for f in failures[:self.config.max_failures_shown]:
395
+ col_name = f"[{f.column}]" if f.column else "[table]"
396
+ lines.append(f" - {col_name} {f.message}")
397
+ remaining = len(failures) - self.config.max_failures_shown
398
+ if remaining > 0:
399
+ lines.append(f" ...and {remaining} more")
400
+ lines.append("")
401
+
402
+ warnings = result.get_warnings()
403
+ if warnings and self.config.on_warning:
404
+ lines.append("WARNINGS:")
405
+ for w in warnings[:self.config.max_failures_shown]:
406
+ col_name = f"[{w.column}]" if w.column else "[table]"
407
+ lines.append(f" - {col_name} {w.message}")
408
+ remaining = len(warnings) - self.config.max_failures_shown
409
+ if remaining > 0:
410
+ lines.append(f" ...and {remaining} more")
411
+
412
+ return "\n".join(lines)
413
+
414
+ def _send(self, result: ExecutionResult) -> bool:
415
+ """Send the email notification.
416
+
417
+ Args:
418
+ result: ExecutionResult to send
419
+
420
+ Returns:
421
+ True if sent successfully
422
+ """
423
+ message_data = self._format_message(result)
424
+
425
+ # Create message
426
+ msg = MIMEMultipart("alternative")
427
+ msg["Subject"] = message_data["subject"]
428
+ msg["From"] = self.email_config.from_address or self.email_config.smtp_user or ""
429
+ msg["To"] = ", ".join(self.email_config.to_addresses)
430
+
431
+ # Attach text and HTML parts
432
+ text_part = MIMEText(message_data["text_body"], "plain")
433
+ html_part = MIMEText(message_data["html_body"], "html")
434
+ msg.attach(text_part)
435
+ msg.attach(html_part)
436
+
437
+ try:
438
+ if self.email_config.use_ssl:
439
+ # SSL connection (port 465)
440
+ context = ssl.create_default_context()
441
+ with smtplib.SMTP_SSL(
442
+ self.email_config.smtp_host,
443
+ self.email_config.smtp_port,
444
+ context=context,
445
+ ) as server:
446
+ if self.email_config.smtp_user and self.email_config.smtp_password:
447
+ server.login(self.email_config.smtp_user, self.email_config.smtp_password)
448
+ server.sendmail(
449
+ msg["From"],
450
+ self.email_config.to_addresses,
451
+ msg.as_string(),
452
+ )
453
+ else:
454
+ # TLS connection (port 587)
455
+ with smtplib.SMTP(
456
+ self.email_config.smtp_host,
457
+ self.email_config.smtp_port,
458
+ ) as server:
459
+ if self.email_config.use_tls:
460
+ server.starttls()
461
+ if self.email_config.smtp_user and self.email_config.smtp_password:
462
+ server.login(self.email_config.smtp_user, self.email_config.smtp_password)
463
+ server.sendmail(
464
+ msg["From"],
465
+ self.email_config.to_addresses,
466
+ msg.as_string(),
467
+ )
468
+
469
+ return True
470
+
471
+ except smtplib.SMTPException as e:
472
+ raise NotificationError(f"Failed to send email: {e}") from e
473
+ except Exception as e:
474
+ raise NotificationError(f"Email error: {e}") from e
475
+
476
+ def send_results(self, result: ExecutionResult) -> bool:
477
+ """Send notification based on execution results.
478
+
479
+ Args:
480
+ result: ExecutionResult from rule execution
481
+
482
+ Returns:
483
+ True if notification was sent, False if skipped
484
+ """
485
+ should_send = False
486
+
487
+ if not result.passed and self.config.on_failure:
488
+ should_send = True
489
+ elif result.warning_count > 0 and self.config.on_warning:
490
+ should_send = True
491
+ elif result.passed and self.config.on_success:
492
+ should_send = True
493
+
494
+ if not should_send:
495
+ return False
496
+
497
+ return self._send(result)
498
+
499
+ def send_failure_alert(self, result: ExecutionResult) -> bool:
500
+ """Send an alert for failures (ignores config settings).
501
+
502
+ Args:
503
+ result: ExecutionResult from rule execution
504
+
505
+ Returns:
506
+ True if sent successfully
507
+ """
508
+ return self._send(result)
@@ -0,0 +1,118 @@
1
+ """Message formatting utilities for notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from duckguard.rules.executor import ExecutionResult
8
+
9
+
10
+ def format_results_text(result: ExecutionResult, include_passed: bool = False) -> str:
11
+ """Format execution results as plain text.
12
+
13
+ Args:
14
+ result: ExecutionResult from rule execution
15
+ include_passed: Whether to include passed checks
16
+
17
+ Returns:
18
+ Formatted text string
19
+ """
20
+ lines = []
21
+
22
+ status = "PASSED" if result.passed else "FAILED"
23
+ lines.append(f"DuckGuard Validation {status}")
24
+ lines.append("=" * 40)
25
+ lines.append(f"Source: {result.source}")
26
+ lines.append(f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
27
+ lines.append(f"Checks: {result.passed_count}/{result.total_checks} passed")
28
+ lines.append(f"Score: {result.quality_score:.1f}%")
29
+ lines.append("")
30
+
31
+ failures = result.get_failures()
32
+ if failures:
33
+ lines.append("FAILURES:")
34
+ lines.append("-" * 20)
35
+ for f in failures:
36
+ col = f"[{f.column}]" if f.column else "[table]"
37
+ lines.append(f" {col} {f.message}")
38
+ if f.details.get("failed_rows"):
39
+ lines.append(f" Sample: {f.details['failed_rows'][:3]}")
40
+ lines.append("")
41
+
42
+ warnings = result.get_warnings()
43
+ if warnings:
44
+ lines.append("WARNINGS:")
45
+ lines.append("-" * 20)
46
+ for w in warnings:
47
+ col = f"[{w.column}]" if w.column else "[table]"
48
+ lines.append(f" {col} {w.message}")
49
+ lines.append("")
50
+
51
+ if include_passed:
52
+ passed = [r for r in result.results if r.passed]
53
+ if passed:
54
+ lines.append("PASSED:")
55
+ lines.append("-" * 20)
56
+ for p in passed:
57
+ col = f"[{p.column}]" if p.column else "[table]"
58
+ lines.append(f" {col} {p.message}")
59
+
60
+ return "\n".join(lines)
61
+
62
+
63
+ def format_results_markdown(result: ExecutionResult, include_passed: bool = False) -> str:
64
+ """Format execution results as Markdown.
65
+
66
+ Args:
67
+ result: ExecutionResult from rule execution
68
+ include_passed: Whether to include passed checks
69
+
70
+ Returns:
71
+ Formatted Markdown string
72
+ """
73
+ lines = []
74
+
75
+ emoji = ":white_check_mark:" if result.passed else ":x:"
76
+ status = "PASSED" if result.passed else "FAILED"
77
+ lines.append(f"# {emoji} DuckGuard Validation {status}")
78
+ lines.append("")
79
+ lines.append("| Metric | Value |")
80
+ lines.append("|--------|-------|")
81
+ lines.append(f"| Source | `{result.source}` |")
82
+ lines.append(f"| Time | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |")
83
+ lines.append(f"| Checks | {result.passed_count}/{result.total_checks} passed |")
84
+ lines.append(f"| Score | {result.quality_score:.1f}% |")
85
+ lines.append("")
86
+
87
+ failures = result.get_failures()
88
+ if failures:
89
+ lines.append("## :rotating_light: Failures")
90
+ lines.append("")
91
+ for f in failures:
92
+ col = f"`{f.column}`" if f.column else "_table_"
93
+ lines.append(f"- **{col}**: {f.message}")
94
+ if f.details.get("failed_rows"):
95
+ sample = f.details["failed_rows"][:3]
96
+ lines.append(f" - _Sample values: {sample}_")
97
+ lines.append("")
98
+
99
+ warnings = result.get_warnings()
100
+ if warnings:
101
+ lines.append("## :warning: Warnings")
102
+ lines.append("")
103
+ for w in warnings:
104
+ col = f"`{w.column}`" if w.column else "_table_"
105
+ lines.append(f"- **{col}**: {w.message}")
106
+ lines.append("")
107
+
108
+ if include_passed:
109
+ passed = [r for r in result.results if r.passed]
110
+ if passed:
111
+ lines.append("## :white_check_mark: Passed")
112
+ lines.append("")
113
+ for p in passed:
114
+ col = f"`{p.column}`" if p.column else "_table_"
115
+ lines.append(f"- **{col}**: {p.message}")
116
+ lines.append("")
117
+
118
+ return "\n".join(lines)