truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.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
|
+
)
|