tablevalidator-databricks 0.1.0__tar.gz → 0.1.1__tar.gz

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 (22) hide show
  1. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/PKG-INFO +14 -4
  2. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/README.md +13 -3
  3. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks/cli/main.py +5 -3
  4. tablevalidator_databricks-0.1.1/tablevalidator_databricks/templates/widget_notebook.py.tmpl +249 -0
  5. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/PKG-INFO +14 -4
  6. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/SOURCES.txt +1 -0
  7. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/scm_file_list.json +1 -0
  8. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/scm_version.json +2 -2
  9. tablevalidator_databricks-0.1.1/tests/test_generated_notebook_execution.py +231 -0
  10. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tests/test_generator.py +16 -0
  11. tablevalidator_databricks-0.1.0/tablevalidator_databricks/templates/widget_notebook.py.tmpl +0 -178
  12. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/pyproject.toml +0 -0
  13. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/setup.cfg +0 -0
  14. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks/__init__.py +0 -0
  15. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks/cli/__init__.py +0 -0
  16. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks/generator/__init__.py +0 -0
  17. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks/generator/generator.py +0 -0
  18. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/dependency_links.txt +0 -0
  19. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/entry_points.txt +0 -0
  20. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/requires.txt +0 -0
  21. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tablevalidator_databricks.egg-info/top_level.txt +0 -0
  22. {tablevalidator_databricks-0.1.0 → tablevalidator_databricks-0.1.1}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablevalidator-databricks
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Generates a widget-driven Databricks notebook UI on top of the table-validator package - no code required to run a comparison.
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -72,9 +72,19 @@ markers and opens it as a real notebook.
72
72
 
73
73
  **Cascading dropdowns**: Databricks widgets don't auto-refresh when an
74
74
  upstream selection changes. After changing Source/Target Catalog or
75
- Schema, re-run the notebook's "Create widgets" cell once to repopulate the
76
- dropdowns below it before running the rest of the notebook. This is a
77
- Databricks widget limitation, not a bug in the generated notebook.
75
+ Schema, re-run the notebook's "Create / refresh widgets" cell once to
76
+ repopulate the dropdowns below it before running the rest of the notebook.
77
+ This is a Databricks widget limitation, not a bug in the generated
78
+ notebook. If you skip this and run validation anyway with a stale Schema/
79
+ Table selection, the notebook fails with a clear error telling you which
80
+ cell to re-run, rather than silently comparing the wrong table.
81
+
82
+ **Can't find a name in a dropdown?** Every Catalog/Schema/Table dropdown
83
+ has a matching "(manual override)" text widget next to it. Type the exact
84
+ name there and it's used instead of the dropdown - useful if something
85
+ isn't showing up (a permissions lag, sort order, anything). It's used
86
+ as-is, not checked against the live catalog listing. Leave every override
87
+ blank to just use the dropdowns as normal.
78
88
 
79
89
  ## Why a separate package
80
90
 
@@ -58,9 +58,19 @@ markers and opens it as a real notebook.
58
58
 
59
59
  **Cascading dropdowns**: Databricks widgets don't auto-refresh when an
60
60
  upstream selection changes. After changing Source/Target Catalog or
61
- Schema, re-run the notebook's "Create widgets" cell once to repopulate the
62
- dropdowns below it before running the rest of the notebook. This is a
63
- Databricks widget limitation, not a bug in the generated notebook.
61
+ Schema, re-run the notebook's "Create / refresh widgets" cell once to
62
+ repopulate the dropdowns below it before running the rest of the notebook.
63
+ This is a Databricks widget limitation, not a bug in the generated
64
+ notebook. If you skip this and run validation anyway with a stale Schema/
65
+ Table selection, the notebook fails with a clear error telling you which
66
+ cell to re-run, rather than silently comparing the wrong table.
67
+
68
+ **Can't find a name in a dropdown?** Every Catalog/Schema/Table dropdown
69
+ has a matching "(manual override)" text widget next to it. Type the exact
70
+ name there and it's used instead of the dropdown - useful if something
71
+ isn't showing up (a permissions lag, sort order, anything). It's used
72
+ as-is, not checked against the live catalog listing. Leave every override
73
+ blank to just use the dropdowns as normal.
64
74
 
65
75
  ## Why a separate package
66
76
 
@@ -88,9 +88,11 @@ def info() -> None:
88
88
  " 3. Fill in the widgets and Run All\n"
89
89
  " Pick Source/Target Catalog, Schema, Table, and which checks "
90
90
  "to run, then run the notebook top to bottom. Re-run the "
91
- "'Create widgets' cell after changing a Catalog/Schema selection, "
92
- "to repopulate the dropdowns below it (a Databricks widget "
93
- "limitation, not a bug).\n"
91
+ "'Create / refresh widgets' cell after changing a Catalog/Schema "
92
+ "selection, to repopulate the dropdowns below it (a Databricks "
93
+ "widget limitation, not a bug). Can't find a name in a dropdown? "
94
+ "Each dropdown has a '(manual override)' text widget next to it - "
95
+ "type the exact name there instead.\n"
94
96
  "\n"
