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,468 @@
1
+ """Validate command for Kontra CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, Optional
6
+
7
+ import typer
8
+
9
+ from kontra.cli.constants import (
10
+ EXIT_CONFIG_ERROR,
11
+ EXIT_RUNTIME_ERROR,
12
+ EXIT_SUCCESS,
13
+ EXIT_VALIDATION_FAILED,
14
+ )
15
+ from kontra.cli.renderers import print_rich_stats
16
+
17
+
18
+ def handle_dry_run(contract_path: str, data_path: Optional[str], verbose: bool) -> None:
19
+ """
20
+ Validate contract syntax and rule definitions without executing.
21
+
22
+ Checks:
23
+ 1. Contract file exists and is valid YAML
24
+ 2. Contract structure is valid (has dataset, rules list)
25
+ 3. All rules are recognized
26
+ 4. Dataset URI is parseable
27
+ """
28
+ from kontra.config.loader import ContractLoader
29
+ from kontra.connectors.handle import DatasetHandle
30
+ from kontra.rules.factory import RuleFactory
31
+ from kontra.rules.registry import get_all_rule_names
32
+
33
+ # Import built-in rules to populate registry
34
+ import kontra.rules.builtin.allowed_values # noqa: F401
35
+ import kontra.rules.builtin.disallowed_values # noqa: F401
36
+ import kontra.rules.builtin.compare # noqa: F401
37
+ import kontra.rules.builtin.conditional_not_null # noqa: F401
38
+ import kontra.rules.builtin.conditional_range # noqa: F401
39
+ import kontra.rules.builtin.contains # noqa: F401
40
+ import kontra.rules.builtin.custom_sql_check # noqa: F401
41
+ import kontra.rules.builtin.dtype # noqa: F401
42
+ import kontra.rules.builtin.ends_with # noqa: F401
43
+ import kontra.rules.builtin.freshness # noqa: F401
44
+ import kontra.rules.builtin.length # noqa: F401
45
+ import kontra.rules.builtin.max_rows # noqa: F401
46
+ import kontra.rules.builtin.min_rows # noqa: F401
47
+ import kontra.rules.builtin.not_null # noqa: F401
48
+ import kontra.rules.builtin.range # noqa: F401
49
+ import kontra.rules.builtin.regex # noqa: F401
50
+ import kontra.rules.builtin.starts_with # noqa: F401
51
+ import kontra.rules.builtin.unique # noqa: F401
52
+
53
+ checks_passed = 0
54
+ checks_failed = 0
55
+ issues = []
56
+
57
+ typer.echo("\nDry run validation\n" + "=" * 40)
58
+
59
+ # 1. Check contract exists and is valid YAML
60
+ try:
61
+ if contract_path.lower().startswith("s3://"):
62
+ contract = ContractLoader.from_s3(contract_path)
63
+ else:
64
+ contract = ContractLoader.from_path(contract_path)
65
+ typer.secho(
66
+ f" ✓ Contract syntax valid: {contract_path}", fg=typer.colors.GREEN
67
+ )
68
+ checks_passed += 1
69
+ except FileNotFoundError as e:
70
+ typer.secho(f" ✗ Contract not found: {contract_path}", fg=typer.colors.RED)
71
+ issues.append(str(e))
72
+ checks_failed += 1
73
+ typer.echo(f"\n{checks_passed} checks passed, {checks_failed} failed")
74
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
75
+ except Exception as e:
76
+ typer.secho(f" ✗ Contract parse error: {e}", fg=typer.colors.RED)
77
+ issues.append(str(e))
78
+ checks_failed += 1
79
+ typer.echo(f"\n{checks_passed} checks passed, {checks_failed} failed")
80
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
81
+
82
+ # 2. Check dataset URI is parseable
83
+ dataset_uri = data_path or contract.datasource
84
+ try:
85
+ handle = DatasetHandle.from_uri(dataset_uri)
86
+ scheme_info = f" ({handle.scheme})" if handle.scheme else ""
87
+ typer.secho(
88
+ f" ✓ Dataset URI parseable{scheme_info}: {dataset_uri}",
89
+ fg=typer.colors.GREEN,
90
+ )
91
+ checks_passed += 1
92
+ except Exception as e:
93
+ typer.secho(f" ✗ Dataset URI invalid: {e}", fg=typer.colors.RED)
94
+ issues.append(f"Invalid dataset URI: {e}")
95
+ checks_failed += 1
96
+
97
+ # 3. Check all rules are recognized
98
+ known_rules = get_all_rule_names()
99
+ unrecognized_rules = []
100
+ rule_count = len(contract.rules)
101
+
102
+ for rule_spec in contract.rules:
103
+ # Normalize rule name (strip namespace prefix like "DATASET:" or "COL:")
104
+ rule_name = (
105
+ rule_spec.name.split(":")[-1] if ":" in rule_spec.name else rule_spec.name
106
+ )
107
+ if rule_name not in known_rules:
108
+ unrecognized_rules.append(rule_spec.name)
109
+
110
+ if unrecognized_rules:
111
+ typer.secho(
112
+ f" ✗ {len(unrecognized_rules)} unrecognized rule(s): {', '.join(unrecognized_rules)}",
113
+ fg=typer.colors.RED,
114
+ )
115
+ typer.secho(
116
+ f" Known rules: {', '.join(sorted(known_rules))}", fg=typer.colors.YELLOW
117
+ )
118
+ issues.append(f"Unrecognized rules: {', '.join(unrecognized_rules)}")
119
+ checks_failed += 1
120
+ else:
121
+ typer.secho(f" ✓ All {rule_count} rules recognized", fg=typer.colors.GREEN)
122
+ checks_passed += 1
123
+
124
+ # 4. Try to build rules (validates parameters)
125
+ try:
126
+ rules = RuleFactory(contract.rules).build_rules()
127
+ typer.secho(f" ✓ All {len(rules)} rules valid", fg=typer.colors.GREEN)
128
+ checks_passed += 1
129
+
130
+ # Show rule breakdown
131
+ if verbose:
132
+ typer.echo("\n Rules:")
133
+ for r in rules:
134
+ cols = getattr(r, "params", {}).get("column", "")
135
+ col_info = f" ({cols})" if cols else ""
136
+ typer.echo(f" - {r.name}{col_info}")
137
+
138
+ except Exception as e:
139
+ typer.secho(f" ✗ Rule validation failed: {e}", fg=typer.colors.RED)
140
+ issues.append(f"Rule validation: {e}")
141
+ checks_failed += 1
142
+
143
+ # Summary
144
+ typer.echo("")
145
+ if checks_failed == 0:
146
+ typer.secho(
147
+ f"✓ Ready to validate ({checks_passed} checks passed)", fg=typer.colors.GREEN
148
+ )
149
+ typer.echo(f"\nRun without --dry-run to execute:")
150
+ typer.echo(f" kontra validate {contract_path}")
151
+ raise typer.Exit(code=EXIT_SUCCESS)
152
+ else:
153
+ typer.secho(
154
+ f"✗ Validation would fail ({checks_failed} issues)", fg=typer.colors.RED
155
+ )
156
+ for issue in issues:
157
+ typer.echo(f" - {issue}")
158
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
159
+
160
+
161
+ def register(app: typer.Typer) -> None:
162
+ """Register the validate command with the app."""
163
+
164
+ @app.command("validate")
165
+ def validate(
166
+ contract: str = typer.Argument(
167
+ ..., help="Path or URI to the contract.yml (local or s3://…)"
168
+ ),
169
+ data: Optional[str] = typer.Option(
170
+ None,
171
+ "--data",
172
+ help="Optional dataset path/URI override (e.g., data/users.parquet or s3://bucket/key)",
173
+ ),
174
+ # Config-aware options (None = use config, explicit = override)
175
+ output_format: Optional[Literal["rich", "json"]] = typer.Option(
176
+ None,
177
+ "--output-format",
178
+ "-o",
179
+ help="Output format (default: from config or 'rich').",
180
+ ),
181
+ stats: Optional[Literal["none", "summary", "profile"]] = typer.Option(
182
+ None,
183
+ "--stats",
184
+ help="Attach run statistics (default: from config or 'none').",
185
+ ),
186
+ # Independent execution controls
187
+ preplan: Optional[Literal["on", "off", "auto"]] = typer.Option(
188
+ None,
189
+ "--preplan",
190
+ help="Metadata preflight (default: from config or 'auto').",
191
+ ),
192
+ pushdown: Optional[Literal["on", "off", "auto"]] = typer.Option(
193
+ None,
194
+ "--pushdown",
195
+ help="SQL pushdown (default: from config or 'auto').",
196
+ ),
197
+ projection: Optional[Literal["on", "off"]] = typer.Option(
198
+ None,
199
+ "--projection",
200
+ help="Column projection/pruning (default: from config or 'on').",
201
+ ),
202
+ # CSV handling
203
+ csv_mode: Optional[Literal["auto", "duckdb", "parquet"]] = typer.Option(
204
+ None,
205
+ "--csv-mode",
206
+ help="CSV handling mode (default: from config or 'auto').",
207
+ ),
208
+ # Environment selection
209
+ env: Optional[str] = typer.Option(
210
+ None,
211
+ "--env",
212
+ "-e",
213
+ help="Environment profile from .kontra/config.yml.",
214
+ envvar="KONTRA_ENV",
215
+ ),
216
+ # Back-compat alias (deprecated): maps 'none' => pushdown=off
217
+ sql_engine: Literal["auto", "none"] = typer.Option(
218
+ "auto",
219
+ "--sql-engine",
220
+ help="(deprecated) Use '--pushdown off' instead. 'none' disables pushdown.",
221
+ ),
222
+ show_plan: bool = typer.Option(
223
+ False,
224
+ "--show-plan",
225
+ help="If SQL pushdown is enabled, print the generated SQL for debugging.",
226
+ ),
227
+ explain_preplan: bool = typer.Option(
228
+ False,
229
+ "--explain-preplan",
230
+ help="Print preplan manifest and metadata decisions (debug aid).",
231
+ ),
232
+ no_actions: bool = typer.Option(
233
+ False,
234
+ "--no-actions",
235
+ help="Run without executing remediation actions (placeholder).",
236
+ ),
237
+ dry_run: bool = typer.Option(
238
+ False,
239
+ "--dry-run",
240
+ help="Validate contract syntax and rule definitions without executing against data.",
241
+ ),
242
+ # State management
243
+ state_backend: Optional[str] = typer.Option(
244
+ None,
245
+ "--state-backend",
246
+ help="State storage backend (default: from config or 'local').",
247
+ envvar="KONTRA_STATE_BACKEND",
248
+ ),
249
+ no_state: bool = typer.Option(
250
+ False,
251
+ "--no-state",
252
+ help="Disable state saving for this run.",
253
+ ),
254
+ storage_options: Optional[str] = typer.Option(
255
+ None,
256
+ "--storage-options",
257
+ help='Cloud storage credentials as JSON, e.g. \'{"aws_access_key_id": "...", "aws_region": "us-east-1"}\'',
258
+ ),
259
+ verbose: bool = typer.Option(
260
+ False, "--verbose", "-v", help="Enable verbose errors."
261
+ ),
262
+ ) -> None:
263
+ """
264
+ Validate data against a declarative contract.
265
+
266
+ The CLI remains stateless and declarative:
267
+ - Delegates to ValidationEngine for execution.
268
+ - JSON output via reporters for CI/CD.
269
+ - Rich output for humans.
270
+ """
271
+ del no_actions # placeholder until actions are wired
272
+
273
+ # Validate contract path is not empty
274
+ if not contract or not contract.strip():
275
+ typer.secho("Error: Contract path cannot be empty", fg=typer.colors.RED)
276
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
277
+
278
+ try:
279
+ # --- DRY RUN MODE ---
280
+ if dry_run:
281
+ handle_dry_run(contract, data, verbose)
282
+ return
283
+
284
+ # --- LOAD CONFIG ---
285
+ from kontra.config.settings import resolve_effective_config
286
+
287
+ cli_overrides = {
288
+ "preplan": preplan,
289
+ "pushdown": pushdown,
290
+ "projection": projection,
291
+ "output_format": output_format,
292
+ "stats": stats,
293
+ "state_backend": state_backend,
294
+ "csv_mode": csv_mode,
295
+ }
296
+
297
+ try:
298
+ config = resolve_effective_config(
299
+ env_name=env, cli_overrides=cli_overrides
300
+ )
301
+ except Exception as e:
302
+ from kontra.errors import format_error_for_cli
303
+
304
+ typer.secho(
305
+ f"Config error: {format_error_for_cli(e)}", fg=typer.colors.RED
306
+ )
307
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
308
+
309
+ # Use resolved config values
310
+ effective_output_format = config.output_format
311
+ effective_stats = config.stats
312
+ effective_csv_mode = config.csv_mode
313
+ effective_state_backend = config.state_backend
314
+
315
+ # --- RESOLVE DATASOURCE ---
316
+ from kontra.config.settings import resolve_datasource
317
+
318
+ resolved_data = data
319
+ if data:
320
+ try:
321
+ resolved_data = resolve_datasource(data)
322
+ except ValueError as e:
323
+ typer.secho(f"Datasource error: {e}", fg=typer.colors.RED)
324
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
325
+
326
+ emit_report = effective_output_format == "rich"
327
+
328
+ # Deprecation nudge (once per process execution)
329
+ if sql_engine == "none" and pushdown != "off":
330
+ typer.secho(
331
+ "⚠️ --sql-engine is deprecated; use '--pushdown off'.",
332
+ fg=typer.colors.YELLOW,
333
+ err=True,
334
+ )
335
+
336
+ # Effective SQL pushdown: explicit flag wins; back-compat maps sql_engine=none → off
337
+ effective_pushdown: Literal["on", "off", "auto"]
338
+ if sql_engine == "none":
339
+ effective_pushdown = "off"
340
+ else:
341
+ effective_pushdown = config.pushdown # type: ignore
342
+
343
+ # Effective preplan
344
+ effective_preplan: Literal["on", "off", "auto"]
345
+ effective_preplan = config.preplan # type: ignore
346
+
347
+ # Effective projection
348
+ enable_projection = config.projection == "on"
349
+
350
+ # State backend
351
+ state_store = None
352
+ if (
353
+ effective_state_backend
354
+ and effective_state_backend != "local"
355
+ and not no_state
356
+ ):
357
+ from kontra.state.backends import get_store
358
+
359
+ state_store = get_store(effective_state_backend)
360
+
361
+ from kontra.engine.engine import ValidationEngine
362
+
363
+ # Parse storage_options JSON if provided
364
+ parsed_storage_options = None
365
+ if storage_options:
366
+ import json
367
+ try:
368
+ parsed_storage_options = json.loads(storage_options)
369
+ except json.JSONDecodeError as e:
370
+ typer.secho(
371
+ f"Invalid --storage-options JSON: {e}",
372
+ fg=typer.colors.RED,
373
+ )
374
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
375
+
376
+ eng = ValidationEngine(
377
+ contract_path=contract,
378
+ data_path=resolved_data,
379
+ emit_report=emit_report,
380
+ stats_mode=effective_stats,
381
+ # Independent controls
382
+ preplan=effective_preplan,
383
+ pushdown=effective_pushdown,
384
+ enable_projection=enable_projection,
385
+ csv_mode=effective_csv_mode,
386
+ # Diagnostics
387
+ show_plan=show_plan,
388
+ explain_preplan=explain_preplan,
389
+ # State management
390
+ state_store=state_store,
391
+ save_state=not no_state,
392
+ # Cloud storage
393
+ storage_options=parsed_storage_options,
394
+ )
395
+ result = eng.run()
396
+
397
+ if effective_output_format == "json":
398
+ from kontra.reporters.json_reporter import render_json
399
+
400
+ payload = render_json(
401
+ dataset_name=result["summary"]["dataset_name"],
402
+ summary=result["summary"],
403
+ results=result["results"],
404
+ stats=result.get("stats"),
405
+ quarantine=result.get("summary", {}).get("quarantine"),
406
+ validate=False,
407
+ )
408
+ typer.echo(payload)
409
+ else:
410
+ if effective_stats != "none":
411
+ print_rich_stats(result.get("stats"))
412
+
413
+ raise typer.Exit(
414
+ code=EXIT_SUCCESS
415
+ if result["summary"]["passed"]
416
+ else EXIT_VALIDATION_FAILED
417
+ )
418
+
419
+ except typer.Exit:
420
+ raise
421
+
422
+ except FileNotFoundError as e:
423
+ from kontra.errors import format_error_for_cli
424
+
425
+ msg = format_error_for_cli(e)
426
+ typer.secho(f"Error: {msg}", fg=typer.colors.RED)
427
+ if verbose:
428
+ import traceback
429
+
430
+ typer.secho(f"\n{traceback.format_exc()}", fg=typer.colors.YELLOW)
431
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
432
+
433
+ except ValueError as e:
434
+ from kontra.errors import format_error_for_cli
435
+
436
+ msg = format_error_for_cli(e)
437
+ typer.secho(f"Error: {msg}", fg=typer.colors.RED)
438
+ if verbose:
439
+ import traceback
440
+
441
+ typer.secho(f"\n{traceback.format_exc()}", fg=typer.colors.YELLOW)
442
+ raise typer.Exit(code=EXIT_CONFIG_ERROR)
443
+
444
+ except ConnectionError as e:
445
+ from kontra.errors import format_error_for_cli
446
+
447
+ msg = format_error_for_cli(e)
448
+ typer.secho(f"Error: {msg}", fg=typer.colors.RED)
449
+ if verbose:
450
+ import traceback
451
+
452
+ typer.secho(f"\n{traceback.format_exc()}", fg=typer.colors.YELLOW)
453
+ raise typer.Exit(code=EXIT_RUNTIME_ERROR)
454
+
455
+ except Exception as e:
456
+ from kontra.errors import format_error_for_cli
457
+
458
+ msg = format_error_for_cli(e)
459
+ if verbose:
460
+ import traceback
461
+
462
+ typer.secho(
463
+ f"Error: {msg}\n\n{traceback.format_exc()}", fg=typer.colors.RED
464
+ )
465
+ else:
466
+ typer.secho(f"Error: {msg}", fg=typer.colors.RED)
467
+ typer.secho("Use --verbose for full traceback.", fg=typer.colors.YELLOW)
468
+ raise typer.Exit(code=EXIT_RUNTIME_ERROR)
@@ -0,0 +1,6 @@
1
+ """CLI exit codes (stable for CI/CD)."""
2
+
3
+ EXIT_SUCCESS = 0
4
+ EXIT_VALIDATION_FAILED = 1
5
+ EXIT_CONFIG_ERROR = 2
6
+ EXIT_RUNTIME_ERROR = 3
kontra/cli/main.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ Kontra CLI — Developer-first Data Quality Engine
3
+
4
+ Thin layer: parse args → call engine → print via reporters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import typer
12
+
13
+ from kontra.cli.commands import config, diff, history, profile, validate
14
+ from kontra.version import VERSION
15
+
16
+ app = typer.Typer(help="Kontra CLI — Developer-first Data Quality Engine")
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def _version(
21
+ ctx: typer.Context,
22
+ version: Optional[bool] = typer.Option(
23
+ None, "--version", help="Show the Kontra version and exit.", is_eager=True
24
+ ),
25
+ ) -> None:
26
+ if version:
27
+ typer.echo(f"kontra {VERSION}")
28
+ raise typer.Exit(code=0)
29
+ # If no command given and no version flag, show help
30
+ if ctx.invoked_subcommand is None:
31
+ typer.echo(ctx.get_help())
32
+ raise typer.Exit(code=0)
33
+
34
+
35
+ # Register all commands
36
+ validate.register(app)
37
+ profile.register(app)
38
+ diff.register(app)
39
+ history.register(app)
40
+ config.register(app)
41
+
42
+
43
+ def main() -> None:
44
+ app()
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()