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/errors.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# src/kontra/errors.py
|
|
2
|
+
"""
|
|
3
|
+
Kontra error types with actionable error messages.
|
|
4
|
+
|
|
5
|
+
All errors inherit from KontraError and provide:
|
|
6
|
+
- Clear description of what went wrong
|
|
7
|
+
- Suggestions for how to fix it
|
|
8
|
+
- Context about what was being attempted
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional, List
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KontraError(Exception):
|
|
17
|
+
"""Base class for all Kontra errors."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
suggestions: Optional[List[str]] = None,
|
|
24
|
+
context: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
self.message = message
|
|
27
|
+
self.suggestions = suggestions or []
|
|
28
|
+
self.context = context
|
|
29
|
+
super().__init__(self._format())
|
|
30
|
+
|
|
31
|
+
def _format(self) -> str:
|
|
32
|
+
parts = [self.message]
|
|
33
|
+
if self.context:
|
|
34
|
+
parts.append(f"\n Context: {self.context}")
|
|
35
|
+
if self.suggestions:
|
|
36
|
+
parts.append("\n\nTry:")
|
|
37
|
+
for s in self.suggestions:
|
|
38
|
+
parts.append(f" - {s}")
|
|
39
|
+
return "".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Contract Errors
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ContractError(KontraError):
|
|
48
|
+
"""Base class for contract-related errors."""
|
|
49
|
+
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ContractNotFoundError(ContractError):
|
|
54
|
+
"""Contract file not found."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, path: str):
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"Contract file not found: {path}",
|
|
59
|
+
suggestions=[
|
|
60
|
+
"Check the file path is correct",
|
|
61
|
+
"Ensure the file exists and is readable",
|
|
62
|
+
f"Create a contract at: {path}",
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ContractParseError(ContractError):
|
|
68
|
+
"""Contract YAML is invalid or malformed."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, path: str, error: str):
|
|
71
|
+
super().__init__(
|
|
72
|
+
f"Failed to parse contract YAML: {error}",
|
|
73
|
+
context=path,
|
|
74
|
+
suggestions=[
|
|
75
|
+
"Check YAML syntax (indentation, colons, quotes)",
|
|
76
|
+
"Validate YAML online: https://www.yamllint.com/",
|
|
77
|
+
"See contract examples in docs/",
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ContractValidationError(ContractError):
|
|
83
|
+
"""Contract structure is invalid."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, issue: str, path: str):
|
|
86
|
+
super().__init__(
|
|
87
|
+
f"Invalid contract: {issue}",
|
|
88
|
+
context=path,
|
|
89
|
+
suggestions=[
|
|
90
|
+
"Contract must have 'dataset' and 'rules' keys",
|
|
91
|
+
"Each rule must have a 'name' key",
|
|
92
|
+
"Check rule parameters match the rule type",
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Rule Errors
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RuleError(KontraError):
|
|
103
|
+
"""Base class for rule-related errors."""
|
|
104
|
+
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class UnknownRuleError(RuleError):
|
|
109
|
+
"""Rule type is not recognized."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, rule_name: str, available_rules: Optional[List[str]] = None):
|
|
112
|
+
suggestions = []
|
|
113
|
+
if available_rules:
|
|
114
|
+
suggestions.append(f"Available rules: {', '.join(sorted(available_rules))}")
|
|
115
|
+
suggestions.extend([
|
|
116
|
+
"Check rule name spelling",
|
|
117
|
+
"See docs/rules.md for all supported rules",
|
|
118
|
+
])
|
|
119
|
+
super().__init__(
|
|
120
|
+
f"Unknown rule type: '{rule_name}'",
|
|
121
|
+
suggestions=suggestions,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RuleParameterError(RuleError):
|
|
126
|
+
"""Rule parameters are invalid."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, rule_name: str, param: str, issue: str):
|
|
129
|
+
super().__init__(
|
|
130
|
+
f"Invalid parameter '{param}' for rule '{rule_name}': {issue}",
|
|
131
|
+
suggestions=[
|
|
132
|
+
f"Check {rule_name} documentation for valid parameters",
|
|
133
|
+
"Ensure parameter types are correct (string, int, list, etc.)",
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class DuplicateRuleIdError(RuleError):
|
|
139
|
+
"""Duplicate rule ID detected in contract.
|
|
140
|
+
|
|
141
|
+
This error occurs when multiple rules resolve to the same ID.
|
|
142
|
+
The automatic ID format is:
|
|
143
|
+
- COL:{column}:{rule_name} for column-based rules
|
|
144
|
+
- DATASET:{rule_name} for dataset-level rules
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
rule_id: str,
|
|
150
|
+
rule_name: str,
|
|
151
|
+
rule_index: int,
|
|
152
|
+
conflict_index: int,
|
|
153
|
+
column: Optional[str] = None,
|
|
154
|
+
):
|
|
155
|
+
self.rule_id = rule_id
|
|
156
|
+
self.rule_name = rule_name
|
|
157
|
+
self.rule_index = rule_index
|
|
158
|
+
self.conflict_index = conflict_index
|
|
159
|
+
self.column = column
|
|
160
|
+
|
|
161
|
+
# Build suggestion with example
|
|
162
|
+
if column:
|
|
163
|
+
example = (
|
|
164
|
+
f" - name: {rule_name}\n"
|
|
165
|
+
f" id: {column}_{rule_name}_v2 # Choose a unique ID\n"
|
|
166
|
+
f" params:\n"
|
|
167
|
+
f" column: {column}"
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
example = (
|
|
171
|
+
f" - name: {rule_name}\n"
|
|
172
|
+
f" id: {rule_name}_v2 # Choose a unique ID"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
super().__init__(
|
|
176
|
+
f"Duplicate rule ID '{rule_id}' in contract",
|
|
177
|
+
context=f"Rule at index {rule_index} conflicts with rule at index {conflict_index}",
|
|
178
|
+
suggestions=[
|
|
179
|
+
"Multiple rules resolved to the same ID",
|
|
180
|
+
f"Add an explicit 'id' field to distinguish rules:\n\n{example}",
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# =============================================================================
|
|
186
|
+
# Connection Errors
|
|
187
|
+
# =============================================================================
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ConnectionError(KontraError):
|
|
191
|
+
"""Base class for connection-related errors."""
|
|
192
|
+
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class PostgresConnectionError(ConnectionError):
|
|
197
|
+
"""PostgreSQL connection failed."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, host: str, port: int, database: str, error: str):
|
|
200
|
+
super().__init__(
|
|
201
|
+
f"PostgreSQL connection failed: {error}",
|
|
202
|
+
context=f"{host}:{port}/{database}",
|
|
203
|
+
suggestions=[
|
|
204
|
+
"Verify the database server is running",
|
|
205
|
+
"Check host, port, and database name",
|
|
206
|
+
"Verify username and password",
|
|
207
|
+
"Set environment variables:\n"
|
|
208
|
+
" export PGHOST=localhost\n"
|
|
209
|
+
" export PGPORT=5432\n"
|
|
210
|
+
" export PGUSER=your_user\n"
|
|
211
|
+
" export PGPASSWORD=your_password\n"
|
|
212
|
+
" export PGDATABASE=your_database",
|
|
213
|
+
"Or use full URI: postgres://user:pass@host:5432/database/schema.table",
|
|
214
|
+
],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class SqlServerConnectionError(ConnectionError):
|
|
219
|
+
"""SQL Server connection failed."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, host: str, database: str, error: str):
|
|
222
|
+
super().__init__(
|
|
223
|
+
f"SQL Server connection failed: {error}",
|
|
224
|
+
context=f"{host}/{database}",
|
|
225
|
+
suggestions=[
|
|
226
|
+
"Verify the database server is running",
|
|
227
|
+
"Check host and database name",
|
|
228
|
+
"Verify username and password",
|
|
229
|
+
"Check if SQL Server allows TCP/IP connections",
|
|
230
|
+
"Use full URI: mssql://user:pass@host/database/schema.table",
|
|
231
|
+
],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class S3ConnectionError(ConnectionError):
|
|
236
|
+
"""S3/MinIO connection failed."""
|
|
237
|
+
|
|
238
|
+
def __init__(self, uri: str, error: str):
|
|
239
|
+
super().__init__(
|
|
240
|
+
f"S3 access failed: {error}",
|
|
241
|
+
context=uri,
|
|
242
|
+
suggestions=[
|
|
243
|
+
"Set AWS credentials:\n"
|
|
244
|
+
" export AWS_ACCESS_KEY_ID=your_key\n"
|
|
245
|
+
" export AWS_SECRET_ACCESS_KEY=your_secret",
|
|
246
|
+
"For MinIO/custom S3:\n"
|
|
247
|
+
" export AWS_ENDPOINT_URL=http://localhost:9000",
|
|
248
|
+
"Check bucket and key names are correct",
|
|
249
|
+
"Verify bucket permissions",
|
|
250
|
+
],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# =============================================================================
|
|
255
|
+
# Data Errors
|
|
256
|
+
# =============================================================================
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class DataError(KontraError):
|
|
260
|
+
"""Base class for data-related errors."""
|
|
261
|
+
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class InvalidDataError(DataError):
|
|
266
|
+
"""Data type is invalid for validation."""
|
|
267
|
+
|
|
268
|
+
def __init__(self, data_type: str, *, detail: Optional[str] = None):
|
|
269
|
+
message = f"Invalid data type: {data_type}"
|
|
270
|
+
if detail:
|
|
271
|
+
message = f"{message}. {detail}"
|
|
272
|
+
|
|
273
|
+
super().__init__(
|
|
274
|
+
message,
|
|
275
|
+
suggestions=[
|
|
276
|
+
"Supported data types:",
|
|
277
|
+
" - Polars DataFrame",
|
|
278
|
+
" - pandas DataFrame",
|
|
279
|
+
" - dict (single record)",
|
|
280
|
+
" - list[dict] (multiple records)",
|
|
281
|
+
" - str (file path, URI, or datasource name)",
|
|
282
|
+
" - Database connection (requires table= parameter)",
|
|
283
|
+
],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class InvalidPathError(DataError):
|
|
288
|
+
"""Path is invalid (e.g., directory instead of file)."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, path: str, issue: str):
|
|
291
|
+
super().__init__(
|
|
292
|
+
f"Invalid path: {issue}",
|
|
293
|
+
context=path,
|
|
294
|
+
suggestions=[
|
|
295
|
+
"Provide a path to a file, not a directory",
|
|
296
|
+
"Supported formats: .parquet, .csv",
|
|
297
|
+
"Or use a URI: s3://bucket/key, postgres://..., mssql://...",
|
|
298
|
+
],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class DataNotFoundError(DataError):
|
|
303
|
+
"""Data file or table not found."""
|
|
304
|
+
|
|
305
|
+
def __init__(self, source: str):
|
|
306
|
+
suggestions = ["Check the path or URI is correct"]
|
|
307
|
+
if source.lower().startswith("s3://"):
|
|
308
|
+
suggestions.extend([
|
|
309
|
+
"Verify bucket and key exist",
|
|
310
|
+
"Check S3 credentials are set",
|
|
311
|
+
])
|
|
312
|
+
elif source.lower().startswith("postgres://"):
|
|
313
|
+
suggestions.extend([
|
|
314
|
+
"Verify schema.table exists",
|
|
315
|
+
"Check database permissions",
|
|
316
|
+
])
|
|
317
|
+
else:
|
|
318
|
+
suggestions.append("Ensure the file exists and is readable")
|
|
319
|
+
|
|
320
|
+
super().__init__(
|
|
321
|
+
f"Data source not found: {source}",
|
|
322
|
+
suggestions=suggestions,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class DataFormatError(DataError):
|
|
327
|
+
"""Data format is invalid or unsupported."""
|
|
328
|
+
|
|
329
|
+
def __init__(self, source: str, issue: str):
|
|
330
|
+
super().__init__(
|
|
331
|
+
f"Data format error: {issue}",
|
|
332
|
+
context=source,
|
|
333
|
+
suggestions=[
|
|
334
|
+
"Supported formats: Parquet, CSV",
|
|
335
|
+
"Check file extension matches actual format",
|
|
336
|
+
"For CSV: check encoding (UTF-8 recommended)",
|
|
337
|
+
],
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# =============================================================================
|
|
342
|
+
# Config Errors
|
|
343
|
+
# =============================================================================
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ConfigError(KontraError):
|
|
347
|
+
"""Base class for configuration errors."""
|
|
348
|
+
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class ConfigNotFoundError(ConfigError):
|
|
353
|
+
"""Config file not found (only raised if explicitly required)."""
|
|
354
|
+
|
|
355
|
+
def __init__(self, path: str):
|
|
356
|
+
super().__init__(
|
|
357
|
+
f"Config file not found: {path}",
|
|
358
|
+
suggestions=[
|
|
359
|
+
"Run 'kontra init' to create a default config",
|
|
360
|
+
"Or continue without a config file (all defaults apply)",
|
|
361
|
+
],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class ConfigParseError(ConfigError):
|
|
366
|
+
"""Config YAML is invalid."""
|
|
367
|
+
|
|
368
|
+
def __init__(self, path: str, error: str):
|
|
369
|
+
super().__init__(
|
|
370
|
+
f"Failed to parse config: {error}",
|
|
371
|
+
context=path,
|
|
372
|
+
suggestions=[
|
|
373
|
+
"Check YAML syntax (indentation, colons, quotes)",
|
|
374
|
+
"Validate at https://www.yamllint.com/",
|
|
375
|
+
"Run 'kontra init --force' to regenerate",
|
|
376
|
+
],
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ConfigValidationError(ConfigError):
|
|
381
|
+
"""Config structure is invalid."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, errors: list, path: str):
|
|
384
|
+
formatted = "\n".join(f" - {e}" for e in errors)
|
|
385
|
+
super().__init__(
|
|
386
|
+
f"Invalid config:\n{formatted}",
|
|
387
|
+
context=path,
|
|
388
|
+
suggestions=[
|
|
389
|
+
"Check field names and types",
|
|
390
|
+
"Valid preplan/pushdown values: on, off, auto",
|
|
391
|
+
"Valid projection values: on, off",
|
|
392
|
+
"Run 'kontra init --force' to see valid structure",
|
|
393
|
+
],
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class UnknownEnvironmentError(ConfigError):
|
|
398
|
+
"""Requested environment doesn't exist in config."""
|
|
399
|
+
|
|
400
|
+
def __init__(self, env_name: str, available: list):
|
|
401
|
+
available_str = ", ".join(available) if available else "(none defined)"
|
|
402
|
+
super().__init__(
|
|
403
|
+
f"Unknown environment: '{env_name}'",
|
|
404
|
+
suggestions=[
|
|
405
|
+
f"Available environments: {available_str}",
|
|
406
|
+
"Add the environment to .kontra/config.yml",
|
|
407
|
+
"Or remove the --env flag to use defaults",
|
|
408
|
+
],
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# =============================================================================
|
|
413
|
+
# State Errors
|
|
414
|
+
# =============================================================================
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class StateError(KontraError):
|
|
418
|
+
"""Base class for state-related errors."""
|
|
419
|
+
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class StateCorruptedError(StateError):
|
|
424
|
+
"""State files are corrupted or unreadable."""
|
|
425
|
+
|
|
426
|
+
def __init__(self, contract: str, error: str):
|
|
427
|
+
super().__init__(
|
|
428
|
+
f"State data is corrupted for contract '{contract}': {error}",
|
|
429
|
+
suggestions=[
|
|
430
|
+
"Delete the corrupted state files in .kontra/state/",
|
|
431
|
+
"Run 'kontra validate' again to regenerate state",
|
|
432
|
+
"Check if state files were modified externally",
|
|
433
|
+
],
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class StateNotFoundError(StateError):
|
|
438
|
+
"""No state history exists for the contract."""
|
|
439
|
+
|
|
440
|
+
def __init__(self, contract: str):
|
|
441
|
+
super().__init__(
|
|
442
|
+
f"No validation history found for contract '{contract}'",
|
|
443
|
+
suggestions=[
|
|
444
|
+
"Run 'kontra validate' at least twice to generate history for diff",
|
|
445
|
+
"Check the contract name is correct",
|
|
446
|
+
],
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# =============================================================================
|
|
451
|
+
# Validation Errors
|
|
452
|
+
# =============================================================================
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class ValidationError(KontraError):
|
|
456
|
+
"""
|
|
457
|
+
Raised when validation fails in a decorated function.
|
|
458
|
+
|
|
459
|
+
This error is raised by the @kontra.validate() decorator when
|
|
460
|
+
on_fail="raise" and the decorated function returns data that
|
|
461
|
+
fails blocking validation rules.
|
|
462
|
+
|
|
463
|
+
Attributes:
|
|
464
|
+
result: The ValidationResult with details about failures
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
def __init__(self, result: "ValidationResult", message: Optional[str] = None):
|
|
468
|
+
from kontra.api.results import ValidationResult # noqa: F811
|
|
469
|
+
|
|
470
|
+
self.result = result
|
|
471
|
+
if message is None:
|
|
472
|
+
blocking = [r for r in result.rules if not r.passed and r.severity == "blocking"]
|
|
473
|
+
message = (
|
|
474
|
+
f"Validation failed: {len(blocking)} blocking rule(s) failed "
|
|
475
|
+
f"({result.failed_count} total violations)"
|
|
476
|
+
)
|
|
477
|
+
# Don't use suggestions for this - the message is clear
|
|
478
|
+
super().__init__(message)
|
|
479
|
+
|
|
480
|
+
def _format(self) -> str:
|
|
481
|
+
# Override to not add "Try:" section
|
|
482
|
+
return self.message
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# Type hint for ValidationResult (avoid circular import)
|
|
486
|
+
from typing import TYPE_CHECKING
|
|
487
|
+
|
|
488
|
+
if TYPE_CHECKING:
|
|
489
|
+
from kontra.api.results import ValidationResult
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# =============================================================================
|
|
493
|
+
# Helper Functions
|
|
494
|
+
# =============================================================================
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def format_error_for_cli(error: Exception) -> str:
|
|
498
|
+
"""Format any exception for CLI display."""
|
|
499
|
+
if isinstance(error, KontraError):
|
|
500
|
+
return str(error)
|
|
501
|
+
|
|
502
|
+
# Handle common exception types with better messages
|
|
503
|
+
error_str = str(error).lower()
|
|
504
|
+
|
|
505
|
+
if isinstance(error, FileNotFoundError):
|
|
506
|
+
return f"File not found: {error}\n\nCheck the file path is correct."
|
|
507
|
+
|
|
508
|
+
if "connection refused" in error_str:
|
|
509
|
+
return (
|
|
510
|
+
f"Connection refused: {error}\n\n"
|
|
511
|
+
"The database server may not be running, or the host/port is incorrect."
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
if "timeout" in error_str or "timed out" in error_str:
|
|
515
|
+
return (
|
|
516
|
+
f"Connection timed out: {error}\n\n"
|
|
517
|
+
"The server took too long to respond. Check network connectivity."
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if "permission denied" in error_str or "access denied" in error_str:
|
|
521
|
+
return (
|
|
522
|
+
f"Permission denied: {error}\n\n"
|
|
523
|
+
"Check credentials and access permissions."
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if "authentication" in error_str or "password" in error_str:
|
|
527
|
+
return (
|
|
528
|
+
f"Authentication failed: {error}\n\n"
|
|
529
|
+
"Check username and password are correct."
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Default: just return the error message
|
|
533
|
+
return str(error)
|
kontra/logging.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# src/kontra/logging.py
|
|
2
|
+
"""
|
|
3
|
+
Logging utilities for Kontra.
|
|
4
|
+
|
|
5
|
+
Provides consistent, opt-in verbose logging across the codebase.
|
|
6
|
+
Logging is controlled by the KONTRA_VERBOSE environment variable.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from kontra.logging import get_logger
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
logger.debug("This appears only when KONTRA_VERBOSE is set")
|
|
13
|
+
logger.warning("This always appears but with more detail when KONTRA_VERBOSE is set")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
# Module-level flag for verbose mode
|
|
24
|
+
_verbose_mode: Optional[bool] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_verbose() -> bool:
|
|
28
|
+
"""Check if verbose mode is enabled via KONTRA_VERBOSE env var."""
|
|
29
|
+
global _verbose_mode
|
|
30
|
+
if _verbose_mode is None:
|
|
31
|
+
_verbose_mode = bool(os.getenv("KONTRA_VERBOSE"))
|
|
32
|
+
return _verbose_mode
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_logger(name: str) -> logging.Logger:
|
|
36
|
+
"""
|
|
37
|
+
Get a logger configured for Kontra.
|
|
38
|
+
|
|
39
|
+
When KONTRA_VERBOSE is set:
|
|
40
|
+
- DEBUG and above messages are shown
|
|
41
|
+
- Format includes module name and level
|
|
42
|
+
|
|
43
|
+
When KONTRA_VERBOSE is not set:
|
|
44
|
+
- Only WARNING and above are shown
|
|
45
|
+
- Format is minimal
|
|
46
|
+
"""
|
|
47
|
+
logger = logging.getLogger(name)
|
|
48
|
+
|
|
49
|
+
# Only configure if not already configured
|
|
50
|
+
if not logger.handlers:
|
|
51
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
52
|
+
|
|
53
|
+
if is_verbose():
|
|
54
|
+
logger.setLevel(logging.DEBUG)
|
|
55
|
+
handler.setFormatter(
|
|
56
|
+
logging.Formatter("[%(levelname)s] %(name)s: %(message)s")
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
logger.setLevel(logging.WARNING)
|
|
60
|
+
handler.setFormatter(
|
|
61
|
+
logging.Formatter("[%(levelname)s] %(message)s")
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
logger.addHandler(handler)
|
|
65
|
+
logger.propagate = False
|
|
66
|
+
|
|
67
|
+
return logger
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def log_exception(
|
|
71
|
+
logger: logging.Logger,
|
|
72
|
+
msg: str,
|
|
73
|
+
exc: Exception,
|
|
74
|
+
level: int = logging.DEBUG,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Log an exception with appropriate detail level.
|
|
78
|
+
|
|
79
|
+
In verbose mode: logs full exception details
|
|
80
|
+
Otherwise: logs just the message (if level >= WARNING)
|
|
81
|
+
"""
|
|
82
|
+
if is_verbose():
|
|
83
|
+
logger.log(level, f"{msg}: {type(exc).__name__}: {exc}")
|
|
84
|
+
elif level >= logging.WARNING:
|
|
85
|
+
logger.log(level, msg)
|