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/lakehouse.py ADDED
@@ -0,0 +1,386 @@
1
+ """One resolved Lakehouse — where an authored object's bytes are.
2
+
3
+ An authored object is an ordinary Python object bound to a Spark session, and
4
+ this is the second half of that binding: the *destination*. It exists because a
5
+ Spark session cannot answer "which Lakehouse" on its own. A session builds
6
+ several destinations in one invocation (see
7
+ :class:`~weaver.locations.LakehouseSparkLocation`), so the destination is
8
+ resolved and supplied, never inferred from whatever the session happens to be
9
+ attached to.
10
+
11
+ There is exactly one exception, and it is the notebook case:
12
+
13
+ * a developer working in a Fabric notebook has attached a default Lakehouse, and
14
+ that attachment *is* the answer — :func:`default_lakehouse` reads it;
15
+ * the orchestrator, and anyone addressing more than one Lakehouse, resolves the
16
+ destination by name and passes it in — :func:`lakehouse_for`.
17
+
18
+ Both produce the same value, so authored code cannot tell which path built it.
19
+
20
+ **One root, both areas.** ``spark_root`` is what Spark and Hadoop address — an
21
+ ``abfss://`` URL on Fabric, a directory locally — and *everything* an authored
22
+ object reaches hangs off it: tables under ``Tables/``, folders under ``Files/``.
23
+ It is turned into a :class:`~weaver.locations.LakehouseSparkLocation` and joined
24
+ by that, so there is one piece of path arithmetic rather than a second set of
25
+ string joins that could drift.
26
+
27
+ Deliberately not a mount. ``/lakehouse/default`` addresses whichever Lakehouse a
28
+ notebook attached, and orchestration runs detached against Lakehouses it resolved
29
+ by name — so a folder that could only be reached through a mount could not be
30
+ loaded at all by the thing that loads it. The Hadoop-compatible root reaches
31
+ every resolved Lakehouse, attached or not, which is why it is the only one here.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass
37
+ from typing import Any
38
+
39
+ from .errors import LoadError
40
+ from .locations import LakehouseSparkLocation
41
+ from .resolution import TABLES_AREA
42
+ from .spark.destination import SparkDestination
43
+ from .targets import FILES_AREA, ItemRef
44
+
45
+ #: The Spark-facing root of a Fabric item. The same template as
46
+ #: :func:`weaver.fabric.onelake.abfss_root`, repeated because the core imports
47
+ #: without the optional ``fabric`` extra and a notebook must be able to infer its
48
+ #: own Lakehouse with nothing installed beyond Weaver. ``test_lakehouse`` asserts
49
+ #: the two stay identical.
50
+ _ABFSS_ROOT = "abfss://{workspace}@onelake.dfs.fabric.microsoft.com/{item}"
51
+
52
+ #: Session settings Fabric sets for the attached Lakehouse. Read in order; the
53
+ #: first that answers wins.
54
+ _WORKSPACE_KEYS = ("trident.workspace.id", "trident.artifact.workspace.id")
55
+ _LAKEHOUSE_ID_KEYS = ("trident.lakehouse.id",)
56
+ _LAKEHOUSE_NAME_KEYS = ("trident.lakehouse.name",)
57
+
58
+ #: The same three facts as the notebook runtime reports them, for a host that
59
+ #: carries the context but not the session settings.
60
+ _CONTEXT_WORKSPACE_KEYS = ("defaultLakehouseWorkspaceId", "currentWorkspaceId")
61
+ _CONTEXT_LAKEHOUSE_ID_KEYS = ("defaultLakehouseId",)
62
+ _CONTEXT_LAKEHOUSE_NAME_KEYS = ("defaultLakehouseName",)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Lakehouse:
67
+ """One destination Lakehouse, resolved once, as authored code reaches it.
68
+
69
+ ``destination`` is how a *statement* names this Lakehouse, and it is only
70
+ needed by objects that have no path of their own — a view exists as a
71
+ catalogue name and nothing else. It has no default: a bare ``Schema.Object``
72
+ resolves through whatever the session is attached to, which is the
73
+ ambient-context anti-pattern the rest of Weaver refuses. The one Lakehouse
74
+ that may be named that way is the session's own attachment, and
75
+ :func:`default_lakehouse` says so explicitly.
76
+ """
77
+
78
+ name: str
79
+ spark_root: str
80
+ destination: SparkDestination | None = None
81
+
82
+ def __post_init__(self) -> None:
83
+ object.__setattr__(self, "spark_root", _root(self.spark_root, what="root"))
84
+ if not str(self.name).strip():
85
+ raise LoadError("a Lakehouse must be named")
86
+
87
+ # --- one object's physical location ------------------------------------
88
+
89
+ @property
90
+ def location(self) -> LakehouseSparkLocation:
91
+ """The two areas this Lakehouse presents, joined by one arithmetic."""
92
+
93
+ return _areas(self.name, self.spark_root)
94
+
95
+ def table_path(self, schema: str, name: str) -> str:
96
+ """Where one table's Delta files live.
97
+
98
+ The Spark root, because Spark reads ``abfss://`` natively and a table is
99
+ only ever reached through it.
100
+ """
101
+
102
+ return self.location.table_path(schema, name)
103
+
104
+ def files_root(self) -> str:
105
+ """The ``Files`` area as *Python* can address it, resolved on use.
106
+
107
+ Two roots, because two things read them. Spark takes ``abfss://`` and is
108
+ content; ``open()`` and ``pathlib`` cannot parse a URL at all, and a
109
+ Folder object's authored code is ordinary Python — it writes files. So a
110
+ folder needs the same bytes presented as a filesystem path.
111
+
112
+ Locally the two are the same directory and this returns it unchanged. In
113
+ Fabric the storage is object storage, so Weaver mounts its own root and
114
+ returns the mount path. Nothing is copied: a write through the mount is a
115
+ write to OneLake, visible immediately at the ``abfss://`` address.
116
+
117
+ **The result is session-scoped and must never be stored.** Fabric spells
118
+ it ``/synfs/notebook/<session id>/…`` — valid only inside the session
119
+ that made it, and different in the next one. Durable identity is
120
+ ``spark_root``; this is derived on use and thrown away, which is why it
121
+ is a method rather than a field.
122
+ """
123
+
124
+ return _files_root(self.name, self.spark_root)
125
+
126
+ def folder_path(self, schema: str, name: str) -> str:
127
+ """Where one folder object's files live, as *Spark* addresses them.
128
+
129
+ The Spark root, because this is what one object hands another. A table
130
+ reading a folder's files does it with ``spark.read``, and Spark wants the
131
+ ``abfss://`` form — given a mount path it resolves it against its own
132
+ default filesystem, which is OneLake, and asks for a path that does not
133
+ exist.
134
+ """
135
+
136
+ return self.location.folder_path(schema, name)
137
+
138
+ def folder_local_path(self, schema: str, name: str) -> str:
139
+ """The same folder, as *Python* addresses it.
140
+
141
+ For code that opens files rather than reading them through Spark: a
142
+ Folder's own ``read()`` writing into staging, and the reconciliation that
143
+ publishes what it wrote.
144
+
145
+ Two spellings of one location, because two things read them and neither
146
+ understands the other's. Session-scoped, like :meth:`files_root`.
147
+ """
148
+
149
+ return _join(self.files_root(), schema, name)
150
+
151
+ def qualify(self, schema: str, name: str) -> str:
152
+ """One object, as a statement in this session must name it."""
153
+
154
+ if self.destination is None:
155
+ raise LoadError(
156
+ f"Lakehouse {self.name!r} was resolved without a Spark destination, so "
157
+ "a statement cannot name its objects — resolve it with "
158
+ "weaver.lakehouse_for(resolver, item), which supplies one"
159
+ )
160
+ return self.destination.qualify(schema, name)
161
+
162
+ def __str__(self) -> str:
163
+ return f"{self.name} ({self.spark_root})"
164
+
165
+
166
+ def lakehouse_for(resolver: Any, item: ItemRef | str) -> Lakehouse:
167
+ """Resolve a Lakehouse by name, through a workspace resolver.
168
+
169
+ This is the orchestrator's path, and the one an advanced caller uses to reach
170
+ a Lakehouse that is not the attached default. Name resolution stays here,
171
+ outside the authored object: an object is given a resolved Lakehouse, never a
172
+ name to look up.
173
+ """
174
+
175
+ reference = ItemRef(item) if isinstance(item, str) else item
176
+ return Lakehouse(
177
+ name=reference.name,
178
+ spark_root=resolver.spark_root(reference),
179
+ destination=resolver.spark_destination(reference),
180
+ )
181
+
182
+
183
+ def default_lakehouse(spark: Any) -> Lakehouse:
184
+ """The Lakehouse this Fabric session has attached, or fail saying so.
185
+
186
+ Only ever the *default* attachment. A session with none, or a host that is not
187
+ Fabric, cannot answer — and answering wrongly would write a build into
188
+ whichever Lakehouse happened to be first, so it raises instead.
189
+ """
190
+
191
+ workspace, item, name = _attached_from_settings(spark)
192
+ if not (workspace and item):
193
+ # Field by field, so a host that answers half through the session and half
194
+ # through its runtime context still resolves — and so a blank second source
195
+ # never erases what the first one knew.
196
+ settings = (workspace, item, name)
197
+ context = _attached_from_runtime_context()
198
+ workspace, item, name = tuple(a or b for a, b in zip(settings, context))
199
+
200
+ if not item:
201
+ raise LoadError(
202
+ "no Lakehouse is attached to this Spark session, so there is no "
203
+ "destination to infer — attach a default Lakehouse to the notebook, or "
204
+ "construct the object with lakehouse=<resolved Lakehouse>"
205
+ )
206
+ if not workspace:
207
+ raise LoadError(
208
+ "this session reports an attached Lakehouse but no workspace, so its "
209
+ "storage root cannot be composed — construct the object with "
210
+ "lakehouse=<resolved Lakehouse>"
211
+ )
212
+ return Lakehouse(
213
+ name=name or item,
214
+ # The attachment's storage is reached the same way every other Lakehouse
215
+ # is — by its OneLake root. The ``/lakehouse/default`` mount addresses the
216
+ # same bytes, but only from a session that attached it, so nothing here
217
+ # depends on one.
218
+ spark_root=_ABFSS_ROOT.format(workspace=workspace, item=item),
219
+ # The one place plain two-part naming is correct: this Lakehouse *is* what
220
+ # the session is attached to, so its catalogue is the session's own.
221
+ destination=SparkDestination(item=name or item),
222
+ )
223
+
224
+
225
+ # --- the files root ---------------------------------------------------------
226
+
227
+ #: Mount points already established in this session, by ``abfss://`` root. A
228
+ #: session is one process, so this is process state: a second load of the same
229
+ #: Lakehouse reuses the mount rather than asking Fabric to make another, which
230
+ #: it refuses.
231
+ _MOUNTS: dict[str, str] = {}
232
+
233
+ #: Where Weaver mounts a Lakehouse. Keyed by item id rather than fixed, because
234
+ #: an estate spans several Lakehouses and one session may load from more than
235
+ #: one — a single fixed point would let the second quietly address the first.
236
+ _MOUNT_POINT = "/weaver/{item}"
237
+
238
+
239
+ def _files_root(name: str, spark_root: str) -> str:
240
+ """``Files`` as a path ``open()`` understands, for whichever host this is."""
241
+
242
+ if not spark_root.startswith("abfss://"):
243
+ # The emulator: storage already *is* a filesystem, so the two roots are
244
+ # the same directory and there is nothing to mount.
245
+ return _join(spark_root, FILES_AREA)
246
+ return _join(_mounted(name, spark_root), FILES_AREA)
247
+
248
+
249
+ def _mounted(name: str, spark_root: str) -> str:
250
+ """Mount this Lakehouse's OneLake root, or reuse the mount already made.
251
+
252
+ A mount turns the remote root into a local address; writes through it go
253
+ straight to OneLake, so nothing is copied and nothing needs flushing. It is
254
+ scoped to the job, which is why it is resolved here on use rather than
255
+ carried in the :class:`Lakehouse`.
256
+
257
+ Weaver mounts a root it resolved by name, so this works detached — it is not
258
+ the ``/lakehouse/default`` attachment, which only ever addresses whatever a
259
+ notebook happened to attach.
260
+ """
261
+
262
+ cached = _MOUNTS.get(spark_root)
263
+ if cached:
264
+ return cached
265
+
266
+ utils = _notebook_utils()
267
+ if utils is None:
268
+ raise LoadError(
269
+ f"Lakehouse {name!r} is in OneLake, and reaching its Files area as a "
270
+ "filesystem needs the Fabric notebook utilities, which are not "
271
+ "available here. A Folder's authored code writes ordinary files, so "
272
+ "there is no way to address them from outside a Fabric session."
273
+ )
274
+
275
+ point = _MOUNT_POINT.format(item=_item_of(spark_root))
276
+ try:
277
+ utils.fs.mount(spark_root, point)
278
+ except Exception:
279
+ # Already mounted, by us in a path that did not reach the cache or by the
280
+ # host itself. Mounting twice is an error, so the useful move is to ask
281
+ # where it landed and carry on.
282
+ pass
283
+ try:
284
+ local = utils.fs.getMountPath(point)
285
+ except Exception as exc:
286
+ raise LoadError(
287
+ f"Lakehouse {name!r} could not be mounted at {point!r}: {exc}"
288
+ ) from exc
289
+ if not local:
290
+ raise LoadError(f"Lakehouse {name!r} mounted at {point!r} reports no path")
291
+ _MOUNTS[spark_root] = local
292
+ return local
293
+
294
+
295
+ def _notebook_utils() -> Any:
296
+ for module_name in ("notebookutils", "mssparkutils"):
297
+ try:
298
+ return __import__(module_name)
299
+ except Exception:
300
+ continue
301
+ return None
302
+
303
+
304
+ def _item_of(spark_root: str) -> str:
305
+ """The item id in an ``abfss://ws@host/item`` root — the mount's name."""
306
+
307
+ return spark_root.rstrip("/").rsplit("/", 1)[-1]
308
+
309
+
310
+ def _join(root: str, *parts: str) -> str:
311
+ joined = root.rstrip("/")
312
+ for part in parts:
313
+ joined = f"{joined}/{str(part).strip('/')}"
314
+ return joined
315
+
316
+
317
+ # --- reading the host -------------------------------------------------------
318
+
319
+
320
+ def _attached_from_settings(spark: Any) -> tuple[str, str, str]:
321
+ """What the Spark session's own settings say. Silent when it says nothing."""
322
+
323
+ def setting(key: str) -> str:
324
+ try:
325
+ return _text(spark.conf.get(key, None))
326
+ except Exception: # pragma: no cover - a host that raises for unset keys
327
+ return ""
328
+
329
+ return (
330
+ _first(setting, _WORKSPACE_KEYS),
331
+ _first(setting, _LAKEHOUSE_ID_KEYS),
332
+ _first(setting, _LAKEHOUSE_NAME_KEYS),
333
+ )
334
+
335
+
336
+ def _attached_from_runtime_context() -> tuple[str, str, str]:
337
+ """What the notebook runtime says. Silent when it is not present."""
338
+
339
+ context: Any = None
340
+ for module_name in ("notebookutils", "mssparkutils"):
341
+ try:
342
+ module = __import__(module_name)
343
+ except Exception:
344
+ continue
345
+ context = getattr(getattr(module, "runtime", None), "context", None)
346
+ if context:
347
+ break
348
+ if not isinstance(context, dict):
349
+ return "", "", ""
350
+
351
+ def entry(key: str) -> str:
352
+ return _text(context.get(key))
353
+
354
+ return (
355
+ _first(entry, _CONTEXT_WORKSPACE_KEYS),
356
+ _first(entry, _CONTEXT_LAKEHOUSE_ID_KEYS),
357
+ _first(entry, _CONTEXT_LAKEHOUSE_NAME_KEYS),
358
+ )
359
+
360
+
361
+ def _first(read, keys: tuple[str, ...]) -> str:
362
+ for key in keys:
363
+ value = read(key)
364
+ if value:
365
+ return value
366
+ return ""
367
+
368
+
369
+ def _text(value: Any) -> str:
370
+ return value.strip() if isinstance(value, str) else ""
371
+
372
+
373
+ def _root(value: Any, *, what: str) -> str:
374
+ if not isinstance(value, str) or not value.strip():
375
+ raise LoadError(f"a Lakehouse {what} must be a non-empty string, got {value!r}")
376
+ return value.strip().replace("\\", "/").rstrip("/")
377
+
378
+
379
+ def _areas(name: str, root: str) -> LakehouseSparkLocation:
380
+ """One root, split into the two areas a Lakehouse presents."""
381
+
382
+ return LakehouseSparkLocation(
383
+ item=name,
384
+ tables_root=f"{root}/{TABLES_AREA}",
385
+ files_root=f"{root}/{FILES_AREA}",
386
+ )