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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
  162. truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
  169. {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 = "![Status](https://img.shields.io/badge/Status-PASSED-success)"
90
+ else:
91
+ badge = "![Status](https://img.shields.io/badge/Status-FAILED-critical)"
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"![{severity}](https://img.shields.io/badge/{severity}-{color})"
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)*"""