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
@@ -0,0 +1,400 @@
1
+ """Main deduplication service.
2
+
3
+ This module provides the NotificationDeduplicator service that
4
+ combines storage, strategies, and policies for complete
5
+ deduplication functionality.
6
+
7
+ Example:
8
+ # Create deduplicator
9
+ deduplicator = NotificationDeduplicator(
10
+ store=InMemoryDeduplicationStore(),
11
+ default_window=TimeWindow(minutes=5),
12
+ policy=DeduplicationPolicy.SEVERITY,
13
+ )
14
+
15
+ # Check and record
16
+ fingerprint = deduplicator.generate_fingerprint(
17
+ checkpoint_name="daily_check",
18
+ action_type="slack",
19
+ severity="high",
20
+ )
21
+
22
+ if not deduplicator.is_duplicate(fingerprint):
23
+ await send_notification()
24
+ deduplicator.mark_sent(fingerprint)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from datetime import datetime
31
+ from typing import Any
32
+
33
+ from ...validation_limits import (
34
+ ValidationLimitError,
35
+ get_time_window_limits,
36
+ )
37
+ from .policies import DeduplicationPolicy, FingerprintConfig, FingerprintGenerator
38
+ from .stores import BaseDeduplicationStore, InMemoryDeduplicationStore
39
+ from .strategies import BaseWindowStrategy, SlidingWindowStrategy
40
+
41
+
42
+ @dataclass
43
+ class TimeWindow:
44
+ """Time window configuration with validation.
45
+
46
+ Provides a convenient way to specify window durations with built-in
47
+ validation to prevent DoS attacks from excessive window sizes.
48
+
49
+ Validation Limits (configurable via environment variables):
50
+ - Total seconds: 1 to 604800 (7 days)
51
+ - Individual components must be non-negative
52
+
53
+ Environment Variables:
54
+ - TRUTHOUND_TIMEWINDOW_MIN: Minimum total duration (default: 1)
55
+ - TRUTHOUND_TIMEWINDOW_MAX: Maximum total duration (default: 604800)
56
+
57
+ Attributes:
58
+ seconds: Additional seconds (default: 0).
59
+ minutes: Additional minutes (default: 0).
60
+ hours: Additional hours (default: 0).
61
+ days: Additional days (default: 0).
62
+
63
+ Raises:
64
+ ValidationLimitError: If total duration exceeds limits.
65
+ ValueError: If any component is negative.
66
+ """
67
+
68
+ seconds: int = 0
69
+ minutes: int = 0
70
+ hours: int = 0
71
+ days: int = 0
72
+
73
+ def __post_init__(self) -> None:
74
+ """Validate window configuration after initialization.
75
+
76
+ Raises:
77
+ ValueError: If any component is negative.
78
+ ValidationLimitError: If total duration exceeds limits.
79
+ """
80
+ # Validate non-negative values
81
+ if self.seconds < 0:
82
+ raise ValueError(f"seconds must be non-negative, got {self.seconds}")
83
+ if self.minutes < 0:
84
+ raise ValueError(f"minutes must be non-negative, got {self.minutes}")
85
+ if self.hours < 0:
86
+ raise ValueError(f"hours must be non-negative, got {self.hours}")
87
+ if self.days < 0:
88
+ raise ValueError(f"days must be non-negative, got {self.days}")
89
+
90
+ # Validate total duration against limits
91
+ total = self.total_seconds
92
+ limits = get_time_window_limits()
93
+ valid, error = limits.validate_total_seconds(total)
94
+ if not valid:
95
+ raise ValidationLimitError(
96
+ error or f"Invalid total duration: {total}",
97
+ parameter="total_seconds",
98
+ value=total,
99
+ )
100
+
101
+ @property
102
+ def total_seconds(self) -> int:
103
+ """Get total duration in seconds."""
104
+ return (
105
+ self.seconds
106
+ + self.minutes * 60
107
+ + self.hours * 3600
108
+ + self.days * 86400
109
+ )
110
+
111
+ @classmethod
112
+ def from_seconds(cls, seconds: int) -> "TimeWindow":
113
+ """Create from seconds with validation.
114
+
115
+ Args:
116
+ seconds: Total duration in seconds.
117
+
118
+ Returns:
119
+ TimeWindow instance.
120
+
121
+ Raises:
122
+ ValidationLimitError: If seconds exceeds limits.
123
+ ValueError: If seconds is negative.
124
+ """
125
+ if seconds < 0:
126
+ raise ValueError(f"seconds must be non-negative, got {seconds}")
127
+
128
+ # Validate against limits before creating
129
+ limits = get_time_window_limits()
130
+ valid, error = limits.validate_total_seconds(seconds)
131
+ if not valid:
132
+ raise ValidationLimitError(
133
+ error or f"Invalid duration: {seconds}",
134
+ parameter="seconds",
135
+ value=seconds,
136
+ )
137
+
138
+ return cls(seconds=seconds)
139
+
140
+ def __repr__(self) -> str:
141
+ parts = []
142
+ if self.days:
143
+ parts.append(f"{self.days}d")
144
+ if self.hours:
145
+ parts.append(f"{self.hours}h")
146
+ if self.minutes:
147
+ parts.append(f"{self.minutes}m")
148
+ if self.seconds:
149
+ parts.append(f"{self.seconds}s")
150
+ return f"TimeWindow({' '.join(parts) or '0s'})"
151
+
152
+
153
+ class NotificationDeduplicator:
154
+ """Main deduplication service.
155
+
156
+ Provides complete deduplication functionality by combining:
157
+ - Storage backend for tracking sent notifications
158
+ - Window strategy for calculating deduplication windows
159
+ - Fingerprint policy for generating unique identifiers
160
+
161
+ Thread-safe for concurrent use.
162
+
163
+ Example:
164
+ deduplicator = NotificationDeduplicator(
165
+ store=SQLiteDeduplicationStore("dedup.db"),
166
+ default_window=TimeWindow(minutes=5),
167
+ policy=DeduplicationPolicy.SEVERITY,
168
+ strategy=AdaptiveWindowStrategy(),
169
+ )
170
+
171
+ # Generate fingerprint
172
+ fp = deduplicator.generate_fingerprint(
173
+ checkpoint_name="check1",
174
+ action_type="slack",
175
+ severity="high",
176
+ )
177
+
178
+ # Check if duplicate
179
+ if not deduplicator.is_duplicate(fp):
180
+ send_notification()
181
+ deduplicator.mark_sent(fp)
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ store: BaseDeduplicationStore | None = None,
187
+ default_window: TimeWindow | None = None,
188
+ policy: DeduplicationPolicy = DeduplicationPolicy.BASIC,
189
+ strategy: BaseWindowStrategy | None = None,
190
+ fingerprint_config: FingerprintConfig | None = None,
191
+ ) -> None:
192
+ """Initialize deduplicator.
193
+
194
+ Args:
195
+ store: Storage backend (default: InMemoryDeduplicationStore).
196
+ default_window: Default deduplication window.
197
+ policy: Fingerprint policy.
198
+ strategy: Window strategy (default: SlidingWindowStrategy).
199
+ fingerprint_config: Custom fingerprint config.
200
+ """
201
+ self.store = store or InMemoryDeduplicationStore()
202
+ self.default_window = default_window or TimeWindow(minutes=5)
203
+ self.policy = policy
204
+ self.strategy = strategy or SlidingWindowStrategy(
205
+ window_seconds=self.default_window.total_seconds
206
+ )
207
+ self.fingerprint_generator = FingerprintGenerator(
208
+ policy=policy,
209
+ config=fingerprint_config,
210
+ )
211
+
212
+ def generate_fingerprint(
213
+ self,
214
+ checkpoint_name: str | None = None,
215
+ action_type: str | None = None,
216
+ severity: str | None = None,
217
+ issues: list[dict[str, Any]] | None = None,
218
+ timestamp: datetime | None = None,
219
+ **custom_fields: Any,
220
+ ) -> str:
221
+ """Generate a deduplication fingerprint.
222
+
223
+ Args:
224
+ checkpoint_name: Name of checkpoint/source.
225
+ action_type: Type of notification channel.
226
+ severity: Severity level.
227
+ issues: List of issues.
228
+ timestamp: Event timestamp.
229
+ **custom_fields: Additional fields.
230
+
231
+ Returns:
232
+ Generated fingerprint string.
233
+ """
234
+ return self.fingerprint_generator.generate(
235
+ checkpoint_name=checkpoint_name,
236
+ action_type=action_type,
237
+ severity=severity,
238
+ issues=issues,
239
+ timestamp=timestamp,
240
+ **custom_fields,
241
+ )
242
+
243
+ def is_duplicate(
244
+ self,
245
+ fingerprint: str,
246
+ window: TimeWindow | None = None,
247
+ context: dict[str, Any] | None = None,
248
+ ) -> bool:
249
+ """Check if a fingerprint is a duplicate.
250
+
251
+ Args:
252
+ fingerprint: The fingerprint to check.
253
+ window: Optional window override.
254
+ context: Optional context for strategy.
255
+
256
+ Returns:
257
+ True if this is a duplicate notification.
258
+ """
259
+ # Get window duration from strategy
260
+ window_seconds = self.strategy.get_window_seconds(fingerprint, context)
261
+
262
+ # Override with explicit window if provided
263
+ if window is not None:
264
+ window_seconds = window.total_seconds
265
+
266
+ # Use default if strategy returns 0
267
+ if window_seconds <= 0:
268
+ window_seconds = self.default_window.total_seconds
269
+
270
+ # Get window-aligned key
271
+ window_key = self.strategy.get_window_key(fingerprint)
272
+
273
+ # Check store
274
+ return self.store.exists(window_key, window_seconds)
275
+
276
+ def mark_sent(
277
+ self,
278
+ fingerprint: str,
279
+ metadata: dict[str, Any] | None = None,
280
+ ) -> None:
281
+ """Mark a fingerprint as sent.
282
+
283
+ Args:
284
+ fingerprint: The fingerprint to record.
285
+ metadata: Optional metadata to store.
286
+ """
287
+ # Get window-aligned key
288
+ window_key = self.strategy.get_window_key(fingerprint)
289
+
290
+ # Record in store
291
+ self.store.record(window_key, metadata)
292
+
293
+ def check_and_mark(
294
+ self,
295
+ fingerprint: str,
296
+ window: TimeWindow | None = None,
297
+ context: dict[str, Any] | None = None,
298
+ metadata: dict[str, Any] | None = None,
299
+ ) -> bool:
300
+ """Atomically check if duplicate and mark as sent if not.
301
+
302
+ Args:
303
+ fingerprint: The fingerprint to check.
304
+ window: Optional window override.
305
+ context: Optional context for strategy.
306
+ metadata: Optional metadata to store.
307
+
308
+ Returns:
309
+ True if this is NOT a duplicate (notification should be sent).
310
+ False if this IS a duplicate (notification should be skipped).
311
+ """
312
+ if self.is_duplicate(fingerprint, window, context):
313
+ return False
314
+
315
+ self.mark_sent(fingerprint, metadata)
316
+ return True
317
+
318
+ def get_stats(self) -> dict[str, Any]:
319
+ """Get deduplication statistics.
320
+
321
+ Returns:
322
+ Dictionary with statistics.
323
+ """
324
+ return {
325
+ "total_entries": self.store.count(),
326
+ "policy": self.policy.value,
327
+ "default_window_seconds": self.default_window.total_seconds,
328
+ "strategy_type": getattr(self.strategy, "strategy_type", "unknown"),
329
+ }
330
+
331
+ def cleanup(self, max_age: TimeWindow | None = None) -> int:
332
+ """Remove expired entries.
333
+
334
+ Args:
335
+ max_age: Maximum age of entries to keep.
336
+
337
+ Returns:
338
+ Number of entries removed.
339
+ """
340
+ if max_age is None:
341
+ # Default to 24 hours
342
+ max_age = TimeWindow(hours=24)
343
+
344
+ return self.store.cleanup(max_age.total_seconds)
345
+
346
+ def clear(self) -> None:
347
+ """Clear all deduplication state."""
348
+ self.store.clear()
349
+
350
+
351
+ def deduplicated(
352
+ policy: DeduplicationPolicy = DeduplicationPolicy.BASIC,
353
+ window: TimeWindow | None = None,
354
+ ):
355
+ """Decorator for deduplicating async functions.
356
+
357
+ Creates a deduplicator and checks for duplicates before
358
+ executing the decorated function.
359
+
360
+ Args:
361
+ policy: Deduplication policy.
362
+ window: Deduplication window.
363
+
364
+ Example:
365
+ @deduplicated(
366
+ policy=DeduplicationPolicy.SEVERITY,
367
+ window=TimeWindow(minutes=10),
368
+ )
369
+ async def send_slack_notification(
370
+ checkpoint_name: str,
371
+ severity: str,
372
+ message: str,
373
+ ):
374
+ await slack.post(message)
375
+ """
376
+ import functools
377
+
378
+ # Create shared deduplicator
379
+ deduplicator = NotificationDeduplicator(
380
+ policy=policy,
381
+ default_window=window or TimeWindow(minutes=5),
382
+ )
383
+
384
+ def decorator(func):
385
+ @functools.wraps(func)
386
+ async def wrapper(*args, **kwargs):
387
+ # Generate fingerprint from kwargs
388
+ fingerprint = deduplicator.generate_fingerprint(**kwargs)
389
+
390
+ # Check and mark
391
+ if not deduplicator.check_and_mark(fingerprint):
392
+ # Duplicate - skip execution
393
+ return None
394
+
395
+ # Execute function
396
+ return await func(*args, **kwargs)
397
+
398
+ return wrapper
399
+
400
+ return decorator