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
weaver/fabric/onelake.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""OneLake as a :class:`~weaver.store.Store`, over the DFS API.
|
|
2
|
+
|
|
3
|
+
This is the **desktop's** way of reaching into a Fabric workspace: authenticated
|
|
4
|
+
HTTPS against the ADLS Gen2 DFS endpoint. It is what the CLI uses to push
|
|
5
|
+
repository files up and to inspect results from a test on the laptop. It is not
|
|
6
|
+
the path Weaver should use when it is *running inside* Fabric — there the native
|
|
7
|
+
in-session mechanisms (``notebookutils.fs``, Spark) apply — so this store is a
|
|
8
|
+
cross-into-Fabric transport, not the canonical in-workspace Fabric implementation.
|
|
9
|
+
|
|
10
|
+
Two things OneLake does over DFS that a plain filesystem does not:
|
|
11
|
+
|
|
12
|
+
**Writing is three calls, not one.** Create the file, append the bytes, flush at
|
|
13
|
+
the final offset.
|
|
14
|
+
|
|
15
|
+
**Listing is paged.** A large directory returns a continuation token rather than
|
|
16
|
+
everything. Pagination is not implemented yet, so a paged listing fails loudly
|
|
17
|
+
(see :meth:`OneLakeDfsClient.list`) rather than silently returning a first page —
|
|
18
|
+
which would quietly truncate a wipe, a sync or a reconciliation.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import timezone
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from urllib.parse import quote, urlencode
|
|
28
|
+
|
|
29
|
+
from ..errors import CommandError
|
|
30
|
+
from ..locations import Location
|
|
31
|
+
from ..store import Entry, StoreError
|
|
32
|
+
from .auth import STORAGE_SCOPE, token_source
|
|
33
|
+
from .client import ONELAKE_DFS
|
|
34
|
+
|
|
35
|
+
STORAGE_API_VERSION = "2023-11-03"
|
|
36
|
+
DEFAULT_TIMEOUT = 120.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def lakehouse_artifact_segment(item: str) -> str:
|
|
40
|
+
"""A OneLake path segment for a **Lakehouse**, by id or by name.
|
|
41
|
+
|
|
42
|
+
A GUID stands alone; a name needs its item type, as ``Weaver.Lakehouse``.
|
|
43
|
+
The rule is Lakehouse-specific — the ``.Lakehouse`` suffix — which is why the
|
|
44
|
+
name says so. OneLake file paths only ever address Lakehouses; a Warehouse
|
|
45
|
+
is reached over TDS, not here.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
uuid.UUID(item)
|
|
50
|
+
return item
|
|
51
|
+
except ValueError:
|
|
52
|
+
return f"{item}.Lakehouse"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def onelake_url(
|
|
56
|
+
workspace: str,
|
|
57
|
+
item: str,
|
|
58
|
+
relative_path: str = "",
|
|
59
|
+
*,
|
|
60
|
+
base_url: str = ONELAKE_DFS,
|
|
61
|
+
query: dict[str, str] | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""A DFS URL beneath one item, e.g. ``…/{ws}/{lh}/Files/weaver_items/x``."""
|
|
64
|
+
|
|
65
|
+
parts = [workspace, lakehouse_artifact_segment(item)]
|
|
66
|
+
parts.extend(part for part in relative_path.strip("/").split("/") if part)
|
|
67
|
+
url = f"{base_url.rstrip('/')}/" + "/".join(quote(part, safe="") for part in parts)
|
|
68
|
+
return f"{url}?{urlencode(query)}" if query else url
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def abfss_root(workspace_id: str, item_id: str) -> str:
|
|
72
|
+
"""The Spark-facing root for an item.
|
|
73
|
+
|
|
74
|
+
Proven to list, read and write Lakehouses that are not attached to the
|
|
75
|
+
notebook, which is the whole reason destination roots are explicit.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
return f"abfss://{workspace_id}@onelake.dfs.fabric.microsoft.com/{item_id}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class OneLakePath:
|
|
83
|
+
"""A OneLake location split back into the parts DFS needs."""
|
|
84
|
+
|
|
85
|
+
workspace: str
|
|
86
|
+
item: str
|
|
87
|
+
relative: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_onelake(location: Location, *, base_url: str = ONELAKE_DFS) -> OneLakePath:
|
|
91
|
+
prefix = base_url.rstrip("/") + "/"
|
|
92
|
+
if not location.value.startswith(prefix):
|
|
93
|
+
raise CommandError(
|
|
94
|
+
f"{location.value!r} is not a OneLake location — expected it to start "
|
|
95
|
+
f"with {prefix}"
|
|
96
|
+
)
|
|
97
|
+
parts = [part for part in location.value[len(prefix):].split("/") if part]
|
|
98
|
+
if len(parts) < 2:
|
|
99
|
+
raise CommandError(f"{location.value!r} names no item beneath its workspace")
|
|
100
|
+
return OneLakePath(workspace=parts[0], item=parts[1], relative="/".join(parts[2:]))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class OneLakeDfsClient:
|
|
104
|
+
"""An ADLS Gen2 DFS client for one workspace, used **from outside Fabric**.
|
|
105
|
+
|
|
106
|
+
This is how a local caller — the CLI, or a Fabric integration test — reaches
|
|
107
|
+
into a workspace: authenticated HTTPS to the OneLake DFS endpoint. It
|
|
108
|
+
satisfies the :class:`~weaver.store.Store` protocol so the CLI can hand it to
|
|
109
|
+
the same code a ``LocalStore`` drives, but it is *cross-boundary access*, not
|
|
110
|
+
the store Weaver uses when it runs inside Fabric. The in-Fabric,
|
|
111
|
+
session-native store is a separate implementation for when it exists.
|
|
112
|
+
|
|
113
|
+
Because it crosses a boundary, it is constructed explicitly by the caller
|
|
114
|
+
that crosses — never returned by a workspace-to-store factory, which returns the
|
|
115
|
+
NotebookUtils-backed ``FabricStore`` only inside a Fabric session.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
base_url: str = ONELAKE_DFS,
|
|
122
|
+
token: str | None = None,
|
|
123
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.base_url = base_url.rstrip("/")
|
|
126
|
+
self.timeout = timeout
|
|
127
|
+
self._token_source = token_source(token, scope=STORAGE_SCOPE)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def token(self) -> str:
|
|
131
|
+
"""A currently-valid bearer, renewed when it is close to expiring.
|
|
132
|
+
|
|
133
|
+
A push or a repository upload can run for a long time on one client, so
|
|
134
|
+
the token has to be read per request rather than snapshotted.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
return self._token_source()
|
|
138
|
+
|
|
139
|
+
def _request(
|
|
140
|
+
self,
|
|
141
|
+
method: str,
|
|
142
|
+
url: str,
|
|
143
|
+
*,
|
|
144
|
+
data: bytes | None = None,
|
|
145
|
+
headers: dict[str, str] | None = None,
|
|
146
|
+
expected: tuple[int, ...] = (200, 201, 202),
|
|
147
|
+
):
|
|
148
|
+
import requests
|
|
149
|
+
|
|
150
|
+
merged = {
|
|
151
|
+
"Authorization": f"Bearer {self.token}",
|
|
152
|
+
"x-ms-version": STORAGE_API_VERSION,
|
|
153
|
+
}
|
|
154
|
+
merged.update(headers or {})
|
|
155
|
+
response = requests.request(
|
|
156
|
+
method, url, headers=merged, data=data, timeout=self.timeout
|
|
157
|
+
)
|
|
158
|
+
if response.status_code not in expected:
|
|
159
|
+
raise StoreError(
|
|
160
|
+
f"{method} {url.split('?')[0]} returned {response.status_code}: "
|
|
161
|
+
f"{response.text.strip()[:300] or 'no body'}"
|
|
162
|
+
)
|
|
163
|
+
return response
|
|
164
|
+
|
|
165
|
+
def _url(self, location: Location, query: dict[str, str] | None = None) -> str:
|
|
166
|
+
parsed = parse_onelake(location, base_url=self.base_url)
|
|
167
|
+
return onelake_url(
|
|
168
|
+
parsed.workspace,
|
|
169
|
+
parsed.item,
|
|
170
|
+
parsed.relative,
|
|
171
|
+
base_url=self.base_url,
|
|
172
|
+
query=query,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# --- the Store protocol ----------------------------------------------
|
|
176
|
+
|
|
177
|
+
def exists(self, location: Location) -> bool:
|
|
178
|
+
return self._request("HEAD", self._url(location), expected=(200, 404)).status_code == 200
|
|
179
|
+
|
|
180
|
+
def is_directory(self, location: Location) -> bool:
|
|
181
|
+
response = self._request("HEAD", self._url(location), expected=(200, 404))
|
|
182
|
+
if response.status_code != 200:
|
|
183
|
+
return False
|
|
184
|
+
return response.headers.get("x-ms-resource-type") == "directory"
|
|
185
|
+
|
|
186
|
+
def list(self, location: Location, *, recursive: bool = False) -> list[Entry]:
|
|
187
|
+
parsed = parse_onelake(location, base_url=self.base_url)
|
|
188
|
+
directory = "/".join(
|
|
189
|
+
part for part in (lakehouse_artifact_segment(parsed.item), parsed.relative) if part
|
|
190
|
+
)
|
|
191
|
+
url = f"{self.base_url}/{quote(parsed.workspace, safe='')}?" + urlencode(
|
|
192
|
+
{
|
|
193
|
+
"resource": "filesystem",
|
|
194
|
+
"recursive": "true" if recursive else "false",
|
|
195
|
+
"directory": directory,
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
response = self._request("GET", url, expected=(200, 404))
|
|
199
|
+
if response.status_code == 404:
|
|
200
|
+
raise StoreError(f"cannot list a location that does not exist: {location}")
|
|
201
|
+
|
|
202
|
+
# A large directory pages, returning a continuation token. Until that is
|
|
203
|
+
# handled, returning only the first page would silently truncate a wipe,
|
|
204
|
+
# a sync or a reconciliation, so fail before returning anything.
|
|
205
|
+
if response.headers.get("x-ms-continuation"):
|
|
206
|
+
raise NotImplementedError("OneLake listing pagination is not implemented")
|
|
207
|
+
|
|
208
|
+
entries: list[Entry] = []
|
|
209
|
+
prefix = f"{lakehouse_artifact_segment(parsed.item)}/"
|
|
210
|
+
for path in response.json().get("paths", []):
|
|
211
|
+
name = path.get("name", "")
|
|
212
|
+
relative = name[len(prefix):] if name.startswith(prefix) else name
|
|
213
|
+
entries.append(
|
|
214
|
+
Entry(
|
|
215
|
+
location=Location(
|
|
216
|
+
f"{self.base_url}/{parsed.workspace}/"
|
|
217
|
+
f"{lakehouse_artifact_segment(parsed.item)}/{relative}"
|
|
218
|
+
),
|
|
219
|
+
is_directory=str(path.get("isDirectory", "false")).lower() == "true",
|
|
220
|
+
size=int(path["contentLength"]) if path.get("contentLength") else None,
|
|
221
|
+
modified=_parse_time(path.get("lastModified")),
|
|
222
|
+
etag=path.get("etag"),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return entries
|
|
226
|
+
|
|
227
|
+
def read(self, location: Location) -> bytes:
|
|
228
|
+
return self._request("GET", self._url(location), expected=(200,)).content
|
|
229
|
+
|
|
230
|
+
def write(self, location: Location, data: bytes) -> None:
|
|
231
|
+
url = self._url(location)
|
|
232
|
+
self._request("PUT", f"{url}?resource=file", expected=(201,))
|
|
233
|
+
if data:
|
|
234
|
+
self._request(
|
|
235
|
+
"PATCH",
|
|
236
|
+
f"{url}?action=append&position=0",
|
|
237
|
+
data=data,
|
|
238
|
+
headers={"Content-Length": str(len(data))},
|
|
239
|
+
expected=(202,),
|
|
240
|
+
)
|
|
241
|
+
self._request("PATCH", f"{url}?action=flush&position={len(data)}", expected=(200,))
|
|
242
|
+
|
|
243
|
+
def delete(self, location: Location, *, recursive: bool = False) -> None:
|
|
244
|
+
query = "?recursive=true" if recursive else ""
|
|
245
|
+
self._request(
|
|
246
|
+
"DELETE", f"{self._url(location)}{query}", expected=(200, 202, 204, 404)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def make_directory(self, location: Location) -> None:
|
|
250
|
+
self._request(
|
|
251
|
+
"PUT", f"{self._url(location)}?resource=directory", expected=(201, 409)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_time(value: str | None) -> datetime | None:
|
|
256
|
+
if not value:
|
|
257
|
+
return None
|
|
258
|
+
from email.utils import parsedate_to_datetime
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
return parsedate_to_datetime(value).astimezone(timezone.utc)
|
|
262
|
+
except (TypeError, ValueError):
|
|
263
|
+
return None
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Fabric workspace resolution — names to OneLake locations.
|
|
2
|
+
|
|
3
|
+
The twin of :class:`~weaver.resolution.LocalResolver`, and deliberately the same
|
|
4
|
+
surface, so everything above resolution is written once and neither build nor
|
|
5
|
+
wipe learns which workspace it is talking to.
|
|
6
|
+
|
|
7
|
+
The difference is that a Fabric name has to be *asked about* — a name maps to a
|
|
8
|
+
GUID only by consulting the workspace. It is asked about **with its type**:
|
|
9
|
+
identity is ``workspace + type + name``, so a Lakehouse and a Warehouse may share a
|
|
10
|
+
display name (indeed a Lakehouse grows a same-named SQL endpoint), and the
|
|
11
|
+
caller always knows the type from the slot. Answers are cached, because asking
|
|
12
|
+
costs an API call.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from ..errors import CommandError
|
|
18
|
+
from ..workspaces import BUILD_BUNDLES_AREA, CLI_AREA, WEAVER_ITEMS_AREA, FabricWorkspace
|
|
19
|
+
from ..locations import LakehouseSparkLocation, Location
|
|
20
|
+
from ..resolution import TABLES_AREA
|
|
21
|
+
from ..spark import SparkDestination, fabric_destination
|
|
22
|
+
from ..targets import (
|
|
23
|
+
FILES_AREA,
|
|
24
|
+
DeltaTarget,
|
|
25
|
+
FolderTarget,
|
|
26
|
+
ItemRef,
|
|
27
|
+
WarehouseTarget,
|
|
28
|
+
validate_name,
|
|
29
|
+
)
|
|
30
|
+
from .client import ONELAKE_DFS, FabricClient
|
|
31
|
+
from .onelake import abfss_root, lakehouse_artifact_segment
|
|
32
|
+
from .resources import (
|
|
33
|
+
LAKEHOUSE,
|
|
34
|
+
SQL_ENDPOINT,
|
|
35
|
+
WAREHOUSE,
|
|
36
|
+
Item,
|
|
37
|
+
Workspace,
|
|
38
|
+
find_item,
|
|
39
|
+
find_workspace,
|
|
40
|
+
refresh_sql_endpoint_metadata,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FabricResolver:
|
|
45
|
+
"""Resolves level-three names against one Fabric workspace."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
workspace: FabricWorkspace,
|
|
50
|
+
*,
|
|
51
|
+
client: FabricClient | None = None,
|
|
52
|
+
base_url: str = ONELAKE_DFS,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not isinstance(workspace, FabricWorkspace):
|
|
55
|
+
raise CommandError(
|
|
56
|
+
f"FabricResolver needs a FabricWorkspace, got {type(workspace).__name__}"
|
|
57
|
+
)
|
|
58
|
+
self.configuration = workspace
|
|
59
|
+
self.client = client or FabricClient()
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self._workspace: Workspace | None = None
|
|
62
|
+
self._items: dict[str, Item] = {}
|
|
63
|
+
|
|
64
|
+
# --- level four -------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def workspace(self) -> Workspace:
|
|
68
|
+
if self._workspace is None:
|
|
69
|
+
self._workspace = find_workspace(
|
|
70
|
+
self.configuration.workspace, client=self.client
|
|
71
|
+
)
|
|
72
|
+
return self._workspace
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def root(self) -> Location:
|
|
76
|
+
"""The workspace root. Everything resolved sits beneath it."""
|
|
77
|
+
|
|
78
|
+
return Location(f"{self.base_url}/{self.workspace.id}")
|
|
79
|
+
|
|
80
|
+
# --- level three ------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def resolve(self, item: ItemRef, *, item_type: str) -> Item:
|
|
83
|
+
"""The workspace item of this name and type. Cached.
|
|
84
|
+
|
|
85
|
+
A type is required: identity is ``workspace + type + name``, and asking the
|
|
86
|
+
workspace what a bare name *is* would make a caller depend on ambiguous
|
|
87
|
+
name inference. The caller knows the type from the slot — a
|
|
88
|
+
``DeltaTarget`` is a Lakehouse, a ``WarehouseTarget`` is a Warehouse.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
key = f"{item.name}:{item_type}"
|
|
92
|
+
if key not in self._items:
|
|
93
|
+
self._items[key] = find_item(
|
|
94
|
+
self.workspace, item.name, item_type=item_type, client=self.client
|
|
95
|
+
)
|
|
96
|
+
return self._items[key]
|
|
97
|
+
|
|
98
|
+
def _rest_client(self) -> FabricClient:
|
|
99
|
+
return self.client
|
|
100
|
+
|
|
101
|
+
def refresh_sql_endpoint(self, item: ItemRef) -> dict:
|
|
102
|
+
"""Refresh the SQL analytics endpoint paired with a named Lakehouse."""
|
|
103
|
+
|
|
104
|
+
client = self._rest_client()
|
|
105
|
+
endpoint = find_item(
|
|
106
|
+
self.workspace,
|
|
107
|
+
item.name,
|
|
108
|
+
item_type=SQL_ENDPOINT,
|
|
109
|
+
client=client,
|
|
110
|
+
)
|
|
111
|
+
return refresh_sql_endpoint_metadata(endpoint, client=client)
|
|
112
|
+
|
|
113
|
+
def lakehouse(self, item: ItemRef) -> Location:
|
|
114
|
+
return self.root / lakehouse_artifact_segment(
|
|
115
|
+
self.resolve(item, item_type=LAKEHOUSE).id
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def files_root(self, item: ItemRef) -> Location:
|
|
119
|
+
return self.lakehouse(item) / FILES_AREA
|
|
120
|
+
|
|
121
|
+
def tables_root(self, item: ItemRef) -> Location:
|
|
122
|
+
return self.lakehouse(item) / TABLES_AREA
|
|
123
|
+
|
|
124
|
+
def spark_root(self, item: ItemRef) -> str:
|
|
125
|
+
"""The ``abfss://`` root Spark writes through, for a Lakehouse.
|
|
126
|
+
|
|
127
|
+
Explicit, so a session never needs the item attached.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
return abfss_root(self.workspace.id, self.resolve(item, item_type=LAKEHOUSE).id)
|
|
131
|
+
|
|
132
|
+
# --- targets ----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def folder_root(self, target: FolderTarget) -> Location:
|
|
135
|
+
return self.files_root(target.lakehouse)
|
|
136
|
+
|
|
137
|
+
def folder_object(self, target: FolderTarget, schema: str, name: str) -> Location:
|
|
138
|
+
return self.folder_root(target).join(
|
|
139
|
+
validate_name(schema, what="schema"),
|
|
140
|
+
validate_name(name, what="object name"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def folder_staging(self, target: FolderTarget, schema: str, name: str) -> Location:
|
|
144
|
+
destination = self.folder_object(target, schema, name)
|
|
145
|
+
return Location(f"{destination.value}_Staging")
|
|
146
|
+
|
|
147
|
+
def delta_table(self, target: DeltaTarget, schema: str, name: str) -> Location:
|
|
148
|
+
return self.tables_root(target.lakehouse).join(
|
|
149
|
+
validate_name(schema, what="schema"),
|
|
150
|
+
validate_name(name, what="object name"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def warehouse(self, target: WarehouseTarget) -> Item:
|
|
154
|
+
"""The Warehouse item. Its SQL endpoint is reached over TDS, not OneLake."""
|
|
155
|
+
|
|
156
|
+
return self.resolve(target.warehouse, item_type=WAREHOUSE)
|
|
157
|
+
|
|
158
|
+
# --- what a Fabric workspace can do that a filesystem cannot -----------
|
|
159
|
+
#
|
|
160
|
+
# Two operations an installed bundle needs that are neither path arithmetic
|
|
161
|
+
# nor a SQL statement: pointing one Lakehouse at another's data, and asking a
|
|
162
|
+
# Lakehouse's SQL analytics endpoint to catch up. Both belong here because
|
|
163
|
+
# this is the adapter that already knows how to reach this workspace, and the
|
|
164
|
+
# local resolver deliberately offers neither — the emulator links files, and
|
|
165
|
+
# has no SQL endpoint at all.
|
|
166
|
+
|
|
167
|
+
def create_onelake_shortcut(
|
|
168
|
+
self,
|
|
169
|
+
item: ItemRef,
|
|
170
|
+
*,
|
|
171
|
+
path: str,
|
|
172
|
+
name: str,
|
|
173
|
+
source: ItemRef,
|
|
174
|
+
source_path: str,
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Point ``item``'s ``path/name`` at ``source``'s ``source_path``."""
|
|
177
|
+
|
|
178
|
+
from .shortcuts import create_shortcut
|
|
179
|
+
|
|
180
|
+
return create_shortcut(
|
|
181
|
+
self.resolve(item, item_type=LAKEHOUSE),
|
|
182
|
+
path=path,
|
|
183
|
+
name=name,
|
|
184
|
+
source=self.resolve(source, item_type=LAKEHOUSE),
|
|
185
|
+
source_path=source_path,
|
|
186
|
+
client=self._rest_client(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def onelake_shortcuts(self, item: ItemRef) -> tuple:
|
|
190
|
+
"""Every shortcut a Lakehouse holds. Scoping is the caller's business."""
|
|
191
|
+
|
|
192
|
+
from .shortcuts import list_shortcuts
|
|
193
|
+
|
|
194
|
+
return list_shortcuts(
|
|
195
|
+
self.resolve(item, item_type=LAKEHOUSE), client=self._rest_client()
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def remove_onelake_shortcut(self, item: ItemRef, *, path: str, name: str) -> None:
|
|
199
|
+
"""Take away this Lakehouse's name for another item's data.
|
|
200
|
+
|
|
201
|
+
Never the data: see :func:`weaver.fabric.shortcuts.delete_shortcut`.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
from .shortcuts import delete_shortcut
|
|
205
|
+
|
|
206
|
+
delete_shortcut(
|
|
207
|
+
self.resolve(item, item_type=LAKEHOUSE),
|
|
208
|
+
path=path,
|
|
209
|
+
name=name,
|
|
210
|
+
client=self._rest_client(),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def sql_endpoint(self, target: WarehouseTarget):
|
|
215
|
+
"""Resolve a typed Warehouse to the common SQL endpoint record."""
|
|
216
|
+
|
|
217
|
+
from ..sql import SqlEndpoint
|
|
218
|
+
|
|
219
|
+
warehouse = self.warehouse(target)
|
|
220
|
+
payload = self.client.get_json(
|
|
221
|
+
f"workspaces/{self.workspace.id}/warehouses/"
|
|
222
|
+
f"{warehouse.id}/connectionString"
|
|
223
|
+
)
|
|
224
|
+
value = payload.get("connectionString")
|
|
225
|
+
if not isinstance(value, str) or not value.strip():
|
|
226
|
+
raise CommandError(
|
|
227
|
+
f"Fabric returned no SQL connection string for Warehouse "
|
|
228
|
+
f"{warehouse.name!r}"
|
|
229
|
+
)
|
|
230
|
+
return SqlEndpoint(
|
|
231
|
+
server=_server_name(value),
|
|
232
|
+
database=warehouse.name,
|
|
233
|
+
workspace_id=self.workspace.id,
|
|
234
|
+
warehouse_id=warehouse.id,
|
|
235
|
+
warehouse_name=warehouse.name,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# --- the weaver lakehouse ---------------------------------------------
|
|
239
|
+
|
|
240
|
+
def _weaver_lakehouse(self) -> ItemRef:
|
|
241
|
+
name = self.configuration.weaver_lakehouse
|
|
242
|
+
if name is None:
|
|
243
|
+
raise CommandError(
|
|
244
|
+
"no Weaver Lakehouse for this Workspace — set weaver_lakehouse on the Workspace "
|
|
245
|
+
"or supply it explicitly"
|
|
246
|
+
)
|
|
247
|
+
return ItemRef(name)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def weaver_lakehouse(self) -> Location:
|
|
251
|
+
return self.lakehouse(self._weaver_lakehouse())
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def weaver_items_root(self) -> Location:
|
|
255
|
+
"""The workspace's one declaration, with item types directly below it."""
|
|
256
|
+
|
|
257
|
+
return self.files_root(self._weaver_lakehouse()) / WEAVER_ITEMS_AREA
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def build_bundles_root(self) -> Location:
|
|
261
|
+
return self.files_root(self._weaver_lakehouse()) / BUILD_BUNDLES_AREA
|
|
262
|
+
|
|
263
|
+
def build_bundle(self, name: str) -> Location:
|
|
264
|
+
from ..targets import validate_name
|
|
265
|
+
|
|
266
|
+
return self.build_bundles_root / validate_name(name, what="bundle name")
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def cli_root(self) -> Location:
|
|
270
|
+
return self.files_root(self._weaver_lakehouse()) / CLI_AREA
|
|
271
|
+
|
|
272
|
+
def cli_execution(self, execution_id: str) -> Location:
|
|
273
|
+
return self.cli_root / validate_name(execution_id, what="execution id")
|
|
274
|
+
|
|
275
|
+
def cli_bundle(self, execution_id: str) -> Location:
|
|
276
|
+
return self.cli_execution(execution_id) / "install.weaver.zip"
|
|
277
|
+
|
|
278
|
+
def lakehouse_spark_location(self, item: ItemRef) -> LakehouseSparkLocation:
|
|
279
|
+
"""One destination Lakehouse's ``abfss://`` roots, for Spark to address.
|
|
280
|
+
|
|
281
|
+
Built from :meth:`spark_root`, which exists precisely so a session never
|
|
282
|
+
needs the item attached. The session stays attached to the Weaver
|
|
283
|
+
Lakehouse — the control plane — and destinations are reached explicitly.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
root = self.spark_root(item).rstrip("/")
|
|
287
|
+
return LakehouseSparkLocation(
|
|
288
|
+
item=item.name,
|
|
289
|
+
tables_root=f"{root}/{TABLES_AREA}",
|
|
290
|
+
files_root=f"{root}/{FILES_AREA}",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def spark_destination(self, item: ItemRef) -> SparkDestination:
|
|
294
|
+
"""One Lakehouse, as Fabric's Spark catalogue names it.
|
|
295
|
+
|
|
296
|
+
Fabric's namespace is the fundamental representation:
|
|
297
|
+
``workspace.lakehouse.schema.object``. One session addresses every
|
|
298
|
+
Lakehouse in the workspace through it, so nothing has to be attached and
|
|
299
|
+
nothing has to be switched — and a schema-enabled Lakehouse pins its own
|
|
300
|
+
managed tables, which is why no path appears in the destination.
|
|
301
|
+
|
|
302
|
+
Display names, because that is what the namespace is spelled with. The
|
|
303
|
+
ids stay in resolution and in the bundle's target block.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
return fabric_destination(
|
|
307
|
+
workspace=self.workspace.name,
|
|
308
|
+
lakehouse=self.resolve(item, item_type=LAKEHOUSE).name,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def control_tables_root(self) -> Location:
|
|
313
|
+
return self.tables_root(self._weaver_lakehouse())
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _server_name(value: str) -> str:
|
|
317
|
+
"""Extract a server from Fabric's endpoint response.
|
|
318
|
+
|
|
319
|
+
The current Warehouse API returns a bare FQDN. Accepting the familiar full
|
|
320
|
+
connection-string form as well makes the boundary tolerant without keeping
|
|
321
|
+
a credential-bearing connection string as endpoint identity.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
text = value.strip().strip(";")
|
|
325
|
+
if ";" in text or "=" in text:
|
|
326
|
+
fields = {}
|
|
327
|
+
for part in text.split(";"):
|
|
328
|
+
if "=" in part:
|
|
329
|
+
key, item = part.split("=", 1)
|
|
330
|
+
fields[key.strip().lower()] = item.strip()
|
|
331
|
+
text = (
|
|
332
|
+
fields.get("server")
|
|
333
|
+
or fields.get("data source")
|
|
334
|
+
or fields.get("address")
|
|
335
|
+
or fields.get("addr")
|
|
336
|
+
or fields.get("network address")
|
|
337
|
+
or ""
|
|
338
|
+
)
|
|
339
|
+
text = text.removeprefix("tcp:").strip()
|
|
340
|
+
if "," in text:
|
|
341
|
+
text = text.rsplit(",", 1)[0].strip()
|
|
342
|
+
if not text:
|
|
343
|
+
raise CommandError("Fabric returned an invalid Warehouse SQL endpoint")
|
|
344
|
+
return text
|