truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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 (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 +645 -23
  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 +15 -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.1.dist-info/METADATA +312 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.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.1.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,650 @@
1
+ """Reporter interfaces for validation result reporting.
2
+
3
+ Reporters generate formatted reports from validation results.
4
+ They support multiple output formats (HTML, CSV, JSON)
5
+ and can be customized for different use cases.
6
+
7
+ This module defines abstract interfaces for reporters that are loosely
8
+ coupled from truthound's reporters module.
9
+
10
+ Reporter features:
11
+ - Multiple output formats
12
+ - Template-based customization
13
+ - Localization support
14
+ - Custom reporter plugins
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from enum import Enum
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
25
+
26
+ if TYPE_CHECKING:
27
+ from truthound_dashboard.core.interfaces.checkpoint import CheckpointResult
28
+
29
+
30
+ class ReportFormat(str, Enum):
31
+ """Supported report formats."""
32
+
33
+ HTML = "html"
34
+ CSV = "csv"
35
+ JSON = "json"
36
+ SLACK = "slack" # Slack-formatted blocks
37
+ TEXT = "text"
38
+
39
+
40
+ @dataclass
41
+ class ReporterConfig:
42
+ """Configuration for report generation.
43
+
44
+ Attributes:
45
+ format: Output format.
46
+ template: Template name or path.
47
+ locale: Locale for localization.
48
+ title: Report title.
49
+ description: Report description.
50
+ include_summary: Include summary section.
51
+ include_issues: Include issues detail.
52
+ include_statistics: Include statistics.
53
+ include_charts: Include visualizations.
54
+ max_issues: Maximum issues to include.
55
+ output_path: Path for file output.
56
+ metadata: Additional metadata.
57
+ """
58
+
59
+ format: ReportFormat = ReportFormat.HTML
60
+ template: str | None = None
61
+ locale: str = "en"
62
+ title: str = "Validation Report"
63
+ description: str = ""
64
+ include_summary: bool = True
65
+ include_issues: bool = True
66
+ include_statistics: bool = True
67
+ include_charts: bool = True
68
+ max_issues: int = 1000
69
+ output_path: str | None = None
70
+ metadata: dict[str, Any] = field(default_factory=dict)
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ """Convert to dictionary."""
74
+ return {
75
+ "format": self.format.value,
76
+ "template": self.template,
77
+ "locale": self.locale,
78
+ "title": self.title,
79
+ "description": self.description,
80
+ "include_summary": self.include_summary,
81
+ "include_issues": self.include_issues,
82
+ "include_statistics": self.include_statistics,
83
+ "include_charts": self.include_charts,
84
+ "max_issues": self.max_issues,
85
+ "output_path": self.output_path,
86
+ "metadata": self.metadata,
87
+ }
88
+
89
+ @classmethod
90
+ def from_dict(cls, data: dict[str, Any]) -> "ReporterConfig":
91
+ """Create from dictionary."""
92
+ format_str = data.get("format", "html")
93
+ if isinstance(format_str, str):
94
+ format_enum = ReportFormat(format_str)
95
+ else:
96
+ format_enum = format_str
97
+
98
+ return cls(
99
+ format=format_enum,
100
+ template=data.get("template"),
101
+ locale=data.get("locale", "en"),
102
+ title=data.get("title", "Validation Report"),
103
+ description=data.get("description", ""),
104
+ include_summary=data.get("include_summary", True),
105
+ include_issues=data.get("include_issues", True),
106
+ include_statistics=data.get("include_statistics", True),
107
+ include_charts=data.get("include_charts", True),
108
+ max_issues=data.get("max_issues", 1000),
109
+ output_path=data.get("output_path"),
110
+ metadata=data.get("metadata", {}),
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class ReportData:
116
+ """Data container for report generation.
117
+
118
+ This is the standardized input for reporters, decoupled from
119
+ any specific validation result format.
120
+
121
+ Attributes:
122
+ run_id: Validation run identifier.
123
+ checkpoint_name: Checkpoint name.
124
+ source_name: Data source name.
125
+ status: Validation status.
126
+ generated_at: Report generation time.
127
+ validation_started_at: When validation started.
128
+ validation_completed_at: When validation completed.
129
+ duration_ms: Validation duration.
130
+ row_count: Number of rows validated.
131
+ column_count: Number of columns.
132
+ issue_count: Total issues found.
133
+ critical_count: Critical issues.
134
+ high_count: High severity issues.
135
+ medium_count: Medium severity issues.
136
+ low_count: Low severity issues.
137
+ issues: List of issue dictionaries.
138
+ summary: Summary statistics.
139
+ metadata: Additional metadata.
140
+ """
141
+
142
+ run_id: str
143
+ checkpoint_name: str
144
+ source_name: str
145
+ status: str
146
+ generated_at: datetime = field(default_factory=datetime.now)
147
+ validation_started_at: datetime | None = None
148
+ validation_completed_at: datetime | None = None
149
+ duration_ms: float = 0.0
150
+ row_count: int = 0
151
+ column_count: int = 0
152
+ issue_count: int = 0
153
+ critical_count: int = 0
154
+ high_count: int = 0
155
+ medium_count: int = 0
156
+ low_count: int = 0
157
+ issues: list[dict[str, Any]] = field(default_factory=list)
158
+ summary: dict[str, Any] = field(default_factory=dict)
159
+ metadata: dict[str, Any] = field(default_factory=dict)
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ """Convert to dictionary."""
163
+ return {
164
+ "run_id": self.run_id,
165
+ "checkpoint_name": self.checkpoint_name,
166
+ "source_name": self.source_name,
167
+ "status": self.status,
168
+ "generated_at": self.generated_at.isoformat(),
169
+ "validation_started_at": (
170
+ self.validation_started_at.isoformat()
171
+ if self.validation_started_at else None
172
+ ),
173
+ "validation_completed_at": (
174
+ self.validation_completed_at.isoformat()
175
+ if self.validation_completed_at else None
176
+ ),
177
+ "duration_ms": self.duration_ms,
178
+ "row_count": self.row_count,
179
+ "column_count": self.column_count,
180
+ "issue_count": self.issue_count,
181
+ "critical_count": self.critical_count,
182
+ "high_count": self.high_count,
183
+ "medium_count": self.medium_count,
184
+ "low_count": self.low_count,
185
+ "issues": self.issues,
186
+ "summary": self.summary,
187
+ "metadata": self.metadata,
188
+ }
189
+
190
+ @classmethod
191
+ def from_checkpoint_result(
192
+ cls,
193
+ result: "CheckpointResult",
194
+ ) -> "ReportData":
195
+ """Create from a checkpoint result.
196
+
197
+ Args:
198
+ result: Checkpoint result.
199
+
200
+ Returns:
201
+ ReportData instance.
202
+ """
203
+ # Build summary
204
+ summary = {
205
+ "total_issues": result.issue_count,
206
+ "by_severity": {
207
+ "critical": result.critical_count,
208
+ "high": result.high_count,
209
+ "medium": result.medium_count,
210
+ "low": result.low_count,
211
+ },
212
+ "pass_rate": (
213
+ 1 - (result.issue_count / result.row_count)
214
+ if result.row_count > 0 else 1.0
215
+ ),
216
+ }
217
+
218
+ return cls(
219
+ run_id=result.run_id,
220
+ checkpoint_name=result.checkpoint_name,
221
+ source_name=result.source_name,
222
+ status=result.status.value,
223
+ validation_started_at=result.started_at,
224
+ validation_completed_at=result.completed_at,
225
+ duration_ms=result.duration_ms,
226
+ row_count=result.row_count,
227
+ column_count=result.column_count,
228
+ issue_count=result.issue_count,
229
+ critical_count=result.critical_count,
230
+ high_count=result.high_count,
231
+ medium_count=result.medium_count,
232
+ low_count=result.low_count,
233
+ issues=result.issues,
234
+ summary=summary,
235
+ metadata=result.metadata,
236
+ )
237
+
238
+
239
+ @dataclass
240
+ class ReportOutput:
241
+ """Output from report generation.
242
+
243
+ Attributes:
244
+ format: Output format.
245
+ content: Report content (string for text formats, bytes for binary).
246
+ file_path: Path to output file (if saved).
247
+ mime_type: MIME type of the output.
248
+ size_bytes: Size in bytes.
249
+ generated_at: When report was generated.
250
+ metadata: Additional output metadata.
251
+ """
252
+
253
+ format: ReportFormat
254
+ content: str | bytes
255
+ file_path: str | None = None
256
+ mime_type: str = "text/html"
257
+ size_bytes: int = 0
258
+ generated_at: datetime = field(default_factory=datetime.now)
259
+ metadata: dict[str, Any] = field(default_factory=dict)
260
+
261
+ def __post_init__(self):
262
+ """Calculate size if not set."""
263
+ if self.size_bytes == 0:
264
+ if isinstance(self.content, bytes):
265
+ self.size_bytes = len(self.content)
266
+ else:
267
+ self.size_bytes = len(self.content.encode("utf-8"))
268
+
269
+ def save(self, path: str | Path) -> str:
270
+ """Save report content to file.
271
+
272
+ Args:
273
+ path: Output path.
274
+
275
+ Returns:
276
+ Absolute path to saved file.
277
+ """
278
+ path = Path(path)
279
+ path.parent.mkdir(parents=True, exist_ok=True)
280
+
281
+ if isinstance(self.content, bytes):
282
+ path.write_bytes(self.content)
283
+ else:
284
+ path.write_text(self.content, encoding="utf-8")
285
+
286
+ self.file_path = str(path.absolute())
287
+ return self.file_path
288
+
289
+ def to_dict(self) -> dict[str, Any]:
290
+ """Convert to dictionary (without content)."""
291
+ return {
292
+ "format": self.format.value,
293
+ "file_path": self.file_path,
294
+ "mime_type": self.mime_type,
295
+ "size_bytes": self.size_bytes,
296
+ "generated_at": self.generated_at.isoformat(),
297
+ "metadata": self.metadata,
298
+ }
299
+
300
+
301
+ @runtime_checkable
302
+ class ReporterProtocol(Protocol):
303
+ """Protocol for reporter implementations.
304
+
305
+ Reporters generate formatted reports from validation data.
306
+
307
+ Example:
308
+ class HTMLReporter:
309
+ def generate(self, data: ReportData, config: ReporterConfig) -> ReportOutput:
310
+ html = render_template(data, config)
311
+ return ReportOutput(format=ReportFormat.HTML, content=html)
312
+ """
313
+
314
+ @property
315
+ def name(self) -> str:
316
+ """Get reporter name."""
317
+ ...
318
+
319
+ @property
320
+ def supported_formats(self) -> list[ReportFormat]:
321
+ """Get supported output formats."""
322
+ ...
323
+
324
+ def generate(
325
+ self,
326
+ data: ReportData,
327
+ config: ReporterConfig | None = None,
328
+ ) -> ReportOutput:
329
+ """Generate a report synchronously.
330
+
331
+ Args:
332
+ data: Report data.
333
+ config: Report configuration.
334
+
335
+ Returns:
336
+ Report output.
337
+ """
338
+ ...
339
+
340
+
341
+ @runtime_checkable
342
+ class AsyncReporterProtocol(Protocol):
343
+ """Protocol for async reporter implementations."""
344
+
345
+ @property
346
+ def name(self) -> str:
347
+ """Get reporter name."""
348
+ ...
349
+
350
+ @property
351
+ def supported_formats(self) -> list[ReportFormat]:
352
+ """Get supported output formats."""
353
+ ...
354
+
355
+ async def generate_async(
356
+ self,
357
+ data: ReportData,
358
+ config: ReporterConfig | None = None,
359
+ ) -> ReportOutput:
360
+ """Generate a report asynchronously.
361
+
362
+ Args:
363
+ data: Report data.
364
+ config: Report configuration.
365
+
366
+ Returns:
367
+ Report output.
368
+ """
369
+ ...
370
+
371
+
372
+ class BaseReporter(ABC):
373
+ """Abstract base class for reporters.
374
+
375
+ Provides common functionality for all reporters.
376
+ Subclasses must implement the _do_generate method.
377
+ """
378
+
379
+ def __init__(
380
+ self,
381
+ name: str | None = None,
382
+ default_config: ReporterConfig | None = None,
383
+ ) -> None:
384
+ """Initialize reporter.
385
+
386
+ Args:
387
+ name: Reporter name.
388
+ default_config: Default configuration.
389
+ """
390
+ self._name = name or self.__class__.__name__
391
+ self._default_config = default_config or ReporterConfig()
392
+
393
+ @property
394
+ def name(self) -> str:
395
+ """Get reporter name."""
396
+ return self._name
397
+
398
+ @property
399
+ @abstractmethod
400
+ def supported_formats(self) -> list[ReportFormat]:
401
+ """Get supported output formats."""
402
+ ...
403
+
404
+ def generate(
405
+ self,
406
+ data: ReportData,
407
+ config: ReporterConfig | None = None,
408
+ ) -> ReportOutput:
409
+ """Generate a report.
410
+
411
+ Args:
412
+ data: Report data.
413
+ config: Report configuration.
414
+
415
+ Returns:
416
+ Report output.
417
+ """
418
+ config = config or self._default_config
419
+
420
+ # Validate format
421
+ if config.format not in self.supported_formats:
422
+ raise ValueError(
423
+ f"Format {config.format} not supported by {self.name}. "
424
+ f"Supported: {[f.value for f in self.supported_formats]}"
425
+ )
426
+
427
+ output = self._do_generate(data, config)
428
+
429
+ # Save to file if path specified
430
+ if config.output_path:
431
+ output.save(config.output_path)
432
+
433
+ return output
434
+
435
+ @abstractmethod
436
+ def _do_generate(
437
+ self,
438
+ data: ReportData,
439
+ config: ReporterConfig,
440
+ ) -> ReportOutput:
441
+ """Perform the actual report generation.
442
+
443
+ Subclasses must implement this method.
444
+
445
+ Args:
446
+ data: Report data.
447
+ config: Report configuration.
448
+
449
+ Returns:
450
+ Report output.
451
+ """
452
+ ...
453
+
454
+
455
+ class AsyncBaseReporter(ABC):
456
+ """Abstract base class for async reporters."""
457
+
458
+ def __init__(
459
+ self,
460
+ name: str | None = None,
461
+ default_config: ReporterConfig | None = None,
462
+ ) -> None:
463
+ """Initialize reporter."""
464
+ self._name = name or self.__class__.__name__
465
+ self._default_config = default_config or ReporterConfig()
466
+
467
+ @property
468
+ def name(self) -> str:
469
+ """Get reporter name."""
470
+ return self._name
471
+
472
+ @property
473
+ @abstractmethod
474
+ def supported_formats(self) -> list[ReportFormat]:
475
+ """Get supported output formats."""
476
+ ...
477
+
478
+ async def generate_async(
479
+ self,
480
+ data: ReportData,
481
+ config: ReporterConfig | None = None,
482
+ ) -> ReportOutput:
483
+ """Generate a report asynchronously."""
484
+ config = config or self._default_config
485
+
486
+ if config.format not in self.supported_formats:
487
+ raise ValueError(
488
+ f"Format {config.format} not supported by {self.name}"
489
+ )
490
+
491
+ output = await self._do_generate_async(data, config)
492
+
493
+ if config.output_path:
494
+ output.save(config.output_path)
495
+
496
+ return output
497
+
498
+ @abstractmethod
499
+ async def _do_generate_async(
500
+ self,
501
+ data: ReportData,
502
+ config: ReporterConfig,
503
+ ) -> ReportOutput:
504
+ """Perform the actual async report generation."""
505
+ ...
506
+
507
+
508
+ # =============================================================================
509
+ # Reporter Registry
510
+ # =============================================================================
511
+
512
+
513
+ class ReporterRegistry:
514
+ """Registry for reporter types.
515
+
516
+ Manages reporter registration and access.
517
+
518
+ Example:
519
+ registry = ReporterRegistry()
520
+ registry.register("html", HTMLReporter())
521
+ registry.register("pdf", PDFReporter())
522
+
523
+ reporter = registry.get("html")
524
+ output = reporter.generate(data, config)
525
+ """
526
+
527
+ def __init__(self) -> None:
528
+ """Initialize registry."""
529
+ self._reporters: dict[str, BaseReporter | AsyncBaseReporter] = {}
530
+ self._factories: dict[str, Callable[..., BaseReporter | AsyncBaseReporter]] = {}
531
+
532
+ def register(
533
+ self,
534
+ name: str,
535
+ reporter: BaseReporter | AsyncBaseReporter,
536
+ ) -> None:
537
+ """Register a reporter instance.
538
+
539
+ Args:
540
+ name: Reporter name.
541
+ reporter: Reporter instance.
542
+ """
543
+ self._reporters[name] = reporter
544
+
545
+ def register_factory(
546
+ self,
547
+ name: str,
548
+ factory: Callable[..., BaseReporter | AsyncBaseReporter],
549
+ ) -> None:
550
+ """Register a reporter factory.
551
+
552
+ Args:
553
+ name: Reporter name.
554
+ factory: Factory function.
555
+ """
556
+ self._factories[name] = factory
557
+
558
+ def get(self, name: str) -> BaseReporter | AsyncBaseReporter | None:
559
+ """Get a reporter by name.
560
+
561
+ Args:
562
+ name: Reporter name.
563
+
564
+ Returns:
565
+ Reporter or None if not found.
566
+ """
567
+ return self._reporters.get(name)
568
+
569
+ def create(
570
+ self,
571
+ name: str,
572
+ **kwargs: Any,
573
+ ) -> BaseReporter | AsyncBaseReporter:
574
+ """Create a reporter using a factory.
575
+
576
+ Args:
577
+ name: Reporter name.
578
+ **kwargs: Factory arguments.
579
+
580
+ Returns:
581
+ Reporter instance.
582
+
583
+ Raises:
584
+ KeyError: If factory not found.
585
+ """
586
+ if name not in self._factories:
587
+ raise KeyError(f"Reporter factory not found: {name}")
588
+ return self._factories[name](**kwargs)
589
+
590
+ def list_reporters(self) -> list[str]:
591
+ """List all registered reporter names.
592
+
593
+ Returns:
594
+ List of reporter names.
595
+ """
596
+ return list(set(self._reporters.keys()) | set(self._factories.keys()))
597
+
598
+ def has_reporter(self, name: str) -> bool:
599
+ """Check if a reporter is registered.
600
+
601
+ Args:
602
+ name: Reporter name.
603
+
604
+ Returns:
605
+ True if reporter is registered.
606
+ """
607
+ return name in self._reporters or name in self._factories
608
+
609
+ def get_supported_formats(self) -> dict[str, list[str]]:
610
+ """Get supported formats for all reporters.
611
+
612
+ Returns:
613
+ Dictionary mapping reporter names to supported formats.
614
+ """
615
+ result = {}
616
+ for name, reporter in self._reporters.items():
617
+ result[name] = [f.value for f in reporter.supported_formats]
618
+ return result
619
+
620
+
621
+ # Global reporter registry
622
+ _reporter_registry: ReporterRegistry | None = None
623
+
624
+
625
+ def get_reporter_registry() -> ReporterRegistry:
626
+ """Get the global reporter registry.
627
+
628
+ Returns:
629
+ Global ReporterRegistry instance.
630
+ """
631
+ global _reporter_registry
632
+ if _reporter_registry is None:
633
+ _reporter_registry = ReporterRegistry()
634
+ return _reporter_registry
635
+
636
+
637
+ def register_reporter(name: str) -> Callable[[type], type]:
638
+ """Decorator to register a reporter class.
639
+
640
+ Example:
641
+ @register_reporter("my_custom")
642
+ class MyCustomReporter(BaseReporter):
643
+ ...
644
+ """
645
+
646
+ def decorator(cls: type) -> type:
647
+ get_reporter_registry().register_factory(name, cls)
648
+ return cls
649
+
650
+ return decorator