95
97
  "This package contains no validation logic of its own - every "
96
98
  "generated notebook calls directly into table-validator's own "
@@ -0,0 +1,249 @@
1
+ # Databricks notebook source
2
+ # MAGIC %md
3
+ # MAGIC # Table Validator
4
+ # MAGIC Generated by `tablevalidator-databricks init --mode {{MODE}}`.
5
+ # MAGIC
6
+ # MAGIC Fill in the widgets above (Source/Target Catalog, Schema, Table, and
7
+ # MAGIC which checks to run), then **Run All**. No code needs to be written or
8
+ # MAGIC edited - every cell below just reads the widgets and calls the
9
+ # MAGIC `table_validator` package's `validate_tables()` API.
10
+ # MAGIC
11
+ # MAGIC **Changing Catalog or Schema after the dropdowns are already built?**
12
+ # MAGIC Databricks widgets have no on-change event, so picking a new Catalog
13
+ # MAGIC does not repopulate the Schema dropdown by itself (same for Schema ->
14
+ # MAGIC Table) - only re-running the **"Create / refresh widgets"** cell does.
15
+ # MAGIC After changing Source/Target Catalog or Schema, re-run just that one
16
+ # MAGIC cell (Shift+Enter) to refresh everything below it, then continue. This
17
+ # MAGIC is a Databricks platform limitation, not a bug in this notebook.
18
+ # MAGIC
19
+ # MAGIC **Can't find a name in a dropdown?** Each Catalog/Schema/Table dropdown
20
+ # MAGIC has a matching "(manual override)" text box right next to it. Type the
21
+ # MAGIC exact name there and it's used INSTEAD of the dropdown - useful if
22
+ # MAGIC something isn't showing up (a permissions lag, a name that doesn't sort
23
+ # MAGIC into view, etc.). Leave every override blank to just use the dropdowns
24
+ # MAGIC as normal.
25
+
26
+ # COMMAND ----------
27
+
28
+ # MAGIC %pip install table-validator
29
+
30
+ # COMMAND ----------
31
+
32
+ dbutils.library.restartPython()
33
+
34
+ # COMMAND ----------
35
+
36
+ from table_validator import validate_tables
37
+ from table_validator.config.schema import ValidationType
38
+
39
+ MODE = "{{MODE}}"
40
+ SHOW_OPTIONAL_WIDGETS = {{SHOW_OPTIONAL_WIDGETS}}
41
+
42
+
43
+ def _list_catalogs():
44
+ return sorted(r["catalog"] for r in spark.sql("SHOW CATALOGS").collect())
45
+
46
+
47
+ def _list_schemas(catalog: str):
48
+ if not catalog:
49
+ return []
50
+ rows = spark.sql(f"SHOW SCHEMAS IN `{catalog}`").collect()
51
+ return sorted(r["databaseName"] for r in rows)
52
+
53
+
54
+ def _list_tables(catalog: str, schema: str):
55
+ if not catalog or not schema:
56
+ return []
57
+ rows = spark.sql(f"SHOW TABLES IN `{catalog}`.`{schema}`").collect()
58
+ return sorted(r["tableName"] for r in rows)
59
+
60
+
61
+ def _current_or_first(options, current):
62
+ """Keep a widget's existing selection across a re-run of this cell if
63
+ it's still valid for the (possibly new) upstream selection, otherwise
64
+ fall back to the first available option (or "" if there are none)."""
65
+ if current in options:
66
+ return current
67
+ return options[0] if options else ""
68
+
69
+ # COMMAND ----------
70
+
71
+ # MAGIC %md ### Create / refresh widgets
72
+ # MAGIC **Run this cell (Shift+Enter) every time you change Source/Target
73
+ # MAGIC Catalog or Schema**, to repopulate the Schema/Table dropdowns below
74
+ # MAGIC them - Databricks widgets don't refresh automatically. Re-running it
75
+ # MAGIC is always safe: your current Catalog/Schema/Table picks are kept if
76
+ # MAGIC still valid, and only stale downstream selections reset.
77
+
78
+ # COMMAND ----------
79
+
80
+ def _get_widget(name: str, default: str = "") -> str:
81
+ try:
82
+ return dbutils.widgets.get(name)
83
+ except Exception:
84
+ return default
85
+
86
+
87
+ def _effective_value(prefix: str) -> str:
88
+ """The manual-override text widget wins whenever it's non-blank,
89
+ otherwise fall back to the dropdown's current value - lets a name
90
+ that isn't showing up in a dropdown (permissions lag, sort order,
91
+ anything) be typed in directly instead. Used as-is, not validated
92
+ against the live catalog/schema/table list - same convention as
93
+ row_filter elsewhere in this notebook."""
94
+ override = _get_widget(f"{prefix}_override").strip()
95
+ return override or _get_widget(prefix)
96
+
97
+
98
+ def _build_cascading_widgets(prefix: str, label_prefix: str) -> None:
99
+ """Build/refresh one side's (source or target) Catalog -> Schema ->
100
+ Table dropdowns from Unity Catalog, in one pass - each level is
101
+ re-queried from the CURRENT value of the level above, so this single
102
+ cell run always reflects whatever Catalog/Schema is selected right
103
+ now, not a stale snapshot from an earlier run. Also creates a
104
+ "(manual override)" text widget next to each dropdown, for a name
105
+ that isn't showing up in the dropdown for any reason - see
106
+ _effective_value() below for how the override takes priority."""
107
+ catalog_current = _current_or_first(catalogs, _effective_value(f"{prefix}_catalog"))
108
+ schemas = _list_schemas(catalog_current)
109
+ schema_current = _current_or_first(schemas, _effective_value(f"{prefix}_schema"))
110
+ tables = _list_tables(catalog_current, schema_current)
111
+ table_current = _current_or_first(tables, _effective_value(f"{prefix}_table"))
112
+
113
+ dbutils.widgets.dropdown(f"{prefix}_catalog", catalog_current, catalogs or [""], f"{label_prefix} Catalog")
114
+ dbutils.widgets.text(f"{prefix}_catalog_override", "", f"{label_prefix} Catalog (manual override)")
115
+ dbutils.widgets.dropdown(f"{prefix}_schema", schema_current, schemas or [""], f"{label_prefix} Schema")
116
+ dbutils.widgets.text(f"{prefix}_schema_override", "", f"{label_prefix} Schema (manual override)")
117
+ dbutils.widgets.dropdown(f"{prefix}_table", table_current, tables or [""], f"{label_prefix} Table")
118
+ dbutils.widgets.text(f"{prefix}_table_override", "", f"{label_prefix} Table (manual override)")
119
+
120
+
121
+ catalogs = _list_catalogs()
122
+ _build_cascading_widgets("source", "Source")
123
+ _build_cascading_widgets("target", "Target")
124
+
125
+ print(
126
+ "Widgets refreshed for the CURRENT Catalog/Schema selection.\n"
127
+ "Changed Catalog or Schema just now? Run this cell again (Shift+Enter) "
128
+ "before moving on - Databricks widgets don't repopulate on their own."
129
+ )
130
+
131
+ # COMMAND ----------
132
+
133
+ # MAGIC %md ### Validation checks
134
+
135
+ # COMMAND ----------
136
+
137
+ _default_checks = ["Catalog & Schema", "Column", "Row"] if MODE != "schema" else ["Catalog & Schema"]
138
+ dbutils.widgets.multiselect(
139
+ "checks", ",".join(_default_checks),
140
+ ["Catalog & Schema", "Column", "Row"], "Validation Checks",
141
+ )
142
+
143
+ if SHOW_OPTIONAL_WIDGETS:
144
+ dbutils.widgets.text("only_columns", "", "Only compare these columns (comma-separated, optional)")
145
+ dbutils.widgets.text("ignore_columns", "", "Skip these columns entirely (comma-separated, optional)")
146
+ dbutils.widgets.text("row_filter", "", "Row filter - SQL WHERE-fragment (optional)")
147
+ dbutils.widgets.text("primary_key", "", "Primary key column(s) (comma-separated, optional)")
148
+
149
+ # COMMAND ----------
150
+
151
+ # MAGIC %md ### Run validation
152
+
153
+ # COMMAND ----------
154
+
155
+ def _validate_current_selection(prefix: str, label: str) -> None:
156
+ """Guard against a stale Schema/Table dropdown: if the Schema/Table
157
+ widget's CURRENT value isn't actually in that catalog/schema right
158
+ now (e.g. the user picked a new Catalog but never re-ran the "Create
159
+ / refresh widgets" cell above), fail loudly here with a clear fix
160
+ instead of silently validating the wrong table. A field with a
161
+ manual override set is trusted as typed and skipped here - the whole
162
+ point of the override is to name something the live catalog listing
163
+ doesn't (currently) show, so it can never pass this check by
164
+ definition."""
165
+ catalog = _effective_value(f"{prefix}_catalog")
166
+ schema_overridden = bool(_get_widget(f"{prefix}_schema_override").strip())
167
+ table_overridden = bool(_get_widget(f"{prefix}_table_override").strip())
168
+ schema = _effective_value(f"{prefix}_schema")
169
+ table = _effective_value(f"{prefix}_table")
170
+
171
+ if not schema_overridden:
172
+ live_schemas = _list_schemas(catalog)
173
+ if schema not in live_schemas:
174
+ raise ValueError(
175
+ f"{label} Schema widget is set to {schema!r}, which is not "
176
+ f"a schema in catalog {catalog!r} right now. You likely "
177
+ f"changed the {label} Catalog without refreshing - go back "
178
+ "and re-run the 'Create / refresh widgets' cell, then "
179
+ "re-pick the Schema and Table before running this cell "
180
+ "again. (Or, if this schema genuinely isn't showing up in "
181
+ "the dropdown, type it into the Schema manual override "
182
+ "widget instead.)"
183
+ )
184
+ if not table_overridden:
185
+ live_tables = _list_tables(catalog, schema)
186
+ if table not in live_tables:
187
+ raise ValueError(
188
+ f"{label} Table widget is set to {table!r}, which is not a "
189
+ f"table in {catalog}.{schema} right now. You likely changed "
190
+ f"the {label} Schema without refreshing - go back and "
191
+ "re-run the 'Create / refresh widgets' cell, then re-pick "
192
+ "the Table before running this cell again. (Or, if this "
193
+ "table genuinely isn't showing up in the dropdown, type it "
194
+ "into the Table manual override widget instead.)"
195
+ )
196
+
197
+
198
+ _validate_current_selection("source", "Source")
199
+ _validate_current_selection("target", "Target")
200
+
201
+ source = f"{_effective_value('source_catalog')}.{_effective_value('source_schema')}.{_effective_value('source_table')}"
202
+ target = f"{_effective_value('target_catalog')}.{_effective_value('target_schema')}.{_effective_value('target_table')}"
203
+
204
+ _CHECK_MAP = {
205
+ "Catalog & Schema": {ValidationType.CATALOG, ValidationType.SCHEMA},
206
+ "Column": {ValidationType.COLUMN},
207
+ "Row": {ValidationType.ROW},
208
+ }
209
+ selected_checks = [c for c in dbutils.widgets.get("checks").split(",") if c]
210
+ enabled_validations = set()
211
+ for check in selected_checks:
212
+ enabled_validations |= _CHECK_MAP.get(check, set())
213
+ if not enabled_validations:
214
+ enabled_validations = None # fall back to validate_tables()'s own "run everything" default
215
+
216
+ kwargs = {"enabled_validations": enabled_validations}
217
+
218
+ if SHOW_OPTIONAL_WIDGETS:
219
+ only_columns = [c.strip() for c in dbutils.widgets.get("only_columns").split(",") if c.strip()]
220
+ ignore_columns = [c.strip() for c in dbutils.widgets.get("ignore_columns").split(",") if c.strip()]
221
+ row_filter = dbutils.widgets.get("row_filter").strip()
222
+ primary_key = [c.strip() for c in dbutils.widgets.get("primary_key").split(",") if c.strip()]
223
+
224
+ if only_columns:
225
+ kwargs["only_columns"] = only_columns
226
+ if ignore_columns:
227
+ kwargs["ignore_columns"] = ignore_columns
228
+ if row_filter:
229
+ kwargs["row_filter"] = row_filter
230
+ if primary_key:
231
+ kwargs["primary_key"] = primary_key
232
+
233
+ result = validate_tables(source, target, **kwargs)
234
+
235
+ # COMMAND ----------
236
+
237
+ # MAGIC %md ### Results
238
+
239
+ # COMMAND ----------
240
+
241
+ print(result)
242
+ display(result.table_validation.to_dataframe())
243
+
244
+ # Other sheets, mirroring the Excel report's own tabs - uncomment as needed:
245
+ # display(result.column_validation.to_dataframe())
246
+ # display(result.data_mismatches.to_dataframe())
247
+ # display(result.row_hash_mismatches.to_dataframe())
248
+ # display(result.mismatch_categories.to_dataframe())
249
+ # display(result.suggestions.to_dataframe())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablevalidator-databricks
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Generates a widget-driven Databricks notebook UI on top of the table-validator package - no code required to run a comparison.
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -72,9 +72,19 @@ markers and opens it as a real notebook.
72
72
 
