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
|
@@ -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)
|
kontra/cli/constants.py
ADDED
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()
|