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.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -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