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/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)
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Thin re-export for convenience.
3
+
4
+ from .types import PrePlan, Decision
5
+ from .planner import preplan_single_parquet