normalized-schema-generator 0.4.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: normalized-schema-generator
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: An anywidget for Microsoft Fabric notebooks that visually configures normalized schema JSON for Process Mining data import.
5
5
  Project-URL: Homepage, https://github.com/rosikm/normalized-schema-generator
6
6
  Project-URL: Repository, https://github.com/rosikm/normalized-schema-generator
@@ -43,6 +43,14 @@ w
43
43
 
44
44
  The widget auto-detects the Fabric environment and populates lakehouses, tables, and columns using `notebookutils` and Spark.
45
45
 
46
+ For schema-enabled lakehouses, it uses the [OneLake Delta metadata API](https://learn.microsoft.com/fabric/onelake/table-apis/delta-table-apis-overview)
47
+ to discover schemas, tables, and columns. This works even when the notebook's
48
+ default lakehouse uses the legacy Spark catalog. Authentication uses the current
49
+ notebook identity via `notebookutils.credentials.getToken("storage")`; no extra
50
+ credentials are required. That identity must have permission to read the selected
51
+ lakehouse's tables. Discovery errors are shown in the widget instead of appearing
52
+ as an empty table list.
53
+
46
54
  After configuring your schema, retrieve the generated JSON:
47
55
 
48
56
  ```python
@@ -23,6 +23,14 @@ w
23
23
 
24
24
  The widget auto-detects the Fabric environment and populates lakehouses, tables, and columns using `notebookutils` and Spark.
25
25
 
26
+ For schema-enabled lakehouses, it uses the [OneLake Delta metadata API](https://learn.microsoft.com/fabric/onelake/table-apis/delta-table-apis-overview)
27
+ to discover schemas, tables, and columns. This works even when the notebook's
28
+ default lakehouse uses the legacy Spark catalog. Authentication uses the current
29
+ notebook identity via `notebookutils.credentials.getToken("storage")`; no extra
30
+ credentials are required. That identity must have permission to read the selected
31
+ lakehouse's tables. Discovery errors are shown in the widget instead of appearing
32
+ as an empty table list.
33
+
26
34
  After configuring your schema, retrieve the generated JSON:
27
35
 
28
36
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "normalized-schema-generator"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "An anywidget for Microsoft Fabric notebooks that visually configures normalized schema JSON for Process Mining data import."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -96,7 +96,7 @@ export function render({ model, el }) {
96
96
  return h("div", { className: "nsw-header" },
97
97
  h("div", { style: "display:flex;align-items:baseline;gap:8px" },
98
98
  h("h2", { textContent: "Normalized Schema Generator" }),
99
- h("span", { className: "nsw-version", textContent: "v0.4.0" })
99
+ h("span", { className: "nsw-version", textContent: "v0.4.1" })
100
100
  ),
101
101
  h("p", { textContent: "Configure your process mining star-schema and generate the dataSource JSON." })
102
102
  );
@@ -117,8 +117,8 @@ export function render({ model, el }) {
117
117
  h("span", { className: "nsw-label", textContent: "Lakehouse" }),
118
118
  selectEl(lhOpts, selId, (val) => {
119
119
  const lh = lakehouses.find(l => l.id === val);
120
- model.set("selected_lakehouse_id", val);
121
120
  model.set("selected_lakehouse_name", lh ? lh.displayName : "");
121
+ model.set("selected_lakehouse_id", val);
122
122
  model.save_changes();
123
123
  }, "Select a lakehouse...", lhLoading)
124
124
  );
@@ -18,6 +18,9 @@ For local development (outside Fabric), mock data is used automatically.
18
18
 
19
19
  import json
20
20
  import pathlib
21
+ from urllib.error import HTTPError, URLError
22
+ from urllib.parse import quote, urlencode
23
+ from urllib.request import Request, urlopen
21
24
 
22
25
  import anywidget
23
26
  import traitlets
@@ -37,6 +40,11 @@ except ImportError:
37
40
  _runtime_utils = None
38
41
  _fs_utils = None
39
42
 
43
+ try:
44
+ from notebookutils import credentials as _credentials_utils
45
+ except ImportError:
46
+ _credentials_utils = None
47
+
40
48
  try:
41
49
  from pyspark.sql import SparkSession
42
50
  _spark = SparkSession.builder.getOrCreate()
@@ -116,51 +124,95 @@ def _quote_spark_identifier(identifier):
116
124
  return f"`{identifier.replace('`', '``')}`"
117
125
 
118
126
 
119
- def _row_value(row, *names):
120
- if hasattr(row, "asDict"):
121
- row = row.asDict()
122
- if isinstance(row, dict):
123
- for name in names:
124
- if name in row:
125
- return row[name]
126
- return None
127
- for name in names:
128
- if hasattr(row, name):
129
- return getattr(row, name)
127
+ def _onelake_metadata(lakehouse_id, resource, **params):
128
+ """Read catalog metadata independently of the notebook's Spark catalog."""
129
+ workspace_id = _get_fabric_context()["workspace_id"]
130
+ if not workspace_id:
131
+ raise RuntimeError("The current Fabric workspace ID is unavailable.")
132
+ if _credentials_utils is None:
133
+ raise RuntimeError("NotebookUtils credentials are required for OneLake metadata.")
134
+ token = _credentials_utils.getToken("storage")
135
+ if not token:
136
+ raise RuntimeError("NotebookUtils did not return a OneLake access token.")
137
+ base_url = (
138
+ "https://onelake.table.fabric.microsoft.com/delta/"
139
+ f"{quote(workspace_id, safe='')}/{quote(lakehouse_id, safe='')}"
140
+ "/api/2.1/unity-catalog/"
141
+ )
142
+ query = urlencode({"catalog_name": lakehouse_id, **params})
143
+ request = Request(base_url + resource + "?" + query, headers={
144
+ "Authorization": f"Bearer {token}",
145
+ "Accept": "application/json",
146
+ })
130
147
  try:
131
- return row[0]
132
- except (IndexError, KeyError, TypeError):
133
- return None
134
-
135
-
136
- def _list_schema_tables(lakehouse_name, lakehouse_id):
137
- """Discover tables via Spark for schema-enabled lakehouses."""
138
- if _spark is None:
139
- return []
140
-
141
- lakehouse = _quote_spark_identifier(lakehouse_name)
142
- schemas = _spark.sql(f"SHOW SCHEMAS IN {lakehouse}").collect()
148
+ with urlopen(request, timeout=30) as response:
149
+ payload = json.load(response)
150
+ except HTTPError as exc:
151
+ raise RuntimeError(
152
+ f"OneLake metadata request failed (HTTP {exc.code}). "
153
+ "Check lakehouse access and OneLake permissions."
154
+ ) from None
155
+ except URLError:
156
+ raise RuntimeError("Unable to connect to the OneLake metadata API.") from None
157
+ if not isinstance(payload, dict):
158
+ raise RuntimeError("The OneLake metadata API returned an invalid response.")
159
+ return payload
160
+
161
+
162
+ def _onelake_items(lakehouse_id, resource, **params):
163
+ page_token = None
164
+ seen_tokens = set()
165
+ while True:
166
+ query = dict(params)
167
+ if page_token:
168
+ query["page_token"] = page_token
169
+ payload = _onelake_metadata(lakehouse_id, resource, **query)
170
+ items = payload.get(resource)
171
+ if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
172
+ raise RuntimeError(f"The OneLake metadata API returned invalid {resource}.")
173
+ yield from items
174
+ page_token = payload.get("next_page_token")
175
+ if not page_token:
176
+ break
177
+ if not isinstance(page_token, str) or page_token in seen_tokens:
178
+ raise RuntimeError("The OneLake metadata API returned an invalid page token.")
179
+ seen_tokens.add(page_token)
180
+
181
+
182
+ def _list_schema_tables(lakehouse_id):
143
183
  results = []
144
- for row in schemas:
145
- schema = _row_value(row, "namespace", "databaseName", "schemaName")
146
- if not schema:
147
- continue
148
- schema_identifier = _quote_spark_identifier(schema)
149
- tables = _spark.sql(
150
- f"SHOW TABLES IN {lakehouse}.{schema_identifier}"
151
- ).collect()
152
- for table_row in tables:
153
- table_name = _row_value(table_row, "tableName", "table_name", "name")
154
- if not table_name:
155
- continue
184
+ for schema in _onelake_items(lakehouse_id, "schemas"):
185
+ schema_name = schema["name"]
186
+ for table in _onelake_items(lakehouse_id, "tables", schema_name=schema_name):
187
+ table_name = table["name"]
156
188
  results.append({
157
- "name": f"{schema}.{table_name}",
158
- "path": f"/{lakehouse_id}/Tables/{schema}/{table_name}",
189
+ "name": f"{schema_name}.{table_name}",
190
+ "path": f"/{lakehouse_id}/Tables/{schema_name}/{table_name}",
159
191
  "type": "delta",
160
192
  })
161
193
  return results
162
194
 
163
195
 
196
+ def _get_schema_columns(table_name, lakehouse_id):
197
+ schema, name = table_name.split(".", 1)
198
+ full_name = quote(f"{lakehouse_id}.{schema}.{name}", safe="")
199
+ table = _onelake_metadata(
200
+ lakehouse_id, f"tables/{full_name}", schema_name=schema
201
+ )
202
+ columns = table.get("columns")
203
+ if not isinstance(columns, list):
204
+ raise RuntimeError("The OneLake metadata API did not return table columns.")
205
+ return [
206
+ {
207
+ "name": column["name"],
208
+ "dtype": _spark_type_to_simple(
209
+ column.get("type_text") or column["type_name"]
210
+ ),
211
+ }
212
+ for column in columns
213
+ ]
214
+
215
+
164
216
  def _list_tables(lakehouse_name, lakehouse_id, file_type):
165
217
  if FABRIC_AVAILABLE:
166
218
  if file_type == 2:
@@ -188,13 +240,7 @@ def _list_tables(lakehouse_name, lakehouse_id, file_type):
188
240
  })
189
241
  return results
190
242
 
191
- schema_tables = _list_schema_tables(lakehouse_name, lakehouse_id)
192
- if schema_tables or tables == []:
193
- return schema_tables
194
- raise RuntimeError(
195
- f"Unable to list tables in lakehouse {lakehouse_name!r}; "
196
- "NotebookUtils listing failed and Spark catalog discovery is unavailable."
197
- )
243
+ return _list_schema_tables(lakehouse_id)
198
244
  return _list_files_recursive("Files", lakehouse_id, file_type)
199
245
  # Mock data
200
246
  if file_type == 2:
@@ -239,33 +285,32 @@ def _spark_type_to_simple(dtype_str):
239
285
 
240
286
 
241
287
  def _get_columns(table_name, lakehouse_name, lakehouse_id, file_type):
288
+ if FABRIC_AVAILABLE and file_type == 2 and "." in table_name:
289
+ return _get_schema_columns(table_name, lakehouse_id)
290
+ if FABRIC_AVAILABLE and _spark is None:
291
+ raise RuntimeError("Spark is required to load columns for this source.")
242
292
  if FABRIC_AVAILABLE and _spark:
243
- try:
244
- if file_type == 2:
245
- name_parts = table_name.split(".", 1)
246
- identifiers = [lakehouse_name, *name_parts]
247
- full_name = ".".join(
248
- _quote_spark_identifier(part) for part in identifiers
249
- )
250
- df = _spark.sql(f"DESCRIBE TABLE {full_name}")
251
- rows = df.collect()
252
- return [
253
- {"name": r["col_name"], "dtype": _spark_type_to_simple(r["data_type"])}
254
- for r in rows
255
- if r["col_name"] and not r["col_name"].startswith("#")
256
- ]
293
+ if file_type == 2:
294
+ full_name = ".".join(
295
+ _quote_spark_identifier(part) for part in (lakehouse_name, table_name)
296
+ )
297
+ df = _spark.sql(f"DESCRIBE TABLE {full_name}")
298
+ rows = df.collect()
299
+ return [
300
+ {"name": r["col_name"], "dtype": _spark_type_to_simple(r["data_type"])}
301
+ for r in rows
302
+ if r["col_name"] and not r["col_name"].startswith("#")
303
+ ]
304
+ else:
305
+ base = f"Files/{table_name}"
306
+ if file_type == 0:
307
+ df = _spark.read.option("header", "true").option("inferSchema", "true").csv(base)
257
308
  else:
258
- base = f"Files/{table_name}"
259
- if file_type == 0:
260
- df = _spark.read.option("header", "true").option("inferSchema", "true").csv(base)
261
- else:
262
- df = _spark.read.parquet(base)
263
- return [
264
- {"name": f.name, "dtype": _spark_type_to_simple(f.dataType.simpleString())}
265
- for f in df.schema.fields
266
- ]
267
- except Exception:
268
- return []
309
+ df = _spark.read.parquet(base)
310
+ return [
311
+ {"name": f.name, "dtype": _spark_type_to_simple(f.dataType.simpleString())}
312
+ for f in df.schema.fields
313
+ ]
269
314
  # Mock data
270
315
  _mock = {
271
316
  "events": [
@@ -345,6 +390,7 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
345
390
  config_json = traitlets.Unicode("").tag(sync=True)
346
391
  loading_status = traitlets.Unicode("").tag(sync=True)
347
392
  error_message = traitlets.Unicode("").tag(sync=True)
393
+ _data_error = traitlets.Unicode("")
348
394
 
349
395
  # Fabric context
350
396
  workspace_id = traitlets.Unicode("").tag(sync=True)
@@ -361,10 +407,17 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
361
407
 
362
408
  @traitlets.observe("selected_lakehouse_id")
363
409
  def _on_lakehouse_change(self, change):
410
+ self._data_error = ""
411
+ self.error_message = ""
364
412
  lh_id = change["new"]
365
413
  if not lh_id:
366
414
  self.available_tables = []
415
+ self.config_json = ""
367
416
  return
417
+ for lakehouse in self.lakehouses:
418
+ if lakehouse["id"] == lh_id:
419
+ self.selected_lakehouse_name = lakehouse["displayName"]
420
+ break
368
421
  self.loading_status = "Loading tables..."
369
422
  self.events_table_name = ""
370
423
  self.events_columns = []
@@ -378,8 +431,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
378
431
  self.selected_lakehouse_name, lh_id, self.data_source_file_type
379
432
  )
380
433
  except Exception as e:
381
- self.error_message = f"Failed to load tables: {e}"
382
434
  self.available_tables = []
435
+ self._data_error = f"Failed to load tables: {e}"
383
436
  self.loading_status = ""
384
437
  self._generate_config()
385
438
 
@@ -387,6 +440,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
387
440
  def _on_file_type_change(self, change):
388
441
  if not self.selected_lakehouse_id:
389
442
  return
443
+ self._data_error = ""
444
+ self.error_message = ""
390
445
  self.loading_status = "Reloading tables..."
391
446
  self.events_table_name = ""
392
447
  self.events_columns = []
@@ -402,13 +457,15 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
402
457
  change["new"],
403
458
  )
404
459
  except Exception as e:
405
- self.error_message = f"Failed to load tables: {e}"
406
460
  self.available_tables = []
461
+ self._data_error = f"Failed to load tables: {e}"
407
462
  self.loading_status = ""
408
463
  self._generate_config()
409
464
 
410
465
  @traitlets.observe("events_table_name")
411
466
  def _on_events_table_change(self, change):
467
+ self._data_error = ""
468
+ self.error_message = ""
412
469
  name = change["new"]
413
470
  if not name:
414
471
  self.events_columns = []
@@ -425,13 +482,15 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
425
482
  for c in raw
426
483
  ]
427
484
  except Exception as e:
428
- self.error_message = f"Failed to load columns: {e}"
429
485
  self.events_columns = []
486
+ self._data_error = f"Failed to load columns: {e}"
430
487
  self.loading_status = ""
431
488
  self._generate_config()
432
489
 
433
490
  @traitlets.observe("cases_table_name")
434
491
  def _on_cases_table_change(self, change):
492
+ self._data_error = ""
493
+ self.error_message = ""
435
494
  name = change["new"]
436
495
  if not name:
437
496
  self.cases_columns = []
@@ -450,8 +509,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
450
509
  for c in raw
451
510
  ]
452
511
  except Exception as e:
453
- self.error_message = f"Failed to load columns: {e}"
454
512
  self.cases_columns = []
513
+ self._data_error = f"Failed to load columns: {e}"
455
514
  self.loading_status = ""
456
515
  self._generate_config()
457
516
 
@@ -465,6 +524,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
465
524
  if len(parts) < 2:
466
525
  return
467
526
  table_name = parts[1]
527
+ self._data_error = ""
528
+ self.error_message = ""
468
529
  try:
469
530
  raw = _get_columns(
470
531
  table_name, self.selected_lakehouse_name,
@@ -475,8 +536,9 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
475
536
  for c in raw
476
537
  ]
477
538
  except Exception as e:
478
- self.error_message = f"Failed to load columns for {table_name}: {e}"
479
539
  self.fetch_columns_response = []
540
+ self._data_error = f"Failed to load columns for {table_name}: {e}"
541
+ self._generate_config()
480
542
 
481
543
  @traitlets.observe("events_columns", "events_joins", "cases_enabled",
482
544
  "cases_columns", "cases_joins", "cases_relationship")
@@ -519,8 +581,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
519
581
  }.get(dtype, "String")
520
582
 
521
583
  def _generate_config(self, sync_attributes=True):
522
- self.error_message = ""
523
- if not self.selected_lakehouse_id or not self.events_table_name:
584
+ self.error_message = self._data_error
585
+ if self._data_error or not self.selected_lakehouse_id or not self.events_table_name:
524
586
  self.config_json = ""
525
587
  return
526
588
 
@@ -0,0 +1,316 @@
1
+ import io
2
+ import json
3
+ import unittest
4
+ from types import SimpleNamespace
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import parse_qs, urlsplit
7
+ from unittest.mock import Mock, patch
8
+
9
+ with patch.dict("sys.modules", {"pyspark.sql": None}):
10
+ from normalized_schema_generator import widget
11
+
12
+
13
+ class LakehouseTableTests(unittest.TestCase):
14
+ def setUp(self):
15
+ self.lakehouse = Mock()
16
+ self.lakehouse.listTables.side_effect = RuntimeError(
17
+ "UnsupportedOperationForSchemasEnabledLakehouse"
18
+ )
19
+ self.lakehouse.list.return_value = [
20
+ SimpleNamespace(id="lakehouse-id", displayName="Sales"),
21
+ ]
22
+ self.spark = Mock()
23
+ self.spark.sql.side_effect = RuntimeError(
24
+ "[SCHEMA_NOT_FOUND] The schema spark_catalog cannot be found."
25
+ )
26
+ self.credentials = Mock()
27
+ self.credentials.getToken.return_value = "test-access-token"
28
+ patches = {
29
+ "FABRIC_AVAILABLE": True,
30
+ "_spark": self.spark,
31
+ "_lh_utils": self.lakehouse,
32
+ "_credentials_utils": self.credentials,
33
+ "_get_fabric_context": Mock(return_value={
34
+ "workspace_id": "workspace-id", "tenant_id": "tenant-id",
35
+ }),
36
+ }
37
+ for name, value in patches.items():
38
+ self.enter_patch(name, value)
39
+ self.urlopen = self.enter_patch("urlopen", Mock())
40
+
41
+ def enter_patch(self, name, value):
42
+ patcher = patch.object(widget, name, value)
43
+ self.addCleanup(patcher.stop)
44
+ return patcher.start()
45
+
46
+ def respond(self, *payloads):
47
+ self.urlopen.side_effect = [
48
+ io.BytesIO(json.dumps(payload).encode("utf-8"))
49
+ for payload in payloads
50
+ ]
51
+
52
+ def request_query(self, index=0):
53
+ request = self.urlopen.call_args_list[index].args[0]
54
+ return parse_qs(urlsplit(request.full_url).query)
55
+
56
+ def new_widget(self):
57
+ instance = widget.NormalizedSchemaWidget()
58
+ self.addCleanup(instance.close)
59
+ return instance
60
+
61
+ def test_schema_discovery_does_not_use_incompatible_spark_catalog(self):
62
+ self.respond(
63
+ {"schemas": [{"name": "bronze"}, {"name": "dbo"}]},
64
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}]},
65
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}]},
66
+ )
67
+
68
+ tables = widget._list_tables("Sales", "lakehouse-id", 2)
69
+
70
+ self.assertEqual(tables, [
71
+ {
72
+ "name": "bronze.events",
73
+ "path": "/lakehouse-id/Tables/bronze/events",
74
+ "type": "delta",
75
+ },
76
+ {
77
+ "name": "dbo.events",
78
+ "path": "/lakehouse-id/Tables/dbo/events",
79
+ "type": "delta",
80
+ },
81
+ ])
82
+ self.spark.sql.assert_not_called()
83
+ self.credentials.getToken.assert_called_with("storage")
84
+ request = self.urlopen.call_args_list[0].args[0]
85
+ self.assertEqual(urlsplit(request.full_url).path,
86
+ "/delta/workspace-id/lakehouse-id/api/2.1/unity-catalog/schemas")
87
+ self.assertEqual(request.get_header("Authorization"), "Bearer test-access-token")
88
+ self.assertEqual(self.request_query(), {"catalog_name": ["lakehouse-id"]})
89
+ self.assertEqual(self.request_query(1)["schema_name"], ["bronze"])
90
+ self.assertEqual(self.urlopen.call_args.kwargs["timeout"], 30)
91
+
92
+ def test_empty_notebookutils_listing_also_uses_onelake(self):
93
+ self.lakehouse.listTables.side_effect = None
94
+ self.lakehouse.listTables.return_value = []
95
+ self.respond(
96
+ {"schemas": [{"name": "bronze"}]},
97
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}]},
98
+ )
99
+ self.assertEqual(
100
+ widget._list_tables("Sales", "lakehouse-id", 2)[0]["name"],
101
+ "bronze.events",
102
+ )
103
+
104
+ def test_schema_and_table_pagination(self):
105
+ self.respond(
106
+ {"schemas": [{"name": "bronze"}], "next_page_token": "schema page 2"},
107
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}],
108
+ "next_page_token": "table page +2"},
109
+ {"tables": [{"name": "cases", "data_source_format": "DELTA"}]},
110
+ {"schemas": [{"name": "silver"}]},
111
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}]},
112
+ )
113
+ tables = widget._list_tables("Sales", "lakehouse-id", 2)
114
+
115
+ self.assertEqual([table["name"] for table in tables],
116
+ ["bronze.events", "bronze.cases", "silver.events"])
117
+ self.assertEqual(self.request_query(2)["page_token"], ["table page +2"])
118
+ self.assertEqual(self.request_query(3)["page_token"], ["schema page 2"])
119
+ self.assertNotIn("page_token", self.request_query(4))
120
+
121
+ def test_repeated_pagination_token_raises_instead_of_looping(self):
122
+ self.respond(
123
+ {"schemas": [], "next_page_token": "same"},
124
+ {"schemas": [], "next_page_token": "same"},
125
+ )
126
+ with self.assertRaisesRegex(RuntimeError, "invalid page token"):
127
+ widget._list_tables("Sales", "lakehouse-id", 2)
128
+
129
+ def test_empty_lakehouse_is_a_valid_empty_list(self):
130
+ self.respond({"schemas": [{"name": "dbo"}]}, {"tables": []})
131
+ self.assertEqual(widget._list_tables("Sales", "lakehouse-id", 2), [])
132
+
133
+ def test_invalid_metadata_is_not_treated_as_empty_lakehouse(self):
134
+ for payload in ([], {}, {"schemas": None}, {"schemas": ["dbo"]}):
135
+ with self.subTest(payload=payload):
136
+ self.respond(payload)
137
+ with self.assertRaisesRegex(RuntimeError, "invalid"):
138
+ widget._list_tables("Sales", "lakehouse-id", 2)
139
+
140
+ def test_http_error_is_actionable_and_does_not_expose_token(self):
141
+ self.urlopen.side_effect = HTTPError(
142
+ "https://onelake.table.fabric.microsoft.com", 403, "Forbidden", {}, None
143
+ )
144
+ with self.assertRaisesRegex(RuntimeError, "HTTP 403") as caught:
145
+ widget._list_tables("Sales", "lakehouse-id", 2)
146
+ self.assertNotIn("test-access-token", str(caught.exception))
147
+
148
+ def test_connection_error_is_not_an_empty_list(self):
149
+ self.urlopen.side_effect = URLError("Connection refused")
150
+ with self.assertRaisesRegex(RuntimeError, "Unable to connect"):
151
+ widget._list_tables("Sales", "lakehouse-id", 2)
152
+
153
+ def test_missing_workspace_or_credentials_fail_explicitly(self):
154
+ with patch.object(widget, "_get_fabric_context", return_value={
155
+ "workspace_id": "", "tenant_id": "",
156
+ }):
157
+ with self.assertRaisesRegex(RuntimeError, "workspace ID"):
158
+ widget._list_tables("Sales", "lakehouse-id", 2)
159
+ with patch.object(widget, "_credentials_utils", None):
160
+ with self.assertRaisesRegex(RuntimeError, "credentials"):
161
+ widget._list_tables("Sales", "lakehouse-id", 2)
162
+ self.urlopen.assert_not_called()
163
+
164
+ def test_missing_access_token_fails_before_request(self):
165
+ self.credentials.getToken.return_value = ""
166
+ with self.assertRaisesRegex(RuntimeError, "access token"):
167
+ widget._list_tables("Sales", "lakehouse-id", 2)
168
+ self.urlopen.assert_not_called()
169
+
170
+ def test_schema_columns_do_not_depend_on_spark(self):
171
+ self.respond({"columns": [
172
+ {"name": "CaseID", "type_text": None, "type_name": "long"},
173
+ {"name": "Cost", "type_text": "decimal(18,2)", "type_name": "decimal"},
174
+ {"name": "Activity", "type_name": "string"},
175
+ {"name": "Started", "type_name": "timestamp"},
176
+ ]})
177
+ with patch.object(widget, "_spark", None):
178
+ columns = widget._get_columns(
179
+ "bronze.events", "Sales", "lakehouse-id", 2
180
+ )
181
+ self.assertEqual(columns, [
182
+ {"name": "CaseID", "dtype": "int"},
183
+ {"name": "Cost", "dtype": "double"},
184
+ {"name": "Activity", "dtype": "string"},
185
+ {"name": "Started", "dtype": "timestamp"},
186
+ ])
187
+ request = self.urlopen.call_args.args[0]
188
+ self.assertTrue(urlsplit(request.full_url).path.endswith(
189
+ "/tables/lakehouse-id.bronze.events"
190
+ ))
191
+ self.assertEqual(self.request_query()["schema_name"], ["bronze"])
192
+ self.spark.sql.assert_not_called()
193
+
194
+ def test_missing_columns_fail_explicitly(self):
195
+ self.respond({"columns": None})
196
+ with self.assertRaisesRegex(RuntimeError, "did not return table columns"):
197
+ widget._get_columns("bronze.events", "Sales", "lakehouse-id", 2)
198
+
199
+ def test_legacy_listing_does_not_call_onelake(self):
200
+ self.lakehouse.listTables.side_effect = None
201
+ self.lakehouse.listTables.return_value = [SimpleNamespace(name="events")]
202
+
203
+ self.assertEqual(widget._list_tables("Legacy", "lakehouse-id", 2), [{
204
+ "name": "events",
205
+ "path": "/lakehouse-id/Tables/dbo/events",
206
+ "type": "delta",
207
+ }])
208
+ self.urlopen.assert_not_called()
209
+ self.spark.sql.assert_not_called()
210
+
211
+ def test_legacy_columns_preserve_spark_identifier_escaping(self):
212
+ self.spark.sql.side_effect = None
213
+ self.spark.sql.return_value.collect.return_value = [
214
+ {"col_name": "CaseID", "data_type": "bigint"},
215
+ ]
216
+ self.assertEqual(widget._get_columns("events", "Sales`LH", "lakehouse-id", 2),
217
+ [{"name": "CaseID", "dtype": "int"}])
218
+ self.spark.sql.assert_called_once_with("DESCRIBE TABLE `Sales``LH`.`events`")
219
+ self.urlopen.assert_not_called()
220
+
221
+ def test_legacy_column_errors_propagate(self):
222
+ with self.assertRaisesRegex(RuntimeError, "SCHEMA_NOT_FOUND"):
223
+ widget._get_columns("events", "Sales", "lakehouse-id", 2)
224
+
225
+ def test_table_error_survives_config_generation_and_recovers_on_reload(self):
226
+ instance = self.new_widget()
227
+ self.urlopen.side_effect = HTTPError(
228
+ "https://onelake.table.fabric.microsoft.com", 403, "Forbidden", {}, None
229
+ )
230
+ instance.selected_lakehouse_name = "Stale selection"
231
+ instance.selected_lakehouse_id = "lakehouse-id"
232
+ self.assertEqual(instance.selected_lakehouse_name, "Sales")
233
+ self.assertEqual(instance.available_tables, [])
234
+ self.assertIn("HTTP 403", instance.error_message)
235
+ self.assertEqual(instance.loading_status, "")
236
+ instance._generate_config()
237
+ self.assertIn("HTTP 403", instance.error_message)
238
+ self.assertEqual(instance.config_json, "")
239
+
240
+ self.respond(
241
+ {"schemas": [{"name": "bronze"}]},
242
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}]},
243
+ )
244
+ instance._on_lakehouse_change({"new": "lakehouse-id"})
245
+ self.assertEqual(instance.error_message, "")
246
+ self.assertEqual(instance.available_tables[0]["name"], "bronze.events")
247
+
248
+ def test_source_type_reload_error_remains_visible(self):
249
+ instance = self.new_widget()
250
+ with patch.object(widget, "_list_tables", return_value=[]):
251
+ instance.selected_lakehouse_id = "lakehouse-id"
252
+ with patch.object(widget, "_list_tables", side_effect=RuntimeError("Access denied")):
253
+ instance.data_source_file_type = 1
254
+ self.assertIn("Access denied", instance.error_message)
255
+ self.assertEqual(instance.loading_status, "")
256
+
257
+ def test_column_errors_remain_visible_for_events_cases_and_references(self):
258
+ for target in ("events", "cases", "reference"):
259
+ with self.subTest(target=target):
260
+ instance = self.new_widget()
261
+ with patch.object(widget, "_list_tables", return_value=[]):
262
+ instance.selected_lakehouse_id = "lakehouse-id"
263
+ with patch.object(widget, "_get_columns",
264
+ side_effect=RuntimeError("Metadata denied")):
265
+ if target == "reference":
266
+ instance.fetch_columns_response = [{"name": "stale"}]
267
+ instance.fetch_columns_request = "1:bronze.activity"
268
+ self.assertEqual(instance.fetch_columns_response, [])
269
+ else:
270
+ setattr(instance, f"{target}_table_name", "bronze.events")
271
+ self.assertEqual(getattr(instance, f"{target}_columns"), [])
272
+ instance._generate_config()
273
+ self.assertIn("Metadata denied", instance.error_message)
274
+ self.assertEqual(instance.config_json, "")
275
+ self.assertEqual(instance.loading_status, "")
276
+
277
+ def test_schema_selection_generates_qualified_dataset_and_join_paths(self):
278
+ instance = self.new_widget()
279
+ self.respond(
280
+ {"schemas": [{"name": "bronze"}, {"name": "silver"}]},
281
+ {"tables": [
282
+ {"name": "events", "data_source_format": "DELTA"},
283
+ {"name": "cases", "data_source_format": "DELTA"},
284
+ ]},
285
+ {"tables": [{"name": "events", "data_source_format": "DELTA"}]},
286
+ {"columns": [{"name": "CaseID", "type_name": "long"}]},
287
+ {"columns": [{"name": "CaseID", "type_name": "long"}]},
288
+ {"columns": [{"name": "CaseID", "type_name": "long"}]},
289
+ )
290
+ instance.selected_lakehouse_id = "lakehouse-id"
291
+ instance.events_table_name = "bronze.events"
292
+ instance.cases_enabled = True
293
+ instance.cases_table_name = "bronze.cases"
294
+ instance.cases_relationship = {"fk_column": "CaseID", "pk_column": "CaseID"}
295
+ instance.fetch_columns_request = "1:silver.events"
296
+ instance.events_joins = [{
297
+ "target_table": "silver.events", "source_column": "CaseID",
298
+ "target_column": "CaseID", "join_key_type": "Integer",
299
+ "target_columns": [{"name": "CaseID", "dtype": "int", "included": True}],
300
+ }]
301
+
302
+ self.assertEqual(instance.error_message, "")
303
+ config = json.loads(instance.config_json)
304
+ datasets = config["inputDataBinding"]["dataSource"]["datasets"]
305
+ self.assertEqual([(d["Name"], d["Path"]) for d in datasets], [
306
+ ("bronze.events", "/lakehouse-id/Tables/bronze/events"),
307
+ ("bronze.cases", "/lakehouse-id/Tables/bronze/cases"),
308
+ ("silver.events", "/lakehouse-id/Tables/silver/events"),
309
+ ])
310
+ self.assertEqual([j["TargetDatasetName"] for j in datasets[0]["Join"]],
311
+ ["bronze.cases", "silver.events"])
312
+ self.spark.sql.assert_not_called()
313
+
314
+
315
+ if __name__ == "__main__":
316
+ unittest.main()
@@ -1,92 +0,0 @@
1
- import unittest
2
- from types import SimpleNamespace
3
- from unittest.mock import patch
4
-
5
- from normalized_schema_generator import widget
6
-
7
-
8
- class FakeSpark:
9
- def __init__(self):
10
- self.queries = []
11
-
12
- def sql(self, query):
13
- self.queries.append(query)
14
- if query == "SHOW SCHEMAS IN `Sales`":
15
- rows = [SimpleNamespace(namespace="dbo"), SimpleNamespace(namespace="marketing")]
16
- elif query == "SHOW TABLES IN `Sales`.`dbo`":
17
- rows = [SimpleNamespace(tableName="customers")]
18
- elif query == "SHOW TABLES IN `Sales`.`marketing`":
19
- rows = [SimpleNamespace(tableName="orders")]
20
- elif query == "DESCRIBE TABLE `Sales`.`marketing`.`orders`":
21
- rows = [{"col_name": "order_id", "data_type": "int"}]
22
- else:
23
- raise AssertionError(f"Unexpected Spark SQL query: {query}")
24
- return SimpleNamespace(collect=lambda: rows)
25
-
26
-
27
- class LakehouseTableTests(unittest.TestCase):
28
- def test_schema_catalog_fallback_lists_tables_with_schema_paths(self):
29
- spark = FakeSpark()
30
- lakehouse_utils = SimpleNamespace(
31
- listTables=lambda _name: (_ for _ in ()).throw(
32
- RuntimeError("schemas are unsupported")
33
- )
34
- )
35
-
36
- with patch.object(widget, "FABRIC_AVAILABLE", True):
37
- with patch.object(widget, "_lh_utils", lakehouse_utils):
38
- with patch.object(widget, "_spark", spark):
39
- tables = widget._list_tables("Sales", "lakehouse-id", 2)
40
-
41
- self.assertEqual(
42
- tables,
43
- [
44
- {
45
- "name": "dbo.customers",
46
- "path": "/lakehouse-id/Tables/dbo/customers",
47
- "type": "delta",
48
- },
49
- {
50
- "name": "marketing.orders",
51
- "path": "/lakehouse-id/Tables/marketing/orders",
52
- "type": "delta",
53
- },
54
- ],
55
- )
56
-
57
- def test_schema_qualified_table_name_is_used_for_column_lookup(self):
58
- spark = FakeSpark()
59
-
60
- with patch.object(widget, "FABRIC_AVAILABLE", True):
61
- with patch.object(widget, "_spark", spark):
62
- columns = widget._get_columns(
63
- "marketing.orders", "Sales", "lakehouse-id", 2
64
- )
65
-
66
- self.assertEqual(columns, [{"name": "order_id", "dtype": "int"}])
67
- self.assertIn(
68
- "DESCRIBE TABLE `Sales`.`marketing`.`orders`",
69
- spark.queries,
70
- )
71
-
72
- def test_legacy_listing_keeps_table_names_and_dbo_paths(self):
73
- lakehouse_utils = SimpleNamespace(
74
- listTables=lambda _name: [SimpleNamespace(name="events")]
75
- )
76
-
77
- with patch.object(widget, "FABRIC_AVAILABLE", True):
78
- with patch.object(widget, "_lh_utils", lakehouse_utils):
79
- tables = widget._list_tables("Legacy", "lakehouse-id", 2)
80
-
81
- self.assertEqual(
82
- tables,
83
- [{
84
- "name": "events",
85
- "path": "/lakehouse-id/Tables/dbo/events",
86
- "type": "delta",
87
- }],
88
- )
89
-
90
-
91
- if __name__ == "__main__":
92
- unittest.main()