duckguard 2.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 +110 -0
- duckguard/anomaly/__init__.py +34 -0
- duckguard/anomaly/detector.py +394 -0
- duckguard/anomaly/methods.py +432 -0
- duckguard/cli/__init__.py +5 -0
- duckguard/cli/main.py +706 -0
- duckguard/connectors/__init__.py +58 -0
- duckguard/connectors/base.py +80 -0
- duckguard/connectors/bigquery.py +171 -0
- duckguard/connectors/databricks.py +201 -0
- duckguard/connectors/factory.py +292 -0
- duckguard/connectors/files.py +135 -0
- duckguard/connectors/kafka.py +343 -0
- duckguard/connectors/mongodb.py +236 -0
- duckguard/connectors/mysql.py +121 -0
- duckguard/connectors/oracle.py +196 -0
- duckguard/connectors/postgres.py +99 -0
- duckguard/connectors/redshift.py +154 -0
- duckguard/connectors/snowflake.py +226 -0
- duckguard/connectors/sqlite.py +112 -0
- duckguard/connectors/sqlserver.py +242 -0
- duckguard/contracts/__init__.py +48 -0
- duckguard/contracts/diff.py +432 -0
- duckguard/contracts/generator.py +334 -0
- duckguard/contracts/loader.py +367 -0
- duckguard/contracts/schema.py +242 -0
- duckguard/contracts/validator.py +453 -0
- duckguard/core/__init__.py +8 -0
- duckguard/core/column.py +437 -0
- duckguard/core/dataset.py +284 -0
- duckguard/core/engine.py +261 -0
- duckguard/core/result.py +119 -0
- duckguard/core/scoring.py +508 -0
- duckguard/profiler/__init__.py +5 -0
- duckguard/profiler/auto_profile.py +350 -0
- duckguard/pytest_plugin/__init__.py +5 -0
- duckguard/pytest_plugin/plugin.py +161 -0
- duckguard/reporting/__init__.py +6 -0
- duckguard/reporting/console.py +88 -0
- duckguard/reporting/json_report.py +96 -0
- duckguard/rules/__init__.py +28 -0
- duckguard/rules/executor.py +616 -0
- duckguard/rules/generator.py +341 -0
- duckguard/rules/loader.py +483 -0
- duckguard/rules/schema.py +289 -0
- duckguard/semantic/__init__.py +31 -0
- duckguard/semantic/analyzer.py +270 -0
- duckguard/semantic/detector.py +459 -0
- duckguard/semantic/validators.py +354 -0
- duckguard/validators/__init__.py +7 -0
- duckguard-2.0.0.dist-info/METADATA +221 -0
- duckguard-2.0.0.dist-info/RECORD +55 -0
- duckguard-2.0.0.dist-info/WHEEL +4 -0
- duckguard-2.0.0.dist-info/entry_points.txt +5 -0
- duckguard-2.0.0.dist-info/licenses/LICENSE +55 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Auto-profiling and rule suggestion engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from duckguard.core.dataset import Dataset
|
|
10
|
+
from duckguard.core.result import ProfileResult, ColumnProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RuleSuggestion:
|
|
15
|
+
"""A suggested validation rule."""
|
|
16
|
+
|
|
17
|
+
rule: str
|
|
18
|
+
confidence: float # 0-1
|
|
19
|
+
reason: str
|
|
20
|
+
category: str # null, unique, range, pattern, enum
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AutoProfiler:
|
|
24
|
+
"""
|
|
25
|
+
Automatically profiles datasets and suggests validation rules.
|
|
26
|
+
|
|
27
|
+
The profiler analyzes data patterns and generates Python assertions
|
|
28
|
+
that can be used directly in test files.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Thresholds for rule generation
|
|
32
|
+
NULL_THRESHOLD_SUGGEST = 1.0 # Suggest not_null if nulls < 1%
|
|
33
|
+
UNIQUE_THRESHOLD_SUGGEST = 99.0 # Suggest unique if > 99% unique
|
|
34
|
+
ENUM_MAX_VALUES = 20 # Max distinct values to suggest enum check
|
|
35
|
+
PATTERN_SAMPLE_SIZE = 1000 # Sample size for pattern detection
|
|
36
|
+
|
|
37
|
+
# Common patterns to detect
|
|
38
|
+
PATTERNS = {
|
|
39
|
+
"email": r"^[\w\.-]+@[\w\.-]+\.\w+$",
|
|
40
|
+
"uuid": r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
41
|
+
"phone": r"^\+?[\d\s\-\(\)]{10,}$",
|
|
42
|
+
"url": r"^https?://[\w\.-]+",
|
|
43
|
+
"ip_address": r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$",
|
|
44
|
+
"date_iso": r"^\d{4}-\d{2}-\d{2}$",
|
|
45
|
+
"datetime_iso": r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def __init__(self, dataset_var_name: str = "data"):
|
|
49
|
+
"""
|
|
50
|
+
Initialize the profiler.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
dataset_var_name: Variable name to use in generated rules
|
|
54
|
+
"""
|
|
55
|
+
self.dataset_var_name = dataset_var_name
|
|
56
|
+
|
|
57
|
+
def profile(self, dataset: Dataset) -> ProfileResult:
|
|
58
|
+
"""
|
|
59
|
+
Generate a comprehensive profile of the dataset.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
dataset: Dataset to profile
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
ProfileResult with statistics and suggested rules
|
|
66
|
+
"""
|
|
67
|
+
column_profiles = []
|
|
68
|
+
all_suggestions: list[str] = []
|
|
69
|
+
|
|
70
|
+
for col_name in dataset.columns:
|
|
71
|
+
col = dataset[col_name]
|
|
72
|
+
col_profile = self._profile_column(col)
|
|
73
|
+
column_profiles.append(col_profile)
|
|
74
|
+
all_suggestions.extend(col_profile.suggested_rules)
|
|
75
|
+
|
|
76
|
+
return ProfileResult(
|
|
77
|
+
source=dataset.source,
|
|
78
|
+
row_count=dataset.row_count,
|
|
79
|
+
column_count=dataset.column_count,
|
|
80
|
+
columns=column_profiles,
|
|
81
|
+
suggested_rules=all_suggestions,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _profile_column(self, col) -> ColumnProfile:
|
|
85
|
+
"""Profile a single column."""
|
|
86
|
+
# Get basic stats
|
|
87
|
+
stats = col._get_stats()
|
|
88
|
+
numeric_stats = col._get_numeric_stats()
|
|
89
|
+
|
|
90
|
+
# Get sample values for pattern detection
|
|
91
|
+
sample_values = col.get_distinct_values(limit=self.PATTERN_SAMPLE_SIZE)
|
|
92
|
+
|
|
93
|
+
# Generate suggestions
|
|
94
|
+
suggestions = self._generate_suggestions(col, stats, numeric_stats, sample_values)
|
|
95
|
+
|
|
96
|
+
return ColumnProfile(
|
|
97
|
+
name=col.name,
|
|
98
|
+
dtype=self._infer_dtype(stats, sample_values),
|
|
99
|
+
null_count=stats.get("null_count", 0),
|
|
100
|
+
null_percent=stats.get("null_percent", 0.0),
|
|
101
|
+
unique_count=stats.get("unique_count", 0),
|
|
102
|
+
unique_percent=stats.get("unique_percent", 0.0),
|
|
103
|
+
min_value=stats.get("min_value"),
|
|
104
|
+
max_value=stats.get("max_value"),
|
|
105
|
+
mean_value=numeric_stats.get("mean"),
|
|
106
|
+
stddev_value=numeric_stats.get("stddev"),
|
|
107
|
+
sample_values=sample_values[:10],
|
|
108
|
+
suggested_rules=[s.rule for s in suggestions],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _generate_suggestions(
|
|
112
|
+
self,
|
|
113
|
+
col,
|
|
114
|
+
stats: dict[str, Any],
|
|
115
|
+
numeric_stats: dict[str, Any],
|
|
116
|
+
sample_values: list[Any],
|
|
117
|
+
) -> list[RuleSuggestion]:
|
|
118
|
+
"""Generate rule suggestions for a column."""
|
|
119
|
+
suggestions = []
|
|
120
|
+
col_name = col.name
|
|
121
|
+
var = self.dataset_var_name
|
|
122
|
+
|
|
123
|
+
# 1. Null check suggestions
|
|
124
|
+
null_pct = stats.get("null_percent", 0.0)
|
|
125
|
+
if null_pct == 0:
|
|
126
|
+
suggestions.append(
|
|
127
|
+
RuleSuggestion(
|
|
128
|
+
rule=f"assert {var}.{col_name}.null_percent == 0",
|
|
129
|
+
confidence=1.0,
|
|
130
|
+
reason="Column has no null values",
|
|
131
|
+
category="null",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
elif null_pct < self.NULL_THRESHOLD_SUGGEST:
|
|
135
|
+
threshold = max(1, round(null_pct * 2)) # 2x buffer
|
|
136
|
+
suggestions.append(
|
|
137
|
+
RuleSuggestion(
|
|
138
|
+
rule=f"assert {var}.{col_name}.null_percent < {threshold}",
|
|
139
|
+
confidence=0.9,
|
|
140
|
+
reason=f"Column has only {null_pct:.2f}% nulls",
|
|
141
|
+
category="null",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 2. Uniqueness suggestions
|
|
146
|
+
unique_pct = stats.get("unique_percent", 0.0)
|
|
147
|
+
if unique_pct == 100:
|
|
148
|
+
suggestions.append(
|
|
149
|
+
RuleSuggestion(
|
|
150
|
+
rule=f"assert {var}.{col_name}.has_no_duplicates()",
|
|
151
|
+
confidence=1.0,
|
|
152
|
+
reason="All values are unique",
|
|
153
|
+
category="unique",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
elif unique_pct > self.UNIQUE_THRESHOLD_SUGGEST:
|
|
157
|
+
suggestions.append(
|
|
158
|
+
RuleSuggestion(
|
|
159
|
+
rule=f"assert {var}.{col_name}.unique_percent > 99",
|
|
160
|
+
confidence=0.8,
|
|
161
|
+
reason=f"Column has {unique_pct:.2f}% unique values",
|
|
162
|
+
category="unique",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# 3. Range suggestions for numeric columns
|
|
167
|
+
if numeric_stats.get("mean") is not None:
|
|
168
|
+
min_val = stats.get("min_value")
|
|
169
|
+
max_val = stats.get("max_value")
|
|
170
|
+
|
|
171
|
+
if min_val is not None and max_val is not None:
|
|
172
|
+
# Add buffer for range
|
|
173
|
+
range_size = max_val - min_val
|
|
174
|
+
buffer = range_size * 0.1 if range_size > 0 else 1
|
|
175
|
+
|
|
176
|
+
suggested_min = self._round_nice(min_val - buffer)
|
|
177
|
+
suggested_max = self._round_nice(max_val + buffer)
|
|
178
|
+
|
|
179
|
+
suggestions.append(
|
|
180
|
+
RuleSuggestion(
|
|
181
|
+
rule=f"assert {var}.{col_name}.between({suggested_min}, {suggested_max})",
|
|
182
|
+
confidence=0.7,
|
|
183
|
+
reason=f"Values range from {min_val} to {max_val}",
|
|
184
|
+
category="range",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Non-negative check
|
|
189
|
+
if min_val is not None and min_val >= 0:
|
|
190
|
+
suggestions.append(
|
|
191
|
+
RuleSuggestion(
|
|
192
|
+
rule=f"assert {var}.{col_name}.min >= 0",
|
|
193
|
+
confidence=0.9,
|
|
194
|
+
reason="All values are non-negative",
|
|
195
|
+
category="range",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# 4. Enum suggestions for low-cardinality columns
|
|
200
|
+
unique_count = stats.get("unique_count", 0)
|
|
201
|
+
total_count = stats.get("total_count", 0)
|
|
202
|
+
|
|
203
|
+
if 0 < unique_count <= self.ENUM_MAX_VALUES and total_count > unique_count * 2:
|
|
204
|
+
# Get all distinct values
|
|
205
|
+
distinct_values = col.get_distinct_values(limit=self.ENUM_MAX_VALUES + 1)
|
|
206
|
+
if len(distinct_values) <= self.ENUM_MAX_VALUES:
|
|
207
|
+
# Format values for Python code
|
|
208
|
+
formatted_values = self._format_values(distinct_values)
|
|
209
|
+
suggestions.append(
|
|
210
|
+
RuleSuggestion(
|
|
211
|
+
rule=f"assert {var}.{col_name}.isin({formatted_values})",
|
|
212
|
+
confidence=0.85,
|
|
213
|
+
reason=f"Column has only {unique_count} distinct values",
|
|
214
|
+
category="enum",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# 5. Pattern suggestions for string columns
|
|
219
|
+
string_values = [v for v in sample_values if isinstance(v, str)]
|
|
220
|
+
if string_values:
|
|
221
|
+
detected_pattern = self._detect_pattern(string_values)
|
|
222
|
+
if detected_pattern:
|
|
223
|
+
pattern_name, pattern = detected_pattern
|
|
224
|
+
suggestions.append(
|
|
225
|
+
RuleSuggestion(
|
|
226
|
+
rule=f'assert {var}.{col_name}.matches(r"{pattern}")',
|
|
227
|
+
confidence=0.75,
|
|
228
|
+
reason=f"Values appear to be {pattern_name}",
|
|
229
|
+
category="pattern",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return suggestions
|
|
234
|
+
|
|
235
|
+
def _detect_pattern(self, values: list[str]) -> tuple[str, str] | None:
|
|
236
|
+
"""Detect common patterns in string values."""
|
|
237
|
+
if not values:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
# Sample for pattern detection
|
|
241
|
+
sample = values[: min(100, len(values))]
|
|
242
|
+
|
|
243
|
+
for pattern_name, pattern in self.PATTERNS.items():
|
|
244
|
+
matches = sum(1 for v in sample if re.match(pattern, str(v), re.IGNORECASE))
|
|
245
|
+
match_rate = matches / len(sample)
|
|
246
|
+
|
|
247
|
+
if match_rate > 0.9: # 90% match threshold
|
|
248
|
+
return pattern_name, pattern
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def _infer_dtype(self, stats: dict[str, Any], sample_values: list[Any]) -> str:
|
|
253
|
+
"""Infer the data type from statistics and samples."""
|
|
254
|
+
if not sample_values:
|
|
255
|
+
return "unknown"
|
|
256
|
+
|
|
257
|
+
# Get first non-null value
|
|
258
|
+
first_val = next((v for v in sample_values if v is not None), None)
|
|
259
|
+
|
|
260
|
+
if first_val is None:
|
|
261
|
+
return "unknown"
|
|
262
|
+
|
|
263
|
+
if isinstance(first_val, bool):
|
|
264
|
+
return "boolean"
|
|
265
|
+
if isinstance(first_val, int):
|
|
266
|
+
return "integer"
|
|
267
|
+
if isinstance(first_val, float):
|
|
268
|
+
return "float"
|
|
269
|
+
if isinstance(first_val, str):
|
|
270
|
+
return "string"
|
|
271
|
+
|
|
272
|
+
return type(first_val).__name__
|
|
273
|
+
|
|
274
|
+
def _round_nice(self, value: float) -> int | float:
|
|
275
|
+
"""Round to a nice human-readable number."""
|
|
276
|
+
if abs(value) < 1:
|
|
277
|
+
return round(value, 2)
|
|
278
|
+
if abs(value) < 100:
|
|
279
|
+
return round(value)
|
|
280
|
+
if abs(value) < 1000:
|
|
281
|
+
return round(value / 10) * 10
|
|
282
|
+
return round(value / 100) * 100
|
|
283
|
+
|
|
284
|
+
def _format_values(self, values: list[Any]) -> str:
|
|
285
|
+
"""Format a list of values for Python code."""
|
|
286
|
+
formatted = []
|
|
287
|
+
for v in values:
|
|
288
|
+
if v is None:
|
|
289
|
+
continue
|
|
290
|
+
if isinstance(v, str):
|
|
291
|
+
# Escape quotes
|
|
292
|
+
escaped = v.replace("'", "\\'")
|
|
293
|
+
formatted.append(f"'{escaped}'")
|
|
294
|
+
else:
|
|
295
|
+
formatted.append(str(v))
|
|
296
|
+
|
|
297
|
+
return "[" + ", ".join(formatted) + "]"
|
|
298
|
+
|
|
299
|
+
def generate_test_file(self, dataset: Dataset, output_var: str = "data") -> str:
|
|
300
|
+
"""
|
|
301
|
+
Generate a complete test file from profiling results.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
dataset: Dataset to profile
|
|
305
|
+
output_var: Variable name to use for the dataset
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Python code string for a test file
|
|
309
|
+
"""
|
|
310
|
+
self.dataset_var_name = output_var
|
|
311
|
+
profile = self.profile(dataset)
|
|
312
|
+
|
|
313
|
+
lines = [
|
|
314
|
+
'"""Auto-generated data quality tests by DuckGuard."""',
|
|
315
|
+
"",
|
|
316
|
+
"from duckguard import connect",
|
|
317
|
+
"",
|
|
318
|
+
"",
|
|
319
|
+
f'def test_{dataset.name.replace("-", "_").replace(".", "_")}():',
|
|
320
|
+
f' {output_var} = connect("{dataset.source}")',
|
|
321
|
+
"",
|
|
322
|
+
f" # Basic dataset checks",
|
|
323
|
+
f" assert {output_var}.row_count > 0",
|
|
324
|
+
"",
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
# Group suggestions by column
|
|
328
|
+
for col_profile in profile.columns:
|
|
329
|
+
if col_profile.suggested_rules:
|
|
330
|
+
lines.append(f" # {col_profile.name} validations")
|
|
331
|
+
for rule in col_profile.suggested_rules:
|
|
332
|
+
lines.append(f" {rule}")
|
|
333
|
+
lines.append("")
|
|
334
|
+
|
|
335
|
+
return "\n".join(lines)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def profile(dataset: Dataset, dataset_var_name: str = "data") -> ProfileResult:
|
|
339
|
+
"""
|
|
340
|
+
Convenience function to profile a dataset.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
dataset: Dataset to profile
|
|
344
|
+
dataset_var_name: Variable name for generated rules
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
ProfileResult
|
|
348
|
+
"""
|
|
349
|
+
profiler = AutoProfiler(dataset_var_name=dataset_var_name)
|
|
350
|
+
return profiler.profile(dataset)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""pytest plugin for DuckGuard data quality testing.
|
|
2
|
+
|
|
3
|
+
This plugin provides fixtures and hooks for seamless pytest integration.
|
|
4
|
+
|
|
5
|
+
Usage in conftest.py:
|
|
6
|
+
import pytest
|
|
7
|
+
from duckguard import connect
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def orders():
|
|
11
|
+
return connect("data/orders.csv")
|
|
12
|
+
|
|
13
|
+
Usage in tests:
|
|
14
|
+
def test_orders_not_empty(orders):
|
|
15
|
+
assert orders.row_count > 0
|
|
16
|
+
|
|
17
|
+
def test_customer_id_valid(orders):
|
|
18
|
+
assert orders.customer_id.null_percent < 5
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from duckguard.core.engine import DuckGuardEngine
|
|
26
|
+
from duckguard.connectors import connect as duckguard_connect
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="session")
|
|
30
|
+
def duckguard_engine():
|
|
31
|
+
"""
|
|
32
|
+
Provide a DuckGuard engine instance for the test session.
|
|
33
|
+
|
|
34
|
+
This fixture provides a shared DuckDB engine that persists
|
|
35
|
+
across all tests in the session.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
def test_something(duckguard_engine):
|
|
39
|
+
result = duckguard_engine.execute("SELECT 1")
|
|
40
|
+
"""
|
|
41
|
+
engine = DuckGuardEngine()
|
|
42
|
+
yield engine
|
|
43
|
+
engine.close()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def duckguard_dataset(request):
|
|
48
|
+
"""
|
|
49
|
+
Factory fixture for creating datasets from markers.
|
|
50
|
+
|
|
51
|
+
Usage with marker:
|
|
52
|
+
@pytest.mark.duckguard_source("data/orders.csv")
|
|
53
|
+
def test_orders(duckguard_dataset):
|
|
54
|
+
assert duckguard_dataset.row_count > 0
|
|
55
|
+
|
|
56
|
+
Usage with parametrize:
|
|
57
|
+
@pytest.mark.parametrize("source", ["data/orders.csv", "data/customers.csv"])
|
|
58
|
+
def test_multiple_sources(duckguard_dataset, source):
|
|
59
|
+
dataset = duckguard_dataset(source)
|
|
60
|
+
assert dataset.row_count > 0
|
|
61
|
+
"""
|
|
62
|
+
# Check for marker
|
|
63
|
+
marker = request.node.get_closest_marker("duckguard_source")
|
|
64
|
+
|
|
65
|
+
if marker:
|
|
66
|
+
source = marker.args[0] if marker.args else None
|
|
67
|
+
table = marker.kwargs.get("table")
|
|
68
|
+
if source:
|
|
69
|
+
return duckguard_connect(source, table=table)
|
|
70
|
+
|
|
71
|
+
# Return factory function
|
|
72
|
+
def _create_dataset(source: str, **kwargs):
|
|
73
|
+
return duckguard_connect(source, **kwargs)
|
|
74
|
+
|
|
75
|
+
return _create_dataset
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pytest_configure(config):
|
|
79
|
+
"""Register DuckGuard markers."""
|
|
80
|
+
config.addinivalue_line(
|
|
81
|
+
"markers",
|
|
82
|
+
"duckguard_source(source, table=None): Mark test with a DuckGuard data source",
|
|
83
|
+
)
|
|
84
|
+
config.addinivalue_line(
|
|
85
|
+
"markers",
|
|
86
|
+
"duckguard_skip_slow: Skip slow DuckGuard tests",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pytest_collection_modifyitems(config, items):
|
|
91
|
+
"""Modify test collection based on DuckGuard options."""
|
|
92
|
+
# Check if slow tests should be skipped
|
|
93
|
+
skip_slow = config.getoption("--duckguard-skip-slow", default=False)
|
|
94
|
+
|
|
95
|
+
if skip_slow:
|
|
96
|
+
skip_marker = pytest.mark.skip(reason="Skipping slow DuckGuard tests")
|
|
97
|
+
for item in items:
|
|
98
|
+
if "duckguard_skip_slow" in item.keywords:
|
|
99
|
+
item.add_marker(skip_marker)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pytest_addoption(parser):
|
|
103
|
+
"""Add DuckGuard-specific command line options."""
|
|
104
|
+
group = parser.getgroup("duckguard", "DuckGuard data quality testing")
|
|
105
|
+
group.addoption(
|
|
106
|
+
"--duckguard-skip-slow",
|
|
107
|
+
action="store_true",
|
|
108
|
+
default=False,
|
|
109
|
+
help="Skip slow DuckGuard tests",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Custom assertion helpers for better error messages
|
|
114
|
+
class DuckGuardAssertionHelper:
|
|
115
|
+
"""Helper class for custom DuckGuard assertions with better error messages."""
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def assert_not_null(column, threshold: float = 0.0):
|
|
119
|
+
"""Assert column null percentage is below threshold."""
|
|
120
|
+
actual = column.null_percent
|
|
121
|
+
if actual > threshold:
|
|
122
|
+
pytest.fail(
|
|
123
|
+
f"Column '{column.name}' has {actual:.2f}% null values, "
|
|
124
|
+
f"expected <= {threshold}%"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def assert_unique(column, threshold: float = 100.0):
|
|
129
|
+
"""Assert column unique percentage is at or above threshold."""
|
|
130
|
+
actual = column.unique_percent
|
|
131
|
+
if actual < threshold:
|
|
132
|
+
pytest.fail(
|
|
133
|
+
f"Column '{column.name}' has {actual:.2f}% unique values, "
|
|
134
|
+
f"expected >= {threshold}%"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def assert_in_range(column, min_val, max_val):
|
|
139
|
+
"""Assert all column values are within range."""
|
|
140
|
+
result = column.between(min_val, max_val)
|
|
141
|
+
if not result:
|
|
142
|
+
pytest.fail(
|
|
143
|
+
f"Column '{column.name}' has {result.actual_value} values "
|
|
144
|
+
f"outside range [{min_val}, {max_val}]"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def assert_matches_pattern(column, pattern: str):
|
|
149
|
+
"""Assert all column values match pattern."""
|
|
150
|
+
result = column.matches(pattern)
|
|
151
|
+
if not result:
|
|
152
|
+
pytest.fail(
|
|
153
|
+
f"Column '{column.name}' has {result.actual_value} values "
|
|
154
|
+
f"not matching pattern '{pattern}'"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.fixture
|
|
159
|
+
def duckguard_assert():
|
|
160
|
+
"""Provide DuckGuard assertion helpers with better error messages."""
|
|
161
|
+
return DuckGuardAssertionHelper()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Console reporter using Rich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from duckguard.core.result import ProfileResult, ScanResult, CheckResult, CheckStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConsoleReporter:
|
|
13
|
+
"""Reporter that outputs to the console using Rich."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.console = Console()
|
|
17
|
+
|
|
18
|
+
def report_profile(self, profile: ProfileResult) -> None:
|
|
19
|
+
"""Display a profile result."""
|
|
20
|
+
self.console.print(
|
|
21
|
+
Panel(
|
|
22
|
+
f"[bold]Source:[/bold] {profile.source}\n"
|
|
23
|
+
f"[bold]Rows:[/bold] {profile.row_count:,}\n"
|
|
24
|
+
f"[bold]Columns:[/bold] {profile.column_count}",
|
|
25
|
+
title="Profile Summary",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Column table
|
|
30
|
+
table = Table(title="Columns")
|
|
31
|
+
table.add_column("Name", style="cyan")
|
|
32
|
+
table.add_column("Type")
|
|
33
|
+
table.add_column("Nulls %", justify="right")
|
|
34
|
+
table.add_column("Unique %", justify="right")
|
|
35
|
+
|
|
36
|
+
for col in profile.columns:
|
|
37
|
+
table.add_row(
|
|
38
|
+
col.name,
|
|
39
|
+
col.dtype,
|
|
40
|
+
f"{col.null_percent:.1f}%",
|
|
41
|
+
f"{col.unique_percent:.1f}%",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.console.print(table)
|
|
45
|
+
|
|
46
|
+
def report_scan(self, scan: ScanResult) -> None:
|
|
47
|
+
"""Display a scan result."""
|
|
48
|
+
status_color = "green" if scan.passed else "red"
|
|
49
|
+
|
|
50
|
+
self.console.print(
|
|
51
|
+
Panel(
|
|
52
|
+
f"[bold]Source:[/bold] {scan.source}\n"
|
|
53
|
+
f"[bold]Rows:[/bold] {scan.row_count:,}\n"
|
|
54
|
+
f"[bold]Checks:[/bold] {scan.checks_passed}/{scan.checks_run} passed "
|
|
55
|
+
f"([{status_color}]{scan.pass_rate:.1f}%[/{status_color}])",
|
|
56
|
+
title="Scan Summary",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if scan.results:
|
|
61
|
+
table = Table(title="Check Results")
|
|
62
|
+
table.add_column("Check", style="cyan")
|
|
63
|
+
table.add_column("Status", justify="center")
|
|
64
|
+
table.add_column("Value")
|
|
65
|
+
table.add_column("Message")
|
|
66
|
+
|
|
67
|
+
for result in scan.results:
|
|
68
|
+
status_style = {
|
|
69
|
+
CheckStatus.PASSED: "[green]PASS[/green]",
|
|
70
|
+
CheckStatus.FAILED: "[red]FAIL[/red]",
|
|
71
|
+
CheckStatus.WARNING: "[yellow]WARN[/yellow]",
|
|
72
|
+
CheckStatus.ERROR: "[red]ERROR[/red]",
|
|
73
|
+
}
|
|
74
|
+
table.add_row(
|
|
75
|
+
result.name,
|
|
76
|
+
status_style.get(result.status, str(result.status)),
|
|
77
|
+
str(result.actual_value),
|
|
78
|
+
result.message,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.console.print(table)
|
|
82
|
+
|
|
83
|
+
def report_check(self, result: CheckResult) -> None:
|
|
84
|
+
"""Display a single check result."""
|
|
85
|
+
if result.passed:
|
|
86
|
+
self.console.print(f"[green]PASS[/green] {result.name}: {result.message}")
|
|
87
|
+
else:
|
|
88
|
+
self.console.print(f"[red]FAIL[/red] {result.name}: {result.message}")
|