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,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
+ )
@@ -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
+ )