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,709 @@
1
+ """Result versioning system for validation results.
2
+
3
+ This module provides versioning capabilities for validation results,
4
+ allowing tracking of changes, comparisons between versions, and rollback.
5
+
6
+ Versioning strategies:
7
+ - Incremental: Simple numeric versioning (v1, v2, v3...)
8
+ - Semantic: Semver-style versioning (major.minor.patch)
9
+ - Timestamp: ISO timestamp-based versioning
10
+ - GitLike: SHA-based versioning similar to git commits
11
+
12
+ Example:
13
+ from truthound_dashboard.core.versioning import (
14
+ VersionManager,
15
+ VersioningStrategy,
16
+ create_version,
17
+ )
18
+
19
+ # Create a version for a validation result
20
+ version = create_version(
21
+ validation_id="...",
22
+ strategy=VersioningStrategy.INCREMENTAL,
23
+ )
24
+
25
+ # Compare versions
26
+ diff = await version_manager.compare_versions(v1_id, v2_id)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import logging
34
+ from abc import ABC, abstractmethod
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime
37
+ from enum import Enum
38
+ from typing import Any
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class VersioningStrategy(str, Enum):
44
+ """Versioning strategy types."""
45
+
46
+ INCREMENTAL = "incremental" # v1, v2, v3...
47
+ SEMANTIC = "semantic" # major.minor.patch
48
+ TIMESTAMP = "timestamp" # ISO timestamp
49
+ GITLIKE = "gitlike" # SHA-based
50
+
51
+
52
+ @dataclass
53
+ class VersionInfo:
54
+ """Version information for a validation result.
55
+
56
+ Attributes:
57
+ version_id: Unique version identifier.
58
+ version_number: Human-readable version number.
59
+ validation_id: ID of the validation this version represents.
60
+ source_id: ID of the data source.
61
+ strategy: Versioning strategy used.
62
+ created_at: When this version was created.
63
+ parent_version_id: Previous version ID (for history chain).
64
+ metadata: Additional version metadata.
65
+ content_hash: Hash of the validation result content.
66
+ """
67
+
68
+ version_id: str
69
+ version_number: str
70
+ validation_id: str
71
+ source_id: str
72
+ strategy: VersioningStrategy
73
+ created_at: datetime = field(default_factory=datetime.utcnow)
74
+ parent_version_id: str | None = None
75
+ metadata: dict[str, Any] = field(default_factory=dict)
76
+ content_hash: str | None = None
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ """Convert to dictionary."""
80
+ return {
81
+ "version_id": self.version_id,
82
+ "version_number": self.version_number,
83
+ "validation_id": self.validation_id,
84
+ "source_id": self.source_id,
85
+ "strategy": self.strategy.value,
86
+ "created_at": self.created_at.isoformat(),
87
+ "parent_version_id": self.parent_version_id,
88
+ "metadata": self.metadata,
89
+ "content_hash": self.content_hash,
90
+ }
91
+
92
+
93
+ @dataclass
94
+ class VersionDiff:
95
+ """Difference between two validation versions.
96
+
97
+ Attributes:
98
+ from_version: Source version info.
99
+ to_version: Target version info.
100
+ issues_added: New issues in target version.
101
+ issues_removed: Issues no longer present in target.
102
+ issues_changed: Issues that changed severity or count.
103
+ summary_changes: Changes to summary statistics.
104
+ """
105
+
106
+ from_version: VersionInfo
107
+ to_version: VersionInfo
108
+ issues_added: list[dict[str, Any]] = field(default_factory=list)
109
+ issues_removed: list[dict[str, Any]] = field(default_factory=list)
110
+ issues_changed: list[dict[str, Any]] = field(default_factory=list)
111
+ summary_changes: dict[str, Any] = field(default_factory=dict)
112
+
113
+
114
+ @dataclass
115
+ class RollbackResult:
116
+ """Result of a version rollback operation.
117
+
118
+ Attributes:
119
+ success: Whether rollback succeeded.
120
+ source_id: ID of the data source.
121
+ from_version: Original version before rollback.
122
+ to_version: Target version after rollback.
123
+ new_validation_id: ID of newly created validation from rollback.
124
+ message: Status message.
125
+ rolled_back_at: When rollback was performed.
126
+ """
127
+
128
+ success: bool
129
+ source_id: str
130
+ from_version: VersionInfo | None = None
131
+ to_version: VersionInfo | None = None
132
+ new_validation_id: str | None = None
133
+ message: str = ""
134
+ rolled_back_at: datetime = field(default_factory=datetime.utcnow)
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ """Convert to dictionary."""
138
+ return {
139
+ "success": self.success,
140
+ "source_id": self.source_id,
141
+ "from_version": self.from_version.to_dict() if self.from_version else None,
142
+ "to_version": self.to_version.to_dict() if self.to_version else None,
143
+ "new_validation_id": self.new_validation_id,
144
+ "message": self.message,
145
+ "rolled_back_at": self.rolled_back_at.isoformat(),
146
+ }
147
+
148
+ @property
149
+ def has_changes(self) -> bool:
150
+ """Check if there are any changes between versions."""
151
+ return bool(
152
+ self.issues_added or self.issues_removed or self.issues_changed
153
+ )
154
+
155
+ def to_dict(self) -> dict[str, Any]:
156
+ """Convert to dictionary."""
157
+ return {
158
+ "from_version": self.from_version.to_dict(),
159
+ "to_version": self.to_version.to_dict(),
160
+ "issues_added": self.issues_added,
161
+ "issues_removed": self.issues_removed,
162
+ "issues_changed": self.issues_changed,
163
+ "summary_changes": self.summary_changes,
164
+ "has_changes": self.has_changes,
165
+ }
166
+
167
+
168
+ class VersionNumberGenerator(ABC):
169
+ """Abstract base for version number generators."""
170
+
171
+ @abstractmethod
172
+ def generate(
173
+ self,
174
+ current_version: str | None,
175
+ metadata: dict[str, Any] | None = None,
176
+ ) -> str:
177
+ """Generate next version number.
178
+
179
+ Args:
180
+ current_version: Current/previous version number.
181
+ metadata: Optional metadata for generation.
182
+
183
+ Returns:
184
+ New version number string.
185
+ """
186
+ ...
187
+
188
+
189
+ class IncrementalVersionGenerator(VersionNumberGenerator):
190
+ """Simple incremental versioning (v1, v2, v3...)."""
191
+
192
+ def generate(
193
+ self,
194
+ current_version: str | None,
195
+ metadata: dict[str, Any] | None = None,
196
+ ) -> str:
197
+ if current_version is None:
198
+ return "v1"
199
+
200
+ # Extract number from current version
201
+ try:
202
+ num = int(current_version.lstrip("v"))
203
+ return f"v{num + 1}"
204
+ except ValueError:
205
+ return "v1"
206
+
207
+
208
+ class SemanticVersionGenerator(VersionNumberGenerator):
209
+ """Semantic versioning (major.minor.patch).
210
+
211
+ Bump rules:
212
+ - patch: Minor fixes, no new issues
213
+ - minor: New issues found, same data
214
+ - major: Schema changes or significant differences
215
+ """
216
+
217
+ def generate(
218
+ self,
219
+ current_version: str | None,
220
+ metadata: dict[str, Any] | None = None,
221
+ ) -> str:
222
+ metadata = metadata or {}
223
+
224
+ if current_version is None:
225
+ return "1.0.0"
226
+
227
+ try:
228
+ parts = current_version.split(".")
229
+ major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
230
+ except (ValueError, IndexError):
231
+ return "1.0.0"
232
+
233
+ # Determine bump type from metadata
234
+ bump_type = metadata.get("bump_type", "patch")
235
+
236
+ if bump_type == "major":
237
+ return f"{major + 1}.0.0"
238
+ elif bump_type == "minor":
239
+ return f"{major}.{minor + 1}.0"
240
+ else: # patch
241
+ return f"{major}.{minor}.{patch + 1}"
242
+
243
+
244
+ class TimestampVersionGenerator(VersionNumberGenerator):
245
+ """ISO timestamp-based versioning."""
246
+
247
+ def generate(
248
+ self,
249
+ current_version: str | None,
250
+ metadata: dict[str, Any] | None = None,
251
+ ) -> str:
252
+ return datetime.utcnow().strftime("%Y%m%d.%H%M%S")
253
+
254
+
255
+ class GitLikeVersionGenerator(VersionNumberGenerator):
256
+ """SHA-based versioning similar to git commits."""
257
+
258
+ def generate(
259
+ self,
260
+ current_version: str | None,
261
+ metadata: dict[str, Any] | None = None,
262
+ ) -> str:
263
+ metadata = metadata or {}
264
+
265
+ # Create content for hashing
266
+ content = {
267
+ "timestamp": datetime.utcnow().isoformat(),
268
+ "parent": current_version,
269
+ "data": metadata.get("content_hash", ""),
270
+ }
271
+
272
+ content_str = json.dumps(content, sort_keys=True)
273
+ sha = hashlib.sha256(content_str.encode()).hexdigest()
274
+
275
+ return sha[:8] # Short SHA like git
276
+
277
+
278
+ def get_version_generator(strategy: VersioningStrategy) -> VersionNumberGenerator:
279
+ """Get version generator for a strategy.
280
+
281
+ Args:
282
+ strategy: Versioning strategy.
283
+
284
+ Returns:
285
+ Appropriate version generator.
286
+ """
287
+ generators = {
288
+ VersioningStrategy.INCREMENTAL: IncrementalVersionGenerator,
289
+ VersioningStrategy.SEMANTIC: SemanticVersionGenerator,
290
+ VersioningStrategy.TIMESTAMP: TimestampVersionGenerator,
291
+ VersioningStrategy.GITLIKE: GitLikeVersionGenerator,
292
+ }
293
+ return generators[strategy]()
294
+
295
+
296
+ def compute_content_hash(result_json: dict[str, Any] | None) -> str:
297
+ """Compute hash of validation result content.
298
+
299
+ Args:
300
+ result_json: Validation result JSON.
301
+
302
+ Returns:
303
+ SHA-256 hash of content.
304
+ """
305
+ if not result_json:
306
+ return hashlib.sha256(b"").hexdigest()[:16]
307
+
308
+ content = json.dumps(result_json, sort_keys=True)
309
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
310
+
311
+
312
+ class VersionManager:
313
+ """Manager for validation result versions.
314
+
315
+ Handles version creation, storage, and comparison.
316
+ """
317
+
318
+ def __init__(self, default_strategy: VersioningStrategy = VersioningStrategy.INCREMENTAL) -> None:
319
+ """Initialize version manager.
320
+
321
+ Args:
322
+ default_strategy: Default versioning strategy to use.
323
+ """
324
+ self._default_strategy = default_strategy
325
+ # In-memory version storage (in production, use database)
326
+ self._versions: dict[str, VersionInfo] = {}
327
+ # Source -> latest version mapping
328
+ self._source_versions: dict[str, str] = {}
329
+
330
+ async def create_version(
331
+ self,
332
+ validation_id: str,
333
+ source_id: str,
334
+ result_json: dict[str, Any] | None = None,
335
+ strategy: VersioningStrategy | None = None,
336
+ metadata: dict[str, Any] | None = None,
337
+ ) -> VersionInfo:
338
+ """Create a new version for a validation result.
339
+
340
+ Args:
341
+ validation_id: ID of the validation.
342
+ source_id: ID of the data source.
343
+ result_json: Validation result data.
344
+ strategy: Versioning strategy (uses default if not specified).
345
+ metadata: Additional version metadata.
346
+
347
+ Returns:
348
+ Created VersionInfo.
349
+ """
350
+ strategy = strategy or self._default_strategy
351
+ metadata = metadata or {}
352
+
353
+ # Get previous version for this source
354
+ parent_version_id = self._source_versions.get(source_id)
355
+ parent_version = self._versions.get(parent_version_id) if parent_version_id else None
356
+ current_version_number = parent_version.version_number if parent_version else None
357
+
358
+ # Compute content hash
359
+ content_hash = compute_content_hash(result_json)
360
+ metadata["content_hash"] = content_hash
361
+
362
+ # Generate version number
363
+ generator = get_version_generator(strategy)
364
+ version_number = generator.generate(current_version_number, metadata)
365
+
366
+ # Create version info
367
+ version_id = f"{source_id}_{validation_id}_{version_number}"
368
+ version_info = VersionInfo(
369
+ version_id=version_id,
370
+ version_number=version_number,
371
+ validation_id=validation_id,
372
+ source_id=source_id,
373
+ strategy=strategy,
374
+ parent_version_id=parent_version_id,
375
+ metadata=metadata,
376
+ content_hash=content_hash,
377
+ )
378
+
379
+ # Store version
380
+ self._versions[version_id] = version_info
381
+ self._source_versions[source_id] = version_id
382
+
383
+ logger.debug(
384
+ f"Created version {version_number} for source {source_id} "
385
+ f"(validation: {validation_id})"
386
+ )
387
+
388
+ return version_info
389
+
390
+ async def get_version(self, version_id: str) -> VersionInfo | None:
391
+ """Get a specific version by ID.
392
+
393
+ Args:
394
+ version_id: Version identifier.
395
+
396
+ Returns:
397
+ VersionInfo or None if not found.
398
+ """
399
+ return self._versions.get(version_id)
400
+
401
+ async def get_latest_version(self, source_id: str) -> VersionInfo | None:
402
+ """Get the latest version for a source.
403
+
404
+ Args:
405
+ source_id: Source identifier.
406
+
407
+ Returns:
408
+ Latest VersionInfo or None if no versions exist.
409
+ """
410
+ version_id = self._source_versions.get(source_id)
411
+ if version_id:
412
+ return self._versions.get(version_id)
413
+ return None
414
+
415
+ async def list_versions(
416
+ self,
417
+ source_id: str,
418
+ limit: int = 20,
419
+ ) -> list[VersionInfo]:
420
+ """List versions for a source.
421
+
422
+ Args:
423
+ source_id: Source identifier.
424
+ limit: Maximum versions to return.
425
+
426
+ Returns:
427
+ List of VersionInfo, newest first.
428
+ """
429
+ versions = [
430
+ v for v in self._versions.values()
431
+ if v.source_id == source_id
432
+ ]
433
+ versions.sort(key=lambda v: v.created_at, reverse=True)
434
+ return versions[:limit]
435
+
436
+ async def compare_versions(
437
+ self,
438
+ from_version_id: str,
439
+ to_version_id: str,
440
+ from_result: dict[str, Any] | None = None,
441
+ to_result: dict[str, Any] | None = None,
442
+ ) -> VersionDiff:
443
+ """Compare two versions and compute differences.
444
+
445
+ Args:
446
+ from_version_id: Source version ID.
447
+ to_version_id: Target version ID.
448
+ from_result: Optional result JSON for source version.
449
+ to_result: Optional result JSON for target version.
450
+
451
+ Returns:
452
+ VersionDiff with changes between versions.
453
+
454
+ Raises:
455
+ ValueError: If either version is not found.
456
+ """
457
+ from_version = await self.get_version(from_version_id)
458
+ to_version = await self.get_version(to_version_id)
459
+
460
+ if not from_version:
461
+ raise ValueError(f"Version not found: {from_version_id}")
462
+ if not to_version:
463
+ raise ValueError(f"Version not found: {to_version_id}")
464
+
465
+ # Extract issues from results
466
+ from_issues = from_result.get("issues", []) if from_result else []
467
+ to_issues = to_result.get("issues", []) if to_result else []
468
+
469
+ # Create issue keys for comparison
470
+ def issue_key(issue: dict[str, Any]) -> str:
471
+ return f"{issue.get('column', '')}:{issue.get('issue_type', '')}"
472
+
473
+ from_issues_map = {issue_key(i): i for i in from_issues}
474
+ to_issues_map = {issue_key(i): i for i in to_issues}
475
+
476
+ # Find differences
477
+ issues_added = [
478
+ to_issues_map[k] for k in to_issues_map
479
+ if k not in from_issues_map
480
+ ]
481
+ issues_removed = [
482
+ from_issues_map[k] for k in from_issues_map
483
+ if k not in to_issues_map
484
+ ]
485
+
486
+ # Find changed issues (same key but different content)
487
+ issues_changed = []
488
+ for key in from_issues_map:
489
+ if key in to_issues_map:
490
+ from_issue = from_issues_map[key]
491
+ to_issue = to_issues_map[key]
492
+ if (
493
+ from_issue.get("count") != to_issue.get("count") or
494
+ from_issue.get("severity") != to_issue.get("severity")
495
+ ):
496
+ issues_changed.append({
497
+ "key": key,
498
+ "from": from_issue,
499
+ "to": to_issue,
500
+ })
501
+
502
+ # Summary changes
503
+ summary_changes = {
504
+ "issues_added_count": len(issues_added),
505
+ "issues_removed_count": len(issues_removed),
506
+ "issues_changed_count": len(issues_changed),
507
+ }
508
+
509
+ return VersionDiff(
510
+ from_version=from_version,
511
+ to_version=to_version,
512
+ issues_added=issues_added,
513
+ issues_removed=issues_removed,
514
+ issues_changed=issues_changed,
515
+ summary_changes=summary_changes,
516
+ )
517
+
518
+ async def get_version_history(
519
+ self,
520
+ version_id: str,
521
+ depth: int = 10,
522
+ ) -> list[VersionInfo]:
523
+ """Get version history chain.
524
+
525
+ Args:
526
+ version_id: Starting version ID.
527
+ depth: Maximum history depth.
528
+
529
+ Returns:
530
+ List of versions in history chain.
531
+ """
532
+ history = []
533
+ current_id = version_id
534
+
535
+ while current_id and len(history) < depth:
536
+ version = await self.get_version(current_id)
537
+ if not version:
538
+ break
539
+ history.append(version)
540
+ current_id = version.parent_version_id
541
+
542
+ return history
543
+
544
+ async def rollback_to_version(
545
+ self,
546
+ source_id: str,
547
+ target_version_id: str,
548
+ create_new_validation: bool = True,
549
+ ) -> RollbackResult:
550
+ """Rollback to a previous version.
551
+
552
+ This operation sets the specified version as the current active version
553
+ for the source. Optionally creates a new validation record based on
554
+ the target version's validation data.
555
+
556
+ Args:
557
+ source_id: ID of the data source.
558
+ target_version_id: ID of the version to rollback to.
559
+ create_new_validation: Whether to create a new validation from target.
560
+
561
+ Returns:
562
+ RollbackResult with operation details.
563
+ """
564
+ # Get current version
565
+ current_version_id = self._source_versions.get(source_id)
566
+ current_version = self._versions.get(current_version_id) if current_version_id else None
567
+
568
+ # Get target version
569
+ target_version = self._versions.get(target_version_id)
570
+ if not target_version:
571
+ return RollbackResult(
572
+ success=False,
573
+ source_id=source_id,
574
+ message=f"Target version not found: {target_version_id}",
575
+ )
576
+
577
+ # Verify target belongs to the same source
578
+ if target_version.source_id != source_id:
579
+ return RollbackResult(
580
+ success=False,
581
+ source_id=source_id,
582
+ message=f"Version {target_version_id} does not belong to source {source_id}",
583
+ )
584
+
585
+ # Verify not rolling back to current version
586
+ if current_version_id == target_version_id:
587
+ return RollbackResult(
588
+ success=False,
589
+ source_id=source_id,
590
+ from_version=current_version,
591
+ to_version=target_version,
592
+ message="Cannot rollback to current version",
593
+ )
594
+
595
+ # Create rollback version (new version pointing to target's validation)
596
+ new_validation_id = None
597
+ if create_new_validation:
598
+ # Generate a new validation ID for the rollback
599
+ import uuid
600
+ new_validation_id = str(uuid.uuid4())
601
+
602
+ # Create a new version that represents the rollback
603
+ rollback_version = await self.create_version(
604
+ validation_id=new_validation_id,
605
+ source_id=source_id,
606
+ result_json=target_version.metadata.get("result_json"),
607
+ strategy=target_version.strategy,
608
+ metadata={
609
+ "rollback_from": current_version_id,
610
+ "rollback_to": target_version_id,
611
+ "rollback_type": "explicit",
612
+ "original_validation_id": target_version.validation_id,
613
+ },
614
+ )
615
+
616
+ logger.info(
617
+ f"Rolled back source {source_id} from {current_version_id} "
618
+ f"to {target_version_id}, new version: {rollback_version.version_id}"
619
+ )
620
+ else:
621
+ # Just update the pointer without creating new version
622
+ self._source_versions[source_id] = target_version_id
623
+
624
+ logger.info(
625
+ f"Rolled back source {source_id} from {current_version_id} "
626
+ f"to {target_version_id} (pointer update only)"
627
+ )
628
+
629
+ return RollbackResult(
630
+ success=True,
631
+ source_id=source_id,
632
+ from_version=current_version,
633
+ to_version=target_version,
634
+ new_validation_id=new_validation_id,
635
+ message=f"Successfully rolled back to version {target_version.version_number}",
636
+ )
637
+
638
+ async def can_rollback(self, source_id: str) -> dict[str, Any]:
639
+ """Check if rollback is available for a source.
640
+
641
+ Args:
642
+ source_id: ID of the data source.
643
+
644
+ Returns:
645
+ Dictionary with rollback availability info.
646
+ """
647
+ versions = await self.list_versions(source_id, limit=100)
648
+ current_version_id = self._source_versions.get(source_id)
649
+
650
+ return {
651
+ "can_rollback": len(versions) > 1,
652
+ "current_version_id": current_version_id,
653
+ "available_versions": len(versions),
654
+ "rollback_targets": [
655
+ v.to_dict() for v in versions
656
+ if v.version_id != current_version_id
657
+ ][:10], # Limit to 10 recent targets
658
+ }
659
+
660
+
661
+ # Singleton instance
662
+ _version_manager: VersionManager | None = None
663
+
664
+
665
+ def get_version_manager() -> VersionManager:
666
+ """Get the singleton version manager.
667
+
668
+ Returns:
669
+ VersionManager instance.
670
+ """
671
+ global _version_manager
672
+ if _version_manager is None:
673
+ _version_manager = VersionManager()
674
+ return _version_manager
675
+
676
+
677
+ def reset_version_manager() -> None:
678
+ """Reset version manager singleton (for testing)."""
679
+ global _version_manager
680
+ _version_manager = None
681
+
682
+
683
+ async def create_version(
684
+ validation_id: str,
685
+ source_id: str,
686
+ result_json: dict[str, Any] | None = None,
687
+ strategy: VersioningStrategy | None = None,
688
+ metadata: dict[str, Any] | None = None,
689
+ ) -> VersionInfo:
690
+ """Convenience function to create a version.
691
+
692
+ Args:
693
+ validation_id: ID of the validation.
694
+ source_id: ID of the data source.
695
+ result_json: Validation result data.
696
+ strategy: Versioning strategy.
697
+ metadata: Additional metadata.
698
+
699
+ Returns:
700
+ Created VersionInfo.
701
+ """
702
+ manager = get_version_manager()
703
+ return await manager.create_version(
704
+ validation_id=validation_id,
705
+ source_id=source_id,
706
+ result_json=result_json,
707
+ strategy=strategy,
708
+ metadata=metadata,
709
+ )