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,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)