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
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")