73
73
  **Cascading dropdowns**: Databricks widgets don't auto-refresh when an
74
74
  upstream selection changes. After changing Source/Target Catalog or
75
- Schema, re-run the notebook's "Create widgets" cell once to repopulate the
76
- dropdowns below it before running the rest of the notebook. This is a
77
- Databricks widget limitation, not a bug in the generated notebook.
75
+ Schema, re-run the notebook's "Create / refresh widgets" cell once to
76
+ repopulate the dropdowns below it before running the rest of the notebook.
77
+ This is a Databricks widget limitation, not a bug in the generated
78
+ notebook. If you skip this and run validation anyway with a stale Schema/
79
+ Table selection, the notebook fails with a clear error telling you which
80
+ cell to re-run, rather than silently comparing the wrong table.
81
+
82
+ **Can't find a name in a dropdown?** Every Catalog/Schema/Table dropdown
83
+ has a matching "(manual override)" text widget next to it. Type the exact
84
+ name there and it's used instead of the dropdown - useful if something
85
+ isn't showing up (a permissions lag, sort order, anything). It's used
86
+ as-is, not checked against the live catalog listing. Leave every override
87
+ blank to just use the dropdowns as normal.
78
88
 
79
89
  ## Why a separate package
80
90
 
@@ -15,4 +15,5 @@ tablevalidator_databricks/generator/__init__.py
15
15
  tablevalidator_databricks/generator/generator.py
