truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""JSON report generator.
|
|
2
|
+
|
|
3
|
+
Generates machine-readable JSON reports suitable for API consumption
|
|
4
|
+
and integration with other tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from .base import Reporter, ReportFormat, ReportMetadata
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from truthound_dashboard.db.models import Validation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JSONReporter(Reporter):
|
|
20
|
+
"""JSON report generator.
|
|
21
|
+
|
|
22
|
+
Produces structured JSON reports with complete validation data.
|
|
23
|
+
Supports pretty printing and compact output modes.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
indent: int | None = 2,
|
|
29
|
+
ensure_ascii: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initialize JSON reporter.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
indent: Indentation for pretty printing. None for compact output.
|
|
35
|
+
ensure_ascii: Whether to escape non-ASCII characters.
|
|
36
|
+
"""
|
|
37
|
+
self._indent = indent
|
|
38
|
+
self._ensure_ascii = ensure_ascii
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def format(self) -> ReportFormat:
|
|
42
|
+
return ReportFormat.JSON
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def content_type(self) -> str:
|
|
46
|
+
return "application/json; charset=utf-8"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def file_extension(self) -> str:
|
|
50
|
+
return ".json"
|
|
51
|
+
|
|
52
|
+
async def _render_content(
|
|
53
|
+
self,
|
|
54
|
+
validation: Validation,
|
|
55
|
+
metadata: ReportMetadata,
|
|
56
|
+
include_samples: bool,
|
|
57
|
+
include_statistics: bool,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Render JSON report content."""
|
|
60
|
+
issues = self._extract_issues(validation)
|
|
61
|
+
|
|
62
|
+
# Process issues to optionally remove samples
|
|
63
|
+
if not include_samples:
|
|
64
|
+
issues = [
|
|
65
|
+
{k: v for k, v in issue.items() if k != "sample_values"}
|
|
66
|
+
for issue in issues
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
report_data: dict[str, Any] = {
|
|
70
|
+
"metadata": {
|
|
71
|
+
"title": metadata.title,
|
|
72
|
+
"generated_at": metadata.generated_at.isoformat(),
|
|
73
|
+
"format": metadata.format.value,
|
|
74
|
+
"theme": metadata.theme.value,
|
|
75
|
+
},
|
|
76
|
+
"validation": {
|
|
77
|
+
"id": validation.id,
|
|
78
|
+
"source_id": validation.source_id,
|
|
79
|
+
"source_name": metadata.source_name,
|
|
80
|
+
"status": validation.status,
|
|
81
|
+
"passed": validation.passed,
|
|
82
|
+
},
|
|
83
|
+
"summary": {
|
|
84
|
+
"total_issues": validation.total_issues or 0,
|
|
85
|
+
"critical_issues": validation.critical_issues or 0,
|
|
86
|
+
"high_issues": validation.high_issues or 0,
|
|
87
|
+
"medium_issues": validation.medium_issues or 0,
|
|
88
|
+
"low_issues": validation.low_issues or 0,
|
|
89
|
+
"has_critical": validation.has_critical or False,
|
|
90
|
+
"has_high": validation.has_high or False,
|
|
91
|
+
},
|
|
92
|
+
"issues": issues,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Add statistics if requested
|
|
96
|
+
if include_statistics:
|
|
97
|
+
report_data["statistics"] = {
|
|
98
|
+
"row_count": validation.row_count,
|
|
99
|
+
"column_count": validation.column_count,
|
|
100
|
+
"duration_ms": validation.duration_ms,
|
|
101
|
+
"started_at": (
|
|
102
|
+
validation.started_at.isoformat()
|
|
103
|
+
if validation.started_at
|
|
104
|
+
else None
|
|
105
|
+
),
|
|
106
|
+
"completed_at": (
|
|
107
|
+
validation.completed_at.isoformat()
|
|
108
|
+
if validation.completed_at
|
|
109
|
+
else None
|
|
110
|
+
),
|
|
111
|
+
"created_at": (
|
|
112
|
+
validation.created_at.isoformat()
|
|
113
|
+
if validation.created_at
|
|
114
|
+
else None
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Add error info if present
|
|
119
|
+
if validation.error_message:
|
|
120
|
+
report_data["error"] = {
|
|
121
|
+
"message": validation.error_message,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return json.dumps(
|
|
125
|
+
report_data,
|
|
126
|
+
indent=self._indent,
|
|
127
|
+
ensure_ascii=self._ensure_ascii,
|
|
128
|
+
default=self._json_serializer,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _json_serializer(self, obj: Any) -> Any:
|
|
132
|
+
"""Custom JSON serializer for non-serializable objects.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
obj: Object to serialize.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Serializable representation.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
TypeError: If object cannot be serialized.
|
|
142
|
+
"""
|
|
143
|
+
if isinstance(obj, datetime):
|
|
144
|
+
return obj.isoformat()
|
|
145
|
+
if hasattr(obj, "to_dict"):
|
|
146
|
+
return obj.to_dict()
|
|
147
|
+
if hasattr(obj, "__dict__"):
|
|
148
|
+
return obj.__dict__
|
|
149
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CompactJSONReporter(JSONReporter):
|
|
153
|
+
"""Compact JSON reporter without formatting.
|
|
154
|
+
|
|
155
|
+
Produces minified JSON for reduced file size.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
"""Initialize compact JSON reporter."""
|
|
160
|
+
super().__init__(indent=None, ensure_ascii=False)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""JUnit XML report generator.
|
|
2
|
+
|
|
3
|
+
Generates JUnit-compatible XML reports for CI/CD integration.
|
|
4
|
+
Output can be consumed by Jenkins, GitHub Actions, GitLab CI, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from .base import Reporter, ReportFormat, ReportMetadata, ReportTheme
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from truthound_dashboard.db.models import Validation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JUnitReporter(Reporter):
|
|
20
|
+
"""JUnit XML report generator for CI/CD integration.
|
|
21
|
+
|
|
22
|
+
Produces JUnit-compatible XML that can be consumed by:
|
|
23
|
+
- Jenkins JUnit Plugin
|
|
24
|
+
- GitHub Actions
|
|
25
|
+
- GitLab CI
|
|
26
|
+
- Azure DevOps
|
|
27
|
+
- CircleCI
|
|
28
|
+
- Any tool supporting JUnit XML format
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def format(self) -> ReportFormat:
|
|
33
|
+
return ReportFormat.JUNIT
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def content_type(self) -> str:
|
|
37
|
+
return "application/xml; charset=utf-8"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def file_extension(self) -> str:
|
|
41
|
+
return ".xml"
|
|
42
|
+
|
|
43
|
+
async def _render_content(
|
|
44
|
+
self,
|
|
45
|
+
validation: Validation,
|
|
46
|
+
metadata: ReportMetadata,
|
|
47
|
+
include_samples: bool,
|
|
48
|
+
include_statistics: bool,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Render JUnit XML report content."""
|
|
51
|
+
issues = self._extract_issues(validation)
|
|
52
|
+
|
|
53
|
+
# Create root element (testsuites)
|
|
54
|
+
testsuites = ET.Element("testsuites")
|
|
55
|
+
testsuites.set("name", "Truthound Validation")
|
|
56
|
+
testsuites.set("tests", str(len(issues) + 1)) # +1 for overall test
|
|
57
|
+
testsuites.set("failures", str(validation.total_issues or 0))
|
|
58
|
+
testsuites.set("errors", str(validation.critical_issues or 0))
|
|
59
|
+
testsuites.set("time", str((validation.duration_ms or 0) / 1000))
|
|
60
|
+
|
|
61
|
+
# Create testsuite for this validation
|
|
62
|
+
testsuite = ET.SubElement(testsuites, "testsuite")
|
|
63
|
+
testsuite.set("name", f"Validation: {metadata.source_name or validation.source_id}")
|
|
64
|
+
testsuite.set("tests", str(len(issues) + 1))
|
|
65
|
+
testsuite.set("failures", str(validation.total_issues or 0))
|
|
66
|
+
testsuite.set("errors", str(validation.critical_issues or 0))
|
|
67
|
+
testsuite.set("time", str((validation.duration_ms or 0) / 1000))
|
|
68
|
+
testsuite.set("timestamp", validation.created_at.isoformat() if validation.created_at else datetime.utcnow().isoformat())
|
|
69
|
+
|
|
70
|
+
# Add properties
|
|
71
|
+
properties = ET.SubElement(testsuite, "properties")
|
|
72
|
+
self._add_property(properties, "source_id", validation.source_id)
|
|
73
|
+
self._add_property(properties, "validation_id", validation.id)
|
|
74
|
+
self._add_property(properties, "row_count", str(validation.row_count or 0))
|
|
75
|
+
self._add_property(properties, "column_count", str(validation.column_count or 0))
|
|
76
|
+
self._add_property(properties, "status", validation.status)
|
|
77
|
+
self._add_property(properties, "passed", str(validation.passed).lower())
|
|
78
|
+
|
|
79
|
+
# Add overall validation test case
|
|
80
|
+
overall_test = ET.SubElement(testsuite, "testcase")
|
|
81
|
+
overall_test.set("name", "Overall Validation")
|
|
82
|
+
overall_test.set("classname", f"truthound.{metadata.source_name or validation.source_id}")
|
|
83
|
+
overall_test.set("time", str((validation.duration_ms or 0) / 1000))
|
|
84
|
+
|
|
85
|
+
if not validation.passed:
|
|
86
|
+
failure = ET.SubElement(overall_test, "failure")
|
|
87
|
+
failure.set("message", f"Validation failed with {validation.total_issues} issues")
|
|
88
|
+
failure.set("type", "ValidationFailure")
|
|
89
|
+
failure.text = self._generate_failure_details(validation, issues)
|
|
90
|
+
|
|
91
|
+
# Add individual test cases for each issue type
|
|
92
|
+
issue_groups = self._group_issues_by_type(issues)
|
|
93
|
+
for issue_type, group_issues in issue_groups.items():
|
|
94
|
+
testcase = ET.SubElement(testsuite, "testcase")
|
|
95
|
+
testcase.set("name", f"Check: {issue_type}")
|
|
96
|
+
testcase.set("classname", f"truthound.validators.{issue_type}")
|
|
97
|
+
testcase.set("time", "0")
|
|
98
|
+
|
|
99
|
+
if group_issues:
|
|
100
|
+
# Determine severity for failure type
|
|
101
|
+
max_severity = max(
|
|
102
|
+
(self._severity_order(i.get("severity", "low")) for i in group_issues),
|
|
103
|
+
default=0
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if max_severity >= 3: # critical
|
|
107
|
+
error = ET.SubElement(testcase, "error")
|
|
108
|
+
error.set("message", f"Critical: {len(group_issues)} issues found")
|
|
109
|
+
error.set("type", "CriticalValidationError")
|
|
110
|
+
error.text = self._format_issues_detail(group_issues, include_samples)
|
|
111
|
+
else:
|
|
112
|
+
failure = ET.SubElement(testcase, "failure")
|
|
113
|
+
failure.set("message", f"{len(group_issues)} issues found")
|
|
114
|
+
failure.set("type", "ValidationFailure")
|
|
115
|
+
failure.text = self._format_issues_detail(group_issues, include_samples)
|
|
116
|
+
|
|
117
|
+
# Add system-out with summary
|
|
118
|
+
system_out = ET.SubElement(testsuite, "system-out")
|
|
119
|
+
system_out.text = self._generate_summary(validation, metadata)
|
|
120
|
+
|
|
121
|
+
# Convert to string with proper formatting
|
|
122
|
+
return self._prettify_xml(testsuites)
|
|
123
|
+
|
|
124
|
+
def _add_property(self, parent: ET.Element, name: str, value: str) -> None:
|
|
125
|
+
"""Add a property element."""
|
|
126
|
+
prop = ET.SubElement(parent, "property")
|
|
127
|
+
prop.set("name", name)
|
|
128
|
+
prop.set("value", value)
|
|
129
|
+
|
|
130
|
+
def _severity_order(self, severity: str) -> int:
|
|
131
|
+
"""Get numeric order for severity."""
|
|
132
|
+
order = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
133
|
+
return order.get(severity.lower(), 0)
|
|
134
|
+
|
|
135
|
+
def _group_issues_by_type(self, issues: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
136
|
+
"""Group issues by their type."""
|
|
137
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
138
|
+
for issue in issues:
|
|
139
|
+
issue_type = issue.get("issue_type", "unknown")
|
|
140
|
+
if issue_type not in groups:
|
|
141
|
+
groups[issue_type] = []
|
|
142
|
+
groups[issue_type].append(issue)
|
|
143
|
+
return groups
|
|
144
|
+
|
|
145
|
+
def _generate_failure_details(
|
|
146
|
+
self,
|
|
147
|
+
validation: Validation,
|
|
148
|
+
issues: list[dict[str, Any]],
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Generate detailed failure message."""
|
|
151
|
+
lines = [
|
|
152
|
+
f"Validation Status: {validation.status}",
|
|
153
|
+
f"Total Issues: {validation.total_issues}",
|
|
154
|
+
f"Critical: {validation.critical_issues}",
|
|
155
|
+
f"High: {validation.high_issues}",
|
|
156
|
+
f"Medium: {validation.medium_issues}",
|
|
157
|
+
f"Low: {validation.low_issues}",
|
|
158
|
+
"",
|
|
159
|
+
"Issue Summary:",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
for issue in issues[:10]: # Limit to first 10
|
|
163
|
+
lines.append(
|
|
164
|
+
f" - [{issue.get('severity', 'unknown').upper()}] "
|
|
165
|
+
f"{issue.get('column', 'N/A')}: {issue.get('issue_type', 'unknown')} "
|
|
166
|
+
f"({issue.get('count', 0)} occurrences)"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if len(issues) > 10:
|
|
170
|
+
lines.append(f" ... and {len(issues) - 10} more issues")
|
|
171
|
+
|
|
172
|
+
return "\n".join(lines)
|
|
173
|
+
|
|
174
|
+
def _format_issues_detail(
|
|
175
|
+
self,
|
|
176
|
+
issues: list[dict[str, Any]],
|
|
177
|
+
include_samples: bool,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Format issues for display in XML."""
|
|
180
|
+
lines = []
|
|
181
|
+
for issue in issues:
|
|
182
|
+
line = (
|
|
183
|
+
f"Column: {issue.get('column', 'N/A')}, "
|
|
184
|
+
f"Count: {issue.get('count', 0)}"
|
|
185
|
+
)
|
|
186
|
+
if issue.get("details"):
|
|
187
|
+
line += f", Details: {issue.get('details')}"
|
|
188
|
+
|
|
189
|
+
if include_samples and issue.get("sample_values"):
|
|
190
|
+
samples = [str(v)[:30] for v in issue["sample_values"][:3]]
|
|
191
|
+
line += f", Samples: [{', '.join(samples)}]"
|
|
192
|
+
|
|
193
|
+
lines.append(line)
|
|
194
|
+
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
def _generate_summary(
|
|
198
|
+
self,
|
|
199
|
+
validation: Validation,
|
|
200
|
+
metadata: ReportMetadata,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Generate summary for system-out."""
|
|
203
|
+
return f"""
|
|
204
|
+
Truthound Validation Report
|
|
205
|
+
===========================
|
|
206
|
+
Source: {metadata.source_name or validation.source_id}
|
|
207
|
+
Validation ID: {validation.id}
|
|
208
|
+
Generated: {metadata.generated_at.isoformat()}
|
|
209
|
+
|
|
210
|
+
Data Statistics:
|
|
211
|
+
Rows: {validation.row_count or 'N/A'}
|
|
212
|
+
Columns: {validation.column_count or 'N/A'}
|
|
213
|
+
|
|
214
|
+
Validation Results:
|
|
215
|
+
Status: {validation.status}
|
|
216
|
+
Passed: {validation.passed}
|
|
217
|
+
Duration: {(validation.duration_ms or 0) / 1000:.2f}s
|
|
218
|
+
|
|
219
|
+
Issue Summary:
|
|
220
|
+
Total: {validation.total_issues or 0}
|
|
221
|
+
Critical: {validation.critical_issues or 0}
|
|
222
|
+
High: {validation.high_issues or 0}
|
|
223
|
+
Medium: {validation.medium_issues or 0}
|
|
224
|
+
Low: {validation.low_issues or 0}
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def _prettify_xml(self, elem: ET.Element) -> str:
|
|
228
|
+
"""Return pretty-printed XML string."""
|
|
229
|
+
from xml.dom import minidom
|
|
230
|
+
|
|
231
|
+
rough_string = ET.tostring(elem, encoding="unicode", method="xml")
|
|
232
|
+
reparsed = minidom.parseString(rough_string)
|
|
233
|
+
return reparsed.toprettyxml(indent=" ", encoding=None)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Markdown report generator.
|
|
2
|
+
|
|
3
|
+
Generates Markdown reports suitable for documentation, GitHub,
|
|
4
|
+
and other platforms that render Markdown.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from .base import Reporter, ReportFormat, ReportMetadata, ReportTheme
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from truthound_dashboard.db.models import Validation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MarkdownReporter(Reporter):
|
|
18
|
+
"""Markdown report generator.
|
|
19
|
+
|
|
20
|
+
Produces GitHub-flavored Markdown reports with tables and badges.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, flavor: str = "github") -> None:
|
|
24
|
+
"""Initialize Markdown reporter.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
flavor: Markdown flavor (github, standard).
|
|
28
|
+
"""
|
|
29
|
+
self._flavor = flavor
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def format(self) -> ReportFormat:
|
|
33
|
+
return ReportFormat.MARKDOWN
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def content_type(self) -> str:
|
|
37
|
+
return "text/markdown; charset=utf-8"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def file_extension(self) -> str:
|
|
41
|
+
return ".md"
|
|
42
|
+
|
|
43
|
+
async def _render_content(
|
|
44
|
+
self,
|
|
45
|
+
validation: Validation,
|
|
46
|
+
metadata: ReportMetadata,
|
|
47
|
+
include_samples: bool,
|
|
48
|
+
include_statistics: bool,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Render Markdown report content."""
|
|
51
|
+
sections = []
|
|
52
|
+
|
|
53
|
+
# Header
|
|
54
|
+
sections.append(self._render_header(validation, metadata))
|
|
55
|
+
|
|
56
|
+
# Status badge
|
|
57
|
+
sections.append(self._render_status_badge(validation))
|
|
58
|
+
|
|
59
|
+
# Summary
|
|
60
|
+
sections.append(self._render_summary(validation))
|
|
61
|
+
|
|
62
|
+
# Statistics
|
|
63
|
+
if include_statistics:
|
|
64
|
+
sections.append(self._render_statistics(validation))
|
|
65
|
+
|
|
66
|
+
# Issues table
|
|
67
|
+
issues = self._extract_issues(validation)
|
|
68
|
+
sections.append(self._render_issues_table(issues, include_samples))
|
|
69
|
+
|
|
70
|
+
# Footer
|
|
71
|
+
sections.append(self._render_footer(metadata))
|
|
72
|
+
|
|
73
|
+
return "\n\n".join(filter(None, sections))
|
|
74
|
+
|
|
75
|
+
def _render_header(self, validation: Validation, metadata: ReportMetadata) -> str:
|
|
76
|
+
"""Render report header."""
|
|
77
|
+
source_name = metadata.source_name or validation.source_id
|
|
78
|
+
generated = metadata.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
79
|
+
|
|
80
|
+
return f"""# {metadata.title}
|
|
81
|
+
|
|
82
|
+
**Source:** {source_name}
|
|
83
|
+
**Generated:** {generated}
|
|
84
|
+
**Validation ID:** `{validation.id}`"""
|
|
85
|
+
|
|
86
|
+
def _render_status_badge(self, validation: Validation) -> str:
|
|
87
|
+
"""Render status badge."""
|
|
88
|
+
if validation.passed:
|
|
89
|
+
badge = ""
|
|
90
|
+
else:
|
|
91
|
+
badge = ""
|
|
92
|
+
|
|
93
|
+
return badge
|
|
94
|
+
|
|
95
|
+
def _render_summary(self, validation: Validation) -> str:
|
|
96
|
+
"""Render validation summary."""
|
|
97
|
+
return f"""## Summary
|
|
98
|
+
|
|
99
|
+
| Metric | Count |
|
|
100
|
+
|--------|-------|
|
|
101
|
+
| Total Issues | {validation.total_issues or 0} |
|
|
102
|
+
| Critical | {validation.critical_issues or 0} |
|
|
103
|
+
| High | {validation.high_issues or 0} |
|
|
104
|
+
| Medium | {validation.medium_issues or 0} |
|
|
105
|
+
| Low | {validation.low_issues or 0} |"""
|
|
106
|
+
|
|
107
|
+
def _render_statistics(self, validation: Validation) -> str:
|
|
108
|
+
"""Render data statistics."""
|
|
109
|
+
duration = validation.duration_ms
|
|
110
|
+
duration_str = f"{duration / 1000:.2f}s" if duration else "N/A"
|
|
111
|
+
|
|
112
|
+
started = (
|
|
113
|
+
validation.started_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
114
|
+
if validation.started_at
|
|
115
|
+
else "N/A"
|
|
116
|
+
)
|
|
117
|
+
completed = (
|
|
118
|
+
validation.completed_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
119
|
+
if validation.completed_at
|
|
120
|
+
else "N/A"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
row_count = f"{validation.row_count:,}" if validation.row_count else "N/A"
|
|
124
|
+
|
|
125
|
+
return f"""## Statistics
|
|
126
|
+
|
|
127
|
+
| Metric | Value |
|
|
128
|
+
|--------|-------|
|
|
129
|
+
| Row Count | {row_count} |
|
|
130
|
+
| Column Count | {validation.column_count or 'N/A'} |
|
|
131
|
+
| Duration | {duration_str} |
|
|
132
|
+
| Status | {validation.status} |
|
|
133
|
+
| Started At | {started} |
|
|
134
|
+
| Completed At | {completed} |"""
|
|
135
|
+
|
|
136
|
+
def _render_issues_table(
|
|
137
|
+
self, issues: list[dict[str, Any]], include_samples: bool
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Render issues as Markdown table."""
|
|
140
|
+
if not issues:
|
|
141
|
+
return """## Issues
|
|
142
|
+
|
|
143
|
+
No issues found. All validations passed."""
|
|
144
|
+
|
|
145
|
+
# Build table header
|
|
146
|
+
headers = ["Column", "Issue Type", "Severity", "Count", "Details"]
|
|
147
|
+
if include_samples:
|
|
148
|
+
headers.append("Samples")
|
|
149
|
+
|
|
150
|
+
header_row = "| " + " | ".join(headers) + " |"
|
|
151
|
+
separator = "| " + " | ".join(["---"] * len(headers)) + " |"
|
|
152
|
+
|
|
153
|
+
# Build rows
|
|
154
|
+
rows = []
|
|
155
|
+
for issue in issues:
|
|
156
|
+
severity = issue.get("severity", "medium")
|
|
157
|
+
severity_badge = self._get_severity_badge(severity)
|
|
158
|
+
|
|
159
|
+
row = [
|
|
160
|
+
f"`{issue.get('column', 'N/A')}`",
|
|
161
|
+
issue.get("issue_type", "Unknown"),
|
|
162
|
+
severity_badge,
|
|
163
|
+
str(issue.get("count", 0)),
|
|
164
|
+
(issue.get("details", "") or "")[:50],
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
if include_samples:
|
|
168
|
+
samples = issue.get("sample_values", [])
|
|
169
|
+
samples_str = ", ".join(str(v)[:20] for v in samples[:3])
|
|
170
|
+
if samples_str:
|
|
171
|
+
samples_str = f"`{samples_str}`"
|
|
172
|
+
row.append(samples_str or "-")
|
|
173
|
+
|
|
174
|
+
rows.append("| " + " | ".join(row) + " |")
|
|
175
|
+
|
|
176
|
+
return f"""## Issues ({len(issues)})
|
|
177
|
+
|
|
178
|
+
{header_row}
|
|
179
|
+
{separator}
|
|
180
|
+
{chr(10).join(rows)}"""
|
|
181
|
+
|
|
182
|
+
def _get_severity_badge(self, severity: str) -> str:
|
|
183
|
+
"""Get Markdown badge for severity level.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
severity: Severity level.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Markdown badge string.
|
|
190
|
+
"""
|
|
191
|
+
colors = {
|
|
192
|
+
"critical": "critical",
|
|
193
|
+
"high": "orange",
|
|
194
|
+
"medium": "yellow",
|
|
195
|
+
"low": "blue",
|
|
196
|
+
}
|
|
197
|
+
color = colors.get(severity.lower(), "lightgrey")
|
|
198
|
+
|
|
199
|
+
if self._flavor == "github":
|
|
200
|
+
return f""
|
|
201
|
+
return f"**{severity.upper()}**"
|
|
202
|
+
|
|
203
|
+
def _render_footer(self, metadata: ReportMetadata) -> str:
|
|
204
|
+
"""Render report footer."""
|
|
205
|
+
return """---
|
|
206
|
+
|
|
207
|
+
*Generated by [Truthound Dashboard](https://github.com/truthound/truthound-dashboard)*"""
|