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.
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/PKG-INFO +9 -1
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/README.md +8 -0
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/pyproject.toml +1 -1
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/widget.js +2 -2
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/widget.py +138 -76
- normalized_schema_generator-0.4.1/tests/test_widget_tables.py +316 -0
- normalized_schema_generator-0.4.0/tests/test_widget_tables.py +0 -92
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/.git +0 -0
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/.gitignore +0 -0
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/LICENSE +0 -0
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/__init__.py +0 -0
- {normalized_schema_generator-0.4.0 → normalized_schema_generator-0.4.1}/src/normalized_schema_generator/widget.css +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: normalized-schema-generator
|
|
3
|
-
Version: 0.4.
|
|
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.
|
|
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.
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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"{
|
|
158
|
-
"path": f"/{lakehouse_id}/Tables/{
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|