16
16
  tablevalidator_databricks/templates/widget_notebook.py.tmpl
17
17
  tests/test_cli.py
18
+ tests/test_generated_notebook_execution.py
18
19
  tests/test_generator.py
@@ -9,6 +9,7 @@
9
9
  "tablevalidator_databricks/generator/generator.py",
10
10
  "tablevalidator_databricks/templates/widget_notebook.py.tmpl",
11
11
  "tests/test_cli.py",
12
+ "tests/test_generated_notebook_execution.py",
12
13
  "tests/test_generator.py"
13
14
  ]
14
15
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.1.0",
2
+ "tag": "0.1.1",
3
3
  "distance": 0,
4
- "node": "g9124e37",
4
+ "node": "g1f85a99",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-09-08"
@@ -0,0 +1,231 @@
1
+ """Executes the generated notebook's Python cells against a fake
2
+ Databricks runtime (dbutils/spark/display stubs), to catch real bugs in
3
+ the widget wiring - not just that the file is syntactically valid.
4
+
5
+ Strips the Databricks-magic-only lines (the notebook source header,
6
+ `# COMMAND ----------` cell separators, `# MAGIC` lines) and execs the
7
+ rest as one script, same approach used to hand-verify this template
8
+ during development.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from unittest.mock import MagicMock
15
+
16
+ import pytest
17
+
18
+ from tablevalidator_databricks.generator.generator import generate_notebook
19
+
20
+
21
+ def _code_from_notebook(path: Path) -> str:
22
+ lines = path.read_text(encoding="utf-8").splitlines()
23
+ code_lines = [
24
+ line for line in lines
25
+ if line.strip() != "# Databricks notebook source"
26
+ and line.strip() != "# COMMAND ----------"
27
+ and not line.strip().startswith("# MAGIC")
28
+ ]
29
+ return "\n".join(code_lines)
30
+
31
+
32
+ class _FakeWidgets:
33
+ def __init__(self, initial=None):
34
+ self._values = dict(initial or {})
35
+
36
+ def get(self, name):
37
+ return self._values.get(name, "")
38
+
39
+ def text(self, name, default, label):
40
+ self._values.setdefault(name, default)
41
+
42
+ def dropdown(self, name, default, choices, label):
43
+ # Real dbutils.widgets.dropdown does NOT overwrite an existing
44
+ # value just because the cell re-ran with a different `default` -
45
+ # it only sets it the first time. Match that.
46
+ self._values.setdefault(name, default)
47
+
48
+ def multiselect(self, name, default, choices, label):
49
+ self._values.setdefault(name, default)
50
+
51
+
52
+ class _FakeRow(dict):
53
+ def __getitem__(self, key):
54
+ return dict.__getitem__(self, key)
55
+
56
+
57
+ def _fake_spark(catalogs, schemas_by_catalog, tables_by_schema):
58
+ def sql(query):
59
+ result = MagicMock()
60
+ if "SHOW CATALOGS" in query:
61
+ result.collect.return_value = [_FakeRow(catalog=c) for c in catalogs]
62
+ elif "SHOW SCHEMAS IN" in query:
63
+ catalog = query.split("`")[1]
64
+ result.collect.return_value = [
65
+ _FakeRow(databaseName=s) for s in schemas_by_catalog.get(catalog, [])
66
+ ]
67
+ elif "SHOW TABLES IN" in query:
68
+ parts = query.split("`")
69
+ catalog, schema = parts[1], parts[3]
70
+ result.collect.return_value = [
71
+ _FakeRow(tableName=t) for t in tables_by_schema.get((catalog, schema), [])
72
+ ]
73
+ else:
74
+ result.collect.return_value = []
75
+ return result
76
+
77
+ spark = MagicMock()
78
+ spark.sql.side_effect = sql
79
+ return spark
80
+
81
+
82
+ def _run_notebook(path: Path, widget_values, spark, validate_tables_mock):
83
+ code = _code_from_notebook(path)
84
+
85
+ fake_dbutils = MagicMock()
86
+ fake_dbutils.widgets = _FakeWidgets(initial=widget_values)
87
+ fake_dbutils.library.restartPython = MagicMock()
88
+
89
+ globs = {
90
+ "dbutils": fake_dbutils,
91
+ "spark": spark,
92
+ "display": MagicMock(),
93
+ "__name__": "__main__",
94
+ }
95
+
96
+ import table_validator
97
+ import table_validator.notebook as nb_mod
98
+
99
+ table_validator.validate_tables = validate_tables_mock
100
+ nb_mod.validate_tables = validate_tables_mock
101
+
102
+ exec(compile(code, str(path), "exec"), globs)
103
+ return globs
104
+
105
+
106
+ @pytest.fixture
107
+ def notebook_path(tmp_path: Path) -> Path:
108
+ path = tmp_path / "TableValidator.py"
109
+ generate_notebook(path, "full")
110
+ return path
111
+
112
+
113
+ def test_happy_path_calls_validate_tables_with_resolved_names(notebook_path):
114
+ spark = _fake_spark(
115
+ catalogs=["main"],
116
+ schemas_by_catalog={"main": ["sales"]},
117
+ tables_by_schema={("main", "sales"): ["orders"]},
118
+ )
119
+ captured = {}
120
+
121
+ def fake_validate_tables(source, target, **kwargs):
122
+ captured["source"] = source
123
+ captured["target"] = target
124
+ captured["kwargs"] = kwargs
125
+ m = MagicMock()
126
+ m.__str__ = MagicMock(return_value="Overall status: PASS")
127
+ return m
128
+
129
+ _run_notebook(notebook_path, widget_values={}, spark=spark, validate_tables_mock=fake_validate_tables)
130
+
131
+ assert captured["source"] == "main.sales.orders"
132
+ assert captured["target"] == "main.sales.orders"
133
+
134
+
135
+ def test_stale_schema_selection_raises_clear_error(notebook_path):
136
+ """Regression test for a real user-reported issue: after picking a
137
+ new Source Catalog, the Source Schema/Table widgets kept their value
138
+ from the PREVIOUS catalog because the 'Create / refresh widgets'
139
+ cell wasn't re-run. Must fail loudly instead of silently validating
140
+ against the wrong table."""
141
+ spark = _fake_spark(
142
+ catalogs=["for_validation1", "for_validation2"],
143
+ schemas_by_catalog={"for_validation1": ["alteryx_silver_source"]},
144
+ tables_by_schema={("for_validation1", "alteryx_silver_source"): ["from_bronze_us_counties"]},
145
+ )
146
+
147
+ with pytest.raises(ValueError, match="not a schema in catalog"):
148
+ _run_notebook(
149
+ notebook_path,
150
+ widget_values={
151
+ "source_catalog": "for_validation1",
152
+ "source_schema": "some_stale_schema_from_before",
153
+ "source_table": "some_stale_table",
154
+ "target_catalog": "for_validation1",
155
+ "target_schema": "alteryx_silver_source",
156
+ "target_table": "from_bronze_us_counties",
157
+ },
158
+ spark=spark,
159
+ validate_tables_mock=MagicMock(),
160
+ )
161
+
162
+
163
+ def test_manual_override_bypasses_dropdown_and_stale_check(notebook_path):
164
+ """A table that isn't showing up in the dropdown must still be
165
+ usable by typing it into the "(manual override)" text widget - and
166
+ the override, not the dropdown, is what gets sent to
167
+ validate_tables()."""
168
+ spark = _fake_spark(
169
+ catalogs=["main"],
170
+ schemas_by_catalog={"main": ["sales"]},
171
+ # "hidden_table" deliberately absent - simulating a table not
172
+ # showing up in the dropdown.
173
+ tables_by_schema={("main", "sales"): ["orders"]},
174
+ )
175
+ captured = {}
176
+
177
+ def fake_validate_tables(source, target, **kwargs):
178
+ captured["source"] = source
179
+ captured["target"] = target
180
+ m = MagicMock()
181
+ m.__str__ = MagicMock(return_value="Overall status: PASS")
182
+ return m
183
+
184
+ _run_notebook(
185
+ notebook_path,
186
+ widget_values={
187
+ "source_catalog": "main", "source_schema": "sales", "source_table": "orders",
188
+ "target_catalog": "main", "target_schema": "sales",
189
+ "target_table_override": "hidden_table",
190
+ },
191
+ spark=spark,
192
+ validate_tables_mock=fake_validate_tables,
193
+ )
194
+
195
+ assert captured["target"] == "main.sales.hidden_table"
196
+
197
+
198
+ def test_catalog_override_drives_schema_and_table_lookup(notebook_path):
199
+ """A manually-overridden Catalog must be used when querying for
200
+ Schemas/Tables too, not just at final source/target string
201
+ construction - otherwise typing a catalog override would leave the
202
+ Schema/Table dropdowns empty."""
203
+ spark = _fake_spark(
204
+ catalogs=["main"], # "hidden_catalog" deliberately absent
205
+ schemas_by_catalog={"hidden_catalog": ["sales"], "main": ["sales"]},
206
+ tables_by_schema={
207
+ ("hidden_catalog", "sales"): ["orders"],
208
+ ("main", "sales"): ["orders"],
209
+ },
210
+ )
211
+ captured = {}
212
+
213
+ def fake_validate_tables(source, target, **kwargs):
214
+ captured["source"] = source
215
+ captured["target"] = target
216
+ m = MagicMock()
217
+ m.__str__ = MagicMock(return_value="Overall status: PASS")
218
+ return m
219
+
220
+ _run_notebook(
221
+ notebook_path,
222
+ widget_values={
223
+ "source_catalog_override": "hidden_catalog",
224
+ "source_schema": "sales", "source_table": "orders",
225
+ "target_catalog": "main", "target_schema": "sales", "target_table": "orders",
226
+ },
227
+ spark=spark,
228
+ validate_tables_mock=fake_validate_tables,
229
+ )
230
+
231
+ assert captured["source"] == "hidden_catalog.sales.orders"
@@ -63,6 +63,22 @@ def test_generate_notebook_creates_parent_directories(tmp_path: Path) -> None:
63
63
  assert output.exists()
64
64
 
65
65
 
66
+ def test_generate_notebook_includes_manual_override_widgets(tmp_path: Path) -> None:
67
+ """Every Catalog/Schema/Table dropdown gets a matching manual-override
68
+ text widget, for a name that isn't showing up in the dropdown. Widget
69
+ names are built at runtime via f-strings (f"{prefix}_catalog_override"),
70
+ not literal per-side text, so check for the shared template fragments
71
+ rather than the fully-resolved names."""
72
+ output = tmp_path / "TableValidator.py"
73
+ generate_notebook(output, "full")
74
+ content = output.read_text(encoding="utf-8")
75
+
76
+ assert '"{prefix}_catalog_override"' in content
77
+ assert '"{prefix}_schema_override"' in content
78
+ assert '"{prefix}_table_override"' in content
79
+ assert "(manual override)" in content
80
+
81
+
66
82
  def test_generate_notebook_no_leftover_placeholders(tmp_path: Path) -> None:
67
83
  """Regression guard: every {{...}} placeholder in the template must be
