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/livy.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Running Weaver code inside a Fabric Spark session.
|
|
2
|
+
|
|
3
|
+
This is the third execution position — not Weaver reaching into a workspace over
|
|
4
|
+
HTTP, but Weaver *running there*. It is the position the product claim rests on,
|
|
5
|
+
and the only one that proves a notebook user could do the same thing.
|
|
6
|
+
|
|
7
|
+
A session is expensive to start and cheap to reuse, so callers should hold one
|
|
8
|
+
open across a batch of work rather than paying for it per statement.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..errors import WeaverError
|
|
19
|
+
from .auth import FABRIC_SCOPE, token_source
|
|
20
|
+
from .client import FABRIC_API
|
|
21
|
+
|
|
22
|
+
DEFAULT_LIVY_API_VERSION = "2023-12-01"
|
|
23
|
+
DEFAULT_POLL_INTERVAL = 3.0
|
|
24
|
+
DEFAULT_SESSION_TIMEOUT = 600.0
|
|
25
|
+
DEFAULT_STATEMENT_TIMEOUT = 900.0
|
|
26
|
+
#: How long a close waits for the session to actually release its capacity slot.
|
|
27
|
+
#: Shorter than a start, because a session being torn down has no work to finish
|
|
28
|
+
#: and a caller should not be held up by one that will not admit it has gone.
|
|
29
|
+
DEFAULT_CLOSE_TIMEOUT = 120.0
|
|
30
|
+
|
|
31
|
+
#: Wrapped around returned values so a result can be told from printed output.
|
|
32
|
+
RESULT_PREFIX = "__weaver_result__"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LivyError(WeaverError):
|
|
36
|
+
"""Raised when a Livy session or statement fails."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class StatementResult:
|
|
41
|
+
"""What one submitted statement produced."""
|
|
42
|
+
|
|
43
|
+
text: str
|
|
44
|
+
payload: Any = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def returned(self) -> bool:
|
|
48
|
+
return self.payload is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class LivySessionInfo:
|
|
53
|
+
"""One entry returned by Fabric's Lakehouse sessions collection."""
|
|
54
|
+
|
|
55
|
+
id: str
|
|
56
|
+
name: str | None = None
|
|
57
|
+
submitter_id: str | None = None
|
|
58
|
+
submitter_name: str | None = None
|
|
59
|
+
artifact_id: str | None = None
|
|
60
|
+
scheduler_state: str | None = None
|
|
61
|
+
plugin_state: str | None = None
|
|
62
|
+
livy_state: str | None = None
|
|
63
|
+
submitted_at: str | None = None
|
|
64
|
+
started_at: str | None = None
|
|
65
|
+
ended_at: str | None = None
|
|
66
|
+
result: str | None = None
|
|
67
|
+
cancellation_reason: str | None = None
|
|
68
|
+
tags: tuple[str, ...] = ()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_mapping(cls, value: dict[str, Any]) -> "LivySessionInfo":
|
|
72
|
+
tags = value.get("tags") or ()
|
|
73
|
+
if isinstance(tags, str):
|
|
74
|
+
tags = (tags,)
|
|
75
|
+
return cls(
|
|
76
|
+
id=str(value.get("id") or value.get("livyId") or ""),
|
|
77
|
+
name=_optional_text(value.get("name")),
|
|
78
|
+
submitter_id=_optional_text(value.get("submitterId")),
|
|
79
|
+
submitter_name=_optional_text(value.get("submitterName")),
|
|
80
|
+
artifact_id=_optional_text(value.get("artifactId")),
|
|
81
|
+
scheduler_state=_optional_text(value.get("schedulerState")),
|
|
82
|
+
plugin_state=_optional_text(value.get("pluginState")),
|
|
83
|
+
livy_state=_optional_text(value.get("livyState") or value.get("state")),
|
|
84
|
+
submitted_at=_optional_text(value.get("submittedAt")),
|
|
85
|
+
started_at=_optional_text(value.get("startedAt")),
|
|
86
|
+
ended_at=_optional_text(value.get("endedAt")),
|
|
87
|
+
result=_optional_text(value.get("result")),
|
|
88
|
+
cancellation_reason=_optional_text(value.get("cancellationReason")),
|
|
89
|
+
tags=tuple(str(tag) for tag in tags),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def active(self) -> bool:
|
|
94
|
+
"""Whether this session still occupies, or waits for, a capacity slot."""
|
|
95
|
+
|
|
96
|
+
if self.scheduler_state:
|
|
97
|
+
return self.scheduler_state.casefold() != "ended"
|
|
98
|
+
if self.livy_state:
|
|
99
|
+
return self.livy_state.casefold() not in {
|
|
100
|
+
"dead",
|
|
101
|
+
"error",
|
|
102
|
+
"killed",
|
|
103
|
+
"success",
|
|
104
|
+
"shutting_down",
|
|
105
|
+
}
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class WorkspaceLivySession:
|
|
111
|
+
"""A Livy collection entry with the Lakehouse whose collection owns it."""
|
|
112
|
+
|
|
113
|
+
lakehouse_id: str
|
|
114
|
+
lakehouse_name: str
|
|
115
|
+
session: LivySessionInfo
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def active(self) -> bool:
|
|
119
|
+
return self.session.active
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sessions_url(
|
|
123
|
+
workspace_id: str,
|
|
124
|
+
lakehouse_id: str,
|
|
125
|
+
*,
|
|
126
|
+
api_base_url: str = FABRIC_API,
|
|
127
|
+
api_version: str = DEFAULT_LIVY_API_VERSION,
|
|
128
|
+
) -> str:
|
|
129
|
+
base = api_base_url.rstrip("/")
|
|
130
|
+
return (
|
|
131
|
+
f"{base}/workspaces/{workspace_id}"
|
|
132
|
+
f"/lakehouses/{lakehouse_id}"
|
|
133
|
+
f"/livyapi/versions/{api_version}/sessions"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_livy_sessions(
|
|
138
|
+
workspace_id: str,
|
|
139
|
+
lakehouse_id: str,
|
|
140
|
+
*,
|
|
141
|
+
client=None,
|
|
142
|
+
) -> tuple[LivySessionInfo, ...]:
|
|
143
|
+
"""List the Spark sessions Fabric records for one Lakehouse.
|
|
144
|
+
|
|
145
|
+
This is read-only. In particular, it never cancels a stale session: the
|
|
146
|
+
caller that owns a session remains the only thing entitled to end it.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
from .client import FabricClient
|
|
150
|
+
|
|
151
|
+
client = client or FabricClient()
|
|
152
|
+
payload = client.get_json(
|
|
153
|
+
sessions_url(workspace_id, lakehouse_id, api_base_url=client.api_base_url)
|
|
154
|
+
)
|
|
155
|
+
return tuple(
|
|
156
|
+
LivySessionInfo.from_mapping(item) for item in payload.get("items", ())
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def list_workspace_livy_sessions(
|
|
161
|
+
workspace,
|
|
162
|
+
*,
|
|
163
|
+
client=None,
|
|
164
|
+
active_only: bool = False,
|
|
165
|
+
) -> tuple[WorkspaceLivySession, ...]:
|
|
166
|
+
"""List sessions across every Lakehouse in a Fabric workspace.
|
|
167
|
+
|
|
168
|
+
Fabric capacities can apply a session limit across the workspace, while the
|
|
169
|
+
API exposes collections per Lakehouse. Looking only at the Lakehouse about
|
|
170
|
+
that will run Weaver would therefore miss a notebook occupying the same slot.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
from .client import FabricClient
|
|
174
|
+
from .resolution import FabricResolver
|
|
175
|
+
from .resources import LAKEHOUSE, list_items
|
|
176
|
+
|
|
177
|
+
client = client or FabricClient()
|
|
178
|
+
resolver = FabricResolver(workspace, client=client)
|
|
179
|
+
found = tuple(
|
|
180
|
+
WorkspaceLivySession(lakehouse.id, lakehouse.name, session)
|
|
181
|
+
for lakehouse in list_items(
|
|
182
|
+
resolver.workspace, item_type=LAKEHOUSE, client=client
|
|
183
|
+
)
|
|
184
|
+
for session in list_livy_sessions(
|
|
185
|
+
resolver.workspace.id, lakehouse.id, client=client
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
if active_only:
|
|
189
|
+
found = tuple(entry for entry in found if entry.active)
|
|
190
|
+
return tuple(
|
|
191
|
+
sorted(found, key=lambda entry: (entry.lakehouse_name, entry.session.id))
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _optional_text(value: Any) -> str | None:
|
|
196
|
+
return None if value is None or value == "" else str(value)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _call(method: str, url: str, token: str, payload: Any = None,
|
|
200
|
+
expected: tuple[int, ...] = (200, 201, 202)) -> dict:
|
|
201
|
+
import requests
|
|
202
|
+
|
|
203
|
+
response = requests.request(
|
|
204
|
+
method,
|
|
205
|
+
url,
|
|
206
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
207
|
+
data=json.dumps(payload) if payload is not None else None,
|
|
208
|
+
timeout=120,
|
|
209
|
+
)
|
|
210
|
+
if response.status_code not in expected:
|
|
211
|
+
raise LivyError(
|
|
212
|
+
f"{method} {url} returned {response.status_code}: "
|
|
213
|
+
f"{response.text.strip()[:400] or 'no body'}"
|
|
214
|
+
)
|
|
215
|
+
return response.json() if response.content else {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class LivySession:
|
|
219
|
+
"""One Fabric Spark session, held open for a batch of statements."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
workspace_id: str,
|
|
224
|
+
lakehouse_id: str,
|
|
225
|
+
*,
|
|
226
|
+
token: str | None = None,
|
|
227
|
+
environment_id: str | None = None,
|
|
228
|
+
api_base_url: str = FABRIC_API,
|
|
229
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
230
|
+
bootstrap: str | None = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
self._token_source = token_source(token, scope=FABRIC_SCOPE)
|
|
233
|
+
self.base = sessions_url(workspace_id, lakehouse_id, api_base_url=api_base_url)
|
|
234
|
+
self.environment_id = environment_id
|
|
235
|
+
self.poll_interval = poll_interval
|
|
236
|
+
self.bootstrap = bootstrap
|
|
237
|
+
self.session_url: str | None = None
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def token(self) -> str:
|
|
241
|
+
"""A currently-valid bearer, renewed when it is close to expiring.
|
|
242
|
+
|
|
243
|
+
A session is the longest-lived thing here: it is held open across a whole
|
|
244
|
+
suite, so a snapshotted token expires mid-run and every statement after
|
|
245
|
+
that fails with ``401``, downstream tests included.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
return self._token_source()
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def for_workspace(
|
|
252
|
+
cls, workspace, *, resolver=None, require_weaver: bool = True, **kwargs
|
|
253
|
+
) -> "LivySession":
|
|
254
|
+
"""A session against a workspace's Weaver Lakehouse, ready to ``import weaver``.
|
|
255
|
+
|
|
256
|
+
``require_weaver=False`` starts the session without asserting the
|
|
257
|
+
Environment carries a usable Weaver. The Environment is still attached —
|
|
258
|
+
a body that wants Weaver can still import it — but the *session* no
|
|
259
|
+
longer depends on the wheel being current.
|
|
260
|
+
|
|
261
|
+
That distinction is worth having. Submitting Spark to a workspace and
|
|
262
|
+
running the installed package are two different things, and conflating
|
|
263
|
+
them put a wheel publish in front of every test that merely needed a
|
|
264
|
+
session to read a table back.
|
|
265
|
+
|
|
266
|
+
The session is created against the Weaver Lakehouse (its default), and
|
|
267
|
+
the workspace's ``environment`` is attached so a plain ``import
|
|
268
|
+
weaver`` finds the installed package — put there by ``weaver install``.
|
|
269
|
+
Nothing is copied into the workspace.
|
|
270
|
+
|
|
271
|
+
The Environment is required: a workspace without one, or an unresolvable one,
|
|
272
|
+
is an error rather than a silent fall back to copied source. The
|
|
273
|
+
bootstrap runs once when the session starts, so callers submit their
|
|
274
|
+
work and nothing else.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
from ..errors import CommandError
|
|
278
|
+
from ..targets import ItemRef
|
|
279
|
+
from .resolution import FabricResolver
|
|
280
|
+
from .resources import LAKEHOUSE
|
|
281
|
+
|
|
282
|
+
resolver = resolver or FabricResolver(workspace)
|
|
283
|
+
home = resolver.resolve(ItemRef(workspace.weaver_lakehouse), item_type=LAKEHOUSE)
|
|
284
|
+
|
|
285
|
+
environment_id = kwargs.pop("environment_id", None)
|
|
286
|
+
if environment_id is None:
|
|
287
|
+
if not getattr(workspace, "environment", None):
|
|
288
|
+
raise CommandError(
|
|
289
|
+
"this workspace names no environment; set one and run "
|
|
290
|
+
"`weaver install --workspace <ws> --environment <env>`"
|
|
291
|
+
)
|
|
292
|
+
environment_id = _resolve_environment_id(workspace, resolver)
|
|
293
|
+
|
|
294
|
+
return cls(
|
|
295
|
+
resolver.workspace.id,
|
|
296
|
+
home.id,
|
|
297
|
+
environment_id=environment_id,
|
|
298
|
+
bootstrap=(
|
|
299
|
+
environment_bootstrap() + emit_source()
|
|
300
|
+
if require_weaver
|
|
301
|
+
else emit_source()
|
|
302
|
+
),
|
|
303
|
+
**kwargs,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def __enter__(self) -> "LivySession":
|
|
307
|
+
self.start()
|
|
308
|
+
return self
|
|
309
|
+
|
|
310
|
+
def __exit__(self, *exc) -> bool:
|
|
311
|
+
self.close()
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
def start(self, *, timeout: float = DEFAULT_SESSION_TIMEOUT) -> None:
|
|
315
|
+
payload: dict[str, Any] = {"name": "weaver"}
|
|
316
|
+
if self.environment_id:
|
|
317
|
+
# Fabric attaches an Environment to a Livy session through a Spark
|
|
318
|
+
# conf, not a top-level field — the published libraries (Weaver and
|
|
319
|
+
# its dependencies) are loaded only when this is set.
|
|
320
|
+
payload["conf"] = {
|
|
321
|
+
"spark.fabric.environmentDetails": json.dumps({"id": self.environment_id})
|
|
322
|
+
}
|
|
323
|
+
created = _call("POST", self.base, self.token, payload)
|
|
324
|
+
session_id = created.get("id") or created.get("livyId")
|
|
325
|
+
if session_id is None:
|
|
326
|
+
raise LivyError(f"Livy did not return a session id: {created}")
|
|
327
|
+
self.session_url = f"{self.base}/{session_id}"
|
|
328
|
+
self._await("idle", timeout=timeout)
|
|
329
|
+
if self.bootstrap:
|
|
330
|
+
self.run(self.bootstrap)
|
|
331
|
+
|
|
332
|
+
def _await(self, wanted: str, *, timeout: float) -> dict:
|
|
333
|
+
deadline = time.time() + timeout
|
|
334
|
+
while time.time() < deadline:
|
|
335
|
+
state = _call("GET", self.session_url, self.token, expected=(200,))
|
|
336
|
+
current = (state.get("state") or "").lower()
|
|
337
|
+
if current == wanted:
|
|
338
|
+
return state
|
|
339
|
+
if current in {"error", "dead", "killed", "shutting_down"}:
|
|
340
|
+
raise LivyError(f"Livy session entered state {current!r}")
|
|
341
|
+
time.sleep(self.poll_interval)
|
|
342
|
+
raise LivyError(f"Livy session did not reach {wanted!r} within {int(timeout)}s")
|
|
343
|
+
|
|
344
|
+
def run(self, code: str, *, timeout: float = DEFAULT_STATEMENT_TIMEOUT) -> StatementResult:
|
|
345
|
+
"""Run code in the session and return what it printed.
|
|
346
|
+
|
|
347
|
+
A statement that wants to return something calls :func:`emit`, which
|
|
348
|
+
prints a tagged JSON line — printed output and returned values are then
|
|
349
|
+
distinguishable, and a result survives whatever else was logged.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
if self.session_url is None:
|
|
353
|
+
raise LivyError("the Livy session has not been started")
|
|
354
|
+
|
|
355
|
+
submitted = _call(
|
|
356
|
+
"POST", f"{self.session_url}/statements", self.token,
|
|
357
|
+
{"code": code, "kind": "pyspark"},
|
|
358
|
+
)
|
|
359
|
+
statement_url = f"{self.session_url}/statements/{submitted['id']}"
|
|
360
|
+
|
|
361
|
+
deadline = time.time() + timeout
|
|
362
|
+
while time.time() < deadline:
|
|
363
|
+
statement = _call("GET", statement_url, self.token, expected=(200,))
|
|
364
|
+
if (statement.get("state") or "").lower() in {"available", "error", "cancelled"}:
|
|
365
|
+
return _result(statement)
|
|
366
|
+
time.sleep(self.poll_interval)
|
|
367
|
+
raise LivyError(f"Livy statement did not finish within {int(timeout)}s")
|
|
368
|
+
|
|
369
|
+
def close(self, *, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None:
|
|
370
|
+
"""Ask Fabric to end the session, and wait until it has.
|
|
371
|
+
|
|
372
|
+
The waiting is the point, and it is not tidiness. A capacity has a limit on
|
|
373
|
+
concurrent Spark sessions — often one — and `DELETE` returns as soon as the
|
|
374
|
+
request is accepted, not when the session has released its slot. A caller
|
|
375
|
+
that closed and immediately opened another would be asking for a second
|
|
376
|
+
session while the first still held the only slot: the new one queues, and
|
|
377
|
+
on a long run it eventually never reaches `idle` at all.
|
|
378
|
+
|
|
379
|
+
A close that cannot be confirmed is reported and not raised. The session is
|
|
380
|
+
being abandoned either way, and a teardown problem must not mask whatever
|
|
381
|
+
the caller was actually doing.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
if self.session_url is None:
|
|
385
|
+
return
|
|
386
|
+
url = self.session_url
|
|
387
|
+
try:
|
|
388
|
+
_call("DELETE", url, self.token, expected=(200, 202, 204, 404))
|
|
389
|
+
self._await_release(url, timeout=timeout)
|
|
390
|
+
finally:
|
|
391
|
+
self.session_url = None
|
|
392
|
+
|
|
393
|
+
def _await_release(self, url: str, *, timeout: float) -> None:
|
|
394
|
+
deadline = time.time() + timeout
|
|
395
|
+
while time.time() < deadline:
|
|
396
|
+
try:
|
|
397
|
+
state = _call("GET", url, self.token, expected=(200, 404))
|
|
398
|
+
except LivyError: # gone, or no longer ours to ask about
|
|
399
|
+
return
|
|
400
|
+
if not state: # 404 — the session is no longer there
|
|
401
|
+
return
|
|
402
|
+
if (state.get("state") or "").lower() in {"dead", "killed", "success", "error"}:
|
|
403
|
+
return
|
|
404
|
+
time.sleep(self.poll_interval)
|
|
405
|
+
print(
|
|
406
|
+
f"warning: Livy session {url.rsplit('/', 1)[-1]} did not report itself "
|
|
407
|
+
f"released within {int(timeout)}s; a capacity limited to one session "
|
|
408
|
+
"may refuse the next one"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _result(statement: dict) -> StatementResult:
|
|
413
|
+
output = statement.get("output") or {}
|
|
414
|
+
if output.get("status") and output["status"] != "ok":
|
|
415
|
+
traceback = "\n".join(output.get("traceback") or [])
|
|
416
|
+
raise LivyError(
|
|
417
|
+
f"{output.get('ename')}: {output.get('evalue')}"
|
|
418
|
+
+ (f"\n{traceback}" if traceback else "")
|
|
419
|
+
)
|
|
420
|
+
text = (output.get("data") or {}).get("text/plain", "")
|
|
421
|
+
return StatementResult(text=text, payload=_payload(text))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _payload(text: str) -> Any:
|
|
425
|
+
for line in reversed((text or "").splitlines()):
|
|
426
|
+
if line.startswith(RESULT_PREFIX):
|
|
427
|
+
try:
|
|
428
|
+
return json.loads(line[len(RESULT_PREFIX):])
|
|
429
|
+
except json.JSONDecodeError:
|
|
430
|
+
return None
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def emit_source() -> str:
|
|
435
|
+
"""The helper a submitted program uses to return a value."""
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
"import json as _json\n"
|
|
439
|
+
f"def emit(value):\n"
|
|
440
|
+
f" print({RESULT_PREFIX!r} + _json.dumps(value, default=str))\n"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _resolve_environment_id(workspace, resolver) -> str:
|
|
445
|
+
"""The item id of the workspace's named Environment.
|
|
446
|
+
|
|
447
|
+
Resolved by type, so a same-named Lakehouse or Warehouse cannot be picked up
|
|
448
|
+
by mistake — identity is ``workspace + type + name``.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
from .resources import ENVIRONMENT, find_item
|
|
452
|
+
|
|
453
|
+
item = find_item(
|
|
454
|
+
resolver.workspace,
|
|
455
|
+
workspace.environment,
|
|
456
|
+
item_type=ENVIRONMENT,
|
|
457
|
+
client=resolver.client,
|
|
458
|
+
)
|
|
459
|
+
return item.id
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def environment_bootstrap() -> str:
|
|
463
|
+
"""The bootstrap for a session whose Weaver comes from an Environment.
|
|
464
|
+
|
|
465
|
+
A plain ``import weaver`` — no source copied, no ``sys.path`` change. If the
|
|
466
|
+
attached Environment has no usable Weaver, the error says so and names the
|
|
467
|
+
fix rather than silently falling back to a shipped copy.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
"try:\n"
|
|
472
|
+
" import weaver\n"
|
|
473
|
+
"except ImportError as _exc:\n"
|
|
474
|
+
" raise ImportError(\n"
|
|
475
|
+
" 'the attached Fabric Environment has no usable Weaver install; run '\n"
|
|
476
|
+
" 'weaver install --workspace <ws> --environment-item-name <env>'\n"
|
|
477
|
+
" ) from _exc\n"
|
|
478
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Deploy and execute Fabric notebooks from the optional desktop CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from ..errors import CommandError
|
|
11
|
+
from .client import FabricClient, FabricError
|
|
12
|
+
from .resources import (
|
|
13
|
+
ENVIRONMENT,
|
|
14
|
+
LAKEHOUSE,
|
|
15
|
+
NOTEBOOK,
|
|
16
|
+
ItemNotFoundError,
|
|
17
|
+
find_item,
|
|
18
|
+
find_workspace,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SUPPORTED = {
|
|
22
|
+
".ipynb": ("ipynb", "notebook-content.ipynb"),
|
|
23
|
+
".py": ("fabricGitSource", "notebook-content.py"),
|
|
24
|
+
}
|
|
25
|
+
_TERMINAL = {"completed", "failed", "cancelled", "canceled", "deduped"}
|
|
26
|
+
_SUCCEEDED = {"completed", "deduped"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class NotebookPushResult:
|
|
31
|
+
workspace: str
|
|
32
|
+
notebook: str
|
|
33
|
+
notebook_id: str
|
|
34
|
+
source: str
|
|
35
|
+
action: str
|
|
36
|
+
|
|
37
|
+
def to_mapping(self) -> dict:
|
|
38
|
+
return self.__dict__.copy()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class NotebookRunResult:
|
|
43
|
+
workspace: str
|
|
44
|
+
notebook: str
|
|
45
|
+
notebook_id: str
|
|
46
|
+
job_url: str
|
|
47
|
+
status: str
|
|
48
|
+
exit_value: str | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def succeeded(self) -> bool:
|
|
52
|
+
return self.status.casefold() in _SUCCEEDED
|
|
53
|
+
|
|
54
|
+
def to_mapping(self) -> dict:
|
|
55
|
+
return self.__dict__.copy()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def push_notebook(
|
|
59
|
+
source: str | Path,
|
|
60
|
+
*,
|
|
61
|
+
workspace: str,
|
|
62
|
+
name: str | None = None,
|
|
63
|
+
description: str | None = None,
|
|
64
|
+
client: FabricClient | None = None,
|
|
65
|
+
) -> NotebookPushResult:
|
|
66
|
+
"""Create or update one Notebook definition; Resources are not transported."""
|
|
67
|
+
|
|
68
|
+
source_path = Path(source).expanduser()
|
|
69
|
+
if not source_path.is_file():
|
|
70
|
+
raise CommandError(f"notebook source not found: {source_path}")
|
|
71
|
+
try:
|
|
72
|
+
definition_format, part_path = _SUPPORTED[source_path.suffix.casefold()]
|
|
73
|
+
except KeyError as exc:
|
|
74
|
+
raise CommandError("notebook source must be .py or .ipynb") from exc
|
|
75
|
+
notebook_name = name or source_path.stem
|
|
76
|
+
client = client or FabricClient()
|
|
77
|
+
physical_workspace = find_workspace(workspace, client=client)
|
|
78
|
+
definition = {
|
|
79
|
+
"definition": {
|
|
80
|
+
"format": definition_format,
|
|
81
|
+
"parts": [
|
|
82
|
+
{
|
|
83
|
+
"path": part_path,
|
|
84
|
+
"payload": base64.b64encode(source_path.read_bytes()).decode("ascii"),
|
|
85
|
+
"payloadType": "InlineBase64",
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
notebook = find_item(
|
|
92
|
+
physical_workspace, notebook_name, item_type=NOTEBOOK, client=client
|
|
93
|
+
)
|
|
94
|
+
except ItemNotFoundError:
|
|
95
|
+
payload = {"displayName": notebook_name, **definition}
|
|
96
|
+
if description:
|
|
97
|
+
payload["description"] = description
|
|
98
|
+
response = client.request(
|
|
99
|
+
"POST",
|
|
100
|
+
f"workspaces/{physical_workspace.id}/notebooks",
|
|
101
|
+
payload=payload,
|
|
102
|
+
expected=(200, 201, 202),
|
|
103
|
+
)
|
|
104
|
+
client.wait_for_operation(response)
|
|
105
|
+
notebook = find_item(
|
|
106
|
+
physical_workspace, notebook_name, item_type=NOTEBOOK, client=client
|
|
107
|
+
)
|
|
108
|
+
action = "created"
|
|
109
|
+
else:
|
|
110
|
+
response = client.request(
|
|
111
|
+
"POST",
|
|
112
|
+
f"workspaces/{physical_workspace.id}/notebooks/{notebook.id}/updateDefinition",
|
|
113
|
+
payload=definition,
|
|
114
|
+
expected=(200, 202),
|
|
115
|
+
)
|
|
116
|
+
client.wait_for_operation(response)
|
|
117
|
+
action = "updated"
|
|
118
|
+
return NotebookPushResult(
|
|
119
|
+
workspace=physical_workspace.name,
|
|
120
|
+
notebook=notebook.name,
|
|
121
|
+
notebook_id=notebook.id,
|
|
122
|
+
source=str(source_path),
|
|
123
|
+
action=action,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_notebook(
|
|
128
|
+
name: str,
|
|
129
|
+
*,
|
|
130
|
+
workspace: str,
|
|
131
|
+
lakehouse: str,
|
|
132
|
+
environment: str,
|
|
133
|
+
wait: bool = True,
|
|
134
|
+
timeout: float = 7200.0,
|
|
135
|
+
poll_interval: float = 10.0,
|
|
136
|
+
client: FabricClient | None = None,
|
|
137
|
+
) -> NotebookRunResult:
|
|
138
|
+
"""Run a notebook with an explicit default Lakehouse and Environment."""
|
|
139
|
+
|
|
140
|
+
client = client or FabricClient()
|
|
141
|
+
physical_workspace = find_workspace(workspace, client=client)
|
|
142
|
+
notebook = find_item(physical_workspace, name, item_type=NOTEBOOK, client=client)
|
|
143
|
+
default_lakehouse = find_item(
|
|
144
|
+
physical_workspace, lakehouse, item_type=LAKEHOUSE, client=client
|
|
145
|
+
)
|
|
146
|
+
attached_environment = find_item(
|
|
147
|
+
physical_workspace, environment, item_type=ENVIRONMENT, client=client
|
|
148
|
+
)
|
|
149
|
+
reference = lambda item: {
|
|
150
|
+
"referenceType": "ById",
|
|
151
|
+
"itemId": item.id,
|
|
152
|
+
"workspaceId": physical_workspace.id,
|
|
153
|
+
}
|
|
154
|
+
payload = {
|
|
155
|
+
"executionData": {
|
|
156
|
+
"compute": "Spark",
|
|
157
|
+
"computeConfiguration": {
|
|
158
|
+
"defaultLakehouse": reference(default_lakehouse),
|
|
159
|
+
"attachedEnvironment": reference(attached_environment),
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
response = client.request(
|
|
164
|
+
"POST",
|
|
165
|
+
f"workspaces/{physical_workspace.id}/notebooks/{notebook.id}/jobs/execute/instances?beta=false",
|
|
166
|
+
payload=payload,
|
|
167
|
+
expected=(202,),
|
|
168
|
+
)
|
|
169
|
+
job_url = response.headers.get("Location") or response.headers.get("location")
|
|
170
|
+
if not job_url:
|
|
171
|
+
raise FabricError("notebook job was accepted without a Location header")
|
|
172
|
+
result = NotebookRunResult(
|
|
173
|
+
workspace=physical_workspace.name,
|
|
174
|
+
notebook=notebook.name,
|
|
175
|
+
notebook_id=notebook.id,
|
|
176
|
+
job_url=job_url,
|
|
177
|
+
status="Accepted",
|
|
178
|
+
)
|
|
179
|
+
if not wait:
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
deadline = time.monotonic() + timeout
|
|
183
|
+
while time.monotonic() < deadline:
|
|
184
|
+
time.sleep(max(0.0, poll_interval))
|
|
185
|
+
body = client.get_json(job_url)
|
|
186
|
+
status = str(body.get("status") or body.get("state") or "Unknown")
|
|
187
|
+
if status.casefold() not in _TERMINAL:
|
|
188
|
+
continue
|
|
189
|
+
result = NotebookRunResult(
|
|
190
|
+
workspace=physical_workspace.name,
|
|
191
|
+
notebook=notebook.name,
|
|
192
|
+
notebook_id=notebook.id,
|
|
193
|
+
job_url=job_url,
|
|
194
|
+
status=status,
|
|
195
|
+
exit_value=body.get("exitValue"),
|
|
196
|
+
)
|
|
197
|
+
if not result.succeeded:
|
|
198
|
+
reason = body.get("failureReason") or body.get("error") or "no reason returned"
|
|
199
|
+
raise FabricError(f"notebook {name!r} {status}: {reason}")
|
|
200
|
+
return result
|
|
201
|
+
raise FabricError(f"notebook {name!r} did not finish within {int(timeout)}s")
|