kontra 0.5.2__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 (124) hide show
  1. kontra/__init__.py +1871 -0
  2. kontra/api/__init__.py +22 -0
  3. kontra/api/compare.py +340 -0
  4. kontra/api/decorators.py +153 -0
  5. kontra/api/results.py +2121 -0
  6. kontra/api/rules.py +681 -0
  7. kontra/cli/__init__.py +0 -0
  8. kontra/cli/commands/__init__.py +1 -0
  9. kontra/cli/commands/config.py +153 -0
  10. kontra/cli/commands/diff.py +450 -0
  11. kontra/cli/commands/history.py +196 -0
  12. kontra/cli/commands/profile.py +289 -0
  13. kontra/cli/commands/validate.py +468 -0
  14. kontra/cli/constants.py +6 -0
  15. kontra/cli/main.py +48 -0
  16. kontra/cli/renderers.py +304 -0
  17. kontra/cli/utils.py +28 -0
  18. kontra/config/__init__.py +34 -0
  19. kontra/config/loader.py +127 -0
  20. kontra/config/models.py +49 -0
  21. kontra/config/settings.py +797 -0
  22. kontra/connectors/__init__.py +0 -0
  23. kontra/connectors/db_utils.py +251 -0
  24. kontra/connectors/detection.py +323 -0
  25. kontra/connectors/handle.py +368 -0
  26. kontra/connectors/postgres.py +127 -0
  27. kontra/connectors/sqlserver.py +226 -0
  28. kontra/engine/__init__.py +0 -0
  29. kontra/engine/backends/duckdb_session.py +227 -0
  30. kontra/engine/backends/duckdb_utils.py +18 -0
  31. kontra/engine/backends/polars_backend.py +47 -0
  32. kontra/engine/engine.py +1205 -0
  33. kontra/engine/executors/__init__.py +15 -0
  34. kontra/engine/executors/base.py +50 -0
  35. kontra/engine/executors/database_base.py +528 -0
  36. kontra/engine/executors/duckdb_sql.py +607 -0
  37. kontra/engine/executors/postgres_sql.py +162 -0
  38. kontra/engine/executors/registry.py +69 -0
  39. kontra/engine/executors/sqlserver_sql.py +163 -0
  40. kontra/engine/materializers/__init__.py +14 -0
  41. kontra/engine/materializers/base.py +42 -0
  42. kontra/engine/materializers/duckdb.py +110 -0
  43. kontra/engine/materializers/factory.py +22 -0
  44. kontra/engine/materializers/polars_connector.py +131 -0
  45. kontra/engine/materializers/postgres.py +157 -0
  46. kontra/engine/materializers/registry.py +138 -0
  47. kontra/engine/materializers/sqlserver.py +160 -0
  48. kontra/engine/result.py +15 -0
  49. kontra/engine/sql_utils.py +611 -0
  50. kontra/engine/sql_validator.py +609 -0
  51. kontra/engine/stats.py +194 -0
  52. kontra/engine/types.py +138 -0
  53. kontra/errors.py +533 -0
  54. kontra/logging.py +85 -0
  55. kontra/preplan/__init__.py +5 -0
  56. kontra/preplan/planner.py +253 -0
  57. kontra/preplan/postgres.py +179 -0
  58. kontra/preplan/sqlserver.py +191 -0
  59. kontra/preplan/types.py +24 -0
  60. kontra/probes/__init__.py +20 -0
  61. kontra/probes/compare.py +400 -0
  62. kontra/probes/relationship.py +283 -0
  63. kontra/reporters/__init__.py +0 -0
  64. kontra/reporters/json_reporter.py +190 -0
  65. kontra/reporters/rich_reporter.py +11 -0
  66. kontra/rules/__init__.py +35 -0
  67. kontra/rules/base.py +186 -0
  68. kontra/rules/builtin/__init__.py +40 -0
  69. kontra/rules/builtin/allowed_values.py +156 -0
  70. kontra/rules/builtin/compare.py +188 -0
  71. kontra/rules/builtin/conditional_not_null.py +213 -0
  72. kontra/rules/builtin/conditional_range.py +310 -0
  73. kontra/rules/builtin/contains.py +138 -0
  74. kontra/rules/builtin/custom_sql_check.py +182 -0
  75. kontra/rules/builtin/disallowed_values.py +140 -0
  76. kontra/rules/builtin/dtype.py +203 -0
  77. kontra/rules/builtin/ends_with.py +129 -0
  78. kontra/rules/builtin/freshness.py +240 -0
  79. kontra/rules/builtin/length.py +193 -0
  80. kontra/rules/builtin/max_rows.py +35 -0
  81. kontra/rules/builtin/min_rows.py +46 -0
  82. kontra/rules/builtin/not_null.py +121 -0
  83. kontra/rules/builtin/range.py +222 -0
  84. kontra/rules/builtin/regex.py +143 -0
  85. kontra/rules/builtin/starts_with.py +129 -0
  86. kontra/rules/builtin/unique.py +124 -0
  87. kontra/rules/condition_parser.py +203 -0
  88. kontra/rules/execution_plan.py +455 -0
  89. kontra/rules/factory.py +103 -0
  90. kontra/rules/predicates.py +25 -0
  91. kontra/rules/registry.py +24 -0
  92. kontra/rules/static_predicates.py +120 -0
  93. kontra/scout/__init__.py +9 -0
  94. kontra/scout/backends/__init__.py +17 -0
  95. kontra/scout/backends/base.py +111 -0
  96. kontra/scout/backends/duckdb_backend.py +359 -0
  97. kontra/scout/backends/postgres_backend.py +519 -0
  98. kontra/scout/backends/sqlserver_backend.py +577 -0
  99. kontra/scout/dtype_mapping.py +150 -0
  100. kontra/scout/patterns.py +69 -0
  101. kontra/scout/profiler.py +801 -0
  102. kontra/scout/reporters/__init__.py +39 -0
  103. kontra/scout/reporters/json_reporter.py +165 -0
  104. kontra/scout/reporters/markdown_reporter.py +152 -0
  105. kontra/scout/reporters/rich_reporter.py +144 -0
  106. kontra/scout/store.py +208 -0
  107. kontra/scout/suggest.py +200 -0
  108. kontra/scout/types.py +652 -0
  109. kontra/state/__init__.py +29 -0
  110. kontra/state/backends/__init__.py +79 -0
  111. kontra/state/backends/base.py +348 -0
  112. kontra/state/backends/local.py +480 -0
  113. kontra/state/backends/postgres.py +1010 -0
  114. kontra/state/backends/s3.py +543 -0
  115. kontra/state/backends/sqlserver.py +969 -0
  116. kontra/state/fingerprint.py +166 -0
  117. kontra/state/types.py +1061 -0
  118. kontra/version.py +1 -0
  119. kontra-0.5.2.dist-info/METADATA +122 -0
  120. kontra-0.5.2.dist-info/RECORD +124 -0
  121. kontra-0.5.2.dist-info/WHEEL +5 -0
  122. kontra-0.5.2.dist-info/entry_points.txt +2 -0
  123. kontra-0.5.2.dist-info/licenses/LICENSE +17 -0
  124. kontra-0.5.2.dist-info/top_level.txt +1 -0
