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/objects.py ADDED
@@ -0,0 +1,392 @@
1
+ """The authoring surface — what a developer writes.
2
+
3
+ An object is a class in a file named for its ID::
4
+
5
+ # Sales__Order.py
6
+ \"\"\"
7
+ Table ID: Sales.Order
8
+
9
+ Description: One row per confirmed customer order.
10
+
11
+ Lineage: The sales system order export.
12
+
13
+ Primary key: Order id
14
+
15
+ Schema:
16
+ Order id: string
17
+ Amount: decimal(18,2)
18
+ \"\"\"
19
+
20
+ from Sales__Customer import Sales__Customer
21
+
22
+ from weaver import Table
23
+
24
+
25
+ class Sales__Order(Table):
26
+ def read(self):
27
+ customers = Sales__Customer(self).dataframe()
28
+ existing = self.dataframe()
29
+ ...
30
+ return upserts, deletes
31
+
32
+ **An object is an ordinary Python object bound to a Spark session.** The session
33
+ is the one mandatory argument, because authored code executes through it::
34
+
35
+ Sales__Order(spark).read()
36
+
37
+ That is the whole runtime model. There is no hidden active object, no ambient
38
+ resolver, and no context injected around a call — an object holds a session, a
39
+ destination Lakehouse and its own identity, and every physical access is an
40
+ ordinary instance method on it.
41
+
42
+ **The destination is a resolved Lakehouse, never a name.** In a notebook it is
43
+ the one attached to the session, and Weaver reads it
44
+ (:func:`weaver.lakehouse.default_lakehouse`); with no unique attachment,
45
+ construction fails rather than guessing which Lakehouse a load should land in. An
46
+ orchestrator, or anyone addressing more than one Lakehouse, resolves it *outside*
47
+ the object and passes it in::
48
+
49
+ Sales__Order(spark, lakehouse=resolved_lakehouse)
50
+
51
+ A string is refused: looking a name up needs a workspace resolver, and that is
52
+ not a decision authored code makes.
53
+
54
+ **Dependencies are imports, and a dependency is constructed from its dependent**::
55
+
56
+ Another__Table(self).dataframe()
57
+ My__Folder(self).path()
58
+
59
+ Importing another object's module declares the dependency; Weaver reads that from
60
+ the source without executing it. Passing ``self`` hands over the session and the
61
+ resolved Lakehouse together, so a dependency always resolves against the same
62
+ target environment as the object reading it. A Python import only ever names an
63
+ object in the *same* item, which is why nothing more is needed here — a
64
+ cross-item dependency is an alias, and aliases are resolved during build.
65
+
66
+ **Identity comes from the class name.** ``Sales__Order`` is ``Sales.Order``: the
67
+ same rule the repository parser applies to the filename, and the parser has
68
+ already refused any file where the two disagree. Nothing is re-parsed, and
69
+ nothing is looked up in the catalogue, to know what an object is.
70
+
71
+ **Objects never mutate the target.** ``read()`` proposes; Weaver owns writing,
72
+ CRUD accounting, staging and logging. A Folder writes into its staging directory
73
+ and returns it; a Table returns rows.
74
+
75
+ Nothing here imports PySpark. The session is used through its ordinary API, so
76
+ this module stays importable anywhere.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ from typing import Any
82
+
83
+ from .errors import LoadError
84
+ from .lakehouse import Lakehouse, default_lakehouse
85
+
86
+ #: What separates schema from object in a class name. A module name cannot carry
87
+ #: a dot, so a Python object spells ``Sales.Order`` as ``Sales__Order`` — the rule
88
+ #: :func:`weaver.declaration.source.object_id_for_filename` applies to the
89
+ #: filename, repeated here because the authoring surface must not import the
90
+ #: parser (the parser imports it, for the base classes).
91
+ CLASS_ID_SEPARATOR = "__"
92
+
93
+
94
+ class WeaverObject:
95
+ """Base for every authored object.
96
+
97
+ ``spark`` is the session authored code runs through, and it is mandatory.
98
+ Another Weaver object may be passed in its place — ``Another__Table(self)`` —
99
+ inheriting that object's session and Lakehouse, which is how one object
100
+ reaches another.
101
+ """
102
+
103
+ def __init__(self, spark: Any, *, lakehouse: Lakehouse | None = None) -> None:
104
+ if isinstance(spark, WeaverObject):
105
+ owner = spark
106
+ spark = owner.spark
107
+ if lakehouse is None:
108
+ lakehouse = owner.lakehouse
109
+ if spark is None:
110
+ raise LoadError(
111
+ f"{type(self).__name__} needs the Spark session it runs through — "
112
+ f"construct it as {type(self).__name__}(spark), or as "
113
+ f"{type(self).__name__}(self) from another object"
114
+ )
115
+ if isinstance(lakehouse, str):
116
+ raise LoadError(
117
+ f"{type(self).__name__} takes a resolved Lakehouse, not the name "
118
+ f"{lakehouse!r} — resolve it first with "
119
+ f"weaver.lakehouse_for(resolver, {lakehouse!r})"
120
+ )
121
+ if lakehouse is not None and not isinstance(lakehouse, Lakehouse):
122
+ raise LoadError(
123
+ f"{type(self).__name__} takes a resolved Lakehouse, got "
124
+ f"{type(lakehouse).__name__}"
125
+ )
126
+
127
+ #: The session this object reads and writes through.
128
+ self.spark = spark
129
+ #: The destination this object materialises into, resolved once.
130
+ self.lakehouse: Lakehouse = (
131
+ lakehouse if lakehouse is not None else default_lakehouse(spark)
132
+ )
133
+ #: The destination's root — what Spark and Hadoop address. Tables and
134
+ #: folders both hang off it, so nothing an object reaches needs a mount.
135
+ self.spark_root = self.lakehouse.spark_root
136
+
137
+ # --- identity ---------------------------------------------------------
138
+
139
+ @property
140
+ def identity(self) -> tuple[str, str]:
141
+ """The schema and object name this class declares."""
142
+
143
+ return _identity(type(self).__name__)
144
+
145
+ @property
146
+ def object_id(self) -> str:
147
+ """This object's ``Schema.Object`` ID, from its class name."""
148
+
149
+ return "{}.{}".format(*self.identity)
150
+
151
+ def read(self):
152
+ raise NotImplementedError(f"{type(self).__name__} must implement read()")
153
+
154
+ # --- the load contract, read from this module's own docstring ----------
155
+
156
+ def _document(self):
157
+ """This object's parsed declaration, from the module it was defined in.
158
+
159
+ The module *is* the contract. A deployed object in a session has no
160
+ repository to reopen and no catalogue to query, so if its docstring were
161
+ not sufficient then ``load()`` would be the tail end of an orchestration
162
+ rather than something runnable on its own.
163
+
164
+ It is read on every call rather than cached, which is what makes an edit
165
+ visible on the next reload — the notebook loop this is meant to support.
166
+ """
167
+
168
+ import sys
169
+
170
+ from .runtime.load_contract import document_for_module
171
+
172
+ module = sys.modules.get(type(self).__module__)
173
+ if module is None: # pragma: no cover - a class with no importable module
174
+ raise LoadError(
175
+ f"{type(self).__name__} was defined outside an importable module, "
176
+ "so its Weaver metadata cannot be read"
177
+ )
178
+ return document_for_module(module)
179
+
180
+ def __repr__(self) -> str:
181
+ return f"<{type(self).__name__} {self.object_id} in {self.lakehouse.name}>"
182
+
183
+
184
+ class Folder(WeaverObject):
185
+ """Files materialised into a Lakehouse Files directory.
186
+
187
+ ``read()`` writes into this object's staging directory and returns
188
+ ``(staging_folder, files_to_delete)``.
189
+ """
190
+
191
+ def path(self) -> str:
192
+ """This folder's materialised location, as Spark addresses it.
193
+
194
+ What one object hands another: a table reading these files does it with
195
+ ``spark.read``, which wants the ``abfss://`` form. Addressed by the root
196
+ the Lakehouse was resolved to, never by ``/lakehouse/default`` — that
197
+ names only whatever a notebook attached, and a load runs detached against
198
+ Lakehouses it resolved by name.
199
+ """
200
+
201
+ return self.lakehouse.folder_path(*self.identity)
202
+
203
+ def local_path(self) -> str:
204
+ """This folder's location for code that opens files rather than reading
205
+ them through Spark.
206
+
207
+ The same bytes as :meth:`path`, spelled as a filesystem path. In OneLake
208
+ that is a mount Weaver makes of the resolved root; locally the two are
209
+ the same directory.
210
+ """
211
+
212
+ return self.lakehouse.folder_local_path(*self.identity)
213
+
214
+ def staging_folder(self) -> str:
215
+ """The object-local staging directory to write into.
216
+
217
+ The destination's own path with ``_Staging`` appended — the same sibling
218
+ :meth:`weaver.resolution.LocalResolver.folder_staging` issues. There is no
219
+ shared staging area and no run identifier: staging belongs to the object,
220
+ so a failed load leaves exactly one directory to look at.
221
+ """
222
+
223
+ return f"{self.local_path()}_Staging"
224
+
225
+ def load(self, fault_tolerant: bool = False) -> "LoadResult":
226
+ """Run this folder's ``read()`` and publish what it staged.
227
+
228
+ Independently runnable, which is the point::
229
+
230
+ Sales__Export(spark).load(fault_tolerant=False)
231
+
232
+ No repository, no catalogue, no bundle and no orchestrator — the module
233
+ carries its own contract and this object carries its own destination.
234
+ """
235
+
236
+ from .runtime.folder_load import load_folder, new_staging_folder
237
+ from .runtime.load_contract import FolderLoadContract
238
+
239
+ contract = FolderLoadContract.from_document(self._document())
240
+ # Weaver issues staging, before read() rather than after the load. A run
241
+ # must begin from nothing it did not itself produce, or the previous
242
+ # run's files are published again and a replacement concludes that
243
+ # nothing was retired. Clearing afterwards instead would destroy the one
244
+ # directory worth looking at when a load fails.
245
+ issued = new_staging_folder(self.local_path(), self.staging_folder())
246
+ staged, deletes = _load_pair(self, self.read())
247
+ if str(staged) != issued:
248
+ raise LoadError(
249
+ f"{type(self).__name__}.read() returned {staged!r}, which is not "
250
+ f"the staging folder Weaver issued ({issued!r}) — return "
251
+ "self.staging_folder()"
252
+ )
253
+ return load_folder(
254
+ contract=contract,
255
+ destination=self.local_path(),
256
+ staging=issued,
257
+ deletes=deletes,
258
+ fault_tolerant=fault_tolerant,
259
+ )
260
+
261
+
262
+ class Table(WeaverObject):
263
+ """Rows materialised into a Delta table or a Warehouse table.
264
+
265
+ ``read()`` returns ``(upserts, deletes)``.
266
+ """
267
+
268
+ def dataframe(self) -> Any:
269
+ """This table as it currently stands, read from its Delta files.
270
+
271
+ Addressed by path rather than by catalogue name, which is how Weaver
272
+ reaches Delta everywhere else: a path needs nothing attached, so the same
273
+ call serves any resolved Lakehouse.
274
+ """
275
+
276
+ return self.spark.read.format("delta").load(
277
+ self.lakehouse.table_path(*self.identity)
278
+ )
279
+
280
+ def empty_dataframe(self) -> Any:
281
+ """This table's shape with no rows — an incremental load's no-op result.
282
+
283
+ Taken from the table itself, so the columns are exactly the ones the load
284
+ has to match. That means the physical table must already exist, which is no
285
+ constraint at all: a load returning *this* table's empty shape is by
286
+ definition running against a target that has been built.
287
+ """
288
+
289
+ return self.dataframe().limit(0)
290
+
291
+ def load(
292
+ self,
293
+ fault_tolerant: bool = False,
294
+ ignore_stability_threshold: bool = False,
295
+ ) -> "LoadResult":
296
+ """Run this table's ``read()`` and write what it staged.
297
+
298
+ Independently runnable, which is the point::
299
+
300
+ Sales__Customer(spark).load(fault_tolerant=True)
301
+
302
+ No repository, no catalogue, no bundle and no orchestrator — the module
303
+ carries its own contract and this object carries its own destination.
304
+
305
+ ``ignore_stability_threshold`` waives the declared delete and update
306
+ limits for one run. It exists for the case where a very large change is
307
+ the correct answer — a genuine bulk retirement — and is a deliberate act
308
+ each time rather than a setting that stays on.
309
+ """
310
+
311
+ from .runtime.load_contract import LoadContract
312
+ from .runtime.table_load import load_table
313
+
314
+ contract = LoadContract.from_document(self._document())
315
+ # The first value is *staging* — unvalidated, unreconciled, nothing yet
316
+ # classified as new or changed. Naming it so is the point.
317
+ staged, deletes = _load_pair(self, self.read())
318
+ return load_table(
319
+ self.spark,
320
+ contract=contract,
321
+ lakehouse=self.lakehouse,
322
+ staging_frame=staged,
323
+ deletes=deletes,
324
+ fault_tolerant=fault_tolerant,
325
+ ignore_stability_threshold=ignore_stability_threshold,
326
+ )
327
+
328
+
329
+ class View(WeaverObject):
330
+ """A view over other objects, declared in SQL.
331
+
332
+ A view has no ``read()``: its definition is its query.
333
+ """
334
+
335
+ def dataframe(self) -> Any:
336
+ """This view's contents.
337
+
338
+ By name, not by path: a view exists only in the catalogue, so unlike a
339
+ table there is nothing on disk to address.
340
+ """
341
+
342
+ return self.spark.table(self.lakehouse.qualify(*self.identity))
343
+
344
+
345
+ def _load_pair(obj, returned):
346
+ """Unpack what ``read()`` returned, refusing anything else by name.
347
+
348
+ Both kinds of object return a pair, and the error has to name the object
349
+ rather than surface as a tuple-unpacking failure three frames deeper — an
350
+ author who returned a single frame should be told that, not shown a
351
+ ValueError about lengths.
352
+ """
353
+
354
+ if not isinstance(returned, tuple) or len(returned) != 2:
355
+ raise LoadError(
356
+ f"{type(obj).__name__}.read() must return a pair — "
357
+ f"(staging, deletes) for a Table, (staging_folder, files_to_delete) "
358
+ f"for a Folder — and returned {type(returned).__name__}"
359
+ )
360
+ return returned
361
+
362
+
363
+ def _identity(class_name: str) -> tuple[str, str]:
364
+ """``Sales__Order`` → ``("Sales", "Order")``; ``___Load`` → ``("_", "Load")``.
365
+
366
+ A run of leading underscores is read as a schema plus the separator, because
367
+ ``_`` is a real schema and spelling ``_.Load`` as a class name produces three
368
+ of them. The rule is the parser's — see
369
+ :func:`weaver.declaration.source.python_id_parts` — repeated rather than
370
+ imported, for the same reason the separator itself is.
371
+ """
372
+
373
+ leading = len(class_name) - len(class_name.lstrip("_"))
374
+ if leading >= len(CLASS_ID_SEPARATOR) + 1:
375
+ split = [
376
+ class_name[: leading - len(CLASS_ID_SEPARATOR)],
377
+ class_name[leading:],
378
+ ]
379
+ else:
380
+ split = class_name.split(CLASS_ID_SEPARATOR)
381
+ parts = [part.strip() for part in split]
382
+ if len(parts) != 2 or not all(parts):
383
+ raise LoadError(
384
+ f"{class_name!r} does not name an object: a Weaver class separates "
385
+ f"schema and object with {CLASS_ID_SEPARATOR!r}, as in Sales__Order"
386
+ )
387
+ return parts[0], parts[1]
388
+
389
+
390
+ #: The authoring base classes, by the metadata kind that selects them.
391
+ BASE_CLASSES = {"Folder": Folder, "Table": Table, "View": View}
392
+ BASE_CLASS_NAMES = frozenset(cls.__name__ for cls in BASE_CLASSES.values())