truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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 (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +437 -10
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +11 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,943 @@
1
+ """Reporter adapters for external backends.
2
+
3
+ This module provides adapters that wrap external reporter implementations
4
+ (e.g., truthound reporters) to conform to our ReporterProtocol interface.
5
+
6
+ The adapter pattern enables:
7
+ 1. Loose coupling with external dependencies
8
+ 2. Easy testing with mock implementations
9
+ 3. Graceful fallback when external libraries unavailable
10
+ 4. Version compatibility across truthound updates
11
+
12
+ Example:
13
+ from truthound_dashboard.core.reporters.adapters import (
14
+ TruthoundReporterAdapter,
15
+ create_truthound_reporter,
16
+ )
17
+
18
+ # Using the factory function
19
+ reporter = create_truthound_reporter("json")
20
+ output = await reporter.generate(data)
21
+
22
+ # Using the adapter directly
23
+ from truthound.reporters import get_reporter
24
+ th_reporter = get_reporter("json")
25
+ adapter = TruthoundReporterAdapter(th_reporter)
26
+ output = await adapter.generate(data)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ from concurrent.futures import ThreadPoolExecutor
34
+ from datetime import datetime
35
+ from functools import partial
36
+ from typing import TYPE_CHECKING, Any, Callable
37
+
38
+ from .interfaces import (
39
+ BaseReporter,
40
+ ReportData,
41
+ ReporterConfig,
42
+ ReporterProtocol,
43
+ ReportFormatType,
44
+ ReportOutput,
45
+ ReportThemeType,
46
+ ValidationIssueData,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ pass
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # Thread pool for running sync truthound reporters
55
+ _executor: ThreadPoolExecutor | None = None
56
+
57
+
58
+ def _get_executor() -> ThreadPoolExecutor:
59
+ """Get shared thread pool executor."""
60
+ global _executor
61
+ if _executor is None:
62
+ _executor = ThreadPoolExecutor(max_workers=4)
63
+ return _executor
64
+
65
+
66
+ def _shutdown_executor() -> None:
67
+ """Shutdown the executor (for testing/cleanup)."""
68
+ global _executor
69
+ if _executor is not None:
70
+ _executor.shutdown(wait=False)
71
+ _executor = None
72
+
73
+
74
+ class TruthoundReporterAdapter(BaseReporter[ReporterConfig]):
75
+ """Adapter for truthound's reporter implementations.
76
+
77
+ This adapter wraps truthound reporters to conform to our ReporterProtocol,
78
+ enabling seamless integration with the dashboard's report generation system.
79
+
80
+ The adapter handles:
81
+ 1. Data conversion from ReportData to truthound's expected format
82
+ 2. Config translation between our ReporterConfig and truthound's config
83
+ 3. Async execution of synchronous truthound reporters
84
+ 4. Format and content type mapping
85
+
86
+ Example:
87
+ from truthound.reporters import get_reporter
88
+
89
+ # Wrap a truthound reporter
90
+ th_reporter = get_reporter("json")
91
+ adapter = TruthoundReporterAdapter(th_reporter)
92
+
93
+ # Generate report using our interface
94
+ output = await adapter.generate(report_data)
95
+ """
96
+
97
+ # Mapping from truthound format names to our ReportFormatType
98
+ FORMAT_MAPPING: dict[str, ReportFormatType] = {
99
+ "json": ReportFormatType.JSON,
100
+ "html": ReportFormatType.HTML,
101
+ "csv": ReportFormatType.CSV,
102
+ "yaml": ReportFormatType.YAML,
103
+ "ndjson": ReportFormatType.NDJSON,
104
+ "console": ReportFormatType.CONSOLE,
105
+ }
106
+
107
+ # Content type mapping
108
+ CONTENT_TYPE_MAPPING: dict[ReportFormatType, str] = {
109
+ ReportFormatType.JSON: "application/json; charset=utf-8",
110
+ ReportFormatType.HTML: "text/html; charset=utf-8",
111
+ ReportFormatType.CSV: "text/csv; charset=utf-8",
112
+ ReportFormatType.YAML: "application/x-yaml; charset=utf-8",
113
+ ReportFormatType.NDJSON: "application/x-ndjson; charset=utf-8",
114
+ ReportFormatType.CONSOLE: "text/plain; charset=utf-8",
115
+ }
116
+
117
+ # File extension mapping
118
+ EXTENSION_MAPPING: dict[ReportFormatType, str] = {
119
+ ReportFormatType.JSON: ".json",
120
+ ReportFormatType.HTML: ".html",
121
+ ReportFormatType.CSV: ".csv",
122
+ ReportFormatType.YAML: ".yaml",
123
+ ReportFormatType.NDJSON: ".ndjson",
124
+ ReportFormatType.CONSOLE: ".txt",
125
+ }
126
+
127
+ def __init__(
128
+ self,
129
+ truthound_reporter: Any,
130
+ format_override: ReportFormatType | None = None,
131
+ ) -> None:
132
+ """Initialize adapter.
133
+
134
+ Args:
135
+ truthound_reporter: A truthound reporter instance
136
+ (e.g., JSONReporter, HTMLReporter from truthound.reporters).
137
+ format_override: Override the detected format. Useful when
138
+ the truthound reporter doesn't expose format information.
139
+ """
140
+ super().__init__()
141
+ self._th_reporter = truthound_reporter
142
+ self._format_override = format_override
143
+
144
+ # Try to detect format from truthound reporter
145
+ self._detected_format = self._detect_format()
146
+
147
+ def _detect_format(self) -> ReportFormatType:
148
+ """Detect the format from the truthound reporter."""
149
+ if self._format_override:
150
+ return self._format_override
151
+
152
+ # Try getting format from truthound reporter
153
+ if hasattr(self._th_reporter, "name"):
154
+ name = self._th_reporter.name.lower()
155
+ if name in self.FORMAT_MAPPING:
156
+ return self.FORMAT_MAPPING[name]
157
+
158
+ # Try from file_extension
159
+ if hasattr(self._th_reporter, "file_extension"):
160
+ ext = self._th_reporter.file_extension.lower()
161
+ for fmt, mapped_ext in self.EXTENSION_MAPPING.items():
162
+ if ext == mapped_ext or ext == mapped_ext[1:]:
163
+ return fmt
164
+
165
+ # Default to JSON
166
+ logger.warning(
167
+ f"Could not detect format for {type(self._th_reporter).__name__}, "
168
+ "defaulting to JSON"
169
+ )
170
+ return ReportFormatType.JSON
171
+
172
+ @property
173
+ def format(self) -> ReportFormatType:
174
+ """Get the report format."""
175
+ return self._detected_format
176
+
177
+ @property
178
+ def content_type(self) -> str:
179
+ """Get the MIME content type."""
180
+ # Try to get from truthound reporter first
181
+ if hasattr(self._th_reporter, "content_type"):
182
+ return self._th_reporter.content_type
183
+ return self.CONTENT_TYPE_MAPPING.get(
184
+ self._detected_format,
185
+ "application/octet-stream",
186
+ )
187
+
188
+ @property
189
+ def file_extension(self) -> str:
190
+ """Get the file extension."""
191
+ # Try to get from truthound reporter first
192
+ if hasattr(self._th_reporter, "file_extension"):
193
+ return self._th_reporter.file_extension
194
+ return self.EXTENSION_MAPPING.get(self._detected_format, ".txt")
195
+
196
+ async def _render_content(
197
+ self,
198
+ data: ReportData,
199
+ config: ReporterConfig,
200
+ ) -> str | bytes:
201
+ """Render content using the truthound reporter.
202
+
203
+ This method converts our ReportData to the format expected by
204
+ truthound reporters and executes the rendering in a thread pool.
205
+ """
206
+ # Convert ReportData to format expected by truthound
207
+ th_input = self._convert_to_truthound_input(data, config)
208
+
209
+ # Run truthound reporter in thread pool (it's synchronous)
210
+ loop = asyncio.get_event_loop()
211
+ executor = _get_executor()
212
+
213
+ try:
214
+ # Try the new truthound API first (render method)
215
+ if hasattr(self._th_reporter, "render"):
216
+ func = partial(self._th_reporter.render, th_input)
217
+ result = await loop.run_in_executor(executor, func)
218
+ return result
219
+
220
+ # Fall back to __call__ method
221
+ if callable(self._th_reporter):
222
+ func = partial(self._th_reporter, th_input)
223
+ result = await loop.run_in_executor(executor, func)
224
+ return result
225
+
226
+ raise ValueError(
227
+ f"Truthound reporter {type(self._th_reporter).__name__} "
228
+ "does not have a render() method or is not callable"
229
+ )
230
+
231
+ except Exception as e:
232
+ logger.error(f"Error rendering with truthound reporter: {e}")
233
+ # Return a fallback output
234
+ return self._render_fallback(data, config, str(e))
235
+
236
+ def _convert_to_truthound_input(
237
+ self,
238
+ data: ReportData,
239
+ config: ReporterConfig,
240
+ ) -> Any:
241
+ """Convert ReportData to truthound's expected input format.
242
+
243
+ Truthound reporters expect a ValidationResult object from
244
+ truthound.stores.results with these attributes:
245
+ - run_id: str
246
+ - run_time: datetime
247
+ - data_asset: str
248
+ - status: ResultStatus
249
+ - results: list[ValidatorResult]
250
+ - statistics: ResultStatistics
251
+ - tags: dict
252
+
253
+ Since we don't have direct access to these, we create a
254
+ mock object that provides the same interface.
255
+ """
256
+ # If raw_data is available and is a truthound Report or ValidationResult, use it
257
+ if data.raw_data is not None:
258
+ # Check if it looks like a ValidationResult (preferred by reporters)
259
+ if hasattr(data.raw_data, "results") and hasattr(data.raw_data, "run_id"):
260
+ return data.raw_data
261
+ # Check if it looks like a truthound Report (some reporters accept this)
262
+ if hasattr(data.raw_data, "issues") and hasattr(data.raw_data, "has_issues"):
263
+ return data.raw_data
264
+
265
+ # Create a mock ValidationResult object that provides the truthound interface
266
+ return _TruthoundValidationResultMock(data, config)
267
+
268
+ def _render_fallback(
269
+ self,
270
+ data: ReportData,
271
+ config: ReporterConfig,
272
+ error: str,
273
+ ) -> str:
274
+ """Render a fallback output when truthound reporter fails."""
275
+ if self._detected_format == ReportFormatType.JSON:
276
+ import json
277
+
278
+ return json.dumps(
279
+ {
280
+ "error": error,
281
+ "fallback": True,
282
+ "data": data.to_dict(),
283
+ },
284
+ indent=2,
285
+ )
286
+ elif self._detected_format == ReportFormatType.HTML:
287
+ return f"""
288
+ <html>
289
+ <head><title>Report Error</title></head>
290
+ <body>
291
+ <h1>Report Generation Error</h1>
292
+ <p>Error: {error}</p>
293
+ <h2>Validation ID: {data.validation_id}</h2>
294
+ <h2>Issues: {data.summary.total_issues}</h2>
295
+ </body>
296
+ </html>
297
+ """
298
+ else:
299
+ return f"Report generation error: {error}\nValidation ID: {data.validation_id}"
300
+
301
+
302
+ class _TruthoundReportMock:
303
+ """Mock object that mimics truthound's Report interface.
304
+
305
+ This allows us to use truthound reporters without having
306
+ a real truthound Report object.
307
+ """
308
+
309
+ def __init__(self, data: ReportData, config: ReporterConfig) -> None:
310
+ self._data = data
311
+ self._config = config
312
+
313
+ # Convert issues to mock ValidationIssue objects
314
+ self._issues = [
315
+ _MockValidationIssue(issue) for issue in data.issues
316
+ ]
317
+
318
+ @property
319
+ def issues(self) -> list[_MockValidationIssue]:
320
+ return self._issues
321
+
322
+ @property
323
+ def source(self) -> str:
324
+ return self._data.source_name or self._data.source_id
325
+
326
+ @property
327
+ def row_count(self) -> int:
328
+ return self._data.statistics.row_count or 0
329
+
330
+ @property
331
+ def column_count(self) -> int:
332
+ return self._data.statistics.column_count or 0
333
+
334
+ @property
335
+ def has_issues(self) -> bool:
336
+ return self._data.summary.total_issues > 0
337
+
338
+ @property
339
+ def has_critical(self) -> bool:
340
+ return self._data.summary.has_critical
341
+
342
+ @property
343
+ def has_high(self) -> bool:
344
+ return self._data.summary.has_high
345
+
346
+ def to_json(self, indent: int | None = 2) -> str:
347
+ """Convert to JSON string."""
348
+ import json
349
+
350
+ return json.dumps(self._data.to_dict(), indent=indent)
351
+
352
+ def to_dict(self) -> dict[str, Any]:
353
+ """Convert to dictionary."""
354
+ return self._data.to_dict()
355
+
356
+
357
+ class _MockValidationIssue:
358
+ """Mock ValidationIssue for truthound reporters."""
359
+
360
+ def __init__(self, issue: ValidationIssueData) -> None:
361
+ self._issue = issue
362
+
363
+ @property
364
+ def column(self) -> str | None:
365
+ return self._issue.column
366
+
367
+ @property
368
+ def issue_type(self) -> str:
369
+ return self._issue.issue_type
370
+
371
+ @property
372
+ def severity(self) -> _MockSeverity:
373
+ return _MockSeverity(self._issue.severity)
374
+
375
+ @property
376
+ def message(self) -> str:
377
+ return self._issue.message
378
+
379
+ @property
380
+ def count(self) -> int:
381
+ return self._issue.count
382
+
383
+ @property
384
+ def expected(self) -> Any:
385
+ return self._issue.expected
386
+
387
+ @property
388
+ def actual(self) -> Any:
389
+ return self._issue.actual
390
+
391
+ @property
392
+ def details(self) -> dict[str, Any] | None:
393
+ return self._issue.details
394
+
395
+
396
+ class _MockSeverity:
397
+ """Mock Severity enum for truthound reporters."""
398
+
399
+ def __init__(self, value: str) -> None:
400
+ self._value = value.lower()
401
+
402
+ @property
403
+ def value(self) -> str:
404
+ return self._value
405
+
406
+ def __str__(self) -> str:
407
+ return self._value
408
+
409
+
410
+ class _TruthoundValidationResultMock:
411
+ """Mock object that mimics truthound's ValidationResult interface.
412
+
413
+ Truthound reporters expect ValidationResult which has:
414
+ - run_id: str
415
+ - run_time: datetime
416
+ - data_asset: str
417
+ - status: ResultStatus
418
+ - results: list[ValidatorResult]
419
+ - statistics: ResultStatistics
420
+ - tags: dict
421
+ - success: bool
422
+ """
423
+
424
+ def __init__(self, data: ReportData, config: ReporterConfig) -> None:
425
+ from datetime import datetime as dt
426
+
427
+ self._data = data
428
+ self._config = config
429
+
430
+ # Convert issues to mock ValidatorResult objects
431
+ self._results = [
432
+ _MockValidatorResult(issue) for issue in data.issues
433
+ ]
434
+ self._statistics = _MockResultStatistics(data)
435
+
436
+ @property
437
+ def run_id(self) -> str:
438
+ return self._data.validation_id
439
+
440
+ @property
441
+ def run_time(self) -> Any:
442
+ from datetime import datetime as dt
443
+
444
+ if self._data.statistics.started_at:
445
+ return self._data.statistics.started_at
446
+ return dt.utcnow()
447
+
448
+ @property
449
+ def data_asset(self) -> str:
450
+ return self._data.source_name or self._data.source_id
451
+
452
+ @property
453
+ def status(self) -> _MockResultStatus:
454
+ return _MockResultStatus(self._data.summary.passed)
455
+
456
+ @property
457
+ def success(self) -> bool:
458
+ """Whether the validation passed."""
459
+ return self._data.summary.passed
460
+
461
+ @property
462
+ def results(self) -> list[_MockValidatorResult]:
463
+ return self._results
464
+
465
+ @property
466
+ def statistics(self) -> _MockResultStatistics:
467
+ return self._statistics
468
+
469
+ @property
470
+ def tags(self) -> dict[str, Any]:
471
+ return self._data.metadata
472
+
473
+ # Additional properties that truthound reporters might expect
474
+ @property
475
+ def suite_name(self) -> str:
476
+ """Test suite name for JUnit-style reporters."""
477
+ return self._config.title or "Truthound Validation"
478
+
479
+ @property
480
+ def source(self) -> str:
481
+ """Alias for data_asset."""
482
+ return self.data_asset
483
+
484
+ @property
485
+ def row_count(self) -> int:
486
+ """Row count for Report-style access."""
487
+ return self._data.statistics.row_count or 0
488
+
489
+ @property
490
+ def column_count(self) -> int:
491
+ """Column count for Report-style access."""
492
+ return self._data.statistics.column_count or 0
493
+
494
+ @property
495
+ def issues(self) -> list[_MockValidatorResult]:
496
+ """Alias for results (Report-style access)."""
497
+ return self._results
498
+
499
+ @property
500
+ def has_issues(self) -> bool:
501
+ """Report-style check for issues."""
502
+ return self._data.summary.total_issues > 0
503
+
504
+ @property
505
+ def has_critical(self) -> bool:
506
+ """Report-style check for critical issues."""
507
+ return self._data.summary.has_critical
508
+
509
+ @property
510
+ def has_high(self) -> bool:
511
+ """Report-style check for high severity issues."""
512
+ return self._data.summary.has_high
513
+
514
+ def to_dict(self) -> dict[str, Any]:
515
+ """Convert to dictionary."""
516
+ return {
517
+ "run_id": self.run_id,
518
+ "run_time": self.run_time.isoformat() if hasattr(self.run_time, 'isoformat') else str(self.run_time),
519
+ "data_asset": self.data_asset,
520
+ "status": self.status.value,
521
+ "success": self.success,
522
+ "results": [r.to_dict() for r in self.results],
523
+ "statistics": self.statistics.to_dict(),
524
+ "tags": self.tags,
525
+ }
526
+
527
+ def to_json(self, indent: int | None = 2) -> str:
528
+ """Convert to JSON string (Report-style)."""
529
+ import json
530
+
531
+ return json.dumps(self.to_dict(), indent=indent, default=str)
532
+
533
+
534
+ class _MockResultStatus:
535
+ """Mock ResultStatus enum."""
536
+
537
+ def __init__(self, passed: bool) -> None:
538
+ self._passed = passed
539
+
540
+ @property
541
+ def value(self) -> str:
542
+ return "SUCCESS" if self._passed else "FAILURE"
543
+
544
+ def __str__(self) -> str:
545
+ return self.value
546
+
547
+
548
+ class _MockResultStatistics:
549
+ """Mock ResultStatistics object."""
550
+
551
+ def __init__(self, data: ReportData) -> None:
552
+ self._data = data
553
+
554
+ @property
555
+ def total_issues(self) -> int:
556
+ return self._data.summary.total_issues
557
+
558
+ @property
559
+ def total_rows(self) -> int:
560
+ return self._data.statistics.row_count or 0
561
+
562
+ @property
563
+ def total_columns(self) -> int:
564
+ return self._data.statistics.column_count or 0
565
+
566
+ @property
567
+ def critical_count(self) -> int:
568
+ return self._data.summary.critical_issues
569
+
570
+ @property
571
+ def high_count(self) -> int:
572
+ return self._data.summary.high_issues
573
+
574
+ @property
575
+ def medium_count(self) -> int:
576
+ return self._data.summary.medium_issues
577
+
578
+ @property
579
+ def low_count(self) -> int:
580
+ return self._data.summary.low_issues
581
+
582
+ @property
583
+ def passed(self) -> bool:
584
+ return self._data.summary.passed
585
+
586
+ def to_dict(self) -> dict[str, Any]:
587
+ return {
588
+ "total_issues": self.total_issues,
589
+ "total_rows": self.total_rows,
590
+ "total_columns": self.total_columns,
591
+ "critical_count": self.critical_count,
592
+ "high_count": self.high_count,
593
+ "medium_count": self.medium_count,
594
+ "low_count": self.low_count,
595
+ "passed": self.passed,
596
+ }
597
+
598
+
599
+ class _MockValidatorResult:
600
+ """Mock ValidatorResult object."""
601
+
602
+ def __init__(self, issue: ValidationIssueData) -> None:
603
+ self._issue = issue
604
+
605
+ @property
606
+ def validator_name(self) -> str:
607
+ return self._issue.validator_name or self._issue.issue_type
608
+
609
+ @property
610
+ def column(self) -> str | None:
611
+ return self._issue.column
612
+
613
+ @property
614
+ def issue_type(self) -> str:
615
+ return self._issue.issue_type
616
+
617
+ @property
618
+ def severity(self) -> _MockSeverity:
619
+ return _MockSeverity(self._issue.severity)
620
+
621
+ @property
622
+ def message(self) -> str:
623
+ return self._issue.message
624
+
625
+ @property
626
+ def count(self) -> int:
627
+ return self._issue.count
628
+
629
+ @property
630
+ def success(self) -> bool:
631
+ return False # All issues are failures
632
+
633
+ @property
634
+ def expected(self) -> Any:
635
+ return self._issue.expected
636
+
637
+ @property
638
+ def actual(self) -> Any:
639
+ return self._issue.actual
640
+
641
+ @property
642
+ def details(self) -> dict[str, Any]:
643
+ return self._issue.details or {}
644
+
645
+ @property
646
+ def sample_values(self) -> list[Any]:
647
+ return self._issue.sample_values or []
648
+
649
+ def to_dict(self) -> dict[str, Any]:
650
+ return {
651
+ "validator_name": self.validator_name,
652
+ "column": self.column,
653
+ "issue_type": self.issue_type,
654
+ "severity": self.severity.value,
655
+ "message": self.message,
656
+ "count": self.count,
657
+ "success": self.success,
658
+ "expected": self.expected,
659
+ "actual": self.actual,
660
+ "details": self.details,
661
+ "sample_values": self.sample_values,
662
+ }
663
+
664
+
665
+ def create_truthound_reporter(
666
+ format_name: str,
667
+ locale: str = "en",
668
+ **config_options: Any,
669
+ ) -> TruthoundReporterAdapter | None:
670
+ """Factory function to create a truthound reporter adapter.
671
+
672
+ This function attempts to import and instantiate a truthound reporter,
673
+ returning None if truthound is not available.
674
+
675
+ Args:
676
+ format_name: Format name (e.g., 'json', 'html', 'markdown').
677
+ locale: Locale for i18n (e.g., 'en', 'ko', 'ja').
678
+ **config_options: Additional configuration passed to truthound reporter.
679
+
680
+ Returns:
681
+ TruthoundReporterAdapter or None if truthound unavailable.
682
+
683
+ Example:
684
+ reporter = create_truthound_reporter("json")
685
+ if reporter:
686
+ output = await reporter.generate(data)
687
+ """
688
+ try:
689
+ from truthound.reporters import get_reporter
690
+
691
+ # Get truthound reporter with locale support
692
+ try:
693
+ th_reporter = get_reporter(format_name, locale=locale, **config_options)
694
+ except TypeError:
695
+ # Fallback for reporters without locale support
696
+ th_reporter = get_reporter(format_name, **config_options)
697
+
698
+ return TruthoundReporterAdapter(th_reporter)
699
+
700
+ except ImportError:
701
+ logger.warning(
702
+ f"truthound.reporters not available, cannot create {format_name} reporter"
703
+ )
704
+ return None
705
+ except ValueError as e:
706
+ logger.warning(f"Failed to create truthound reporter for {format_name}: {e}")
707
+ return None
708
+
709
+
710
+ def is_truthound_available() -> bool:
711
+ """Check if truthound reporters are available.
712
+
713
+ Returns:
714
+ True if truthound.reporters can be imported.
715
+ """
716
+ try:
717
+ from truthound.reporters import get_reporter # noqa: F401
718
+
719
+ return True
720
+ except ImportError:
721
+ return False
722
+
723
+
724
+ def get_truthound_formats() -> list[str]:
725
+ """Get list of available truthound report formats.
726
+
727
+ Returns:
728
+ List of format names available in truthound, or empty list if
729
+ truthound is not available.
730
+ """
731
+ try:
732
+ from truthound.reporters.factory import list_available_formats
733
+
734
+ return list_available_formats()
735
+ except ImportError:
736
+ return []
737
+
738
+
739
+ def create_ci_reporter(
740
+ platform: str | None = None,
741
+ **config_options: Any,
742
+ ) -> TruthoundReporterAdapter | None:
743
+ """Create a CI platform reporter adapter.
744
+
745
+ This function creates a reporter for CI/CD platforms. If no platform
746
+ is specified, it attempts to auto-detect the current CI environment.
747
+
748
+ Supported platforms:
749
+ - github: GitHub Actions (::error::, ::warning::, step summary)
750
+ - gitlab: GitLab CI (section markers, ANSI colors)
751
+ - jenkins: Jenkins (JUnit XML compatible)
752
+ - azure: Azure DevOps (##vso commands)
753
+ - circleci: CircleCI
754
+ - bitbucket: Bitbucket Pipelines
755
+ - travis: Travis CI
756
+ - teamcity: TeamCity (service messages)
757
+ - buildkite: Buildkite (annotations)
758
+ - drone: Drone CI
759
+
760
+ Args:
761
+ platform: CI platform name, or None for auto-detection.
762
+ **config_options: Additional configuration passed to truthound reporter.
763
+
764
+ Returns:
765
+ TruthoundReporterAdapter for the CI platform, or None if unavailable.
766
+
767
+ Example:
768
+ # Auto-detect CI platform
769
+ reporter = create_ci_reporter()
770
+
771
+ # Specific platform
772
+ reporter = create_ci_reporter("github")
773
+ """
774
+ try:
775
+ # Try to auto-detect if no platform specified
776
+ if platform is None:
777
+ from truthound.checkpoint.ci import detect_ci_platform
778
+
779
+ detected = detect_ci_platform()
780
+ if detected:
781
+ platform = detected.value.lower()
782
+ else:
783
+ logger.debug("No CI platform detected")
784
+ return None
785
+ else:
786
+ platform = platform.lower()
787
+
788
+ # Try new CI reporters API
789
+ try:
790
+ from truthound.reporters.ci import get_ci_reporter
791
+
792
+ ci_reporter = get_ci_reporter(platform, **config_options)
793
+ return TruthoundReporterAdapter(
794
+ ci_reporter,
795
+ format_override=_get_ci_format_type(platform),
796
+ )
797
+ except (ImportError, AttributeError):
798
+ pass
799
+
800
+ # Fallback: Try the generic CI reporter factory
801
+ try:
802
+ from truthound.reporters import get_reporter
803
+
804
+ ci_reporter = get_reporter(platform, **config_options)
805
+ return TruthoundReporterAdapter(
806
+ ci_reporter,
807
+ format_override=_get_ci_format_type(platform),
808
+ )
809
+ except ValueError:
810
+ logger.warning(f"CI reporter for {platform} not available")
811
+ return None
812
+
813
+ except ImportError:
814
+ logger.warning("truthound CI reporters not available")
815
+ return None
816
+
817
+
818
+ def _get_ci_format_type(platform: str) -> ReportFormatType:
819
+ """Map CI platform name to ReportFormatType."""
820
+ platform_lower = platform.lower()
821
+ mapping = {
822
+ "github": ReportFormatType.GITHUB,
823
+ "gitlab": ReportFormatType.GITLAB,
824
+ "jenkins": ReportFormatType.JENKINS,
825
+ "azure": ReportFormatType.AZURE,
826
+ "circleci": ReportFormatType.CIRCLECI,
827
+ "bitbucket": ReportFormatType.BITBUCKET,
828
+ "travis": ReportFormatType.TRAVIS,
829
+ "teamcity": ReportFormatType.TEAMCITY,
830
+ "buildkite": ReportFormatType.BUILDKITE,
831
+ "drone": ReportFormatType.DRONE,
832
+ }
833
+ return mapping.get(platform_lower, ReportFormatType.CI)
834
+
835
+
836
+ def is_ci_environment() -> bool:
837
+ """Check if running in a CI environment.
838
+
839
+ Returns:
840
+ True if a CI environment is detected.
841
+ """
842
+ try:
843
+ from truthound.checkpoint.ci import is_ci_environment as _is_ci
844
+
845
+ return _is_ci()
846
+ except ImportError:
847
+ # Fallback: check common CI environment variables
848
+ import os
849
+
850
+ ci_vars = [
851
+ "CI",
852
+ "GITHUB_ACTIONS",
853
+ "GITLAB_CI",
854
+ "JENKINS_URL",
855
+ "CIRCLECI",
856
+ "BITBUCKET_BUILD_NUMBER",
857
+ "TRAVIS",
858
+ "TEAMCITY_VERSION",
859
+ "BUILDKITE",
860
+ "DRONE",
861
+ "TF_BUILD", # Azure DevOps
862
+ "CODEBUILD_BUILD_ID", # AWS CodeBuild
863
+ ]
864
+ return any(os.environ.get(var) for var in ci_vars)
865
+
866
+
867
+ def get_detected_ci_platform() -> str | None:
868
+ """Detect the current CI platform.
869
+
870
+ Returns:
871
+ Platform name string or None if not in CI.
872
+ """
873
+ try:
874
+ from truthound.checkpoint.ci import detect_ci_platform
875
+
876
+ platform = detect_ci_platform()
877
+ return platform.value.lower() if platform else None
878
+ except ImportError:
879
+ # Fallback detection
880
+ import os
881
+
882
+ if os.environ.get("GITHUB_ACTIONS"):
883
+ return "github"
884
+ if os.environ.get("GITLAB_CI"):
885
+ return "gitlab"
886
+ if os.environ.get("JENKINS_URL"):
887
+ return "jenkins"
888
+ if os.environ.get("TF_BUILD"):
889
+ return "azure"
890
+ if os.environ.get("CIRCLECI"):
891
+ return "circleci"
892
+ if os.environ.get("BITBUCKET_BUILD_NUMBER"):
893
+ return "bitbucket"
894
+ if os.environ.get("TRAVIS"):
895
+ return "travis"
896
+ if os.environ.get("TEAMCITY_VERSION"):
897
+ return "teamcity"
898
+ if os.environ.get("BUILDKITE"):
899
+ return "buildkite"
900
+ if os.environ.get("DRONE"):
901
+ return "drone"
902
+ return None
903
+
904
+
905
+ class ValidationModelAdapter:
906
+ """Adapter for converting database Validation models to ReportData.
907
+
908
+ This adapter handles the conversion from our SQLAlchemy Validation
909
+ model to the backend-agnostic ReportData format.
910
+
911
+ Example:
912
+ validation = await service.get_validation(validation_id)
913
+ data = ValidationModelAdapter.to_report_data(validation)
914
+ output = await reporter.generate(data)
915
+ """
916
+
917
+ @staticmethod
918
+ def to_report_data(validation: Any) -> ReportData:
919
+ """Convert a Validation model to ReportData.
920
+
921
+ Args:
922
+ validation: Validation model from database.
923
+
924
+ Returns:
925
+ ReportData instance.
926
+ """
927
+ return ReportData.from_validation_model(validation)
928
+
929
+ @staticmethod
930
+ def to_truthound_result(validation: Any) -> Any:
931
+ """Convert a Validation model to a truthound-compatible result.
932
+
933
+ This is useful when you need to use truthound reporters directly
934
+ without going through our adapter.
935
+
936
+ Args:
937
+ validation: Validation model from database.
938
+
939
+ Returns:
940
+ Object that mimics truthound's ValidationResult interface.
941
+ """
942
+ data = ReportData.from_validation_model(validation)
943
+ return _TruthoundReportMock(data, ReporterConfig())