weaverstack 0.1.1__py3-none-any.whl
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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Resolving Fabric workspace items by name.
|
|
2
|
+
|
|
3
|
+
Item names are unique within a workspace, which is the whole reason level three
|
|
4
|
+
needs no configuration. This is where that assumption meets the API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from ..errors import CommandError
|
|
13
|
+
from .client import FabricClient, FabricError
|
|
14
|
+
|
|
15
|
+
LAKEHOUSE = "Lakehouse"
|
|
16
|
+
WAREHOUSE = "Warehouse"
|
|
17
|
+
ENVIRONMENT = "Environment"
|
|
18
|
+
NOTEBOOK = "Notebook"
|
|
19
|
+
SQL_ENDPOINT = "SQLEndpoint"
|
|
20
|
+
|
|
21
|
+
#: A Lakehouse grows a SQLEndpoint sibling of the same name, a little after it
|
|
22
|
+
#: is created. That is a facet of the Lakehouse rather than an item anyone
|
|
23
|
+
#: addresses, so it is ignored when a name is resolved without a type — item
|
|
24
|
+
#: names are unique per type, not across types.
|
|
25
|
+
FACET_TYPES = frozenset({SQL_ENDPOINT})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ItemNotFoundError(CommandError):
|
|
29
|
+
"""Raised when an item lookup returns no match."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Workspace:
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
return f"{self.name} ({self.id})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Item:
|
|
43
|
+
"""One workspace item — a Lakehouse, a Warehouse, an Environment."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
name: str
|
|
47
|
+
type: str
|
|
48
|
+
workspace_id: str
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return f"{self.type} {self.name} ({self.id})"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_workspace(name: str, *, client: FabricClient | None = None) -> Workspace:
|
|
55
|
+
"""The workspace with this name."""
|
|
56
|
+
|
|
57
|
+
client = client or FabricClient()
|
|
58
|
+
matches = [
|
|
59
|
+
workspace
|
|
60
|
+
for workspace in client.paged("workspaces")
|
|
61
|
+
if workspace.get("displayName") == name
|
|
62
|
+
]
|
|
63
|
+
if not matches:
|
|
64
|
+
available = ", ".join(
|
|
65
|
+
sorted(w.get("displayName", "?") for w in client.paged("workspaces"))
|
|
66
|
+
)
|
|
67
|
+
raise CommandError(
|
|
68
|
+
f"no workspace named {name!r} — found: {available or 'none'}"
|
|
69
|
+
)
|
|
70
|
+
if len(matches) > 1:
|
|
71
|
+
raise CommandError(f"more than one workspace named {name!r}")
|
|
72
|
+
return Workspace(id=matches[0]["id"], name=name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_items(
|
|
76
|
+
workspace: Workspace, *, item_type: str | None = None, client: FabricClient | None = None
|
|
77
|
+
) -> tuple[Item, ...]:
|
|
78
|
+
client = client or FabricClient()
|
|
79
|
+
path = f"workspaces/{workspace.id}/items"
|
|
80
|
+
if item_type:
|
|
81
|
+
path += f"?type={item_type}"
|
|
82
|
+
return tuple(
|
|
83
|
+
Item(
|
|
84
|
+
id=item["id"],
|
|
85
|
+
name=item.get("displayName", ""),
|
|
86
|
+
type=item.get("type", ""),
|
|
87
|
+
workspace_id=workspace.id,
|
|
88
|
+
)
|
|
89
|
+
for item in client.paged(path)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_item(
|
|
94
|
+
workspace: Workspace,
|
|
95
|
+
name: str,
|
|
96
|
+
*,
|
|
97
|
+
item_type: str | None = None,
|
|
98
|
+
client: FabricClient | None = None,
|
|
99
|
+
) -> Item:
|
|
100
|
+
"""The item with this name, which is unique within a workspace."""
|
|
101
|
+
|
|
102
|
+
matches = [
|
|
103
|
+
item
|
|
104
|
+
for item in list_items(workspace, item_type=item_type, client=client)
|
|
105
|
+
if item.name == name and (item_type is None or item.type == item_type)
|
|
106
|
+
]
|
|
107
|
+
if item_type is None and len(matches) > 1:
|
|
108
|
+
matches = [item for item in matches if item.type not in FACET_TYPES] or matches
|
|
109
|
+
if not matches:
|
|
110
|
+
raise ItemNotFoundError(
|
|
111
|
+
f"no {item_type or 'item'} named {name!r} in workspace {workspace.name!r}"
|
|
112
|
+
)
|
|
113
|
+
if len(matches) > 1:
|
|
114
|
+
found = ", ".join(sorted(item.type for item in matches))
|
|
115
|
+
raise CommandError(
|
|
116
|
+
f"more than one item named {name!r} in {workspace.name!r} ({found}) — "
|
|
117
|
+
"say which type is meant"
|
|
118
|
+
)
|
|
119
|
+
return matches[0]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_lakehouse(
|
|
123
|
+
workspace: Workspace, name: str, *, client: FabricClient | None = None
|
|
124
|
+
) -> Item:
|
|
125
|
+
"""Create a **schema-enabled** Lakehouse. Returns the existing one if the name is taken.
|
|
126
|
+
|
|
127
|
+
Schemas are not optional and so are not a parameter. Weaver's catalogue lives
|
|
128
|
+
in a schema called ``_``, and a Lakehouse created without schema support
|
|
129
|
+
cannot hold one — so a Weaver Lakehouse made without ``enableSchemas`` is
|
|
130
|
+
unusable, and a destination made without it puts managed tables somewhere
|
|
131
|
+
other than ``Tables/<schema>/<table>``, which is the layout every resolved
|
|
132
|
+
location assumes. There is no Weaver use for a Lakehouse that has neither.
|
|
133
|
+
|
|
134
|
+
Fabric decides this only at creation, so getting it wrong means deleting the
|
|
135
|
+
item and making it again.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
client = client or FabricClient()
|
|
139
|
+
try:
|
|
140
|
+
return find_item(workspace, name, item_type=LAKEHOUSE, client=client)
|
|
141
|
+
except CommandError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# Fabric holds a deleted item's name for some minutes and answers 409
|
|
145
|
+
# ItemDisplayNameNotAvailableYet until it is free again.
|
|
146
|
+
response = client.request(
|
|
147
|
+
"POST",
|
|
148
|
+
f"workspaces/{workspace.id}/lakehouses",
|
|
149
|
+
payload={"displayName": name, "creationPayload": {"enableSchemas": True}},
|
|
150
|
+
expected=(200, 201, 202, 409),
|
|
151
|
+
)
|
|
152
|
+
if response.status_code == 409:
|
|
153
|
+
raise CommandError(
|
|
154
|
+
f"cannot create Lakehouse {name!r} in {workspace.name!r}: "
|
|
155
|
+
+ (response.json().get("message") or response.text.strip()[:200])
|
|
156
|
+
)
|
|
157
|
+
if response.status_code == 202:
|
|
158
|
+
# Long-running create: the item exists once the operation settles.
|
|
159
|
+
return _await_item(workspace, name, LAKEHOUSE, client=client)
|
|
160
|
+
body = response.json()
|
|
161
|
+
return Item(id=body["id"], name=name, type=LAKEHOUSE, workspace_id=workspace.id)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_warehouse(
|
|
165
|
+
workspace: Workspace, name: str, *, client: FabricClient | None = None
|
|
166
|
+
) -> Item:
|
|
167
|
+
"""Create a disposable Warehouse, returning an existing typed match."""
|
|
168
|
+
|
|
169
|
+
client = client or FabricClient()
|
|
170
|
+
try:
|
|
171
|
+
return find_item(workspace, name, item_type=WAREHOUSE, client=client)
|
|
172
|
+
except ItemNotFoundError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
response = client.request(
|
|
176
|
+
"POST",
|
|
177
|
+
f"workspaces/{workspace.id}/warehouses",
|
|
178
|
+
payload={"displayName": name},
|
|
179
|
+
expected=(200, 201, 202, 409),
|
|
180
|
+
)
|
|
181
|
+
if response.status_code == 409:
|
|
182
|
+
raise CommandError(
|
|
183
|
+
f"cannot create Warehouse {name!r} in {workspace.name!r}: "
|
|
184
|
+
+ (response.json().get("message") or response.text.strip()[:200])
|
|
185
|
+
)
|
|
186
|
+
if response.status_code == 202:
|
|
187
|
+
return _await_item(workspace, name, WAREHOUSE, client=client)
|
|
188
|
+
body = response.json()
|
|
189
|
+
return Item(id=body["id"], name=name, type=WAREHOUSE, workspace_id=workspace.id)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def delete_item(item: Item, *, client: FabricClient | None = None) -> None:
|
|
193
|
+
client = client or FabricClient()
|
|
194
|
+
client.request(
|
|
195
|
+
"DELETE",
|
|
196
|
+
f"workspaces/{item.workspace_id}/items/{item.id}",
|
|
197
|
+
expected=(200, 202, 204),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def refresh_sql_endpoint_metadata(
|
|
202
|
+
endpoint: Item, *, client: FabricClient | None = None
|
|
203
|
+
) -> dict:
|
|
204
|
+
"""Refresh every table in one SQL analytics endpoint and await completion."""
|
|
205
|
+
|
|
206
|
+
if endpoint.type != SQL_ENDPOINT:
|
|
207
|
+
raise CommandError(
|
|
208
|
+
f"SQL endpoint refresh needs a {SQL_ENDPOINT} item, got {endpoint.type!r}"
|
|
209
|
+
)
|
|
210
|
+
client = client or FabricClient()
|
|
211
|
+
response = client.request(
|
|
212
|
+
"POST",
|
|
213
|
+
f"workspaces/{endpoint.workspace_id}/sqlEndpoints/{endpoint.id}/refreshMetadata",
|
|
214
|
+
payload={"recreateTables": False},
|
|
215
|
+
expected=(200, 202),
|
|
216
|
+
)
|
|
217
|
+
result = client.wait_for_operation(response)
|
|
218
|
+
return {
|
|
219
|
+
"lakehouse": endpoint.name,
|
|
220
|
+
"sql_endpoint_id": endpoint.id,
|
|
221
|
+
"operation_id": response.headers.get("x-ms-operation-id"),
|
|
222
|
+
"status": result.get("status", "Succeeded"),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _await_item(
|
|
227
|
+
workspace: Workspace,
|
|
228
|
+
name: str,
|
|
229
|
+
item_type: str,
|
|
230
|
+
*,
|
|
231
|
+
client: FabricClient,
|
|
232
|
+
attempts: int = 30,
|
|
233
|
+
pause: float = 2.0,
|
|
234
|
+
) -> Item:
|
|
235
|
+
import time
|
|
236
|
+
|
|
237
|
+
for _ in range(attempts):
|
|
238
|
+
try:
|
|
239
|
+
return find_item(workspace, name, item_type=item_type, client=client)
|
|
240
|
+
except CommandError:
|
|
241
|
+
time.sleep(pause)
|
|
242
|
+
raise FabricError(
|
|
243
|
+
f"{item_type} {name!r} did not appear in {workspace.name!r} after "
|
|
244
|
+
f"{int(attempts * pause)}s"
|
|
245
|
+
)
|
weaver/fabric/session.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Resolution from inside a Microsoft Fabric session.
|
|
2
|
+
|
|
3
|
+
The desktop resolver crosses into Fabric through REST. This resolver stays
|
|
4
|
+
inside the current workspace: NotebookUtils supplies the workspace identity and
|
|
5
|
+
resolves Lakehouse names, and the resulting locations are native ``abfss``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
from ..errors import CommandError
|
|
13
|
+
from ..workspaces import FabricWorkspace
|
|
14
|
+
from ..locations import Location
|
|
15
|
+
from ..targets import ItemRef
|
|
16
|
+
from .onelake import abfss_root
|
|
17
|
+
from .resolution import FabricResolver
|
|
18
|
+
from .resources import LAKEHOUSE, WAREHOUSE, Item, Workspace, find_item
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _value(record: Any, name: str) -> Any:
|
|
22
|
+
if isinstance(record, Mapping):
|
|
23
|
+
return record.get(name)
|
|
24
|
+
return getattr(record, name, None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FabricSessionResolver(FabricResolver):
|
|
28
|
+
"""Resolve names without leaving the Fabric session."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
workspace: FabricWorkspace,
|
|
33
|
+
*,
|
|
34
|
+
runtime: Any | None = None,
|
|
35
|
+
lakehouse: Any | None = None,
|
|
36
|
+
credentials: Any | None = None,
|
|
37
|
+
client: Any | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not isinstance(workspace, FabricWorkspace):
|
|
40
|
+
raise CommandError(
|
|
41
|
+
f"FabricSessionResolver needs a FabricWorkspace, got {type(workspace).__name__}"
|
|
42
|
+
)
|
|
43
|
+
if runtime is None or lakehouse is None:
|
|
44
|
+
try:
|
|
45
|
+
from notebookutils import lakehouse as notebook_lakehouse
|
|
46
|
+
from notebookutils import runtime as notebook_runtime
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise CommandError(
|
|
49
|
+
"FabricSessionResolver is available only inside a Fabric session"
|
|
50
|
+
) from exc
|
|
51
|
+
runtime = runtime or notebook_runtime
|
|
52
|
+
lakehouse = lakehouse or notebook_lakehouse
|
|
53
|
+
|
|
54
|
+
context = runtime.context
|
|
55
|
+
if callable(context):
|
|
56
|
+
context = context()
|
|
57
|
+
workspace_name = _value(context, "currentWorkspaceName")
|
|
58
|
+
workspace_id = _value(context, "currentWorkspaceId")
|
|
59
|
+
if not workspace_name or not workspace_id:
|
|
60
|
+
raise CommandError("Fabric runtime context carries no current workspace")
|
|
61
|
+
if workspace_name != workspace.workspace:
|
|
62
|
+
raise CommandError(
|
|
63
|
+
f"this session runs in workspace {workspace_name!r}, "
|
|
64
|
+
f"not configured Workspace {workspace.workspace!r}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.configuration = workspace
|
|
68
|
+
self._workspace = Workspace(id=str(workspace_id), name=str(workspace_name))
|
|
69
|
+
self._lakehouse_utils = lakehouse
|
|
70
|
+
self._credentials = credentials
|
|
71
|
+
self.client = client
|
|
72
|
+
self._items: dict[str, Item] = {}
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def workspace(self) -> Workspace:
|
|
76
|
+
return self._workspace
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def root(self) -> Location:
|
|
80
|
+
return Location(
|
|
81
|
+
f"abfss://{self.workspace.id}@onelake.dfs.fabric.microsoft.com"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def resolve(self, item: ItemRef, *, item_type: str) -> Item:
|
|
85
|
+
if item_type == WAREHOUSE:
|
|
86
|
+
key = f"{item.name}:{item_type}"
|
|
87
|
+
if key not in self._items:
|
|
88
|
+
self._items[key] = find_item(
|
|
89
|
+
self.workspace,
|
|
90
|
+
item.name,
|
|
91
|
+
item_type=WAREHOUSE,
|
|
92
|
+
client=self._rest_client(),
|
|
93
|
+
)
|
|
94
|
+
return self._items[key]
|
|
95
|
+
if item_type != LAKEHOUSE:
|
|
96
|
+
raise CommandError(
|
|
97
|
+
f"session-native resolution for {item_type} is not implemented"
|
|
98
|
+
)
|
|
99
|
+
key = f"{item.name}:{item_type}"
|
|
100
|
+
if key not in self._items:
|
|
101
|
+
artifact = self._lakehouse_utils.get(
|
|
102
|
+
item.name, workspaceId=self.workspace.id
|
|
103
|
+
)
|
|
104
|
+
item_id = _value(artifact, "id")
|
|
105
|
+
display_name = _value(artifact, "displayName")
|
|
106
|
+
if not item_id:
|
|
107
|
+
raise CommandError(
|
|
108
|
+
f"no Lakehouse named {item.name!r} in workspace "
|
|
109
|
+
f"{self.workspace.name!r}"
|
|
110
|
+
)
|
|
111
|
+
self._items[key] = Item(
|
|
112
|
+
id=str(item_id),
|
|
113
|
+
name=str(display_name or item.name),
|
|
114
|
+
type=LAKEHOUSE,
|
|
115
|
+
workspace_id=self.workspace.id,
|
|
116
|
+
)
|
|
117
|
+
return self._items[key]
|
|
118
|
+
|
|
119
|
+
def lakehouse(self, item: ItemRef) -> Location:
|
|
120
|
+
resolved = self.resolve(item, item_type=LAKEHOUSE)
|
|
121
|
+
return Location(abfss_root(self.workspace.id, resolved.id))
|
|
122
|
+
|
|
123
|
+
def sql_endpoint(self, target):
|
|
124
|
+
self.client = self._rest_client()
|
|
125
|
+
return super().sql_endpoint(target)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rest_client(self):
|
|
129
|
+
"""Fabric REST using the identity of this Fabric session."""
|
|
130
|
+
|
|
131
|
+
if self.client is None:
|
|
132
|
+
credentials = self._credentials
|
|
133
|
+
if credentials is None:
|
|
134
|
+
try:
|
|
135
|
+
from notebookutils import credentials as notebook_credentials
|
|
136
|
+
except ImportError as exc:
|
|
137
|
+
raise CommandError(
|
|
138
|
+
"Warehouse resolution is available only inside a Fabric session"
|
|
139
|
+
) from exc
|
|
140
|
+
credentials = notebook_credentials
|
|
141
|
+
from .client import FabricClient
|
|
142
|
+
|
|
143
|
+
# A callable, not the string it answers: a session-native token
|
|
144
|
+
# expires like any other, and an install running inside Fabric can
|
|
145
|
+
# outlive one. NotebookUtils serves from its own cache, so asking per
|
|
146
|
+
# request is cheap.
|
|
147
|
+
self.client = FabricClient(token=lambda: credentials.getToken("pbi"))
|
|
148
|
+
return self.client
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""OneLake shortcuts — Fabric's own way of pointing one item at another's data.
|
|
2
|
+
|
|
3
|
+
A shortcut is how an alias exists in Fabric. It has no bytes: the destination
|
|
4
|
+
Lakehouse gains a table (or a folder) under its own name, and reads pass through
|
|
5
|
+
to the item that owns the data. That is precisely what a Weaver alias claims —
|
|
6
|
+
the destination item owns the name, the source stays the canonical producer — so
|
|
7
|
+
the two map onto each other exactly.
|
|
8
|
+
|
|
9
|
+
Created by replacement. A shortcut carries no data of its own, so removing and
|
|
10
|
+
remaking one loses nothing, and a build that could not re-run over its own aliases
|
|
11
|
+
would not be re-runnable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
from .client import FabricClient
|
|
20
|
+
from .resources import Item
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Shortcut:
|
|
25
|
+
"""One shortcut an item holds: where it appears, and what it points at."""
|
|
26
|
+
|
|
27
|
+
path: str
|
|
28
|
+
name: str
|
|
29
|
+
target_workspace_id: str | None = None
|
|
30
|
+
target_item_id: str | None = None
|
|
31
|
+
target_path: str | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def qualified(self) -> str:
|
|
35
|
+
return f"{self.path}/{self.name}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_shortcuts(item: Item, *, client: FabricClient) -> tuple[Shortcut, ...]:
|
|
39
|
+
"""Every shortcut this item holds.
|
|
40
|
+
|
|
41
|
+
Fabric echoes a path back rooted — ``/Tables/DWG`` for the ``Tables/DWG`` it
|
|
42
|
+
was given — so the leading separator is normalised here rather than by every
|
|
43
|
+
caller.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
found = []
|
|
47
|
+
for entry in client.paged(
|
|
48
|
+
f"workspaces/{item.workspace_id}/items/{item.id}/shortcuts"
|
|
49
|
+
):
|
|
50
|
+
onelake = (entry.get("target") or {}).get("oneLake") or {}
|
|
51
|
+
found.append(
|
|
52
|
+
Shortcut(
|
|
53
|
+
path=(entry.get("path") or "").strip("/"),
|
|
54
|
+
name=entry.get("name") or "",
|
|
55
|
+
target_workspace_id=onelake.get("workspaceId"),
|
|
56
|
+
target_item_id=onelake.get("itemId"),
|
|
57
|
+
target_path=onelake.get("path"),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return tuple(sorted(found, key=lambda shortcut: shortcut.qualified))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_shortcut(
|
|
64
|
+
destination: Item,
|
|
65
|
+
*,
|
|
66
|
+
path: str,
|
|
67
|
+
name: str,
|
|
68
|
+
source: Item,
|
|
69
|
+
source_path: str,
|
|
70
|
+
client: FabricClient,
|
|
71
|
+
) -> dict:
|
|
72
|
+
"""Point ``destination``'s ``path/name`` at ``source``'s ``source_path``."""
|
|
73
|
+
|
|
74
|
+
delete_shortcut(destination, path=path, name=name, client=client)
|
|
75
|
+
response = client.request(
|
|
76
|
+
"POST",
|
|
77
|
+
f"workspaces/{destination.workspace_id}/items/{destination.id}/shortcuts",
|
|
78
|
+
payload={
|
|
79
|
+
"path": path,
|
|
80
|
+
"name": name,
|
|
81
|
+
"target": {
|
|
82
|
+
"oneLake": {
|
|
83
|
+
"workspaceId": source.workspace_id,
|
|
84
|
+
"itemId": source.id,
|
|
85
|
+
"path": source_path,
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
"shortcut": f"{path}/{name}",
|
|
92
|
+
"in": destination.name,
|
|
93
|
+
"target": f"{source.name}/{source_path}",
|
|
94
|
+
# Reported because it says which contract Fabric honoured. Creating one
|
|
95
|
+
# shortcut is documented as synchronous — a 201 — while bulk creation is
|
|
96
|
+
# not; so a 202 here would mean the shortcut itself is still being made,
|
|
97
|
+
# which is a different thing from the destination Lakehouse not yet having
|
|
98
|
+
# registered it as a table. Only the second is what the readability wait
|
|
99
|
+
# in `weaver.build_bundle.executors.alias` exists for.
|
|
100
|
+
"status": response.status_code,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def delete_shortcut(
|
|
105
|
+
destination: Item, *, path: str, name: str, client: FabricClient
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Remove a shortcut if it is there. A 404 is the intended state, not a fault.
|
|
108
|
+
|
|
109
|
+
Removing the shortcut is not removing what it points at: the data belongs to
|
|
110
|
+
the item that produced it, and this only takes away this item's name for it.
|
|
111
|
+
That distinction is the whole reason a wipe must remove shortcuts *through the
|
|
112
|
+
workspace* rather than by deleting a directory (see :mod:`weaver.physical_wipe`).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
client.request(
|
|
116
|
+
"DELETE",
|
|
117
|
+
f"workspaces/{destination.workspace_id}/items/{destination.id}/shortcuts/"
|
|
118
|
+
f"{quote(path.strip('/'), safe='')}/{quote(name, safe='')}",
|
|
119
|
+
expected=(200, 202, 204, 404),
|
|
120
|
+
)
|
weaver/fabric/sql.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Explicit desktop and Fabric-session SQL capability factories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..errors import CommandError
|
|
8
|
+
from ..sql import (
|
|
9
|
+
DEFAULT_MAX_CONNECTIONS,
|
|
10
|
+
AccessTokenAuthentication,
|
|
11
|
+
PooledSqlExecutor,
|
|
12
|
+
SqlConnectionPool,
|
|
13
|
+
)
|
|
14
|
+
from .auth import SQL_SCOPE, get_token
|
|
15
|
+
|
|
16
|
+
# NotebookUtils accepts the SQL resource audience, not an OAuth ``.default``
|
|
17
|
+
# scope. Keeping the two constants separate prevents a caller-boundary mix-up.
|
|
18
|
+
FABRIC_SQL_AUDIENCE = "https://database.windows.net/"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def desktop_sql_pool(
|
|
22
|
+
target,
|
|
23
|
+
workspace,
|
|
24
|
+
*,
|
|
25
|
+
credential=None,
|
|
26
|
+
resolver=None,
|
|
27
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
28
|
+
connection_factory=None,
|
|
29
|
+
) -> SqlConnectionPool:
|
|
30
|
+
"""Cross from a desktop caller into a resolved Fabric Warehouse."""
|
|
31
|
+
|
|
32
|
+
from ..resolution import resolver_for
|
|
33
|
+
from ..sql.connection import connect
|
|
34
|
+
|
|
35
|
+
resolver = resolver or resolver_for(workspace)
|
|
36
|
+
endpoint = resolver.sql_endpoint(target)
|
|
37
|
+
authentication = AccessTokenAuthentication(
|
|
38
|
+
lambda: get_token(SQL_SCOPE, credential)
|
|
39
|
+
)
|
|
40
|
+
return SqlConnectionPool(
|
|
41
|
+
endpoint,
|
|
42
|
+
authentication,
|
|
43
|
+
max_connections=max_connections,
|
|
44
|
+
connection_factory=connection_factory or connect,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def desktop_sql_executor(target, workspace, **kwargs) -> PooledSqlExecutor:
|
|
49
|
+
"""An explicitly cross-boundary desktop executor."""
|
|
50
|
+
|
|
51
|
+
return PooledSqlExecutor(
|
|
52
|
+
desktop_sql_pool(target, workspace, **kwargs),
|
|
53
|
+
owns_pool=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fabric_sql_pool(
|
|
58
|
+
target,
|
|
59
|
+
workspace,
|
|
60
|
+
*,
|
|
61
|
+
resolver=None,
|
|
62
|
+
runtime: Any | None = None,
|
|
63
|
+
lakehouse: Any | None = None,
|
|
64
|
+
credentials: Any | None = None,
|
|
65
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
66
|
+
connection_factory=None,
|
|
67
|
+
) -> SqlConnectionPool:
|
|
68
|
+
"""SQL access using the identity made available by a Fabric session."""
|
|
69
|
+
|
|
70
|
+
from ..sql.connection import connect
|
|
71
|
+
from .session import FabricSessionResolver
|
|
72
|
+
|
|
73
|
+
if resolver is None:
|
|
74
|
+
try:
|
|
75
|
+
resolver = FabricSessionResolver(
|
|
76
|
+
workspace,
|
|
77
|
+
runtime=runtime,
|
|
78
|
+
lakehouse=lakehouse,
|
|
79
|
+
credentials=credentials,
|
|
80
|
+
)
|
|
81
|
+
except CommandError as exc:
|
|
82
|
+
raise CommandError(
|
|
83
|
+
"Fabric-native SQL is available only inside a supported Fabric "
|
|
84
|
+
"notebook or Livy session"
|
|
85
|
+
) from exc
|
|
86
|
+
if not isinstance(resolver, FabricSessionResolver):
|
|
87
|
+
raise CommandError(
|
|
88
|
+
"Fabric-native SQL needs a FabricSessionResolver; desktop callers "
|
|
89
|
+
"must use desktop_sql_executor explicitly"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
endpoint = resolver.sql_endpoint(target)
|
|
93
|
+
notebook_credentials = credentials or getattr(resolver, "_credentials", None)
|
|
94
|
+
if notebook_credentials is None:
|
|
95
|
+
try:
|
|
96
|
+
from notebookutils import credentials as notebook_credentials
|
|
97
|
+
except ImportError as exc:
|
|
98
|
+
raise CommandError(
|
|
99
|
+
"Fabric-native SQL cannot acquire the Fabric session identity"
|
|
100
|
+
) from exc
|
|
101
|
+
authentication = AccessTokenAuthentication(
|
|
102
|
+
lambda: notebook_credentials.getToken(FABRIC_SQL_AUDIENCE)
|
|
103
|
+
)
|
|
104
|
+
return SqlConnectionPool(
|
|
105
|
+
endpoint,
|
|
106
|
+
authentication,
|
|
107
|
+
max_connections=max_connections,
|
|
108
|
+
connection_factory=connection_factory or connect,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fabric_sql_executor(target, workspace, **kwargs) -> PooledSqlExecutor:
|
|
113
|
+
"""An explicitly within-Fabric executor."""
|
|
114
|
+
|
|
115
|
+
return PooledSqlExecutor(
|
|
116
|
+
fabric_sql_pool(target, workspace, **kwargs),
|
|
117
|
+
owns_pool=True,
|
|
118
|
+
)
|