68
84
  substituted - a leftover placeholder would silently ship broken Python
@@ -1,178 +0,0 @@
1
- # Databricks notebook source
2
- # MAGIC %md
3
- # MAGIC # Table Validator
4
- # MAGIC Generated by `tablevalidator-databricks init --mode {{MODE}}`.
5
- # MAGIC
6
- # MAGIC Fill in the widgets above (Source/Target Catalog, Schema, Table, and
7
- # MAGIC which checks to run), then **Run All**. No code needs to be written or
8
- # MAGIC edited - every cell below just reads the widgets and calls the
9
- # MAGIC `table_validator` package's `validate_tables()` API.
10
- # MAGIC
11
- # MAGIC **Cascading dropdowns**: Databricks widgets don't auto-refresh when an
12
- # MAGIC upstream selection changes. After picking a different Source/Target
13
- # MAGIC Catalog, re-run the "Create widgets" cell below to repopulate the Schema
14
- # MAGIC dropdown for that catalog; likewise after changing Schema, re-run it
15
- # MAGIC again to repopulate Table. This is a real Databricks widget limitation,
16
- # MAGIC not a bug in this notebook.
17
-
18
- # COMMAND ----------
19
-
20
- # MAGIC %pip install table-validator
21
-
22
- # COMMAND ----------
23
-
24
- dbutils.library.restartPython()
25
-
26
- # COMMAND ----------
27
-
28
- from table_validator import validate_tables
29
- from table_validator.config.schema import ValidationType
30
-
31
- MODE = "{{MODE}}"
32
- SHOW_OPTIONAL_WIDGETS = {{SHOW_OPTIONAL_WIDGETS}}
33
-
34
-
35
- def _list_catalogs():
36
- return sorted(r["catalog"] for r in spark.sql("SHOW CATALOGS").collect())
37
-
38
-
39
- def _list_schemas(catalog: str):
40
- if not catalog:
41
- return []
42
- rows = spark.sql(f"SHOW SCHEMAS IN `{catalog}`").collect()
43
- return sorted(r["databaseName"] for r in rows)
44
-
45
-
46
- def _list_tables(catalog: str, schema: str):
47
- if not catalog or not schema:
48
- return []
49
- rows = spark.sql(f"SHOW TABLES IN `{catalog}`.`{schema}`").collect()
50
- return sorted(r["tableName"] for r in rows)
51
-
52
-
53
- def _current_or_first(options, current):
54
- """Keep a widget's existing selection across a re-run of this cell if
55
- it's still valid for the (possibly new) upstream selection, otherwise
56
- fall back to the first available option (or "" if there are none)."""
57
- if current in options:
58
- return current
59
- return options[0] if options else ""
60
-
61
- # COMMAND ----------
62
-
63
- # MAGIC %md ### Create widgets
64
- # MAGIC Re-run this cell after changing Source/Target Catalog or Schema, to
65
- # MAGIC repopulate the dropdowns below them.
66
-
67
- # COMMAND ----------
68
-
69
- def _get_widget(name: str, default: str = "") -> str:
70
- try:
71
- return dbutils.widgets.get(name)
72
- except Exception:
73
- return default
74
-
75
-
76
- catalogs = _list_catalogs()
77
-
78
- source_catalog_current = _current_or_first(catalogs, _get_widget("source_catalog"))
79
- target_catalog_current = _current_or_first(catalogs, _get_widget("target_catalog"))
80
-
81
- source_schemas = _list_schemas(source_catalog_current)
82
- target_schemas = _list_schemas(target_catalog_current)
83
-
84
- source_schema_current = _current_or_first(source_schemas, _get_widget("source_schema"))
85
- target_schema_current = _current_or_first(target_schemas, _get_widget("target_schema"))
86
-
87
- source_tables = _list_tables(source_catalog_current, source_schema_current)
88
- target_tables = _list_tables(target_catalog_current, target_schema_current)
89
-
90
- source_table_current = _current_or_first(source_tables, _get_widget("source_table"))
91
- target_table_current = _current_or_first(target_tables, _get_widget("target_table"))
92
-
93
- dbutils.widgets.dropdown("source_catalog", source_catalog_current, catalogs or [""], "Source Catalog")
94
- dbutils.widgets.dropdown("source_schema", source_schema_current, source_schemas or [""], "Source Schema")
95
- dbutils.widgets.dropdown("source_table", source_table_current, source_tables or [""], "Source Table")
96
-
97
- dbutils.widgets.dropdown("target_catalog", target_catalog_current, catalogs or [""], "Target Catalog")
98
- dbutils.widgets.dropdown("target_schema", target_schema_current, target_schemas or [""], "Target Schema")
99
- dbutils.widgets.dropdown("target_table", target_table_current, target_tables or [""], "Target Table")
100
-
101
- print(
102
- "Widgets created. If you just changed Source/Target Catalog or Schema, "
103
- "re-run this cell once more to repopulate the dropdowns below it."
104
- )
105
-
106
- # COMMAND ----------
107
-
108
- # MAGIC %md ### Validation checks
109
-
110
- # COMMAND ----------
111
-
112
- _default_checks = ["Catalog & Schema", "Column", "Row"] if MODE != "schema" else ["Catalog & Schema"]
113
- dbutils.widgets.multiselect(
114
- "checks", ",".join(_default_checks),
115
- ["Catalog & Schema", "Column", "Row"], "Validation Checks",
116
- )
117
-
118
- if SHOW_OPTIONAL_WIDGETS:
119
- dbutils.widgets.text("only_columns", "", "Only compare these columns (comma-separated, optional)")
120
- dbutils.widgets.text("ignore_columns", "", "Skip these columns entirely (comma-separated, optional)")
121
- dbutils.widgets.text("row_filter", "", "Row filter - SQL WHERE-fragment (optional)")
122
- dbutils.widgets.text("primary_key", "", "Primary key column(s) (comma-separated, optional)")
123
-
124
- # COMMAND ----------
125
-
126
- # MAGIC %md ### Run validation
127
-
128
- # COMMAND ----------
129
-
130
- source = f"{dbutils.widgets.get('source_catalog')}.{dbutils.widgets.get('source_schema')}.{dbutils.widgets.get('source_table')}"
131
- target = f"{dbutils.widgets.get('target_catalog')}.{dbutils.widgets.get('target_schema')}.{dbutils.widgets.get('target_table')}"
132
-
133
- _CHECK_MAP = {
134
- "Catalog & Schema": {ValidationType.CATALOG, ValidationType.SCHEMA},
135
- "Column": {ValidationType.COLUMN},
136
- "Row": {ValidationType.ROW},
137
- }
138
- selected_checks = [c for c in dbutils.widgets.get("checks").split(",") if c]
139
- enabled_validations = set()
140
- for check in selected_checks:
141
- enabled_validations |= _CHECK_MAP.get(check, set())
142
- if not enabled_validations:
143
- enabled_validations = None # fall back to validate_tables()'s own "run everything" default
144
-
145
- kwargs = {"enabled_validations": enabled_validations}
146
-
147
- if SHOW_OPTIONAL_WIDGETS:
148
- only_columns = [c.strip() for c in dbutils.widgets.get("only_columns").split(",") if c.strip()]
149
- ignore_columns = [c.strip() for c in dbutils.widgets.get("ignore_columns").split(",") if c.strip()]
150
- row_filter = dbutils.widgets.get("row_filter").strip()
151
- primary_key = [c.strip() for c in dbutils.widgets.get("primary_key").split(",") if c.strip()]
152
-
153
- if only_columns:
154
- kwargs["only_columns"] = only_columns
155
- if ignore_columns:
156
- kwargs["ignore_columns"] = ignore_columns
157
- if row_filter:
158
- kwargs["row_filter"] = row_filter
159
- if primary_key:
160
- kwargs["primary_key"] = primary_key
161
-
162
- result = validate_tables(source, target, **kwargs)
163
-
164
- # COMMAND ----------
165
-
166
- # MAGIC %md ### Results
167
-
168
- # COMMAND ----------
169
-
170
- print(result)
171
- display(result.table_validation.to_dataframe())
172
-
173
- # Other sheets, mirroring the Excel report's own tabs - uncomment as needed:
174
- # display(result.column_validation.to_dataframe())
175
- # display(result.data_mismatches.to_dataframe())
176
- # display(result.row_hash_mismatches.to_dataframe())
177
- # display(result.mismatch_categories.to_dataframe())
178
- # display(result.suggestions.to_dataframe())