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.
- normalized_schema_generator-0.4.1/.git +1 -0
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/PKG-INFO +11 -4
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/README.md +9 -1
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/pyproject.toml +2 -2
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/widget.js +2 -2
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/widget.py +174 -41
- normalized_schema_generator-0.4.1/tests/test_widget_tables.py +316 -0
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/.gitignore +0 -0
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/LICENSE +0 -0
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/__init__.py +0 -0
- {normalized_schema_generator-0.3.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/widget.css +0 -0
|
@@ -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.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: normalized-schema-generator
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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
|
-
|
|
118
|
-
|
|
218
|
+
if file_type == 2:
|
|
219
|
+
try:
|
|
119
220
|
tables = _lh_utils.listTables(lakehouse_name)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|