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.
Files changed (55) hide show
  1. duckguard/__init__.py +110 -0
  2. duckguard/anomaly/__init__.py +34 -0
  3. duckguard/anomaly/detector.py +394 -0
  4. duckguard/anomaly/methods.py +432 -0
  5. duckguard/cli/__init__.py +5 -0
  6. duckguard/cli/main.py +706 -0
  7. duckguard/connectors/__init__.py +58 -0
  8. duckguard/connectors/base.py +80 -0
  9. duckguard/connectors/bigquery.py +171 -0
  10. duckguard/connectors/databricks.py +201 -0
  11. duckguard/connectors/factory.py +292 -0
  12. duckguard/connectors/files.py +135 -0
  13. duckguard/connectors/kafka.py +343 -0
  14. duckguard/connectors/mongodb.py +236 -0
  15. duckguard/connectors/mysql.py +121 -0
  16. duckguard/connectors/oracle.py +196 -0
  17. duckguard/connectors/postgres.py +99 -0
  18. duckguard/connectors/redshift.py +154 -0
  19. duckguard/connectors/snowflake.py +226 -0
  20. duckguard/connectors/sqlite.py +112 -0
  21. duckguard/connectors/sqlserver.py +242 -0
  22. duckguard/contracts/__init__.py +48 -0
  23. duckguard/contracts/diff.py +432 -0
  24. duckguard/contracts/generator.py +334 -0
  25. duckguard/contracts/loader.py +367 -0
  26. duckguard/contracts/schema.py +242 -0
  27. duckguard/contracts/validator.py +453 -0
  28. duckguard/core/__init__.py +8 -0
  29. duckguard/core/column.py +437 -0
  30. duckguard/core/dataset.py +284 -0
  31. duckguard/core/engine.py +261 -0
  32. duckguard/core/result.py +119 -0
  33. duckguard/core/scoring.py +508 -0
  34. duckguard/profiler/__init__.py +5 -0
  35. duckguard/profiler/auto_profile.py +350 -0
  36. duckguard/pytest_plugin/__init__.py +5 -0
  37. duckguard/pytest_plugin/plugin.py +161 -0
  38. duckguard/reporting/__init__.py +6 -0
  39. duckguard/reporting/console.py +88 -0
  40. duckguard/reporting/json_report.py +96 -0
  41. duckguard/rules/__init__.py +28 -0
  42. duckguard/rules/executor.py +616 -0
  43. duckguard/rules/generator.py +341 -0
  44. duckguard/rules/loader.py +483 -0
  45. duckguard/rules/schema.py +289 -0
  46. duckguard/semantic/__init__.py +31 -0
  47. duckguard/semantic/analyzer.py +270 -0
  48. duckguard/semantic/detector.py +459 -0
  49. duckguard/semantic/validators.py +354 -0
  50. duckguard/validators/__init__.py +7 -0
  51. duckguard-2.0.0.dist-info/METADATA +221 -0
  52. duckguard-2.0.0.dist-info/RECORD +55 -0
  53. duckguard-2.0.0.dist-info/WHEEL +4 -0
  54. duckguard-2.0.0.dist-info/entry_points.txt +5 -0
  55. 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,5 @@
1
+ """pytest plugin for DuckGuard."""
2
+
3
+ from duckguard.pytest_plugin.plugin import duckguard_engine, duckguard_dataset
4
+
5
+ __all__ = ["duckguard_engine", "duckguard_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,6 @@
1
+ """Reporting module for DuckGuard."""
2
+
3
+ from duckguard.reporting.console import ConsoleReporter
4
+ from duckguard.reporting.json_report import JSONReporter
5
+
6
+ __all__ = ["ConsoleReporter", "JSONReporter"]
@@ -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}")