truthound-dashboard 1.3.0__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.
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.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -306,6 +306,212 @@ class MemoryCache(CacheBackend):
306
306
  }
307
307
 
308
308
 
309
+ class LFUCacheEntry(Generic[T]):
310
+ """LFU Cache entry with frequency tracking.
311
+
312
+ Attributes:
313
+ value: Cached value.
314
+ frequency: Access frequency count.
315
+ expires_at: Expiration datetime.
316
+ created_at: Creation datetime.
317
+ last_accessed: Last access datetime.
318
+ """
319
+
320
+ __slots__ = ("value", "frequency", "expires_at", "created_at", "last_accessed")
321
+
322
+ def __init__(self, value: T, ttl_seconds: int) -> None:
323
+ """Initialize LFU cache entry.
324
+
325
+ Args:
326
+ value: Value to cache.
327
+ ttl_seconds: Time to live in seconds.
328
+ """
329
+ self.value = value
330
+ self.frequency = 1
331
+ self.created_at = datetime.utcnow()
332
+ self.last_accessed = self.created_at
333
+ self.expires_at = self.created_at + timedelta(seconds=ttl_seconds)
334
+
335
+ @property
336
+ def is_expired(self) -> bool:
337
+ """Check if entry has expired."""
338
+ return datetime.utcnow() >= self.expires_at
339
+
340
+ def access(self) -> None:
341
+ """Record an access to this entry."""
342
+ self.frequency += 1
343
+ self.last_accessed = datetime.utcnow()
344
+
345
+
346
+ class LFUCache(CacheBackend):
347
+ """Least Frequently Used (LFU) cache with TTL support.
348
+
349
+ Evicts least frequently accessed entries when cache is full.
350
+ Includes automatic cleanup of expired entries.
351
+
352
+ Features:
353
+ - Frequency-based eviction (least frequently used first)
354
+ - Tie-breaking by last access time (LRU among same frequency)
355
+ - TTL-based expiration
356
+ - Thread-safe with asyncio.Lock
357
+ """
358
+
359
+ def __init__(self, max_size: int = 1000, cleanup_interval: int = 300) -> None:
360
+ """Initialize LFU cache.
361
+
362
+ Args:
363
+ max_size: Maximum number of entries to store.
364
+ cleanup_interval: Interval for cleanup task in seconds.
365
+ """
366
+ self._cache: dict[str, LFUCacheEntry[Any]] = {}
367
+ self._lock = asyncio.Lock()
368
+ self._max_size = max_size
369
+ self._cleanup_interval = cleanup_interval
370
+ self._cleanup_task: asyncio.Task[None] | None = None
371
+ self._hits = 0
372
+ self._misses = 0
373
+
374
+ async def start_cleanup_task(self) -> None:
375
+ """Start background cleanup task."""
376
+ if self._cleanup_task is None:
377
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
378
+
379
+ async def stop_cleanup_task(self) -> None:
380
+ """Stop background cleanup task."""
381
+ if self._cleanup_task is not None:
382
+ self._cleanup_task.cancel()
383
+ try:
384
+ await self._cleanup_task
385
+ except asyncio.CancelledError:
386
+ pass
387
+ self._cleanup_task = None
388
+
389
+ async def _cleanup_loop(self) -> None:
390
+ """Background cleanup loop."""
391
+ while True:
392
+ try:
393
+ await asyncio.sleep(self._cleanup_interval)
394
+ await self._cleanup_expired()
395
+ except asyncio.CancelledError:
396
+ break
397
+ except Exception as e:
398
+ logger.error(f"LFU cache cleanup error: {e}")
399
+
400
+ async def _cleanup_expired(self) -> int:
401
+ """Remove expired entries.
402
+
403
+ Returns:
404
+ Number of entries removed.
405
+ """
406
+ async with self._lock:
407
+ expired_keys = [
408
+ key for key, entry in self._cache.items() if entry.is_expired
409
+ ]
410
+ for key in expired_keys:
411
+ del self._cache[key]
412
+ return len(expired_keys)
413
+
414
+ async def _evict_if_needed(self) -> None:
415
+ """Evict least frequently used entries if cache is full."""
416
+ if len(self._cache) >= self._max_size:
417
+ # Remove 10% of entries with lowest frequency
418
+ to_remove = max(1, self._max_size // 10)
419
+
420
+ # Sort by frequency (ascending), then by last_accessed (ascending)
421
+ sorted_entries = sorted(
422
+ self._cache.items(),
423
+ key=lambda x: (x[1].frequency, x[1].last_accessed),
424
+ )
425
+ for key, _ in sorted_entries[:to_remove]:
426
+ del self._cache[key]
427
+
428
+ async def get(self, key: str) -> Any | None:
429
+ """Get value from cache and increment frequency."""
430
+ async with self._lock:
431
+ entry = self._cache.get(key)
432
+ if entry is None:
433
+ self._misses += 1
434
+ return None
435
+ if entry.is_expired:
436
+ del self._cache[key]
437
+ self._misses += 1
438
+ return None
439
+ entry.access()
440
+ self._hits += 1
441
+ return entry.value
442
+
443
+ async def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None:
444
+ """Set value in cache with TTL."""
445
+ async with self._lock:
446
+ await self._evict_if_needed()
447
+ self._cache[key] = LFUCacheEntry(value, ttl_seconds)
448
+
449
+ async def delete(self, key: str) -> bool:
450
+ """Delete value from cache."""
451
+ async with self._lock:
452
+ if key in self._cache:
453
+ del self._cache[key]
454
+ return True
455
+ return False
456
+
457
+ async def clear(self) -> None:
458
+ """Clear all cached values."""
459
+ async with self._lock:
460
+ self._cache.clear()
461
+ self._hits = 0
462
+ self._misses = 0
463
+
464
+ async def exists(self, key: str) -> bool:
465
+ """Check if key exists and is not expired."""
466
+ async with self._lock:
467
+ entry = self._cache.get(key)
468
+ if entry is None:
469
+ return False
470
+ if entry.is_expired:
471
+ del self._cache[key]
472
+ return False
473
+ return True
474
+
475
+ async def invalidate_pattern(self, pattern: str) -> int:
476
+ """Invalidate all keys matching pattern (prefix match)."""
477
+ async with self._lock:
478
+ keys_to_remove = [key for key in self._cache if key.startswith(pattern)]
479
+ for key in keys_to_remove:
480
+ del self._cache[key]
481
+ return len(keys_to_remove)
482
+
483
+ @property
484
+ def size(self) -> int:
485
+ """Get current cache size."""
486
+ return len(self._cache)
487
+
488
+ @property
489
+ def hit_rate(self) -> float:
490
+ """Get cache hit rate."""
491
+ total = self._hits + self._misses
492
+ return self._hits / total if total > 0 else 0.0
493
+
494
+ async def get_stats(self) -> dict[str, Any]:
495
+ """Get cache statistics."""
496
+ async with self._lock:
497
+ expired_count = sum(1 for e in self._cache.values() if e.is_expired)
498
+ freq_distribution: dict[int, int] = {}
499
+ for entry in self._cache.values():
500
+ freq = entry.frequency
501
+ freq_distribution[freq] = freq_distribution.get(freq, 0) + 1
502
+
503
+ return {
504
+ "total_entries": len(self._cache),
505
+ "expired_entries": expired_count,
506
+ "valid_entries": len(self._cache) - expired_count,
507
+ "max_size": self._max_size,
508
+ "hits": self._hits,
509
+ "misses": self._misses,
510
+ "hit_rate": self.hit_rate,
511
+ "frequency_distribution": freq_distribution,
512
+ }
513
+
514
+
309
515
  class FileCache(CacheBackend):
310
516
  """File-based cache with TTL support.
311
517
 
@@ -0,0 +1,422 @@
1
+ """Cached service wrappers for improved performance.
2
+
3
+ This module provides caching decorators and cached service classes
4
+ that wrap the base services with cache integration.
5
+
6
+ Cache keys are prefixed by resource type for easy invalidation:
7
+ - validation:{id} - Individual validation results
8
+ - validations:source:{source_id} - Validation list for a source
9
+ - schema:{source_id} - Active schema for a source
10
+ - profile:{source_id} - Latest profile for a source
11
+ - validators - Validator registry (long TTL)
12
+
13
+ Example:
14
+ from truthound_dashboard.core.cached_services import (
15
+ CachedValidationService,
16
+ invalidate_validation_cache,
17
+ )
18
+
19
+ service = CachedValidationService(session)
20
+ validation = await service.get_validation(id) # May return cached
21
+
22
+ # Invalidate cache when data changes
23
+ await invalidate_validation_cache(validation_id=id)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from collections.abc import Sequence
30
+ from datetime import datetime
31
+ from functools import wraps
32
+ from typing import Any, Callable, TypeVar
33
+
34
+ from truthound_dashboard.core.cache import get_cache, CacheBackend
35
+ from truthound_dashboard.core.versioning import create_version, VersioningStrategy
36
+ from truthound_dashboard.db import Validation
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Cache TTL constants (in seconds)
41
+ VALIDATION_TTL = 300 # 5 minutes for individual validations
42
+ VALIDATION_LIST_TTL = 60 # 1 minute for validation lists
43
+ SCHEMA_TTL = 600 # 10 minutes for schemas
44
+ PROFILE_TTL = 600 # 10 minutes for profiles
45
+ VALIDATORS_TTL = 3600 # 1 hour for validator registry
46
+
47
+ # Cache key prefixes
48
+ PREFIX_VALIDATION = "validation:"
49
+ PREFIX_VALIDATIONS_LIST = "validations:source:"
50
+ PREFIX_SCHEMA = "schema:"
51
+ PREFIX_PROFILE = "profile:"
52
+ PREFIX_VALIDATORS = "validators"
53
+
54
+ T = TypeVar("T")
55
+
56
+
57
+ def cache_key(*parts: str) -> str:
58
+ """Build a cache key from parts.
59
+
60
+ Args:
61
+ parts: Key parts to join.
62
+
63
+ Returns:
64
+ Cache key string.
65
+ """
66
+ return ":".join(str(p) for p in parts)
67
+
68
+
69
+ async def get_cached_or_compute(
70
+ cache: CacheBackend,
71
+ key: str,
72
+ compute_fn: Callable[[], Any],
73
+ ttl: int = 300,
74
+ ) -> Any:
75
+ """Get value from cache or compute and cache it.
76
+
77
+ Args:
78
+ cache: Cache backend.
79
+ key: Cache key.
80
+ compute_fn: Async function to compute value if not cached.
81
+ ttl: Time to live in seconds.
82
+
83
+ Returns:
84
+ Cached or computed value.
85
+ """
86
+ # Try cache first
87
+ cached = await cache.get(key)
88
+ if cached is not None:
89
+ logger.debug(f"Cache hit: {key}")
90
+ return cached
91
+
92
+ # Compute and cache
93
+ logger.debug(f"Cache miss: {key}")
94
+ value = await compute_fn()
95
+
96
+ if value is not None:
97
+ await cache.set(key, value, ttl)
98
+
99
+ return value
100
+
101
+
102
+ async def invalidate_validation_cache(
103
+ *,
104
+ validation_id: str | None = None,
105
+ source_id: str | None = None,
106
+ ) -> int:
107
+ """Invalidate validation-related cache entries.
108
+
109
+ Args:
110
+ validation_id: Specific validation ID to invalidate.
111
+ source_id: Invalidate all caches for a source.
112
+
113
+ Returns:
114
+ Number of cache entries invalidated.
115
+ """
116
+ cache = get_cache()
117
+ count = 0
118
+
119
+ if validation_id:
120
+ key = f"{PREFIX_VALIDATION}{validation_id}"
121
+ if await cache.delete(key):
122
+ count += 1
123
+
124
+ if source_id:
125
+ # Invalidate validation list for source
126
+ key = f"{PREFIX_VALIDATIONS_LIST}{source_id}"
127
+ if await cache.delete(key):
128
+ count += 1
129
+
130
+ # Invalidate all validations for source (pattern match)
131
+ count += await cache.invalidate_pattern(f"{PREFIX_VALIDATION}{source_id}")
132
+
133
+ logger.debug(f"Invalidated {count} cache entries")
134
+ return count
135
+
136
+
137
+ async def invalidate_schema_cache(source_id: str) -> bool:
138
+ """Invalidate schema cache for a source.
139
+
140
+ Args:
141
+ source_id: Source ID.
142
+
143
+ Returns:
144
+ True if cache was invalidated.
145
+ """
146
+ cache = get_cache()
147
+ key = f"{PREFIX_SCHEMA}{source_id}"
148
+ return await cache.delete(key)
149
+
150
+
151
+ async def invalidate_profile_cache(source_id: str) -> bool:
152
+ """Invalidate profile cache for a source.
153
+
154
+ Args:
155
+ source_id: Source ID.
156
+
157
+ Returns:
158
+ True if cache was invalidated.
159
+ """
160
+ cache = get_cache()
161
+ key = f"{PREFIX_PROFILE}{source_id}"
162
+ return await cache.delete(key)
163
+
164
+
165
+ class ValidationCacheService:
166
+ """Cache wrapper for validation operations.
167
+
168
+ Provides cached access to validation data with automatic
169
+ cache invalidation on writes.
170
+ """
171
+
172
+ def __init__(self, cache: CacheBackend | None = None) -> None:
173
+ """Initialize cache service.
174
+
175
+ Args:
176
+ cache: Cache backend. Uses default if not provided.
177
+ """
178
+ self._cache = cache or get_cache()
179
+
180
+ async def get_validation(
181
+ self,
182
+ validation_id: str,
183
+ compute_fn: Callable[[], Any],
184
+ ) -> Validation | None:
185
+ """Get validation from cache or compute.
186
+
187
+ Args:
188
+ validation_id: Validation ID.
189
+ compute_fn: Async function to get validation if not cached.
190
+
191
+ Returns:
192
+ Validation or None.
193
+ """
194
+ key = f"{PREFIX_VALIDATION}{validation_id}"
195
+ return await get_cached_or_compute(
196
+ self._cache,
197
+ key,
198
+ compute_fn,
199
+ VALIDATION_TTL,
200
+ )
201
+
202
+ async def get_validations_for_source(
203
+ self,
204
+ source_id: str,
205
+ compute_fn: Callable[[], Any],
206
+ ) -> Sequence[Validation]:
207
+ """Get validations for source from cache or compute.
208
+
209
+ Args:
210
+ source_id: Source ID.
211
+ compute_fn: Async function to get validations if not cached.
212
+
213
+ Returns:
214
+ Sequence of validations.
215
+ """
216
+ key = f"{PREFIX_VALIDATIONS_LIST}{source_id}"
217
+ result = await get_cached_or_compute(
218
+ self._cache,
219
+ key,
220
+ compute_fn,
221
+ VALIDATION_LIST_TTL,
222
+ )
223
+ return result or []
224
+
225
+ async def invalidate(
226
+ self,
227
+ *,
228
+ validation_id: str | None = None,
229
+ source_id: str | None = None,
230
+ ) -> int:
231
+ """Invalidate cached validation data.
232
+
233
+ Args:
234
+ validation_id: Specific validation to invalidate.
235
+ source_id: Invalidate all for a source.
236
+
237
+ Returns:
238
+ Number of entries invalidated.
239
+ """
240
+ return await invalidate_validation_cache(
241
+ validation_id=validation_id,
242
+ source_id=source_id,
243
+ )
244
+
245
+
246
+ class SchemaCacheService:
247
+ """Cache wrapper for schema operations."""
248
+
249
+ def __init__(self, cache: CacheBackend | None = None) -> None:
250
+ """Initialize cache service."""
251
+ self._cache = cache or get_cache()
252
+
253
+ async def get_schema(
254
+ self,
255
+ source_id: str,
256
+ compute_fn: Callable[[], Any],
257
+ ) -> Any:
258
+ """Get schema from cache or compute.
259
+
260
+ Args:
261
+ source_id: Source ID.
262
+ compute_fn: Async function to get schema if not cached.
263
+
264
+ Returns:
265
+ Schema or None.
266
+ """
267
+ key = f"{PREFIX_SCHEMA}{source_id}"
268
+ return await get_cached_or_compute(
269
+ self._cache,
270
+ key,
271
+ compute_fn,
272
+ SCHEMA_TTL,
273
+ )
274
+
275
+ async def invalidate(self, source_id: str) -> bool:
276
+ """Invalidate schema cache for a source."""
277
+ return await invalidate_schema_cache(source_id)
278
+
279
+
280
+ class ProfileCacheService:
281
+ """Cache wrapper for profile operations."""
282
+
283
+ def __init__(self, cache: CacheBackend | None = None) -> None:
284
+ """Initialize cache service."""
285
+ self._cache = cache or get_cache()
286
+
287
+ async def get_profile(
288
+ self,
289
+ source_id: str,
290
+ compute_fn: Callable[[], Any],
291
+ ) -> Any:
292
+ """Get profile from cache or compute.
293
+
294
+ Args:
295
+ source_id: Source ID.
296
+ compute_fn: Async function to get profile if not cached.
297
+
298
+ Returns:
299
+ Profile or None.
300
+ """
301
+ key = f"{PREFIX_PROFILE}{source_id}"
302
+ return await get_cached_or_compute(
303
+ self._cache,
304
+ key,
305
+ compute_fn,
306
+ PROFILE_TTL,
307
+ )
308
+
309
+ async def invalidate(self, source_id: str) -> bool:
310
+ """Invalidate profile cache for a source."""
311
+ return await invalidate_profile_cache(source_id)
312
+
313
+
314
+ class ValidatorsCacheService:
315
+ """Cache wrapper for validator registry."""
316
+
317
+ def __init__(self, cache: CacheBackend | None = None) -> None:
318
+ """Initialize cache service."""
319
+ self._cache = cache or get_cache()
320
+
321
+ async def get_validators(
322
+ self,
323
+ compute_fn: Callable[[], Any],
324
+ ) -> list[dict[str, Any]]:
325
+ """Get validators from cache or compute.
326
+
327
+ Args:
328
+ compute_fn: Async function to get validators if not cached.
329
+
330
+ Returns:
331
+ List of validator definitions.
332
+ """
333
+ result = await get_cached_or_compute(
334
+ self._cache,
335
+ PREFIX_VALIDATORS,
336
+ compute_fn,
337
+ VALIDATORS_TTL,
338
+ )
339
+ return result or []
340
+
341
+ async def invalidate(self) -> bool:
342
+ """Invalidate validators cache."""
343
+ return await self._cache.delete(PREFIX_VALIDATORS)
344
+
345
+
346
+ # Convenience function to create a versioned validation result
347
+ async def create_versioned_validation(
348
+ validation: Validation,
349
+ strategy: VersioningStrategy = VersioningStrategy.INCREMENTAL,
350
+ ) -> None:
351
+ """Create a version entry for a validation result.
352
+
353
+ This integrates versioning with the validation workflow.
354
+ Call after a validation is complete to track its version.
355
+
356
+ Args:
357
+ validation: Completed validation model.
358
+ strategy: Versioning strategy to use.
359
+ """
360
+ if validation.status not in ("success", "failed"):
361
+ return # Only version completed validations
362
+
363
+ await create_version(
364
+ validation_id=validation.id,
365
+ source_id=validation.source_id,
366
+ result_json=validation.result_json,
367
+ strategy=strategy,
368
+ metadata={
369
+ "passed": validation.passed,
370
+ "total_issues": validation.total_issues,
371
+ "status": validation.status,
372
+ },
373
+ )
374
+ logger.debug(f"Created version for validation {validation.id}")
375
+
376
+
377
+ # Singleton instances for convenience
378
+ _validation_cache: ValidationCacheService | None = None
379
+ _schema_cache: SchemaCacheService | None = None
380
+ _profile_cache: ProfileCacheService | None = None
381
+ _validators_cache: ValidatorsCacheService | None = None
382
+
383
+
384
+ def get_validation_cache() -> ValidationCacheService:
385
+ """Get singleton validation cache service."""
386
+ global _validation_cache
387
+ if _validation_cache is None:
388
+ _validation_cache = ValidationCacheService()
389
+ return _validation_cache
390
+
391
+
392
+ def get_schema_cache() -> SchemaCacheService:
393
+ """Get singleton schema cache service."""
394
+ global _schema_cache
395
+ if _schema_cache is None:
396
+ _schema_cache = SchemaCacheService()
397
+ return _schema_cache
398
+
399
+
400
+ def get_profile_cache() -> ProfileCacheService:
401
+ """Get singleton profile cache service."""
402
+ global _profile_cache
403
+ if _profile_cache is None:
404
+ _profile_cache = ProfileCacheService()
405
+ return _profile_cache
406
+
407
+
408
+ def get_validators_cache() -> ValidatorsCacheService:
409
+ """Get singleton validators cache service."""
410
+ global _validators_cache
411
+ if _validators_cache is None:
412
+ _validators_cache = ValidatorsCacheService()
413
+ return _validators_cache
414
+
415
+
416
+ def reset_cache_services() -> None:
417
+ """Reset all cache service singletons (for testing)."""
418
+ global _validation_cache, _schema_cache, _profile_cache, _validators_cache
419
+ _validation_cache = None
420
+ _schema_cache = None
421
+ _profile_cache = None
422
+ _validators_cache = None