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,153 @@
|
|
|
1
|
+
"""Config commands 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 EXIT_CONFIG_ERROR, EXIT_SUCCESS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
"""Register the config and init commands with the app."""
|
|
14
|
+
|
|
15
|
+
@app.command("init")
|
|
16
|
+
def init(
|
|
17
|
+
force: bool = typer.Option(
|
|
18
|
+
False,
|
|
19
|
+
"--force",
|
|
20
|
+
"-f",
|
|
21
|
+
help="Overwrite existing configuration.",
|
|
22
|
+
),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize a Kontra project.
|
|
26
|
+
|
|
27
|
+
Creates the .kontra/ directory and config.yml with documented defaults
|
|
28
|
+
and example configurations.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
kontra init # Initialize project
|
|
32
|
+
kontra init --force # Overwrite existing config
|
|
33
|
+
|
|
34
|
+
To generate a contract from data, use:
|
|
35
|
+
kontra profile data.parquet --draft > contracts/data.yml
|
|
36
|
+
"""
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from kontra.config.settings import DEFAULT_CONFIG_TEMPLATE
|
|
40
|
+
|
|
41
|
+
kontra_dir = Path.cwd() / ".kontra"
|
|
42
|
+
config_path = kontra_dir / "config.yml"
|
|
43
|
+
|
|
44
|
+
# Check if already initialized
|
|
45
|
+
if config_path.exists() and not force:
|
|
46
|
+
typer.secho(
|
|
47
|
+
f"Kontra already initialized: {config_path}", fg=typer.colors.YELLOW
|
|
48
|
+
)
|
|
49
|
+
typer.echo("Use --force to reinitialize.")
|
|
50
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
51
|
+
|
|
52
|
+
# Create .kontra directory
|
|
53
|
+
kontra_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
# Write config template
|
|
56
|
+
config_path.write_text(DEFAULT_CONFIG_TEMPLATE, encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
# Create contracts directory
|
|
59
|
+
contracts_dir = Path.cwd() / "contracts"
|
|
60
|
+
contracts_dir.mkdir(exist_ok=True)
|
|
61
|
+
|
|
62
|
+
typer.secho("Kontra initialized!", fg=typer.colors.GREEN)
|
|
63
|
+
typer.echo("")
|
|
64
|
+
typer.echo("Created:")
|
|
65
|
+
typer.echo(f" {config_path}")
|
|
66
|
+
typer.echo(f" {contracts_dir}/")
|
|
67
|
+
typer.echo("")
|
|
68
|
+
typer.echo("Next steps:")
|
|
69
|
+
typer.echo(" 1. Edit .kontra/config.yml to configure datasources")
|
|
70
|
+
typer.echo(" 2. Profile your data:")
|
|
71
|
+
typer.secho(" kontra profile data.parquet", fg=typer.colors.CYAN)
|
|
72
|
+
typer.echo(" 3. Generate a contract:")
|
|
73
|
+
typer.secho(
|
|
74
|
+
" kontra profile data.parquet --draft > contracts/data.yml",
|
|
75
|
+
fg=typer.colors.CYAN,
|
|
76
|
+
)
|
|
77
|
+
typer.echo(" 4. Run validation:")
|
|
78
|
+
typer.secho(" kontra validate contracts/data.yml", fg=typer.colors.CYAN)
|
|
79
|
+
|
|
80
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
81
|
+
|
|
82
|
+
@app.command("config")
|
|
83
|
+
def config_cmd(
|
|
84
|
+
action: str = typer.Argument(
|
|
85
|
+
"show",
|
|
86
|
+
help="Action: 'show' displays effective config, 'path' shows config file location.",
|
|
87
|
+
),
|
|
88
|
+
env: Optional[str] = typer.Option(
|
|
89
|
+
None,
|
|
90
|
+
"--env",
|
|
91
|
+
"-e",
|
|
92
|
+
help="Environment to show (simulates --env flag).",
|
|
93
|
+
),
|
|
94
|
+
output_format: Literal["yaml", "json"] = typer.Option(
|
|
95
|
+
"yaml",
|
|
96
|
+
"--output-format",
|
|
97
|
+
"-o",
|
|
98
|
+
help="Output format.",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Show Kontra configuration.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
kontra config show # Show effective config
|
|
106
|
+
kontra config show --env production # Show with environment overlay
|
|
107
|
+
kontra config path # Show config file path
|
|
108
|
+
"""
|
|
109
|
+
from pathlib import Path
|
|
110
|
+
|
|
111
|
+
from kontra.config.settings import find_config_file, resolve_effective_config
|
|
112
|
+
|
|
113
|
+
config_path = find_config_file()
|
|
114
|
+
|
|
115
|
+
if action == "path":
|
|
116
|
+
if config_path:
|
|
117
|
+
typer.echo(f"{config_path} (exists)")
|
|
118
|
+
else:
|
|
119
|
+
default_path = Path.cwd() / ".kontra" / "config.yml"
|
|
120
|
+
typer.echo(f"{default_path} (not found)")
|
|
121
|
+
typer.echo("\nRun 'kontra init' to create one.")
|
|
122
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
123
|
+
|
|
124
|
+
# Show effective configuration
|
|
125
|
+
try:
|
|
126
|
+
effective = resolve_effective_config(env_name=env)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
from kontra.errors import format_error_for_cli
|
|
129
|
+
|
|
130
|
+
typer.secho(f"Error: {format_error_for_cli(e)}", fg=typer.colors.RED)
|
|
131
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
132
|
+
|
|
133
|
+
typer.secho("Effective configuration", fg=typer.colors.CYAN)
|
|
134
|
+
if env:
|
|
135
|
+
typer.echo(f"Environment: {env}")
|
|
136
|
+
if config_path:
|
|
137
|
+
typer.echo(f"Config file: {config_path}")
|
|
138
|
+
else:
|
|
139
|
+
typer.echo("Config file: (none, using defaults)")
|
|
140
|
+
typer.echo("")
|
|
141
|
+
|
|
142
|
+
config_dict = effective.to_dict()
|
|
143
|
+
|
|
144
|
+
if output_format == "json":
|
|
145
|
+
import json
|
|
146
|
+
|
|
147
|
+
typer.echo(json.dumps(config_dict, indent=2))
|
|
148
|
+
else:
|
|
149
|
+
import yaml
|
|
150
|
+
|
|
151
|
+
typer.echo(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))
|
|
152
|
+
|
|
153
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Diff commands 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 render_diff_rich, render_profile_diff_rich
|
|
16
|
+
from kontra.cli.utils import parse_duration
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(app: typer.Typer) -> None:
|
|
20
|
+
"""Register the diff and profile-diff commands with the app."""
|
|
21
|
+
|
|
22
|
+
@app.command("diff")
|
|
23
|
+
def diff_cmd(
|
|
24
|
+
contract: Optional[str] = typer.Argument(
|
|
25
|
+
None, help="Contract path or fingerprint. If not provided, uses most recent."
|
|
26
|
+
),
|
|
27
|
+
output_format: Literal["rich", "json", "llm"] = typer.Option(
|
|
28
|
+
"rich", "--output-format", "-o", help="Output format."
|
|
29
|
+
),
|
|
30
|
+
since: Optional[str] = typer.Option(
|
|
31
|
+
None,
|
|
32
|
+
"--since",
|
|
33
|
+
"-s",
|
|
34
|
+
help="Compare to state from this duration ago (e.g., '7d', '24h', '1h').",
|
|
35
|
+
),
|
|
36
|
+
run: Optional[str] = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--run",
|
|
39
|
+
"-r",
|
|
40
|
+
help="Compare to state from specific date (YYYY-MM-DD or YYYY-MM-DDTHH:MM).",
|
|
41
|
+
),
|
|
42
|
+
state_backend: Optional[str] = typer.Option(
|
|
43
|
+
None,
|
|
44
|
+
"--state-backend",
|
|
45
|
+
help="State storage backend (default: from config or 'local').",
|
|
46
|
+
envvar="KONTRA_STATE_BACKEND",
|
|
47
|
+
),
|
|
48
|
+
# Environment selection
|
|
49
|
+
env: Optional[str] = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--env",
|
|
52
|
+
"-e",
|
|
53
|
+
help="Environment profile from .kontra/config.yml.",
|
|
54
|
+
envvar="KONTRA_ENV",
|
|
55
|
+
),
|
|
56
|
+
verbose: bool = typer.Option(
|
|
57
|
+
False, "--verbose", "-v", help="Enable verbose output."
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Show changes between validation runs.
|
|
62
|
+
|
|
63
|
+
Compares the most recent validation state to a previous state
|
|
64
|
+
and shows what changed (new failures, resolved issues, regressions).
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
kontra diff # Compare last two runs
|
|
68
|
+
kontra diff --since 7d # Compare to 7 days ago
|
|
69
|
+
kontra diff --run 2024-01-12 # Compare to specific date
|
|
70
|
+
kontra diff -o llm # Token-optimized output
|
|
71
|
+
kontra diff contracts/users.yml # Specific contract
|
|
72
|
+
"""
|
|
73
|
+
from datetime import datetime, timedelta, timezone
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
from kontra.config.settings import resolve_effective_config
|
|
77
|
+
from kontra.config.loader import ContractLoader
|
|
78
|
+
from kontra.state.backends import get_default_store, get_store
|
|
79
|
+
from kontra.state.fingerprint import fingerprint_contract
|
|
80
|
+
from kontra.state.types import StateDiff
|
|
81
|
+
|
|
82
|
+
# --- LOAD CONFIG ---
|
|
83
|
+
cli_overrides = {"state_backend": state_backend}
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
config = resolve_effective_config(
|
|
87
|
+
env_name=env, cli_overrides=cli_overrides
|
|
88
|
+
)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
from kontra.errors import format_error_for_cli
|
|
91
|
+
|
|
92
|
+
typer.secho(
|
|
93
|
+
f"Config error: {format_error_for_cli(e)}", fg=typer.colors.RED
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
96
|
+
|
|
97
|
+
effective_state_backend = config.state_backend
|
|
98
|
+
|
|
99
|
+
# Get store
|
|
100
|
+
if effective_state_backend and effective_state_backend != "local":
|
|
101
|
+
store = get_store(effective_state_backend)
|
|
102
|
+
else:
|
|
103
|
+
store = get_default_store()
|
|
104
|
+
|
|
105
|
+
# Determine contract fingerprint
|
|
106
|
+
contract_fp = None
|
|
107
|
+
if contract:
|
|
108
|
+
# Could be a path or a fingerprint
|
|
109
|
+
if len(contract) == 16 and all(
|
|
110
|
+
c in "0123456789abcdef" for c in contract
|
|
111
|
+
):
|
|
112
|
+
# Looks like a fingerprint
|
|
113
|
+
contract_fp = contract
|
|
114
|
+
else:
|
|
115
|
+
# Treat as path, load and compute semantic fingerprint
|
|
116
|
+
contract_obj = ContractLoader.from_path(contract)
|
|
117
|
+
contract_fp = fingerprint_contract(contract_obj)
|
|
118
|
+
|
|
119
|
+
# If no contract specified, find most recent
|
|
120
|
+
if not contract_fp:
|
|
121
|
+
contracts = store.list_contracts()
|
|
122
|
+
if not contracts:
|
|
123
|
+
typer.secho(
|
|
124
|
+
"No validation state found. Run 'kontra validate' first.",
|
|
125
|
+
fg=typer.colors.YELLOW,
|
|
126
|
+
)
|
|
127
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
128
|
+
|
|
129
|
+
# Get most recent across all contracts
|
|
130
|
+
most_recent = None
|
|
131
|
+
most_recent_fp = None
|
|
132
|
+
for fp in contracts:
|
|
133
|
+
latest = store.get_latest(fp)
|
|
134
|
+
if latest and (
|
|
135
|
+
most_recent is None or latest.run_at > most_recent.run_at
|
|
136
|
+
):
|
|
137
|
+
most_recent = latest
|
|
138
|
+
most_recent_fp = fp
|
|
139
|
+
|
|
140
|
+
if not most_recent_fp:
|
|
141
|
+
typer.secho("No validation state found.", fg=typer.colors.YELLOW)
|
|
142
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
143
|
+
|
|
144
|
+
contract_fp = most_recent_fp
|
|
145
|
+
|
|
146
|
+
# Get history for this contract
|
|
147
|
+
history = store.get_history(contract_fp, limit=100)
|
|
148
|
+
|
|
149
|
+
if len(history) < 1:
|
|
150
|
+
typer.secho(
|
|
151
|
+
f"No state history found for contract {contract_fp}.",
|
|
152
|
+
fg=typer.colors.YELLOW,
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
155
|
+
|
|
156
|
+
# Determine which states to compare
|
|
157
|
+
after_state = history[0] # Most recent
|
|
158
|
+
before_state = None
|
|
159
|
+
|
|
160
|
+
if since:
|
|
161
|
+
# Parse duration and find state from that time ago
|
|
162
|
+
try:
|
|
163
|
+
seconds = parse_duration(since)
|
|
164
|
+
target_time = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
|
165
|
+
|
|
166
|
+
for state in history[1:]:
|
|
167
|
+
if state.run_at <= target_time:
|
|
168
|
+
before_state = state
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if not before_state:
|
|
172
|
+
typer.secho(
|
|
173
|
+
f"No state found from {since} ago.", fg=typer.colors.YELLOW
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
176
|
+
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
179
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
180
|
+
|
|
181
|
+
elif run:
|
|
182
|
+
# Parse specific date/time
|
|
183
|
+
try:
|
|
184
|
+
if "T" in run:
|
|
185
|
+
target_time = datetime.fromisoformat(
|
|
186
|
+
run.replace("Z", "+00:00")
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
target_time = datetime.strptime(run, "%Y-%m-%d").replace(
|
|
190
|
+
tzinfo=timezone.utc
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Find state closest to this time
|
|
194
|
+
for state in history:
|
|
195
|
+
if state.run_at.date() <= target_time.date():
|
|
196
|
+
before_state = state
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if not before_state:
|
|
200
|
+
typer.secho(
|
|
201
|
+
f"No state found for date {run}.", fg=typer.colors.YELLOW
|
|
202
|
+
)
|
|
203
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
204
|
+
|
|
205
|
+
except ValueError:
|
|
206
|
+
typer.secho(
|
|
207
|
+
f"Invalid date format: {run}. Use YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
|
208
|
+
fg=typer.colors.RED,
|
|
209
|
+
)
|
|
210
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
211
|
+
|
|
212
|
+
else:
|
|
213
|
+
# Default: compare to previous run
|
|
214
|
+
if len(history) < 2:
|
|
215
|
+
typer.secho(
|
|
216
|
+
"Only one state found. Need at least two runs to diff.",
|
|
217
|
+
fg=typer.colors.YELLOW,
|
|
218
|
+
)
|
|
219
|
+
typer.echo(
|
|
220
|
+
f"\nLatest state: {after_state.run_at.strftime('%Y-%m-%d %H:%M')}"
|
|
221
|
+
)
|
|
222
|
+
typer.echo(
|
|
223
|
+
f"Result: {'PASSED' if after_state.summary.passed else 'FAILED'}"
|
|
224
|
+
)
|
|
225
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
226
|
+
|
|
227
|
+
before_state = history[1]
|
|
228
|
+
|
|
229
|
+
# Compute diff
|
|
230
|
+
diff = StateDiff.compute(before_state, after_state)
|
|
231
|
+
|
|
232
|
+
# Render output
|
|
233
|
+
if output_format == "json":
|
|
234
|
+
typer.echo(diff.to_json())
|
|
235
|
+
elif output_format == "llm":
|
|
236
|
+
typer.echo(diff.to_llm())
|
|
237
|
+
else:
|
|
238
|
+
typer.echo(render_diff_rich(diff))
|
|
239
|
+
|
|
240
|
+
# Exit code based on regressions
|
|
241
|
+
if diff.has_regressions:
|
|
242
|
+
raise typer.Exit(code=EXIT_VALIDATION_FAILED)
|
|
243
|
+
else:
|
|
244
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
245
|
+
|
|
246
|
+
except typer.Exit:
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
except FileNotFoundError as e:
|
|
250
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
251
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
252
|
+
|
|
253
|
+
except Exception as e:
|
|
254
|
+
from kontra.errors import format_error_for_cli
|
|
255
|
+
|
|
256
|
+
msg = format_error_for_cli(e)
|
|
257
|
+
if verbose:
|
|
258
|
+
import traceback
|
|
259
|
+
|
|
260
|
+
typer.secho(
|
|
261
|
+
f"Error: {msg}\n\n{traceback.format_exc()}", fg=typer.colors.RED
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
typer.secho(f"Error: {msg}", fg=typer.colors.RED)
|
|
265
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
266
|
+
|
|
267
|
+
@app.command("profile-diff")
|
|
268
|
+
def profile_diff_cmd(
|
|
269
|
+
source: Optional[str] = typer.Argument(
|
|
270
|
+
None, help="Source URI or fingerprint. If not provided, uses most recent."
|
|
271
|
+
),
|
|
272
|
+
output_format: Literal["rich", "json", "llm"] = typer.Option(
|
|
273
|
+
"rich", "--output-format", "-o", help="Output format."
|
|
274
|
+
),
|
|
275
|
+
since: Optional[str] = typer.Option(
|
|
276
|
+
None,
|
|
277
|
+
"--since",
|
|
278
|
+
"-s",
|
|
279
|
+
help="Compare to profile from this duration ago (e.g., '7d', '24h', '1h').",
|
|
280
|
+
),
|
|
281
|
+
verbose: bool = typer.Option(
|
|
282
|
+
False, "--verbose", "-v", help="Enable verbose output."
|
|
283
|
+
),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""
|
|
286
|
+
Show changes between profiles over time.
|
|
287
|
+
|
|
288
|
+
Compares the most recent profile to a previous one and shows
|
|
289
|
+
schema changes, data quality shifts, and distribution changes.
|
|
290
|
+
|
|
291
|
+
Prerequisites:
|
|
292
|
+
Run `kontra profile <source> --save-profile` to save profiles.
|
|
293
|
+
|
|
294
|
+
Examples:
|
|
295
|
+
kontra profile-diff # Compare last two profiles
|
|
296
|
+
kontra profile-diff data.parquet # Specific source
|
|
297
|
+
kontra profile-diff --since 7d # Compare to 7 days ago
|
|
298
|
+
kontra profile-diff -o llm # Token-optimized output
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
from kontra.scout.store import fingerprint_source, get_default_profile_store
|
|
302
|
+
from kontra.scout.types import ProfileDiff
|
|
303
|
+
|
|
304
|
+
store = get_default_profile_store()
|
|
305
|
+
|
|
306
|
+
# Determine source fingerprint
|
|
307
|
+
source_fp = None
|
|
308
|
+
if source:
|
|
309
|
+
# Could be a URI or a fingerprint
|
|
310
|
+
if len(source) == 16 and all(
|
|
311
|
+
c in "0123456789abcdef" for c in source
|
|
312
|
+
):
|
|
313
|
+
source_fp = source
|
|
314
|
+
else:
|
|
315
|
+
source_fp = fingerprint_source(source)
|
|
316
|
+
|
|
317
|
+
# If no source specified, find most recent
|
|
318
|
+
if not source_fp:
|
|
319
|
+
sources = store.list_sources()
|
|
320
|
+
if not sources:
|
|
321
|
+
typer.secho(
|
|
322
|
+
"No saved profiles found. Run 'kontra profile <source> --save-profile' first.",
|
|
323
|
+
fg=typer.colors.YELLOW,
|
|
324
|
+
)
|
|
325
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
326
|
+
|
|
327
|
+
# Get most recent across all sources
|
|
328
|
+
most_recent = None
|
|
329
|
+
most_recent_fp = None
|
|
330
|
+
for fp in sources:
|
|
331
|
+
latest = store.get_latest(fp)
|
|
332
|
+
if latest and (
|
|
333
|
+
most_recent is None
|
|
334
|
+
or latest.profiled_at > most_recent.profiled_at
|
|
335
|
+
):
|
|
336
|
+
most_recent = latest
|
|
337
|
+
most_recent_fp = fp
|
|
338
|
+
|
|
339
|
+
if not most_recent_fp:
|
|
340
|
+
typer.secho("No saved profiles found.", fg=typer.colors.YELLOW)
|
|
341
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
342
|
+
|
|
343
|
+
source_fp = most_recent_fp
|
|
344
|
+
|
|
345
|
+
# Get history for this source
|
|
346
|
+
history = store.get_history(source_fp, limit=100)
|
|
347
|
+
|
|
348
|
+
if len(history) < 1:
|
|
349
|
+
typer.secho(
|
|
350
|
+
f"No profile history found for source {source_fp}.",
|
|
351
|
+
fg=typer.colors.YELLOW,
|
|
352
|
+
)
|
|
353
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
354
|
+
|
|
355
|
+
# Determine which profiles to compare
|
|
356
|
+
after_state = history[0]
|
|
357
|
+
before_state = None
|
|
358
|
+
|
|
359
|
+
if since:
|
|
360
|
+
from datetime import datetime, timedelta, timezone
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
seconds = parse_duration(since)
|
|
364
|
+
target_dt = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
|
365
|
+
target_str = target_dt.isoformat()
|
|
366
|
+
|
|
367
|
+
for state in history[1:]:
|
|
368
|
+
if state.profiled_at <= target_str:
|
|
369
|
+
before_state = state
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
if not before_state:
|
|
373
|
+
typer.secho(
|
|
374
|
+
f"No profile found from {since} ago.",
|
|
375
|
+
fg=typer.colors.YELLOW,
|
|
376
|
+
)
|
|
377
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
378
|
+
|
|
379
|
+
except ValueError as e:
|
|
380
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
381
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
382
|
+
else:
|
|
383
|
+
# Default: compare to previous profile
|
|
384
|
+
if len(history) < 2:
|
|
385
|
+
typer.secho(
|
|
386
|
+
"Only one profile found. Need at least two to diff.",
|
|
387
|
+
fg=typer.colors.YELLOW,
|
|
388
|
+
)
|
|
389
|
+
typer.echo(f"\nLatest profile: {after_state.profiled_at[:16]}")
|
|
390
|
+
typer.echo(f"Source: {after_state.source_uri}")
|
|
391
|
+
typer.echo(
|
|
392
|
+
f"Rows: {after_state.profile.row_count:,}, "
|
|
393
|
+
f"Columns: {after_state.profile.column_count}"
|
|
394
|
+
)
|
|
395
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
396
|
+
|
|
397
|
+
before_state = history[1]
|
|
398
|
+
|
|
399
|
+
# Compute diff
|
|
400
|
+
diff = ProfileDiff.compute(before_state, after_state)
|
|
401
|
+
|
|
402
|
+
# Render output
|
|
403
|
+
if output_format == "json":
|
|
404
|
+
typer.echo(diff.to_json())
|
|
405
|
+
elif output_format == "llm":
|
|
406
|
+
typer.echo(diff.to_llm())
|
|
407
|
+
else:
|
|
408
|
+
typer.echo(render_profile_diff_rich(diff))
|
|
409
|
+
|
|
410
|
+
raise typer.Exit(code=EXIT_SUCCESS)
|
|
411
|
+
|
|
412
|
+
except typer.Exit:
|
|
413
|
+
raise
|
|
414
|
+
|
|
415
|
+
except FileNotFoundError as e:
|
|
416
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
417
|
+
raise typer.Exit(code=EXIT_CONFIG_ERROR)
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
from kontra.errors import format_error_for_cli
|
|
421
|
+
|
|
422
|
+
msg = format_error_for_cli(e)
|
|
423
|
+
if verbose:
|
|
424
|
+
import traceback
|
|
425
|
+
|
|
426
|
+
typer.secho(
|
|
427
|
+
f"Error: {msg}\n\n{traceback.format_exc()}", fg=typer.colors.RED
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
typer.secho(f"Error: {msg}", fg=typer.colors.RED)
|
|
431
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
432
|
+
|
|
433
|
+
# Deprecated alias for scout-diff
|
|
434
|
+
@app.command("scout-diff", hidden=True)
|
|
435
|
+
def scout_diff_cmd(
|
|
436
|
+
source: Optional[str] = typer.Argument(None),
|
|
437
|
+
output_format: Literal["rich", "json", "llm"] = typer.Option(
|
|
438
|
+
"rich", "--output-format", "-o"
|
|
439
|
+
),
|
|
440
|
+
since: Optional[str] = typer.Option(None, "--since", "-s"),
|
|
441
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Deprecated: Use 'kontra profile-diff' instead."""
|
|
444
|
+
typer.secho(
|
|
445
|
+
"Warning: 'kontra scout-diff' is deprecated, use 'kontra profile-diff' instead.",
|
|
446
|
+
fg=typer.colors.YELLOW,
|
|
447
|
+
err=True,
|
|
448
|
+
)
|
|
449
|
+
# Call profile-diff with same args
|
|
450
|
+
profile_diff_cmd(source, output_format, since, verbose)
|