normalized-schema-generator 0.3.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.
@@ -0,0 +1 @@
1
+ gitdir: /Users/hippo/Library/CloudStorage/OneDrive-Microsoft/Github/rosikm_normalized-schema-generator/.git/worktrees/rosikm-solid-fishstick
@@ -1,13 +1,12 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: normalized-schema-generator
3
- Version: 0.3.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
7
7
  Project-URL: Issues, https://github.com/rosikm/normalized-schema-generator/issues
8
8
  Author: rosikm
9
9
  License: MIT
10
- License-File: LICENSE
11
10
  Keywords: anywidget,fabric,jupyter,process-mining,widget
12
11
  Classifier: Development Status :: 4 - Beta
13
12
  Classifier: Framework :: Jupyter
@@ -44,6 +43,14 @@ w
44
43
 
45
44
  The widget auto-detects the Fabric environment and populates lakehouses, tables, and columns using `notebookutils` and Spark.
46
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
+
47
54
  After configuring your schema, retrieve the generated JSON:
48
55
 
49
56
  ```python
@@ -61,7 +68,7 @@ jupyter lab
61
68
 
62
69
  ## Features
63
70
 
64
- - **Lakehouse selection** from the current workspace
71
+ - **Lakehouse selection** from the current workspace, including schema-enabled lakehouses
65
72
  - **Data source toggle**: Delta Tables or Files (CSV / Parquet)
66
73
  - **Events table** (required): column checklist with data type badges, include/exclude toggles
67
74
  - **Cases table** (optional): same column management, with required Events-to-Cases FK relationship
@@ -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
@@ -40,7 +48,7 @@ jupyter lab
40
48
 
41
49
  ## Features
42
50
 
43
- - **Lakehouse selection** from the current workspace
51
+ - **Lakehouse selection** from the current workspace, including schema-enabled lakehouses
44
52
  - **Data source toggle**: Delta Tables or Files (CSV / Parquet)
45
53
  - **Events table** (required): column checklist with data type badges, include/exclude toggles
46
54
  - **Cases table** (optional): same column management, with required Events-to-Cases FK relationship
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling<1.27"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "normalized-schema-generator"
7
- version = "0.3.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.3.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()
@@ -112,23 +120,128 @@ def _list_files_recursive(base_path, lakehouse_id, file_type, prefix="", depth=0
112
120
  return results
113
121
 
114
122
 
123
+ def _quote_spark_identifier(identifier):
124
+ return f"`{identifier.replace('`', '``')}`"
125
+
126
+
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
+ })
147
+ try:
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):
183
+ results = []
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"]
188
+ results.append({
189
+ "name": f"{schema_name}.{table_name}",
190
+ "path": f"/{lakehouse_id}/Tables/{schema_name}/{table_name}",
191
+ "type": "delta",
192
+ })
193
+ return results
194
+
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
+
115
216
  def _list_tables(lakehouse_name, lakehouse_id, file_type):
116
217
  if FABRIC_AVAILABLE:
117
- try:
118
- if file_type == 2:
218
+ if file_type == 2:
219
+ try:
119
220
  tables = _lh_utils.listTables(lakehouse_name)
120
- return [
121
- {
122
- "name": t.name,
123
- "path": f"/{lakehouse_id}/Tables/dbo/{t.name}",
221
+ except Exception:
222
+ tables = None
223
+
224
+ if tables:
225
+ results = []
226
+ for table in tables:
227
+ name = table.name
228
+ schema = (
229
+ getattr(table, "schemaName", None)
230
+ or getattr(table, "schema", None)
231
+ )
232
+ if schema:
233
+ name = f"{schema}.{name}"
234
+ else:
235
+ schema = "dbo"
236
+ results.append({
237
+ "name": name,
238
+ "path": f"/{lakehouse_id}/Tables/{schema}/{table.name}",
124
239
  "type": "delta",
125
- }
126
- for t in tables
127
- ]
128
- else:
129
- return _list_files_recursive("Files", lakehouse_id, file_type)
130
- except Exception:
131
- return []
240
+ })
241
+ return results
242
+
243
+ return _list_schema_tables(lakehouse_id)
244
+ return _list_files_recursive("Files", lakehouse_id, file_type)
132
245
  # Mock data
133
246
  if file_type == 2:
134
247
  return [
@@ -172,29 +285,32 @@ def _spark_type_to_simple(dtype_str):
172
285
 
173
286
 
174
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.")
175
292
  if FABRIC_AVAILABLE and _spark:
176
- try:
177
- if file_type == 2:
178
- full_name = f"`{lakehouse_name}`.`{table_name}`"
179
- df = _spark.sql(f"DESCRIBE TABLE {full_name}")
180
- rows = df.collect()
181
- return [
182
- {"name": r["col_name"], "dtype": _spark_type_to_simple(r["data_type"])}
183
- for r in rows
184
- if r["col_name"] and not r["col_name"].startswith("#")
185
- ]
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)
186
308
  else:
187
- base = f"Files/{table_name}"
188
- if file_type == 0:
189
- df = _spark.read.option("header", "true").option("inferSchema", "true").csv(base)
190
- else:
191
- df = _spark.read.parquet(base)
192
- return [
193
- {"name": f.name, "dtype": _spark_type_to_simple(f.dataType.simpleString())}
194
- for f in df.schema.fields
195
- ]
196
- except Exception:
197
- 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
+ ]
198
314
  # Mock data
199
315
  _mock = {
200
316
  "events": [
@@ -274,6 +390,7 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
274
390
  config_json = traitlets.Unicode("").tag(sync=True)
275
391
  loading_status = traitlets.Unicode("").tag(sync=True)
276
392
  error_message = traitlets.Unicode("").tag(sync=True)
393
+ _data_error = traitlets.Unicode("")
277
394
 
278
395
  # Fabric context
279
396
  workspace_id = traitlets.Unicode("").tag(sync=True)
@@ -290,10 +407,17 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
290
407
 
291
408
  @traitlets.observe("selected_lakehouse_id")
292
409
  def _on_lakehouse_change(self, change):
410
+ self._data_error = ""
411
+ self.error_message = ""
293
412
  lh_id = change["new"]
294
413
  if not lh_id:
295
414
  self.available_tables = []
415
+ self.config_json = ""
296
416
  return
417
+ for lakehouse in self.lakehouses:
418
+ if lakehouse["id"] == lh_id:
419
+ self.selected_lakehouse_name = lakehouse["displayName"]
420
+ break
297
421
  self.loading_status = "Loading tables..."
298
422
  self.events_table_name = ""
299
423
  self.events_columns = []
@@ -307,8 +431,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
307
431
  self.selected_lakehouse_name, lh_id, self.data_source_file_type
308
432
  )
309
433
  except Exception as e:
310
- self.error_message = f"Failed to load tables: {e}"
311
434
  self.available_tables = []
435
+ self._data_error = f"Failed to load tables: {e}"
312
436
  self.loading_status = ""
313
437
  self._generate_config()
314
438
 
@@ -316,6 +440,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
316
440
  def _on_file_type_change(self, change):
317
441
  if not self.selected_lakehouse_id:
318
442
  return
443
+ self._data_error = ""
444
+ self.error_message = ""
319
445
  self.loading_status = "Reloading tables..."
320
446
  self.events_table_name = ""
321
447
  self.events_columns = []
@@ -331,13 +457,15 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
331
457
  change["new"],
332
458
  )
333
459
  except Exception as e:
334
- self.error_message = f"Failed to load tables: {e}"
335
460
  self.available_tables = []
461
+ self._data_error = f"Failed to load tables: {e}"
336
462
  self.loading_status = ""
337
463
  self._generate_config()
338
464
 
339
465
  @traitlets.observe("events_table_name")
340
466
  def _on_events_table_change(self, change):
467
+ self._data_error = ""
468
+ self.error_message = ""
341
469
  name = change["new"]
342
470
  if not name:
343
471
  self.events_columns = []
@@ -354,13 +482,15 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
354
482
  for c in raw
355
483
  ]
356
484
  except Exception as e:
357
- self.error_message = f"Failed to load columns: {e}"
358
485
  self.events_columns = []
486
+ self._data_error = f"Failed to load columns: {e}"
359
487
  self.loading_status = ""
360
488
  self._generate_config()
361
489
 
362
490
  @traitlets.observe("cases_table_name")
363
491
  def _on_cases_table_change(self, change):
492
+ self._data_error = ""
493
+ self.error_message = ""
364
494
  name = change["new"]
365
495
  if not name:
366
496
  self.cases_columns = []
@@ -379,8 +509,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
379
509
  for c in raw
380
510
  ]
381
511
  except Exception as e:
382
- self.error_message = f"Failed to load columns: {e}"
383
512
  self.cases_columns = []
513
+ self._data_error = f"Failed to load columns: {e}"
384
514
  self.loading_status = ""
385
515
  self._generate_config()
386
516
 
@@ -394,6 +524,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
394
524
  if len(parts) < 2:
395
525
  return
396
526
  table_name = parts[1]
527
+ self._data_error = ""
528
+ self.error_message = ""
397
529
  try:
398
530
  raw = _get_columns(
399
531
  table_name, self.selected_lakehouse_name,
@@ -404,8 +536,9 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
404
536
  for c in raw
405
537
  ]
406
538
  except Exception as e:
407
- self.error_message = f"Failed to load columns for {table_name}: {e}"
408
539
  self.fetch_columns_response = []
540
+ self._data_error = f"Failed to load columns for {table_name}: {e}"
541
+ self._generate_config()
409
542
 
410
543
  @traitlets.observe("events_columns", "events_joins", "cases_enabled",
411
544
  "cases_columns", "cases_joins", "cases_relationship")
@@ -448,8 +581,8 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
448
581
  }.get(dtype, "String")
449
582
 
450
583
  def _generate_config(self, sync_attributes=True):
451
- self.error_message = ""
452
- 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:
453
586
  self.config_json = ""
454
587
  return
455
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()