duckguard 2.2.0__py3-none-any.whl → 3.0.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/anomaly/__init__.py +28 -0
- duckguard/anomaly/baselines.py +294 -0
- duckguard/anomaly/methods.py +16 -2
- duckguard/anomaly/ml_methods.py +724 -0
- duckguard/checks/__init__.py +26 -0
- duckguard/checks/conditional.py +796 -0
- duckguard/checks/distributional.py +524 -0
- duckguard/checks/multicolumn.py +726 -0
- duckguard/checks/query_based.py +643 -0
- duckguard/cli/main.py +257 -2
- duckguard/connectors/factory.py +30 -2
- duckguard/connectors/files.py +7 -3
- duckguard/core/column.py +851 -1
- duckguard/core/dataset.py +1035 -0
- duckguard/core/result.py +236 -0
- duckguard/freshness/__init__.py +33 -0
- duckguard/freshness/monitor.py +429 -0
- duckguard/history/schema.py +119 -1
- duckguard/notifications/__init__.py +20 -2
- duckguard/notifications/email.py +508 -0
- duckguard/profiler/distribution_analyzer.py +384 -0
- duckguard/profiler/outlier_detector.py +497 -0
- duckguard/profiler/pattern_matcher.py +301 -0
- duckguard/profiler/quality_scorer.py +445 -0
- duckguard/reports/html_reporter.py +1 -2
- duckguard/rules/executor.py +642 -0
- duckguard/rules/generator.py +4 -1
- duckguard/rules/schema.py +54 -0
- duckguard/schema_history/__init__.py +40 -0
- duckguard/schema_history/analyzer.py +414 -0
- duckguard/schema_history/tracker.py +288 -0
- duckguard/semantic/detector.py +17 -1
- duckguard-3.0.0.dist-info/METADATA +1072 -0
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/RECORD +38 -21
- duckguard-2.2.0.dist-info/METADATA +0 -351
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/WHEEL +0 -0
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/entry_points.txt +0 -0
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/licenses/LICENSE +0 -0
duckguard/history/schema.py
CHANGED
|
@@ -7,7 +7,7 @@ enabling trend analysis and historical comparison.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
# Schema version for migrations
|
|
10
|
-
SCHEMA_VERSION =
|
|
10
|
+
SCHEMA_VERSION = 2
|
|
11
11
|
|
|
12
12
|
# SQL to create all tables
|
|
13
13
|
CREATE_TABLES_SQL = """
|
|
@@ -88,6 +88,57 @@ CREATE INDEX IF NOT EXISTS idx_runs_source_started ON runs(source, started_at);
|
|
|
88
88
|
CREATE INDEX IF NOT EXISTS idx_check_results_run_id ON check_results(run_id);
|
|
89
89
|
CREATE INDEX IF NOT EXISTS idx_failed_rows_run_id ON failed_rows_sample(run_id);
|
|
90
90
|
CREATE INDEX IF NOT EXISTS idx_quality_trends_source_date ON quality_trends(source, date);
|
|
91
|
+
|
|
92
|
+
-- Schema snapshots: Store schema state at points in time
|
|
93
|
+
CREATE TABLE IF NOT EXISTS schema_snapshots (
|
|
94
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
+
source TEXT NOT NULL,
|
|
96
|
+
snapshot_id TEXT UNIQUE NOT NULL,
|
|
97
|
+
captured_at TEXT NOT NULL,
|
|
98
|
+
schema_json TEXT NOT NULL,
|
|
99
|
+
column_count INTEGER NOT NULL,
|
|
100
|
+
row_count INTEGER,
|
|
101
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
-- Schema changes: Track schema evolution over time
|
|
105
|
+
CREATE TABLE IF NOT EXISTS schema_changes (
|
|
106
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
107
|
+
source TEXT NOT NULL,
|
|
108
|
+
detected_at TEXT NOT NULL,
|
|
109
|
+
previous_snapshot_id TEXT,
|
|
110
|
+
current_snapshot_id TEXT NOT NULL,
|
|
111
|
+
change_type TEXT NOT NULL,
|
|
112
|
+
column_name TEXT,
|
|
113
|
+
previous_value TEXT,
|
|
114
|
+
current_value TEXT,
|
|
115
|
+
is_breaking INTEGER NOT NULL,
|
|
116
|
+
severity TEXT NOT NULL,
|
|
117
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
118
|
+
FOREIGN KEY (previous_snapshot_id) REFERENCES schema_snapshots(snapshot_id),
|
|
119
|
+
FOREIGN KEY (current_snapshot_id) REFERENCES schema_snapshots(snapshot_id)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
-- Baselines: Store learned baselines for anomaly detection
|
|
123
|
+
CREATE TABLE IF NOT EXISTS baselines (
|
|
124
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
|
+
source TEXT NOT NULL,
|
|
126
|
+
column_name TEXT NOT NULL,
|
|
127
|
+
metric TEXT NOT NULL,
|
|
128
|
+
baseline_value TEXT NOT NULL,
|
|
129
|
+
sample_size INTEGER,
|
|
130
|
+
created_at TEXT NOT NULL,
|
|
131
|
+
updated_at TEXT,
|
|
132
|
+
UNIQUE(source, column_name, metric)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
-- Additional indexes for new tables
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_schema_snapshots_source ON schema_snapshots(source);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_schema_snapshots_captured_at ON schema_snapshots(captured_at);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_schema_changes_source ON schema_changes(source);
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_schema_changes_detected_at ON schema_changes(detected_at);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_baselines_source ON baselines(source);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_baselines_source_column ON baselines(source, column_name);
|
|
91
142
|
"""
|
|
92
143
|
|
|
93
144
|
# Pre-built queries for common operations
|
|
@@ -180,4 +231,71 @@ QUERIES = {
|
|
|
180
231
|
SELECT * FROM runs
|
|
181
232
|
WHERE run_id = ?
|
|
182
233
|
""",
|
|
234
|
+
# Schema snapshot queries
|
|
235
|
+
"insert_schema_snapshot": """
|
|
236
|
+
INSERT INTO schema_snapshots (
|
|
237
|
+
source, snapshot_id, captured_at, schema_json, column_count, row_count
|
|
238
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
239
|
+
""",
|
|
240
|
+
"get_schema_snapshots": """
|
|
241
|
+
SELECT * FROM schema_snapshots
|
|
242
|
+
WHERE source = ?
|
|
243
|
+
ORDER BY captured_at DESC
|
|
244
|
+
LIMIT ?
|
|
245
|
+
""",
|
|
246
|
+
"get_latest_schema_snapshot": """
|
|
247
|
+
SELECT * FROM schema_snapshots
|
|
248
|
+
WHERE source = ?
|
|
249
|
+
ORDER BY captured_at DESC
|
|
250
|
+
LIMIT 1
|
|
251
|
+
""",
|
|
252
|
+
"get_schema_snapshot_by_id": """
|
|
253
|
+
SELECT * FROM schema_snapshots
|
|
254
|
+
WHERE snapshot_id = ?
|
|
255
|
+
""",
|
|
256
|
+
# Schema change queries
|
|
257
|
+
"insert_schema_change": """
|
|
258
|
+
INSERT INTO schema_changes (
|
|
259
|
+
source, detected_at, previous_snapshot_id, current_snapshot_id,
|
|
260
|
+
change_type, column_name, previous_value, current_value,
|
|
261
|
+
is_breaking, severity
|
|
262
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
263
|
+
""",
|
|
264
|
+
"get_schema_changes": """
|
|
265
|
+
SELECT * FROM schema_changes
|
|
266
|
+
WHERE source = ?
|
|
267
|
+
ORDER BY detected_at DESC
|
|
268
|
+
LIMIT ?
|
|
269
|
+
""",
|
|
270
|
+
"get_schema_changes_since": """
|
|
271
|
+
SELECT * FROM schema_changes
|
|
272
|
+
WHERE source = ?
|
|
273
|
+
AND detected_at >= ?
|
|
274
|
+
ORDER BY detected_at DESC
|
|
275
|
+
""",
|
|
276
|
+
# Baseline queries
|
|
277
|
+
"upsert_baseline": """
|
|
278
|
+
INSERT INTO baselines (
|
|
279
|
+
source, column_name, metric, baseline_value, sample_size, created_at, updated_at
|
|
280
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
281
|
+
ON CONFLICT(source, column_name, metric) DO UPDATE SET
|
|
282
|
+
baseline_value = excluded.baseline_value,
|
|
283
|
+
sample_size = excluded.sample_size,
|
|
284
|
+
updated_at = excluded.updated_at
|
|
285
|
+
""",
|
|
286
|
+
"get_baseline": """
|
|
287
|
+
SELECT * FROM baselines
|
|
288
|
+
WHERE source = ?
|
|
289
|
+
AND column_name = ?
|
|
290
|
+
AND metric = ?
|
|
291
|
+
""",
|
|
292
|
+
"get_baselines_for_source": """
|
|
293
|
+
SELECT * FROM baselines
|
|
294
|
+
WHERE source = ?
|
|
295
|
+
ORDER BY column_name, metric
|
|
296
|
+
""",
|
|
297
|
+
"delete_baselines_for_source": """
|
|
298
|
+
DELETE FROM baselines
|
|
299
|
+
WHERE source = ?
|
|
300
|
+
""",
|
|
183
301
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
|
-
DuckGuard Notifications - Slack and
|
|
2
|
+
DuckGuard Notifications - Slack, Teams, and Email alerting for data quality checks.
|
|
3
3
|
|
|
4
4
|
Usage:
|
|
5
|
-
from duckguard.notifications import SlackNotifier, TeamsNotifier
|
|
5
|
+
from duckguard.notifications import SlackNotifier, TeamsNotifier, EmailNotifier
|
|
6
6
|
|
|
7
7
|
# Slack
|
|
8
8
|
slack = SlackNotifier(webhook_url="https://hooks.slack.com/...")
|
|
@@ -12,6 +12,15 @@ Usage:
|
|
|
12
12
|
teams = TeamsNotifier(webhook_url="https://outlook.office.com/webhook/...")
|
|
13
13
|
teams.send_results(execution_result)
|
|
14
14
|
|
|
15
|
+
# Email
|
|
16
|
+
email = EmailNotifier(
|
|
17
|
+
smtp_host="smtp.gmail.com",
|
|
18
|
+
smtp_user="alerts@company.com",
|
|
19
|
+
smtp_password="app_password",
|
|
20
|
+
to_addresses=["team@company.com"],
|
|
21
|
+
)
|
|
22
|
+
email.send_results(execution_result)
|
|
23
|
+
|
|
15
24
|
# Auto-notify on failures
|
|
16
25
|
from duckguard import execute_rules, load_rules
|
|
17
26
|
|
|
@@ -20,8 +29,13 @@ Usage:
|
|
|
20
29
|
|
|
21
30
|
if not result.passed:
|
|
22
31
|
slack.send_failure_alert(result)
|
|
32
|
+
email.send_failure_alert(result)
|
|
23
33
|
"""
|
|
24
34
|
|
|
35
|
+
from duckguard.notifications.email import (
|
|
36
|
+
EmailConfig,
|
|
37
|
+
EmailNotifier,
|
|
38
|
+
)
|
|
25
39
|
from duckguard.notifications.formatter import (
|
|
26
40
|
format_results_markdown,
|
|
27
41
|
format_results_text,
|
|
@@ -29,6 +43,7 @@ from duckguard.notifications.formatter import (
|
|
|
29
43
|
from duckguard.notifications.notifiers import (
|
|
30
44
|
BaseNotifier,
|
|
31
45
|
NotificationConfig,
|
|
46
|
+
NotificationError,
|
|
32
47
|
SlackNotifier,
|
|
33
48
|
TeamsNotifier,
|
|
34
49
|
)
|
|
@@ -36,8 +51,11 @@ from duckguard.notifications.notifiers import (
|
|
|
36
51
|
__all__ = [
|
|
37
52
|
"BaseNotifier",
|
|
38
53
|
"NotificationConfig",
|
|
54
|
+
"NotificationError",
|
|
39
55
|
"SlackNotifier",
|
|
40
56
|
"TeamsNotifier",
|
|
57
|
+
"EmailNotifier",
|
|
58
|
+
"EmailConfig",
|
|
41
59
|
"format_results_text",
|
|
42
60
|
"format_results_markdown",
|
|
43
61
|
]
|
|
@@ -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)
|