table-validator 0.1.0__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.
@@ -0,0 +1,78 @@
1
+ """
2
+ Interactive partition-column prompt for `tablevalidator validate`.
3
+
4
+ Invoked by CatalogValidator (validators/catalog_validator.py) via the
5
+ optional partition_prompt callback, only for a table that is both large
6
+ (row count over CatalogValidationRequest.partition_threshold) and has a
7
+ confirmed mismatch (Tier 1 and/or Tier 2). The validator itself has no
8
+ I/O - this module is the only place that actually talks to a human about
9
+ which column to bucket by.
10
+
11
+ build_partition_prompt() is the factory `cli/main.py` calls once per
12
+ `validate` invocation; it captures --yes and TTY state so the returned
13
+ callback can decide, on every call, whether to actually prompt or skip
14
+ straight to "no partitioning" without ever risking a hang in a
15
+ non-interactive/CI run.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import sys
22
+ from typing import Optional
23
+
24
+ import questionary
25
+ import typer
26
+
27
+ from table_validator.models import PartitionPromptContext
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _SKIP_LABEL = "Skip partitioning (compare the whole table)"
32
+
33
+
34
+ def run_partition_prompt(context: PartitionPromptContext) -> Optional[str]:
35
+ """
36
+ Ask the user which column to partition/bucket by for a large,
37
+ confirmed-mismatched table, or let them skip. Returns the chosen
38
+ column name, or None if they chose to skip.
39
+ """
40
+ typer.echo(
41
+ f"\nTable '{context.schema_name}.{context.table}' has "
42
+ f"{context.row_count:,} rows and a confirmed mismatch. Comparing "
43
+ f"it row-by-row could be slow - partitioning first can narrow "
44
+ f"down which part of the table actually differs.",
45
+ )
46
+
47
+ choices = list(context.candidate_columns) + [_SKIP_LABEL]
48
+ answer = questionary.select(
49
+ "Partition by which column?",
50
+ choices=choices,
51
+ ).ask()
52
+
53
+ if answer is None or answer == _SKIP_LABEL:
54
+ return None
55
+ return answer
56
+
57
+
58
+ def build_partition_prompt(yes: bool):
59
+ """
60
+ Factory for the callback passed to CatalogValidator(partition_prompt=...).
61
+
62
+ Returns None (meaning "never prompt, always fall back to unpartitioned
63
+ Tier 4") when --yes was passed or stdin isn't a real terminal - belt
64
+ and suspenders, so a non-interactive invocation can never hang on a
65
+ prompt even if --yes was forgotten.
66
+ """
67
+ if yes:
68
+ logger.debug("--yes passed - partition prompt disabled")
69
+ return None
70
+
71
+ if not sys.stdin.isatty():
72
+ logger.debug("stdin is not a TTY - partition prompt disabled")
73
+ return None
74
+
75
+ def _callback(context: PartitionPromptContext) -> Optional[str]:
76
+ return run_partition_prompt(context)
77
+
78
+ return _callback
@@ -0,0 +1,146 @@
1
+ """
2
+ Shared summary-table rendering, used by both `tablevalidator validate`
3
+ (right after generating a report) and `tablevalidator report` (reading an
4
+ existing one back). Both commands print the exact same aggregate figures
5
+ that live on the Excel report's Summary sheet - overall status, per-table
6
+ totals, pass percentage, and which validation types were run - so the
7
+ console output and the file never disagree.
8
+
9
+ SummaryData is the single shape both entry points build before handing
10
+ off to print_summary_table(), so the rendering logic itself is never
11
+ duplicated between the "just generated a live result" and "read an
12
+ existing .xlsx back" cases.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from openpyxl import load_workbook
22
+ from rich.console import Console
23
+ from rich.table import Table
24
+
25
+ from table_validator.models import CatalogValidationResponse
26
+ from table_validator.reports.excel_report import _build_summary_metrics
27
+
28
+
29
+ @dataclass
30
+ class SummaryData:
31
+ overall_status: str
32
+ total_tables: int
33
+ passed_tables: int
34
+ failed_tables: int
35
+ error_tables: int
36
+ skipped_tables: int
37
+ pass_percentage: str
38
+ source_type: Optional[str] = None
39
+ validations_run: Optional[str] = None
40
+ validation_timestamp: Optional[str] = None
41
+ duration_seconds: Optional[float] = None
42
+
43
+
44
+ def summary_from_response(
45
+ result: CatalogValidationResponse,
46
+ source_type: Optional[str] = None,
47
+ validations_run: Optional[str] = None,
48
+ ) -> SummaryData:
49
+ """Build a SummaryData from a freshly-computed CatalogValidationResponse,
50
+ using the exact same _build_summary_metrics() the Excel Summary sheet
51
+ itself is built from - so the console table and the file always agree."""
52
+ metrics = dict(_build_summary_metrics(result))
53
+ return SummaryData(
54
+ overall_status=result.status.value,
55
+ total_tables=metrics["Total Tables"],
56
+ passed_tables=metrics["Passed Tables"],
57
+ failed_tables=metrics["Failed Tables"],
58
+ error_tables=metrics["Error Tables"],
59
+ skipped_tables=metrics["Skipped Tables"],
60
+ pass_percentage=metrics["Pass Percentage"],
61
+ source_type=source_type,
62
+ validations_run=validations_run,
63
+ validation_timestamp=result.validation_timestamp,
64
+ duration_seconds=result.execution_time_seconds,
65
+ )
66
+
67
+
68
+ def summary_from_excel(path: Path) -> SummaryData:
69
+ """
70
+ Read a SummaryData back out of a saved report's Summary sheet.
71
+
72
+ The Summary sheet's row layout shifts depending on which optional
73
+ fields (Source Type, Validations Run) were present when it was
74
+ generated, so this scans column A for each known label by name
75
+ rather than assuming fixed row numbers.
76
+ """
77
+ wb = load_workbook(path, data_only=True, read_only=True)
78
+ try:
79
+ ws = wb["Summary"]
80
+
81
+ fields: dict = {}
82
+ for row in ws.iter_rows(min_col=1, max_col=2, values_only=True):
83
+ label, value = row[0], row[1]
84
+ if label is not None:
85
+ fields[str(label)] = value
86
+ finally:
87
+ # read_only workbooks hold an open file handle until closed -
88
+ # without this, a caller that immediately tries to overwrite or
89
+ # delete the same path (e.g. a subsequent `validate` run, or a
90
+ # test's tempdir cleanup) can hit a Windows file-lock error.
91
+ wb.close()
92
+
93
+ def _metric(label: str, default=0):
94
+ value = fields.get(label, default)
95
+ return int(value) if isinstance(value, (int, float)) else default
96
+
97
+ return SummaryData(
98
+ overall_status=str(fields.get("Overall Status", "UNKNOWN")),
99
+ total_tables=_metric("Total Tables"),
100
+ passed_tables=_metric("Passed Tables"),
101
+ failed_tables=_metric("Failed Tables"),
102
+ error_tables=_metric("Error Tables"),
103
+ skipped_tables=_metric("Skipped Tables"),
104
+ pass_percentage=str(fields.get("Pass Percentage", "0.00%")),
105
+ source_type=fields.get("Source Type"),
106
+ validations_run=fields.get("Validations Run"),
107
+ validation_timestamp=fields.get("Validation Timestamp") or None,
108
+ duration_seconds=fields.get("Duration (s)"),
109
+ )
110
+
111
+
112
+ _STATUS_STYLES = {
113
+ "PASS": "bold green",
114
+ "FAIL": "bold red",
115
+ "ERROR": "bold yellow",
116
+ "SKIPPED": "bold white",
117
+ }
118
+
119
+
120
+ def print_summary_table(data: SummaryData, console: Optional[Console] = None) -> None:
121
+ """Render a SummaryData as a compact rich Table on the console."""
122
+ console = console or Console()
123
+
124
+ status_style = _STATUS_STYLES.get(data.overall_status, "bold white")
125
+
126
+ table = Table(title="Validation Summary", show_lines=False)
127
+ table.add_column("Metric", style="bold")
128
+ table.add_column("Value")
129
+
130
+ table.add_row("Overall Status", f"[{status_style}]{data.overall_status}[/{status_style}]")
131
+ if data.source_type:
132
+ table.add_row("Source Type", data.source_type)
133
+ if data.validations_run:
134
+ table.add_row("Validations Run", data.validations_run)
135
+ table.add_row("Total Tables", str(data.total_tables))
136
+ table.add_row("Passed Tables", f"[green]{data.passed_tables}[/green]")
137
+ table.add_row("Failed Tables", f"[red]{data.failed_tables}[/red]")
138
+ table.add_row("Error Tables", f"[yellow]{data.error_tables}[/yellow]")
139
+ table.add_row("Skipped Tables", str(data.skipped_tables))
140
+ table.add_row("Pass Percentage", data.pass_percentage)
141
+ if data.validation_timestamp:
142
+ table.add_row("Validation Timestamp", data.validation_timestamp)
143
+ if data.duration_seconds is not None:
144
+ table.add_row("Duration (s)", f"{data.duration_seconds:.3f}")
145
+
146
+ console.print(table)
@@ -0,0 +1,429 @@
1
+ """Interactive configuration wizard for `tablevalidator configure`.
2
+
3
+ Walks the user through: what's being compared (source type), Databricks
4
+ credentials (always needed - the target is always a Databricks catalog),
5
+ source-specific credentials/scoping, target table details, and which
6
+ validations to run. Non-secret answers are saved into ValidatorConfig via
7
+ config/manager.py; secrets are written to ~/.table_validator/.env with
8
+ owner-only file permissions.
9
+
10
+ Phase 1 auth only: credentials are entered manually here and read back by
11
+ auth/azure_auth.py and auth/databricks_auth.py. Nothing else in the
12
+ codebase should read these credentials directly.
13
+
14
+ Every free-text answer goes through _ask()/_ask_secret(), which strip()
15
+ whitespace and normalize "" to None, so no field ever silently saves a
16
+ value the user didn't actually type (this is also where the earlier
17
+ ' for_schema_validation' leading-space bug and the
18
+ myserver.database.windows.net/mydb placeholder-default bug were fixed -
19
+ both were free-text prompts that skipped this normalization).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import stat
26
+ from pathlib import Path
27
+ from typing import Dict, Optional
28
+
29
+ import questionary
30
+ import typer
31
+
32
+ from table_validator.config.manager import CONFIG_PATH, load_config, save_config
33
+ from table_validator.config.schema import SourceType, ValidationType, ValidatorConfig
34
+
35
+ ENV_PATH = Path.home() / ".table_validator" / ".env"
36
+
37
+ _ALL_VALIDATIONS = [
38
+ (ValidationType.CATALOG, "catalog"),
39
+ (ValidationType.SCHEMA, "schema"),
40
+ (ValidationType.COLUMN, "column"),
41
+ (ValidationType.ROW, "row"),
42
+ ]
43
+
44
+ # Wizard-only convenience choice, not a ValidationType and never itself
45
+ # stored in config.yaml - selecting it (alone or alongside individual
46
+ # choices) resolves to the full validations list at answer time. See
47
+ # _resolve_validation_selection().
48
+ _ALL_LABEL = "All"
49
+
50
+ # What's being compared, shown as the wizard's first question. Labels are
51
+ # wizard-only display text; SourceType is what's actually stored.
52
+ _SOURCE_TYPE_CHOICES = [
53
+ (SourceType.DATABRICKS, "Databricks catalog -> Databricks catalog"),
54
+ (SourceType.AZURE_BLOB, "Azure Blob Storage -> Databricks catalog"),
55
+ (SourceType.AZURE_SQL, "Azure SQL Database -> Databricks catalog"),
56
+ ]
57
+
58
+
59
+ def _ask(prompt: questionary.Question) -> Optional[str]:
60
+ """
61
+ Resolve a questionary text/password prompt, stripping surrounding
62
+ whitespace and normalizing a blank answer to None.
63
+
64
+ This is the single choke point every free-text prompt in this wizard
65
+ goes through, specifically so "leave blank" and "stray whitespace"
66
+ are handled consistently everywhere instead of per-call-site (which
67
+ is how both bugs this fixes originally slipped through - one call
68
+ site normalized, the next one didn't).
69
+ """
70
+ answer = prompt.ask()
71
+ if answer is None:
72
+ return None
73
+ stripped = answer.strip()
74
+ return stripped or None
75
+
76
+
77
+ def _prompt_table_ref(label: str, existing) -> Dict[str, Optional[str]]:
78
+ typer.echo(f"\n{label} table:")
79
+ catalog = _ask(questionary.text(" Catalog:", default=existing.catalog or ""))
80
+ schema_name = _ask(
81
+ questionary.text(
82
+ " Schema (leave blank to compare all schemas in this catalog):",
83
+ default=existing.schema_name or "",
84
+ )
85
+ )
86
+ table = _ask(
87
+ questionary.text(
88
+ " Table (leave blank to compare all tables in this schema):",
89
+ default=existing.table or "",
90
+ )
91
+ )
92
+ # Keyed by field name ("schema_name"), not the "schema" alias -
93
+ # model_copy(update=...) matches by field name and silently ignores
94
+ # unknown keys, so using the alias here would drop the schema value.
95
+ return {"catalog": catalog, "schema_name": schema_name, "table": table}
96
+
97
+
98
+ def _prompt_primary_key(existing: Optional[list]) -> Optional[list]:
99
+ """
100
+ Optional primary/business key for the single named source/target
101
+ table - only asked when both are set to a specific table (not a
102
+ catalog-wide sweep). If left blank, row-level comparison falls back
103
+ to a synthetic ROW_NUMBER() match as before; a real key is cheaper
104
+ (no full-table sort) and avoids the row-number fallback's known
105
+ timeout risk on large tables.
106
+ """
107
+ default_str = ", ".join(existing) if existing else ""
108
+ answer = _ask(
109
+ questionary.text(
110
+ "Primary key column(s) for this table, comma-separated "
111
+ "(optional - leave blank to match rows by row-number instead):",
112
+ default=default_str,
113
+ )
114
+ )
115
+ if not answer:
116
+ return None
117
+ return [col.strip() for col in answer.split(",") if col.strip()]
118
+
119
+
120
+ def _write_env_file(values: Dict[str, str], env_path: Optional[Path] = None) -> None:
121
+ """
122
+ Write secrets to env_path (default: ENV_PATH, resolved at call time)
123
+ as KEY=VALUE lines, restricted to owner read/write only. Only
124
+ non-empty values are written, so a step the user skipped doesn't
125
+ clobber a credential set in a previous run with an empty string.
126
+ """
127
+ env_path = env_path or ENV_PATH
128
+ env_path.parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ existing: Dict[str, str] = {}
131
+ if env_path.exists():
132
+ for line in env_path.read_text(encoding="utf-8").splitlines():
133
+ if "=" in line and not line.strip().startswith("#"):
134
+ key, _, val = line.partition("=")
135
+ existing[key.strip()] = val
136
+
137
+ existing.update({k: v for k, v in values.items() if v})
138
+
139
+ content = "\n".join(f"{k}={v}" for k, v in existing.items()) + "\n"
140
+ env_path.write_text(content, encoding="utf-8")
141
+
142
+ # Owner read/write only (chmod 600). On Windows this is best-effort -
143
+ # NTFS ACLs don't map 1:1 onto POSIX mode bits, but os.chmod still
144
+ # clears the broadest "everyone" write/execute bits where supported.
145
+ try:
146
+ os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
147
+ except OSError:
148
+ pass
149
+
150
+
151
+ def _normalize_workspace_url(url: str) -> str:
152
+ """Prepend https:// if the user typed a bare hostname."""
153
+ if not url.startswith(("https://", "http://")):
154
+ return f"https://{url}"
155
+ return url
156
+
157
+
158
+ def _resolve_validation_selection(selected_labels):
159
+ """
160
+ Resolve the checkbox answer from the "Which validations should run?"
161
+ prompt into a deduplicated List[ValidationType].
162
+
163
+ "All" is a wizard-only convenience label, not a ValidationType - if
164
+ it's present (alone or alongside individual choices), the result is
165
+ always the full four-type list, regardless of what else was checked.
166
+ Otherwise, each selected label maps to its ValidationType, in
167
+ _ALL_VALIDATIONS order and with duplicates removed (checkbox answers
168
+ shouldn't contain duplicates, but this stays defensive either way).
169
+ """
170
+ label_to_type = {label: vtype for vtype, label in _ALL_VALIDATIONS}
171
+ selected = selected_labels or []
172
+
173
+ if _ALL_LABEL in selected:
174
+ return [vtype for vtype, _label in _ALL_VALIDATIONS]
175
+
176
+ resolved = [label_to_type[label] for label in selected if label in label_to_type]
177
+ # Preserve _ALL_VALIDATIONS order, drop duplicates.
178
+ return [vtype for vtype, _label in _ALL_VALIDATIONS if vtype in resolved]
179
+
180
+
181
+ def _prompt_source_type(existing: SourceType) -> SourceType:
182
+ label_to_type = {label: stype for stype, label in _SOURCE_TYPE_CHOICES}
183
+ existing_label = next(
184
+ label for stype, label in _SOURCE_TYPE_CHOICES if stype == existing
185
+ )
186
+ answer = questionary.select(
187
+ "What are you comparing?",
188
+ choices=[label for _stype, label in _SOURCE_TYPE_CHOICES],
189
+ default=existing_label,
190
+ ).ask()
191
+ return label_to_type.get(answer, SourceType.DATABRICKS)
192
+
193
+
194
+ def _prompt_databricks_credentials(config: ValidatorConfig, secrets: Dict[str, str]) -> None:
195
+ """Databricks credentials - always collected, since every source type
196
+ targets a Databricks catalog."""
197
+ typer.echo("\n== Databricks (target) ==")
198
+ workspace_url = _ask(
199
+ questionary.text(
200
+ "Databricks workspace URL (e.g. https://adb-123.databricks.net):",
201
+ default=config.databricks.workspace_url or "",
202
+ )
203
+ )
204
+ config.databricks.workspace_url = (
205
+ _normalize_workspace_url(workspace_url) if workspace_url else None
206
+ )
207
+ config.databricks.http_path = _ask(
208
+ questionary.text(
209
+ "Databricks SQL Warehouse HTTP path (e.g. /sql/1.0/warehouses/abc123):",
210
+ default=config.databricks.http_path or "",
211
+ )
212
+ )
213
+ token = _ask(questionary.password("Databricks personal access token:"))
214
+ if token:
215
+ secrets["DATABRICKS_TOKEN"] = token
216
+
217
+
218
+ def _prompt_azure_ad_ids(config: ValidatorConfig) -> None:
219
+ """Optional tenant/subscription IDs, reserved for a future Service
220
+ Principal auth phase - relevant regardless of which Azure source (Blob
221
+ or SQL) is selected, so asked once rather than duplicated per branch."""
222
+ config.azure.tenant_id = _ask(
223
+ questionary.text(
224
+ "Azure AD tenant ID (optional, reserved for future Azure CLI / "
225
+ "Service Principal auth - leave blank to skip):",
226
+ default=config.azure.tenant_id or "",
227
+ )
228
+ )
229
+ config.azure.subscription_id = _ask(
230
+ questionary.text(
231
+ "Azure subscription ID (optional, leave blank to skip):",
232
+ default=config.azure.subscription_id or "",
233
+ )
234
+ )
235
+
236
+
237
+ def _prompt_databricks_source(config: ValidatorConfig) -> None:
238
+ """source_type == databricks: source is another Databricks catalog,
239
+ same shape/prompts as the target."""
240
+ typer.echo("\n== Source table (Databricks) ==")
241
+ source = _prompt_table_ref("Source", config.source_table)
242
+ config.source_table = config.source_table.model_copy(update=source)
243
+
244
+
245
+ def _prompt_azure_blob_source(config: ValidatorConfig, secrets: Dict[str, str]) -> None:
246
+ """source_type == azure_blob: Storage account/container/key, then
247
+ optional folder_prefix/file_pattern scoping which blobs are compared."""
248
+ typer.echo("\n== Azure Blob Storage (source) ==")
249
+ _prompt_azure_ad_ids(config)
250
+
251
+ config.azure.storage_account = _ask(
252
+ questionary.text(
253
+ "Azure Storage account name:",
254
+ default=config.azure.storage_account or "",
255
+ )
256
+ )
257
+ config.blob_source.container = _ask(
258
+ questionary.text(
259
+ "Container name:",
260
+ default=config.blob_source.container or config.azure.container or "",
261
+ )
262
+ )
263
+ # azure.container mirrors blob_source.container for backward
264
+ # compatibility with the Databricks-source-only AzureConfig shape.
265
+ config.azure.container = config.blob_source.container
266
+ storage_key = _ask(questionary.password("Azure Storage account key:"))
267
+ if storage_key:
268
+ secrets["AZURE_STORAGE_KEY"] = storage_key
269
+
270
+ config.blob_source.folder_prefix = _ask(
271
+ questionary.text(
272
+ "Folder prefix to scope blob discovery to "
273
+ "(leave blank to scan the whole container):",
274
+ default=config.blob_source.folder_prefix or "",
275
+ )
276
+ )
277
+ config.blob_source.file_pattern = _ask(
278
+ questionary.text(
279
+ "File pattern to scope blob discovery to, e.g. '*.csv' or "
280
+ "'*.parquet' (leave blank to consider every supported format):",
281
+ default=config.blob_source.file_pattern or "",
282
+ )
283
+ )
284
+ config.blob_source.blob_path = _ask(
285
+ questionary.text(
286
+ "Exact path to one specific source blob, e.g. "
287
+ "'n8ndirectory/customers.csv' (leave blank to match multiple "
288
+ "blobs by filename against catalog tables using folder_prefix/"
289
+ "file_pattern above instead). If set together with a Target "
290
+ "table name below, that exact blob and table are compared "
291
+ "directly, even if their names don't match:",
292
+ default=config.blob_source.blob_path or "",
293
+ )
294
+ )
295
+
296
+
297
+ def _prompt_azure_sql_source(config: ValidatorConfig, secrets: Dict[str, str]) -> None:
298
+ """source_type == azure_sql: SQL server/database/credentials, then
299
+ optional schema/table scoping (blank = compare all, same convention
300
+ as the Databricks source/target prompts)."""
301
+ typer.echo("\n== Azure SQL Database (source) ==")
302
+ _prompt_azure_ad_ids(config)
303
+
304
+ config.azure.sql_server = _ask(
305
+ questionary.text(
306
+ "Azure SQL server:",
307
+ default=config.azure.sql_server or "",
308
+ )
309
+ )
310
+ config.azure.sql_database = _ask(
311
+ questionary.text(
312
+ "Azure SQL database name:",
313
+ default=config.azure.sql_database or "",
314
+ )
315
+ )
316
+ sql_username = _ask(questionary.text("Azure SQL username:"))
317
+ if sql_username:
318
+ secrets["AZURE_SQL_USERNAME"] = sql_username
319
+ sql_password = _ask(questionary.password("Azure SQL password:"))
320
+ if sql_password:
321
+ secrets["AZURE_SQL_PASSWORD"] = sql_password
322
+
323
+ config.sql_source.schema_name = _ask(
324
+ questionary.text(
325
+ "Schema (leave blank to compare all schemas in this database):",
326
+ default=config.sql_source.schema_name or "",
327
+ )
328
+ )
329
+ config.sql_source.table = _ask(
330
+ questionary.text(
331
+ "Table (leave blank to compare all tables in this schema):",
332
+ default=config.sql_source.table or "",
333
+ )
334
+ )
335
+
336
+
337
+ def run_configure_wizard() -> None:
338
+ """Run the full interactive configuration wizard."""
339
+ if CONFIG_PATH.exists():
340
+ overwrite = questionary.confirm(
341
+ f"A config already exists at {CONFIG_PATH}. Overwrite it?",
342
+ default=False,
343
+ ).ask()
344
+ if not overwrite:
345
+ typer.echo("Cancelled. Existing configuration was not changed.")
346
+ raise typer.Exit(code=0)
347
+
348
+ config = load_config()
349
+ secrets: Dict[str, str] = {}
350
+
351
+ # ------------------------------------------------------------------
352
+ # 1. What's being compared
353
+ # ------------------------------------------------------------------
354
+ config.source_type = _prompt_source_type(config.source_type)
355
+
356
+ # ------------------------------------------------------------------
357
+ # 2. Databricks credentials - always needed (target is always
358
+ # Databricks); asked before branching so it's never duplicated below.
359
+ # ------------------------------------------------------------------
360
+ _prompt_databricks_credentials(config, secrets)
361
+
362
+ # ------------------------------------------------------------------
363
+ # 3. Source-specific credentials/scoping
364
+ # ------------------------------------------------------------------
365
+ if config.source_type == SourceType.AZURE_BLOB:
366
+ _prompt_azure_blob_source(config, secrets)
367
+ elif config.source_type == SourceType.AZURE_SQL:
368
+ _prompt_azure_sql_source(config, secrets)
369
+ else:
370
+ _prompt_databricks_source(config)
371
+
372
+ # ------------------------------------------------------------------
373
+ # 4. Target table (always Databricks, regardless of source_type)
374
+ # ------------------------------------------------------------------
375
+ typer.echo("\n== Target table (Databricks) ==")
376
+ target = _prompt_table_ref("Target", config.target_table)
377
+ config.target_table = config.target_table.model_copy(update=target)
378
+
379
+ # Primary key - only meaningful for a single specific table (both
380
+ # source and target named, not a catalog-wide sweep). Every source
381
+ # type's row-level comparison has the same synthetic-ROW_NUMBER()
382
+ # fallback and the same cost/timeout risk without a real key
383
+ # (databricks: source_table.table; azure_sql: sql_source.table;
384
+ # azure_blob: blob_source.blob_path), so this is asked whenever a
385
+ # specific table is named on both sides, not just for Databricks.
386
+ # Blank schema/table on either side means "compare many tables",
387
+ # which a single primary_key field can't represent.
388
+ if config.source_type == SourceType.AZURE_SQL:
389
+ source_table_named = bool(config.sql_source.table)
390
+ elif config.source_type == SourceType.AZURE_BLOB:
391
+ source_table_named = bool(config.blob_source.blob_path)
392
+ else:
393
+ source_table_named = bool(config.source_table.table)
394
+
395
+ if source_table_named and config.target_table.table:
396
+ config.primary_key = _prompt_primary_key(config.primary_key)
397
+ else:
398
+ config.primary_key = None
399
+
400
+ # ------------------------------------------------------------------
401
+ # 5. Validations to run
402
+ # ------------------------------------------------------------------
403
+ typer.echo("\n== Validations ==")
404
+ all_already_selected = set(config.validations) == {v for v, _label in _ALL_VALIDATIONS}
405
+ selected_labels = questionary.checkbox(
406
+ "Which validations should run?",
407
+ choices=[
408
+ questionary.Choice(_ALL_LABEL, checked=all_already_selected),
409
+ *[
410
+ questionary.Choice(label, checked=(vtype in config.validations))
411
+ for vtype, label in _ALL_VALIDATIONS
412
+ ],
413
+ ],
414
+ ).ask()
415
+ # "All" (alone or combined with individual choices) always resolves to
416
+ # the full set - it's a wizard convenience, never itself stored.
417
+ config.validations = _resolve_validation_selection(selected_labels)
418
+
419
+ # ------------------------------------------------------------------
420
+ # Persist
421
+ # ------------------------------------------------------------------
422
+ save_config(config)
423
+ _write_env_file(secrets)
424
+
425
+ typer.echo(f"\nConfiguration saved to {CONFIG_PATH}")
426
+ typer.echo(
427
+ "Credentials are stored in plaintext at ~/.table_validator/.env for now. "
428
+ "A future version will support Azure CLI / Databricks OAuth login."
429
+ )
@@ -0,0 +1 @@
1
+ """Config package: schema definitions and load/save management for ~/.table_validator/."""