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.
- duckguard/__init__.py +55 -28
- duckguard/anomaly/__init__.py +29 -1
- duckguard/anomaly/baselines.py +294 -0
- duckguard/anomaly/detector.py +1 -5
- duckguard/anomaly/methods.py +17 -5
- duckguard/anomaly/ml_methods.py +724 -0
- duckguard/cli/main.py +561 -56
- duckguard/connectors/__init__.py +2 -2
- duckguard/connectors/bigquery.py +1 -1
- duckguard/connectors/databricks.py +1 -1
- duckguard/connectors/factory.py +2 -3
- duckguard/connectors/files.py +1 -1
- duckguard/connectors/kafka.py +2 -2
- duckguard/connectors/mongodb.py +1 -1
- duckguard/connectors/mysql.py +1 -1
- duckguard/connectors/oracle.py +1 -1
- duckguard/connectors/postgres.py +1 -2
- duckguard/connectors/redshift.py +1 -1
- duckguard/connectors/snowflake.py +1 -2
- duckguard/connectors/sqlite.py +1 -1
- duckguard/connectors/sqlserver.py +10 -13
- duckguard/contracts/__init__.py +6 -6
- duckguard/contracts/diff.py +1 -1
- duckguard/contracts/generator.py +5 -6
- duckguard/contracts/loader.py +4 -4
- duckguard/contracts/validator.py +3 -4
- duckguard/core/__init__.py +3 -3
- duckguard/core/column.py +588 -5
- duckguard/core/dataset.py +708 -3
- duckguard/core/result.py +328 -1
- duckguard/core/scoring.py +1 -2
- duckguard/errors.py +362 -0
- duckguard/freshness/__init__.py +33 -0
- duckguard/freshness/monitor.py +429 -0
- duckguard/history/__init__.py +44 -0
- duckguard/history/schema.py +301 -0
- duckguard/history/storage.py +479 -0
- duckguard/history/trends.py +348 -0
- duckguard/integrations/__init__.py +31 -0
- duckguard/integrations/airflow.py +387 -0
- duckguard/integrations/dbt.py +458 -0
- duckguard/notifications/__init__.py +61 -0
- duckguard/notifications/email.py +508 -0
- duckguard/notifications/formatter.py +118 -0
- duckguard/notifications/notifiers.py +357 -0
- duckguard/profiler/auto_profile.py +3 -3
- duckguard/pytest_plugin/__init__.py +1 -1
- duckguard/pytest_plugin/plugin.py +1 -1
- duckguard/reporting/console.py +2 -2
- duckguard/reports/__init__.py +42 -0
- duckguard/reports/html_reporter.py +514 -0
- duckguard/reports/pdf_reporter.py +114 -0
- duckguard/rules/__init__.py +3 -3
- duckguard/rules/executor.py +3 -4
- duckguard/rules/generator.py +8 -5
- duckguard/rules/loader.py +5 -5
- duckguard/rules/schema.py +23 -0
- duckguard/schema_history/__init__.py +40 -0
- duckguard/schema_history/analyzer.py +414 -0
- duckguard/schema_history/tracker.py +288 -0
- duckguard/semantic/__init__.py +1 -1
- duckguard/semantic/analyzer.py +0 -2
- duckguard/semantic/detector.py +17 -1
- duckguard/semantic/validators.py +2 -1
- duckguard-2.3.0.dist-info/METADATA +953 -0
- duckguard-2.3.0.dist-info/RECORD +77 -0
- duckguard-2.0.0.dist-info/METADATA +0 -221
- duckguard-2.0.0.dist-info/RECORD +0 -55
- {duckguard-2.0.0.dist-info → duckguard-2.3.0.dist-info}/WHEEL +0 -0
- {duckguard-2.0.0.dist-info → duckguard-2.3.0.dist-info}/entry_points.txt +0 -0
- {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)
|