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
@@ -0,0 +1,79 @@
1
+ # src/kontra/state/backends/__init__.py
2
+ """
3
+ State storage backends.
4
+
5
+ Backends provide pluggable persistence for validation state:
6
+ - LocalStore: Filesystem storage in .kontra/state/
7
+ - S3Store: S3-compatible object storage
8
+ - PostgresStore: PostgreSQL database
9
+ - SQLServerStore: SQL Server database
10
+ """
11
+
12
+ from .base import StateBackend
13
+ from .local import LocalStore
14
+
15
+ # Default store factory
16
+ _default_store: LocalStore | None = None
17
+
18
+
19
+ def get_default_store() -> LocalStore:
20
+ """
21
+ Get the default state store.
22
+
23
+ Uses .kontra/state/ in the current working directory.
24
+ Lazily initialized on first call.
25
+ """
26
+ global _default_store
27
+ if _default_store is None:
28
+ _default_store = LocalStore()
29
+ return _default_store
30
+
31
+
32
+ def get_store(backend: str = "local") -> StateBackend:
33
+ """
34
+ Get a state store by backend identifier.
35
+
36
+ Args:
37
+ backend: Backend identifier. Options:
38
+ - "local" or "": LocalStore (default)
39
+ - "s3://bucket/prefix": S3Store
40
+ - "postgres://..." or "postgresql://...": PostgresStore
41
+ - "mssql://..." or "sqlserver://...": SQLServerStore
42
+
43
+ Returns:
44
+ A StateBackend instance
45
+
46
+ Environment Variables:
47
+ For S3:
48
+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL, AWS_REGION
49
+
50
+ For PostgreSQL:
51
+ PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, DATABASE_URL
52
+
53
+ For SQL Server:
54
+ MSSQL_HOST, MSSQL_PORT, MSSQL_USER, MSSQL_PASSWORD, MSSQL_DATABASE, MSSQL_DRIVER
55
+ """
56
+ if not backend or backend == "local":
57
+ return get_default_store()
58
+
59
+ if backend.startswith("s3://"):
60
+ from .s3 import S3Store
61
+ return S3Store(backend)
62
+
63
+ if backend.startswith("postgres://") or backend.startswith("postgresql://"):
64
+ from .postgres import PostgresStore
65
+ return PostgresStore(backend)
66
+
67
+ if backend.startswith("mssql://") or backend.startswith("sqlserver://"):
68
+ from .sqlserver import SQLServerStore
69
+ return SQLServerStore(backend)
70
+
71
+ raise ValueError(f"Unknown state backend: {backend}")
72
+
73
+
74
+ __all__ = [
75
+ "StateBackend",
76
+ "LocalStore",
77
+ "get_default_store",
78
+ "get_store",
79
+ ]
@@ -0,0 +1,348 @@
1
+ # src/kontra/state/backends/base.py
2
+ """
3
+ StateBackend protocol definition.
4
+
5
+ All state storage implementations must conform to this protocol.
6
+
7
+ v0.5 adds:
8
+ - Normalized schema (kontra_runs, kontra_rule_results)
9
+ - Annotations (kontra_annotations) with append-only semantics
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from datetime import datetime
16
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from kontra.state.types import Annotation, RunSummary, ValidationState
20
+
21
+
22
+ class StateBackend(ABC):
23
+ """
24
+ Abstract base class for state storage backends.
25
+
26
+ Implementations provide persistence for ValidationState objects,
27
+ enabling history tracking and comparison across runs.
28
+
29
+ Design principles:
30
+ - Immutable writes: Each save creates a new record
31
+ - Query by contract: States are indexed by contract fingerprint
32
+ - Time-ordered: History is returned newest-first
33
+ """
34
+
35
+ @abstractmethod
36
+ def save(self, state: "ValidationState") -> None:
37
+ """
38
+ Save a validation state.
39
+
40
+ The state is immutable once saved. Each call creates a new record
41
+ identified by (contract_fingerprint, run_at).
42
+
43
+ Args:
44
+ state: The ValidationState to persist
45
+
46
+ Raises:
47
+ IOError: If the save fails
48
+ """
49
+ ...
50
+
51
+ @abstractmethod
52
+ def get_latest(self, contract_fingerprint: str) -> Optional["ValidationState"]:
53
+ """
54
+ Get the most recent state for a contract.
55
+
56
+ Args:
57
+ contract_fingerprint: The contract's fingerprint hash
58
+
59
+ Returns:
60
+ The most recent ValidationState, or None if no history exists
61
+ """
62
+ ...
63
+
64
+ @abstractmethod
65
+ def get_history(
66
+ self,
67
+ contract_fingerprint: str,
68
+ limit: int = 10,
69
+ ) -> List["ValidationState"]:
70
+ """
71
+ Get recent history for a contract.
72
+
73
+ Args:
74
+ contract_fingerprint: The contract's fingerprint hash
75
+ limit: Maximum number of states to return
76
+
77
+ Returns:
78
+ List of ValidationState objects, newest first
79
+ """
80
+ ...
81
+
82
+ def get_at(
83
+ self,
84
+ contract_fingerprint: str,
85
+ timestamp: datetime,
86
+ ) -> Optional["ValidationState"]:
87
+ """
88
+ Get state at or before a specific timestamp.
89
+
90
+ Default implementation uses get_history and filters.
91
+ Backends may override with more efficient queries.
92
+
93
+ Args:
94
+ contract_fingerprint: The contract's fingerprint hash
95
+ timestamp: The target timestamp
96
+
97
+ Returns:
98
+ The ValidationState at or before timestamp, or None
99
+ """
100
+ history = self.get_history(contract_fingerprint, limit=100)
101
+ for state in history:
102
+ if state.run_at <= timestamp:
103
+ return state
104
+ return None
105
+
106
+ def get_previous(
107
+ self,
108
+ contract_fingerprint: str,
109
+ before: datetime,
110
+ ) -> Optional["ValidationState"]:
111
+ """
112
+ Get the state immediately before a timestamp.
113
+
114
+ Useful for comparing current run to previous run.
115
+
116
+ Args:
117
+ contract_fingerprint: The contract's fingerprint hash
118
+ before: Get state before this timestamp
119
+
120
+ Returns:
121
+ The most recent ValidationState before timestamp, or None
122
+ """
123
+ history = self.get_history(contract_fingerprint, limit=100)
124
+ for state in history:
125
+ if state.run_at < before:
126
+ return state
127
+ return None
128
+
129
+ def delete_old(
130
+ self,
131
+ contract_fingerprint: str,
132
+ keep_count: int = 100,
133
+ ) -> int:
134
+ """
135
+ Delete old states, keeping the most recent ones.
136
+
137
+ Default implementation does nothing. Backends may override
138
+ to implement retention policies.
139
+
140
+ Args:
141
+ contract_fingerprint: The contract's fingerprint hash
142
+ keep_count: Number of recent states to keep
143
+
144
+ Returns:
145
+ Number of states deleted
146
+ """
147
+ return 0
148
+
149
+ def list_contracts(self) -> List[str]:
150
+ """
151
+ List all contract fingerprints with stored state.
152
+
153
+ Default implementation returns empty list. Backends may override.
154
+
155
+ Returns:
156
+ List of contract fingerprint strings
157
+ """
158
+ return []
159
+
160
+ def get_run_summaries(
161
+ self,
162
+ contract_fingerprint: str,
163
+ limit: int = 20,
164
+ since: Optional[datetime] = None,
165
+ failed_only: bool = False,
166
+ ) -> List["RunSummary"]:
167
+ """
168
+ Get lightweight run summaries for history listing.
169
+
170
+ More efficient than get_history() as it doesn't load rule details.
171
+
172
+ Args:
173
+ contract_fingerprint: The contract's fingerprint hash
174
+ limit: Maximum number of summaries to return
175
+ since: Only return runs after this timestamp
176
+ failed_only: Only return failed runs
177
+
178
+ Returns:
179
+ List of RunSummary objects, newest first
180
+ """
181
+ # Default implementation converts from full states
182
+ from kontra.state.types import RunSummary
183
+
184
+ states = self.get_history(contract_fingerprint, limit=limit * 2)
185
+ summaries = []
186
+
187
+ for i, state in enumerate(states):
188
+ if since and state.run_at < since:
189
+ continue
190
+ if failed_only and state.summary.passed:
191
+ continue
192
+
193
+ run_id = str(state.id) if state.id else f"run_{i}"
194
+ summaries.append(RunSummary.from_validation_state(state, run_id))
195
+
196
+ if len(summaries) >= limit:
197
+ break
198
+
199
+ return summaries
200
+
201
+ # -------------------------------------------------------------------------
202
+ # Annotation Methods (v0.5)
203
+ # -------------------------------------------------------------------------
204
+ #
205
+ # Annotations are append-only records that agents/humans can attach to
206
+ # validation runs. Kontra never reads annotations during validation or diff.
207
+ #
208
+ # Default implementations do nothing. Database backends override with
209
+ # actual persistence logic.
210
+
211
+ @staticmethod
212
+ def _attach_annotations_to_state(
213
+ state: "ValidationState",
214
+ annotations: List["Annotation"],
215
+ ) -> None:
216
+ """
217
+ Attach annotations to a ValidationState, grouping by rule_result_id.
218
+
219
+ Modifies state in-place:
220
+ - Sets state.annotations to run-level annotations (rule_result_id is None)
221
+ - Sets rule.annotations for each rule result
222
+
223
+ Args:
224
+ state: The ValidationState to modify
225
+ annotations: List of annotations to attach
226
+ """
227
+ # Group annotations by rule_result_id
228
+ run_annotations: List["Annotation"] = []
229
+ rule_annotations: Dict[int, List["Annotation"]] = {}
230
+
231
+ for ann in annotations:
232
+ if ann.rule_result_id is None:
233
+ run_annotations.append(ann)
234
+ else:
235
+ rule_annotations.setdefault(ann.rule_result_id, []).append(ann)
236
+
237
+ state.annotations = run_annotations
238
+ for rule in state.rules:
239
+ if rule.id is not None:
240
+ rule.annotations = rule_annotations.get(rule.id, [])
241
+ else:
242
+ rule.annotations = []
243
+
244
+ def save_annotation(self, annotation: "Annotation") -> int:
245
+ """
246
+ Save an annotation.
247
+
248
+ Annotations are append-only. Each save creates a new record.
249
+
250
+ Args:
251
+ annotation: The Annotation to persist
252
+
253
+ Returns:
254
+ The database-assigned ID of the new annotation
255
+
256
+ Raises:
257
+ IOError: If the save fails
258
+ ValueError: If annotation references non-existent run/rule
259
+ """
260
+ raise NotImplementedError(
261
+ f"{self.__class__.__name__} does not support annotations"
262
+ )
263
+
264
+ def get_annotations(
265
+ self,
266
+ run_id: int,
267
+ rule_result_id: Optional[int] = None,
268
+ ) -> List["Annotation"]:
269
+ """
270
+ Get annotations for a run or specific rule result.
271
+
272
+ Args:
273
+ run_id: The run ID to get annotations for
274
+ rule_result_id: If provided, filter to annotations on this rule
275
+
276
+ Returns:
277
+ List of Annotation objects, newest first
278
+ """
279
+ return []
280
+
281
+ def get_annotations_for_contract(
282
+ self,
283
+ contract_fingerprint: str,
284
+ rule_id: Optional[str] = None,
285
+ annotation_type: Optional[str] = None,
286
+ limit: int = 20,
287
+ ) -> List["Annotation"]:
288
+ """
289
+ Get annotations across all runs for a contract.
290
+
291
+ This is the cross-run query for agent memory - "what annotations exist
292
+ for this rule across all past runs?"
293
+
294
+ Args:
295
+ contract_fingerprint: The contract's fingerprint hash
296
+ rule_id: If provided, filter to annotations on this rule
297
+ annotation_type: If provided, filter by annotation type
298
+ limit: Maximum number of annotations to return
299
+
300
+ Returns:
301
+ List of Annotation objects, newest first, with rule_id populated
302
+ """
303
+ return []
304
+
305
+ def get_run_with_annotations(
306
+ self,
307
+ contract_fingerprint: str,
308
+ run_id: Optional[int] = None,
309
+ ) -> Optional["ValidationState"]:
310
+ """
311
+ Get a validation state with its annotations loaded.
312
+
313
+ Args:
314
+ contract_fingerprint: The contract's fingerprint hash
315
+ run_id: Specific run ID. If None, gets the latest run.
316
+
317
+ Returns:
318
+ ValidationState with annotations populated, or None
319
+ """
320
+ # Default: get latest and attach empty annotations
321
+ state = self.get_latest(contract_fingerprint) if run_id is None else None
322
+ if state:
323
+ state.annotations = []
324
+ for rule in state.rules:
325
+ rule.annotations = []
326
+ return state
327
+
328
+ def get_history_with_annotations(
329
+ self,
330
+ contract_fingerprint: str,
331
+ limit: int = 10,
332
+ ) -> List["ValidationState"]:
333
+ """
334
+ Get recent history with annotations loaded.
335
+
336
+ Args:
337
+ contract_fingerprint: The contract's fingerprint hash
338
+ limit: Maximum number of states to return
339
+
340
+ Returns:
341
+ List of ValidationState objects with annotations, newest first
342
+ """
343
+ states = self.get_history(contract_fingerprint, limit=limit)
344
+ for state in states:
345
+ state.annotations = []
346
+ for rule in state.rules:
347
+ rule.annotations = []
348
+ return states