kontra/api/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ # src/kontra/api/__init__.py
2
+ """
3
+ Kontra Python API - Public interface classes and functions.
4
+ """
5
+
6
+ from kontra.api.results import (
7
+ ValidationResult,
8
+ RuleResult,
9
+ Diff,
10
+ Suggestions,
11
+ SuggestedRule,
12
+ )
13
+ from kontra.api.rules import rules
14
+
15
+ __all__ = [
16
+ "ValidationResult",
17
+ "RuleResult",
18
+ "Diff",
19
+ "Suggestions",
20
+ "SuggestedRule",
21
+ "rules",
22
+ ]
kontra/api/compare.py ADDED
@@ -0,0 +1,340 @@
1
+ # src/kontra/api/compare.py
2
+ """
3
+ Result types for transformation probes.
4
+
5
+ These are the structured result types returned by compare() and
6
+ profile_relationship() probes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, List, Optional
14
+
15
+
16
+ @dataclass
17
+ class CompareResult:
18
+ """
19
+ Result of comparing two datasets to measure transformation effects.
20
+
21
+ Answers: "Did my transformation preserve rows and keys as expected?"
22
+
23
+ Does NOT answer: whether the transformation is "correct".
24
+
25
+ All measurements are deterministic and factual. Interpretation
26
+ belongs to the consumer (agent or human).
27
+
28
+ Attributes:
29
+ # Meta
30
+ before_rows: Number of rows in before dataset
31
+ after_rows: Number of rows in after dataset
32
+ key: Column(s) used as row identifier
33
+ execution_tier: Which execution tier computed the result ("polars" | "sql")
34
+
35
+ # Row stats
36
+ row_delta: Change in row count (after - before)
37
+ row_ratio: Ratio of after/before rows
38
+
39
+ # Key stats
40
+ unique_before: Count of unique keys in before
41
+ unique_after: Count of unique keys in after
42
+ preserved: Keys present in both before and after
43
+ dropped: Keys in before but not in after
44
+ added: Keys in after but not in before
45
+ duplicated_after: Keys appearing >1x in after (not row count, key count)
46
+
47
+ # Change stats (for preserved keys only)
48
+ unchanged_rows: Rows where no non-key columns changed
49
+ changed_rows: Rows where at least one non-key column changed
50
+
51
+ # Column stats
52
+ columns_added: Columns in after but not in before
53
+ columns_removed: Columns in before but not in after
54
+ columns_modified: Columns in both where at least one value differs
55
+ modified_fraction: {col: fraction of preserved rows where col changed}
56
+ nullability_delta: {col: {before: rate, after: rate}}
57
+
58
+ # Samples (bounded, explanatory only)
59
+ samples_duplicated_keys: Sample keys appearing >1x in after
60
+ samples_dropped_keys: Sample keys dropped from before
61
+ samples_changed_rows: Sample changed rows with before/after values
62
+
63
+ # Config
64
+ sample_limit: Maximum samples per category
65
+
66
+ Semantic Definitions:
67
+ - changed_rows: Structural value change. Any non-key column inequality
68
+ counts as a change. NULL → value and value → NULL are changes.
69
+ Computed only for preserved keys.
70
+ - duplicated_after: Count of keys (not rows) that appear more than once
71
+ in the after dataset. A key appearing 3x contributes 1 to this count.
72
+ - modified_fraction: For each modified column, the fraction of preserved
73
+ rows where that column's value differs between before and after.
74
+ """
75
+
76
+ # Meta
77
+ before_rows: int
78
+ after_rows: int
79
+ key: List[str]
80
+ execution_tier: str = "polars"
81
+
82
+ # Row stats
83
+ row_delta: int = 0
84
+ row_ratio: float = 1.0
85
+
86
+ # Key stats
87
+ unique_before: int = 0
88
+ unique_after: int = 0
89
+ preserved: int = 0
90
+ dropped: int = 0
91
+ added: int = 0
92
+ duplicated_after: int = 0
93
+
94
+ # Change stats
95
+ unchanged_rows: int = 0
96
+ changed_rows: int = 0
97
+
98
+ # Column stats
99
+ columns_added: List[str] = field(default_factory=list)
100
+ columns_removed: List[str] = field(default_factory=list)
101
+ columns_modified: List[str] = field(default_factory=list)
102
+ modified_fraction: Dict[str, float] = field(default_factory=dict)
103
+ nullability_delta: Dict[str, Dict[str, Optional[float]]] = field(default_factory=dict)
104
+
105
+ # Samples
106
+ samples_duplicated_keys: List[Any] = field(default_factory=list)
107
+ samples_dropped_keys: List[Any] = field(default_factory=list)
108
+ samples_changed_rows: List[Dict[str, Any]] = field(default_factory=list)
109
+
110
+ # Config
111
+ sample_limit: int = 5
112
+
113
+ def __repr__(self) -> str:
114
+ delta_sign = "+" if self.row_delta >= 0 else ""
115
+ return (
116
+ f"CompareResult(rows: {self.before_rows:,} → {self.after_rows:,} "
117
+ f"({delta_sign}{self.row_delta:,}), "
118
+ f"keys: preserved={self.preserved:,}, dropped={self.dropped:,}, added={self.added:,}, "
119
+ f"duplicated={self.duplicated_after:,})"
120
+ )
121
+
122
+ def to_dict(self) -> Dict[str, Any]:
123
+ """
124
+ Convert to dictionary format matching the MVP schema.
125
+
126
+ Returns nested structure with meta, row_stats, key_stats,
127
+ change_stats, column_stats, and samples sections.
128
+ """
129
+ return {
130
+ "meta": {
131
+ "before_rows": self.before_rows,
132
+ "after_rows": self.after_rows,
133
+ "key": self.key,
134
+ "execution_tier": self.execution_tier,
135
+ },
136
+ "row_stats": {
137
+ "delta": self.row_delta,
138
+ "ratio": self.row_ratio,
139
+ },
140
+ "key_stats": {
141
+ "unique_before": self.unique_before,
142
+ "unique_after": self.unique_after,
143
+ "preserved": self.preserved,
144
+ "dropped": self.dropped,
145
+ "added": self.added,
146
+ "duplicated_after": self.duplicated_after,
147
+ },
148
+ "change_stats": {
149
+ "unchanged_rows": self.unchanged_rows,
150
+ "changed_rows": self.changed_rows,
151
+ },
152
+ "column_stats": {
153
+ "added": self.columns_added,
154
+ "removed": self.columns_removed,
155
+ "modified": self.columns_modified,
156
+ "modified_fraction": self.modified_fraction,
157
+ "nullability_delta": self.nullability_delta,
158
+ },
159
+ "samples": {
160
+ "duplicated_keys": self.samples_duplicated_keys,
161
+ "dropped_keys": self.samples_dropped_keys,
162
+ "changed_rows": self.samples_changed_rows,
163
+ },
164
+ }
165
+
166
+ def to_json(self, indent: Optional[int] = 2) -> str:
167
+ """Convert to JSON string."""
168
+ return json.dumps(self.to_dict(), indent=indent, default=str)
169
+
170
+ def to_llm(self) -> str:
171
+ """
172
+ Token-optimized format for LLM context.
173
+
174
+ This is a thin wrapper over to_dict(). No summarization, no prose.
175
+ Agents prompt themselves for interpretation.
176
+ """
177
+ return json.dumps(self.to_dict(), indent=2, default=str)
178
+
179
+
180
+ @dataclass
181
+ class RelationshipProfile:
182
+ """
183
+ Result of profiling the structural relationship between two datasets.
184
+
185
+ Answers: "What is the shape of this join?"
186
+
187
+ Does NOT answer: which join type to use, or whether the join is correct.
188
+
189
+ All measurements are deterministic and factual. Interpretation
190
+ belongs to the consumer (agent or human).
191
+
192
+ Attributes:
193
+ # Meta
194
+ on: Column(s) used as join key
195
+ left_rows: Number of rows in left dataset
196
+ right_rows: Number of rows in right dataset
197
+ execution_tier: Which execution tier computed the result
198
+
199
+ # Key stats - left
200
+ left_null_rate: Fraction of rows with NULL in join key
201
+ left_unique_keys: Count of unique key values
202
+ left_duplicate_keys: Number of keys appearing >1x
203
+
204
+ # Key stats - right
205
+ right_null_rate: Fraction of rows with NULL in join key
206
+ right_unique_keys: Count of unique key values
207
+ right_duplicate_keys: Number of keys appearing >1x
208
+
209
+ # Cardinality (rows per key)
210
+ # NOTE: min/max hides distribution shape. A single pathological key
211
+ # with max=1000 looks like "many-to-many" even if 99.9% of keys have
212
+ # 1 row. Acceptable for MVP since samples are present and we don't label.
213
+ left_key_multiplicity_min: Minimum rows per key (left)
214
+ left_key_multiplicity_max: Maximum rows per key (left)
215
+ right_key_multiplicity_min: Minimum rows per key (right)
216
+ right_key_multiplicity_max: Maximum rows per key (right)
217
+
218
+ # Coverage
219
+ left_keys_with_match: Keys in left that exist in right
220
+ left_keys_without_match: Keys in left that don't exist in right
221
+ right_keys_with_match: Keys in right that exist in left
222
+ right_keys_without_match: Keys in right that don't exist in left
223
+
224
+ # Samples (bounded, explanatory only)
225
+ samples_left_unmatched: Sample keys in left without match
226
+ samples_right_unmatched: Sample keys in right without match
227
+ samples_right_duplicates: Sample keys with >1 row in right
228
+
229
+ # Config
230
+ sample_limit: Maximum samples per category
231
+ """
232
+
233
+ # Meta
234
+ on: List[str]
235
+ left_rows: int
236
+ right_rows: int
237
+ execution_tier: str = "polars"
238
+
239
+ # Key stats - left
240
+ left_null_rate: float = 0.0
241
+ left_unique_keys: int = 0
242
+ left_duplicate_keys: int = 0
243
+
244
+ # Key stats - right
245
+ right_null_rate: float = 0.0
246
+ right_unique_keys: int = 0
247
+ right_duplicate_keys: int = 0
248
+
249
+ # Cardinality
250
+ left_key_multiplicity_min: int = 0
251
+ left_key_multiplicity_max: int = 0
252
+ right_key_multiplicity_min: int = 0
253
+ right_key_multiplicity_max: int = 0
254
+
255
+ # Coverage
256
+ left_keys_with_match: int = 0
257
+ left_keys_without_match: int = 0
258
+ right_keys_with_match: int = 0
259
+ right_keys_without_match: int = 0
260
+
261
+ # Samples
262
+ samples_left_unmatched: List[Any] = field(default_factory=list)
263
+ samples_right_unmatched: List[Any] = field(default_factory=list)
264
+ samples_right_duplicates: List[Any] = field(default_factory=list)
265
+
266
+ # Config
267
+ sample_limit: int = 5
268
+
269
+ def __repr__(self) -> str:
270
+ return (
271
+ f"RelationshipProfile(on={self.on}, "
272
+ f"left={self.left_rows:,} rows/{self.left_unique_keys:,} keys, "
273
+ f"right={self.right_rows:,} rows/{self.right_unique_keys:,} keys, "
274
+ f"coverage: left={self.left_keys_with_match:,}/{self.left_unique_keys:,}, "
275
+ f"right={self.right_keys_with_match:,}/{self.right_unique_keys:,})"
276
+ )
277
+
278
+ def to_dict(self) -> Dict[str, Any]:
279
+ """
280
+ Convert to dictionary format matching the MVP schema.
281
+
282
+ Returns nested structure with meta, key_stats, cardinality,
283
+ coverage, and samples sections.
284
+ """
285
+ return {
286
+ "meta": {
287
+ "on": self.on,
288
+ "left_rows": self.left_rows,
289
+ "right_rows": self.right_rows,
290
+ "execution_tier": self.execution_tier,
291
+ },
292
+ "key_stats": {
293
+ "left": {
294
+ "null_rate": self.left_null_rate,
295
+ "unique_keys": self.left_unique_keys,
296
+ "duplicate_keys": self.left_duplicate_keys,
297
+ "rows": self.left_rows,
298
+ },
299
+ "right": {
300
+ "null_rate": self.right_null_rate,
301
+ "unique_keys": self.right_unique_keys,
302
+ "duplicate_keys": self.right_duplicate_keys,
303
+ "rows": self.right_rows,
304
+ },
305
+ },
306
+ "cardinality": {
307
+ "left_key_multiplicity": {
308
+ "min": self.left_key_multiplicity_min,
309
+ "max": self.left_key_multiplicity_max,
310
+ },
311
+ "right_key_multiplicity": {
312
+ "min": self.right_key_multiplicity_min,
313
+ "max": self.right_key_multiplicity_max,
314
+ },
315
+ },
316
+ "coverage": {
317
+ "left_keys_with_match": self.left_keys_with_match,
318
+ "left_keys_without_match": self.left_keys_without_match,
319
+ "right_keys_with_match": self.right_keys_with_match,
320
+ "right_keys_without_match": self.right_keys_without_match,
321
+ },
322
+ "samples": {
323
+ "left_keys_without_match": self.samples_left_unmatched,
324
+ "right_keys_without_match": self.samples_right_unmatched,
325
+ "right_keys_with_multiple_rows": self.samples_right_duplicates,
326
+ },
327
+ }
328
+
329
+ def to_json(self, indent: Optional[int] = 2) -> str:
330
+ """Convert to JSON string."""
331
+ return json.dumps(self.to_dict(), indent=indent, default=str)
332
+
333
+ def to_llm(self) -> str:
334
+ """
335
+ Token-optimized format for LLM context.
336
+
337
+ This is a thin wrapper over to_dict(). No summarization, no prose.
338
+ Agents prompt themselves for interpretation.
339
+ """
340
+ return json.dumps(self.to_dict(), indent=2, default=str)
@@ -0,0 +1,153 @@
1
+ # src/kontra/api/decorators.py
2
+ """
3
+ Pipeline validation decorators for Kontra.
4
+
5
+ Decorators for validating data returned from functions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import warnings
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ List,
17
+ Literal,
18
+ Optional,
19
+ TypeVar,
20
+ Union,
21
+ )
22
+
23
+ from kontra.errors import ValidationError
24
+
25
+ F = TypeVar("F", bound=Callable[..., Any])
26
+
27
+ # Built-in mode shortcuts
28
+ OnFailMode = Literal["raise", "warn", "return_result"]
29
+
30
+ # Callback signature: (result, data) -> data (or raise)
31
+ OnFailCallback = Callable[["ValidationResult", Any], Any] # type: ignore
32
+
33
+ # Accept either a mode string or a callback
34
+ OnFailHandler = Union[OnFailMode, OnFailCallback]
35
+
36
+
37
+ def validate(
38
+ contract: Optional[str] = None,
39
+ rules: Optional[List[Dict[str, Any]]] = None,
40
+ on_fail: OnFailHandler = "raise",
41
+ save: bool = False,
42
+ sample: int = 0,
43
+ sample_columns: Optional[Union[List[str], str]] = None,
44
+ ) -> Callable[[F], F]:
45
+ """
46
+ Decorator to validate data returned from a function.
47
+
48
+ The decorated function must return a DataFrame (Polars or pandas)
49
+ or other data type supported by `kontra.validate()`.
50
+
51
+ Args:
52
+ contract: Path to a YAML contract file
53
+ rules: List of rule definitions (alternative to contract)
54
+ on_fail: Action when validation fails. Either a mode string or a callback:
55
+ - "raise": Raise ValidationError on blocking failures (default)
56
+ - "warn": Log warning, return data anyway
57
+ - "return_result": Return (data, ValidationResult) tuple
58
+ - Callable[[ValidationResult, data], data]: Custom handler
59
+ save: Whether to save the validation result to state
60
+ sample: Number of sample rows to collect for failures
61
+ sample_columns: Columns to include in samples (None=all, list, or "relevant")
62
+
63
+ Returns:
64
+ Decorated function
65
+
66
+ Raises:
67
+ ValueError: If neither contract nor rules is provided
68
+ ValidationError: If on_fail="raise" and validation has blocking failures
69
+
70
+ Example:
71
+ ```python
72
+ import kontra
73
+ from kontra import rules
74
+
75
+ # Built-in modes
76
+ @kontra.validate_decorator(
77
+ rules=[rules.not_null("id"), rules.unique("email")],
78
+ on_fail="raise"
79
+ )
80
+ def load_users() -> pl.DataFrame:
81
+ return pl.read_parquet("users.parquet")
82
+
83
+ # Custom callback - Kontra measures, you decide
84
+ def notify_slack(result, data):
85
+ if not result.passed:
86
+ slack.post(f"Validation failed: {result.failed_count} violations")
87
+ return data # or raise, or transform, etc.
88
+
89
+ @kontra.validate_decorator(
90
+ rules=[rules.not_null("id")],
91
+ on_fail=notify_slack
92
+ )
93
+ def fetch_orders():
94
+ return db.query("SELECT * FROM orders")
95
+ ```
96
+ """
97
+ if contract is None and rules is None:
98
+ raise ValueError("Either 'contract' or 'rules' must be provided")
99
+
100
+ def decorator(func: F) -> F:
101
+ @functools.wraps(func)
102
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
103
+ # Import here to avoid circular imports
104
+ import kontra
105
+
106
+ # Call the original function
107
+ data = func(*args, **kwargs)
108
+
109
+ # Validate the returned data
110
+ result = kontra.validate(
111
+ data,
112
+ contract=contract,
113
+ rules=rules,
114
+ save=save,
115
+ sample=sample,
116
+ sample_columns=sample_columns,
117
+ )
118
+
119
+ # Handle based on on_fail mode or callback
120
+ if callable(on_fail) and not isinstance(on_fail, str):
121
+ # User-provided callback: Kontra measured, user decides
122
+ return on_fail(result, data)
123
+
124
+ if on_fail == "return_result":
125
+ return (data, result)
126
+
127
+ if not result.passed:
128
+ # Check for blocking failures
129
+ blocking_failures = [
130
+ r for r in result.rules if not r.passed and r.severity == "blocking"
131
+ ]
132
+ if blocking_failures:
133
+ if on_fail == "raise":
134
+ raise ValidationError(result)
135
+ elif on_fail == "warn":
136
+ # Log warning
137
+ warnings.warn(
138
+ f"Validation failed in {func.__name__}: "
139
+ f"{len(blocking_failures)} blocking rule(s) failed "
140
+ f"({result.failed_count} total violations)",
141
+ UserWarning,
142
+ stacklevel=2,
143
+ )
144
+
145
+ return data
146
+
147
+ return wrapper # type: ignore
148
+
149
+ return decorator
150
+
151
+
152
+ # Alias for import convenience
153
+ validate_decorator = validate