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.
- kontra/__init__.py +1871 -0
- kontra/api/__init__.py +22 -0
- kontra/api/compare.py +340 -0
- kontra/api/decorators.py +153 -0
- kontra/api/results.py +2121 -0
- kontra/api/rules.py +681 -0
- kontra/cli/__init__.py +0 -0
- kontra/cli/commands/__init__.py +1 -0
- kontra/cli/commands/config.py +153 -0
- kontra/cli/commands/diff.py +450 -0
- kontra/cli/commands/history.py +196 -0
- kontra/cli/commands/profile.py +289 -0
- kontra/cli/commands/validate.py +468 -0
- kontra/cli/constants.py +6 -0
- kontra/cli/main.py +48 -0
- kontra/cli/renderers.py +304 -0
- kontra/cli/utils.py +28 -0
- kontra/config/__init__.py +34 -0
- kontra/config/loader.py +127 -0
- kontra/config/models.py +49 -0
- kontra/config/settings.py +797 -0
- kontra/connectors/__init__.py +0 -0
- kontra/connectors/db_utils.py +251 -0
- kontra/connectors/detection.py +323 -0
- kontra/connectors/handle.py +368 -0
- kontra/connectors/postgres.py +127 -0
- kontra/connectors/sqlserver.py +226 -0
- kontra/engine/__init__.py +0 -0
- kontra/engine/backends/duckdb_session.py +227 -0
- kontra/engine/backends/duckdb_utils.py +18 -0
- kontra/engine/backends/polars_backend.py +47 -0
- kontra/engine/engine.py +1205 -0
- kontra/engine/executors/__init__.py +15 -0
- kontra/engine/executors/base.py +50 -0
- kontra/engine/executors/database_base.py +528 -0
- kontra/engine/executors/duckdb_sql.py +607 -0
- kontra/engine/executors/postgres_sql.py +162 -0
- kontra/engine/executors/registry.py +69 -0
- kontra/engine/executors/sqlserver_sql.py +163 -0
- kontra/engine/materializers/__init__.py +14 -0
- kontra/engine/materializers/base.py +42 -0
- kontra/engine/materializers/duckdb.py +110 -0
- kontra/engine/materializers/factory.py +22 -0
- kontra/engine/materializers/polars_connector.py +131 -0
- kontra/engine/materializers/postgres.py +157 -0
- kontra/engine/materializers/registry.py +138 -0
- kontra/engine/materializers/sqlserver.py +160 -0
- kontra/engine/result.py +15 -0
- kontra/engine/sql_utils.py +611 -0
- kontra/engine/sql_validator.py +609 -0
- kontra/engine/stats.py +194 -0
- kontra/engine/types.py +138 -0
- kontra/errors.py +533 -0
- kontra/logging.py +85 -0
- kontra/preplan/__init__.py +5 -0
- kontra/preplan/planner.py +253 -0
- kontra/preplan/postgres.py +179 -0
- kontra/preplan/sqlserver.py +191 -0
- kontra/preplan/types.py +24 -0
- kontra/probes/__init__.py +20 -0
- kontra/probes/compare.py +400 -0
- kontra/probes/relationship.py +283 -0
- kontra/reporters/__init__.py +0 -0
- kontra/reporters/json_reporter.py +190 -0
- kontra/reporters/rich_reporter.py +11 -0
- kontra/rules/__init__.py +35 -0
- kontra/rules/base.py +186 -0
- kontra/rules/builtin/__init__.py +40 -0
- kontra/rules/builtin/allowed_values.py +156 -0
- kontra/rules/builtin/compare.py +188 -0
- kontra/rules/builtin/conditional_not_null.py +213 -0
- kontra/rules/builtin/conditional_range.py +310 -0
- kontra/rules/builtin/contains.py +138 -0
- kontra/rules/builtin/custom_sql_check.py +182 -0
- kontra/rules/builtin/disallowed_values.py +140 -0
- kontra/rules/builtin/dtype.py +203 -0
- kontra/rules/builtin/ends_with.py +129 -0
- kontra/rules/builtin/freshness.py +240 -0
- kontra/rules/builtin/length.py +193 -0
- kontra/rules/builtin/max_rows.py +35 -0
- kontra/rules/builtin/min_rows.py +46 -0
- kontra/rules/builtin/not_null.py +121 -0
- kontra/rules/builtin/range.py +222 -0
- kontra/rules/builtin/regex.py +143 -0
- kontra/rules/builtin/starts_with.py +129 -0
- kontra/rules/builtin/unique.py +124 -0
- kontra/rules/condition_parser.py +203 -0
- kontra/rules/execution_plan.py +455 -0
- kontra/rules/factory.py +103 -0
- kontra/rules/predicates.py +25 -0
- kontra/rules/registry.py +24 -0
- kontra/rules/static_predicates.py +120 -0
- kontra/scout/__init__.py +9 -0
- kontra/scout/backends/__init__.py +17 -0
- kontra/scout/backends/base.py +111 -0
- kontra/scout/backends/duckdb_backend.py +359 -0
- kontra/scout/backends/postgres_backend.py +519 -0
- kontra/scout/backends/sqlserver_backend.py +577 -0
- kontra/scout/dtype_mapping.py +150 -0
- kontra/scout/patterns.py +69 -0
- kontra/scout/profiler.py +801 -0
- kontra/scout/reporters/__init__.py +39 -0
- kontra/scout/reporters/json_reporter.py +165 -0
- kontra/scout/reporters/markdown_reporter.py +152 -0
- kontra/scout/reporters/rich_reporter.py +144 -0
- kontra/scout/store.py +208 -0
- kontra/scout/suggest.py +200 -0
- kontra/scout/types.py +652 -0
- kontra/state/__init__.py +29 -0
- kontra/state/backends/__init__.py +79 -0
- kontra/state/backends/base.py +348 -0
- kontra/state/backends/local.py +480 -0
- kontra/state/backends/postgres.py +1010 -0
- kontra/state/backends/s3.py +543 -0
- kontra/state/backends/sqlserver.py +969 -0
- kontra/state/fingerprint.py +166 -0
- kontra/state/types.py +1061 -0
- kontra/version.py +1 -0
- kontra-0.5.2.dist-info/METADATA +122 -0
- kontra-0.5.2.dist-info/RECORD +124 -0
- kontra-0.5.2.dist-info/WHEEL +5 -0
- kontra-0.5.2.dist-info/entry_points.txt +2 -0
- kontra-0.5.2.dist-info/licenses/LICENSE +17 -0
- 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)
|
kontra/api/decorators.py
ADDED
|
@@ -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
|