weaverstack 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,384 @@
1
+ """Installing a bundle — validated execution only, never planning.
2
+
3
+ The installer loads and fully validates a bundle, resolves its targets through
4
+ the supplied environment, and runs the sequences as barriers: each completes
5
+ before the next starts, one action's failure fails its sequence, and no later
6
+ sequence begins. It records exactly one result per action and persists the
7
+ report. It never reads the source repository, resolves a dependency or selects a
8
+ target — every such decision is already in the bundle.
9
+
10
+ Build is not load: the installer runs generated create DDL, creates folder
11
+ directories, deploys an item's runtime code, and reconciles the target — it never
12
+ executes an object's code, and it has no route back to the source repository at
13
+ all, because a bundle carries its outputs rather than a second copy of its
14
+ inputs. Concurrency starts conservatively:
15
+ sequences are serial and actions run serially within a batch, because one shared
16
+ local Spark session gives no useful parallel DDL. The manifest still models
17
+ independent actions, so a Fabric installer can add session concurrency later
18
+ without changing bundle semantics.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from typing import Any, Mapping
26
+
27
+ from ..errors import InstallError
28
+ from ..locations import Location
29
+ from ..store import Store
30
+ from ..targets import ItemRef
31
+ from .bundle import BuildBundle, load_bundle, validate_bundle
32
+ from .executors import default_executors
33
+ from .executors.base import (
34
+ ActionExecutor,
35
+ InstallationContext,
36
+ ResolvedTarget,
37
+ SkippedExecution,
38
+ )
39
+ from .models import BuildAction, BuildBatch, BuildPlan, BuildSequence
40
+ from .report import (
41
+ FAILED,
42
+ SKIPPED,
43
+ SUCCEEDED,
44
+ ActionResult,
45
+ InstallationReport,
46
+ SequenceResult,
47
+ )
48
+ from .targets import WAREHOUSE_TARGET, BoundTarget
49
+
50
+ REPORT_FILENAME = "install-report.yml"
51
+
52
+
53
+ @dataclass
54
+ class InstallationEnvironment:
55
+ """Runtime services the installer executes against — no planning inputs.
56
+
57
+ ``spark`` is optional so a Folder-only bundle needs no session; a bundle
58
+ with Spark work supplies one. ``sql`` is likewise optional: a Warehouse
59
+ install acquires it **Fabric-natively** from the session identity, and only a
60
+ desktop caller crossing into Fabric injects ``desktop_sql_executor``
61
+ explicitly (``workspace`` is then unnecessary). ``executors`` defaults to the
62
+ built-in registry.
63
+ """
64
+
65
+ store: Store
66
+ resolver: Any
67
+ spark: Any = None
68
+ sql: Any = None
69
+ workspace: Any = None
70
+ executors: dict[str, ActionExecutor] = field(default_factory=default_executors)
71
+ #: Set when this environment opened its own Fabric-native SQL, so it closes it.
72
+ _owned_sql: Any = field(default=None, init=False, repr=False)
73
+
74
+ def resolve_target(self, bound: BoundTarget) -> ResolvedTarget:
75
+ # The resolver, store and Spark already define the environment the
76
+ # installer is running in, so a target is its item plus that item's
77
+ # physical roots. Resolving here, once, is what stops an executor deriving
78
+ # a path for itself — and what would let one installation address several
79
+ # destination Lakehouses without ever changing the session's own.
80
+ item = ItemRef(bound.item_id)
81
+ return ResolvedTarget(
82
+ bound=bound,
83
+ lakehouse=item,
84
+ location=self._resolved(bound, item, "lakehouse_spark_location"),
85
+ destination=self._resolved(bound, item, "spark_destination"),
86
+ )
87
+
88
+ def _resolved(self, bound: BoundTarget, item: ItemRef, method: str):
89
+ """One of the destination's two addresses, where the workspace can give it.
90
+
91
+ A Warehouse has neither — it is reached over TDS — and a resolver may not
92
+ implement the method at all. Neither is a failure here, because the
93
+ actions that need an address are Lakehouse actions and each fails
94
+ explicitly, naming the target, when it is missing.
95
+ """
96
+
97
+ if bound.kind == WAREHOUSE_TARGET:
98
+ return None
99
+ resolve = getattr(self.resolver, method, None)
100
+ if resolve is None:
101
+ return None
102
+ return resolve(item)
103
+
104
+ def sql_for(self, bound: BoundTarget) -> Any:
105
+ """The SQL capability for a Warehouse batch — injected, or Fabric-native.
106
+
107
+ Weaver runs in Fabric, so an install against a Warehouse authenticates
108
+ through the session's own identity rather than a desktop connection. The
109
+ executor is opened once per installation and closed with it.
110
+ """
111
+
112
+ if self.sql is not None:
113
+ return self.sql
114
+ if bound.kind != WAREHOUSE_TARGET:
115
+ return None
116
+ if self._owned_sql is None:
117
+ from ..fabric.sql import fabric_sql_executor
118
+ from ..targets import WarehouseTarget
119
+
120
+ self._owned_sql = fabric_sql_executor(
121
+ WarehouseTarget(warehouse=ItemRef(bound.item_id)), self.workspace
122
+ )
123
+ return self._owned_sql
124
+
125
+ def close(self) -> None:
126
+ if self._owned_sql is not None and hasattr(self._owned_sql, "close"):
127
+ self._owned_sql.close()
128
+ self._owned_sql = None
129
+
130
+
131
+ def _now() -> datetime:
132
+ return datetime.now(timezone.utc)
133
+
134
+
135
+ def _epoch(started: datetime) -> str:
136
+ """This installation's instant, as a Spark timestamp literal.
137
+
138
+ Naive rather than offset-carrying: the column is a plain ``timestamp`` and a
139
+ trailing offset would be parsed against the session's zone, which differs
140
+ between a desktop and a Fabric driver. UTC throughout, spelled without one.
141
+ """
142
+
143
+ return started.strftime("%Y-%m-%d %H:%M:%S.%f")
144
+
145
+
146
+ def install_bundle(
147
+ bundle: BuildBundle | Location,
148
+ *,
149
+ environment: InstallationEnvironment,
150
+ ) -> InstallationReport:
151
+ """Validate and run a bundle, returning a complete report."""
152
+
153
+ if isinstance(bundle, Location):
154
+ bundle = load_bundle(bundle, store=environment.store)
155
+ else:
156
+ # Preflight even a pre-loaded bundle: the installer trusts nothing it has
157
+ # not just checked.
158
+ validate_bundle(
159
+ bundle.location,
160
+ bundle.plan,
161
+ store=bundle.store or environment.store,
162
+ )
163
+
164
+ plan = bundle.plan
165
+ resolved = {target.id: environment.resolve_target(target) for target in plan.targets}
166
+
167
+ started = _now()
168
+ # One instant for the whole installation, taken once and handed to every
169
+ # batch. Registry rows are published across several statements — one pair per
170
+ # item — and rows written by one build have to be indistinguishable in age,
171
+ # or an alias and the source it points at could order against each other
172
+ # merely for having been written a few milliseconds apart.
173
+ epoch = _epoch(started)
174
+ sequence_results: list[SequenceResult] = []
175
+ stop = False
176
+
177
+ try:
178
+ for sequence in plan.sequences:
179
+ if stop:
180
+ sequence_results.append(_skipped_sequence(sequence))
181
+ continue
182
+ result = _run_sequence(sequence, resolved, bundle, environment, epoch=epoch)
183
+ sequence_results.append(result)
184
+ if result.status == FAILED:
185
+ stop = True
186
+ finally:
187
+ # Release any SQL connection this installation opened for itself.
188
+ environment.close()
189
+
190
+ finished = _now()
191
+ report = InstallationReport(
192
+ bundle_id=plan.bundle_id,
193
+ status=FAILED if stop else SUCCEEDED,
194
+ started_at=started,
195
+ finished_at=finished,
196
+ sequences=tuple(sequence_results),
197
+ )
198
+ (bundle.store or environment.store).write(
199
+ bundle.location.join(REPORT_FILENAME), report.to_yaml().encode("utf-8")
200
+ )
201
+ return report
202
+
203
+
204
+ def _run_sequence(
205
+ sequence: BuildSequence,
206
+ resolved: dict[str, ResolvedTarget],
207
+ bundle: BuildBundle,
208
+ environment: InstallationEnvironment,
209
+ *,
210
+ epoch: str | None = None,
211
+ ) -> SequenceResult:
212
+ action_results: list[ActionResult] = []
213
+ failed = False
214
+
215
+ for batch in sequence.batches:
216
+ target = resolved[batch.target_id]
217
+ context = InstallationContext(
218
+ spark=environment.spark,
219
+ resolver=environment.resolver,
220
+ store=environment.store,
221
+ target=target,
222
+ sql=environment.sql_for(target.bound),
223
+ targets=resolved,
224
+ epoch=epoch,
225
+ )
226
+ for action in batch.actions:
227
+ if failed:
228
+ action_results.append(_skipped_action(action, batch))
229
+ continue
230
+ result = _run_action(action, batch, context, bundle, environment)
231
+ action_results.append(result)
232
+ if result.status == FAILED:
233
+ failed = True
234
+
235
+ skipped = bool(action_results) and all(
236
+ result.status == SKIPPED for result in action_results
237
+ )
238
+ return SequenceResult(
239
+ number=sequence.number,
240
+ description=sequence.description,
241
+ status=FAILED if failed else SKIPPED if skipped else SUCCEEDED,
242
+ actions=tuple(action_results),
243
+ )
244
+
245
+
246
+ def execute_action(
247
+ action: BuildAction,
248
+ payload: bytes | None = None,
249
+ *,
250
+ context: InstallationContext,
251
+ executors: Mapping[str, ActionExecutor] | None = None,
252
+ ) -> ActionResult:
253
+ """Run one action against one target, with the installer's result semantics.
254
+
255
+ The same execution the installer performs, minus everything that is about a
256
+ *bundle*: no loading, no validation, no sequence barriers, no target
257
+ resolution, no report. One action, one payload, one context, one result.
258
+
259
+ It exists so the platform boundary can be tested where it actually is. A test
260
+ asking whether Fabric accepts Weaver's generated T-SQL, or whether a created
261
+ Warehouse table shows up in inventory, needs the statement *executed* — not a
262
+ repository parsed, a catalogue read, a bundle planned and an installation
263
+ reported. Those are separately proven, and running them again to reach the
264
+ one question costs a full build.
265
+
266
+ A failing action is data here exactly as it is in an installation: the error
267
+ is recorded on the result rather than raised, so the caller asserts on a
268
+ result in both places and the semantics cannot drift apart.
269
+ """
270
+
271
+ return _execute(
272
+ action,
273
+ lambda: payload,
274
+ context=context,
275
+ target_id=context.target.bound.id,
276
+ executors=default_executors() if executors is None else executors,
277
+ )
278
+
279
+
280
+ def _run_action(
281
+ action: BuildAction,
282
+ batch: BuildBatch,
283
+ context: InstallationContext,
284
+ bundle: BuildBundle,
285
+ environment: InstallationEnvironment,
286
+ ) -> ActionResult:
287
+ def load_payload() -> bytes | None:
288
+ if action.payload is None:
289
+ return None
290
+ return (bundle.store or environment.store).read(
291
+ bundle.location.join(*action.payload.split("/"))
292
+ )
293
+
294
+ return _execute(
295
+ action,
296
+ load_payload,
297
+ context=context,
298
+ target_id=batch.target_id,
299
+ executors=environment.executors,
300
+ )
301
+
302
+
303
+ def _execute(
304
+ action: BuildAction,
305
+ load_payload,
306
+ *,
307
+ context: InstallationContext,
308
+ target_id: str,
309
+ executors: Mapping[str, ActionExecutor],
310
+ ) -> ActionResult:
311
+ """The one place an action is run, shared by the installer and by callers.
312
+
313
+ ``load_payload`` is deferred rather than passed as bytes because a payload
314
+ that cannot be read is an action failure like any other, recorded with the
315
+ same timing and the same result shape as one whose executor raised.
316
+ """
317
+
318
+ started = _now()
319
+ executor = executors.get(action.executor)
320
+ if executor is None:
321
+ return _failed(
322
+ action, target_id, started, InstallError(f"no executor named {action.executor!r}")
323
+ )
324
+
325
+ try:
326
+ execution = executor.execute(action, load_payload(), context)
327
+ except Exception as exc: # a failing action is data, not a crash
328
+ return _failed(action, target_id, started, exc)
329
+
330
+ finished = _now()
331
+ skipped = isinstance(execution, SkippedExecution)
332
+ return ActionResult(
333
+ action_id=action.id,
334
+ resource_node_id=action.resource_node_id,
335
+ target_id=target_id,
336
+ executor=action.executor,
337
+ status=SKIPPED if skipped else SUCCEEDED,
338
+ started_at=started,
339
+ finished_at=finished,
340
+ duration_seconds=(finished - started).total_seconds(),
341
+ details=execution.details if skipped else (execution or None),
342
+ )
343
+
344
+
345
+ def _failed(
346
+ action: BuildAction, target_id: str, started: datetime, exc: Exception
347
+ ) -> ActionResult:
348
+ finished = _now()
349
+ return ActionResult(
350
+ action_id=action.id,
351
+ resource_node_id=action.resource_node_id,
352
+ target_id=target_id,
353
+ executor=action.executor,
354
+ status=FAILED,
355
+ started_at=started,
356
+ finished_at=finished,
357
+ duration_seconds=(finished - started).total_seconds(),
358
+ error_type=type(exc).__name__,
359
+ error_message=str(exc),
360
+ )
361
+
362
+
363
+ def _skipped_action(action: BuildAction, batch: BuildBatch) -> ActionResult:
364
+ return ActionResult(
365
+ action_id=action.id,
366
+ resource_node_id=action.resource_node_id,
367
+ target_id=batch.target_id,
368
+ executor=action.executor,
369
+ status=SKIPPED,
370
+ )
371
+
372
+
373
+ def _skipped_sequence(sequence: BuildSequence) -> SequenceResult:
374
+ actions = tuple(
375
+ _skipped_action(action, batch)
376
+ for batch in sequence.batches
377
+ for action in batch.actions
378
+ )
379
+ return SequenceResult(
380
+ number=sequence.number,
381
+ description=sequence.description,
382
+ status=SKIPPED,
383
+ actions=actions,
384
+ )
@@ -0,0 +1,288 @@
1
+ """The build manifest — immutable plan, sequence, batch and action types.
2
+
3
+ A :class:`BuildPlan` is the whole deployment, fully bound: every executable
4
+ identifies one physical target and carries everything an installer needs. It is
5
+ produced once, by the planner, and thereafter only read.
6
+
7
+ The execution shape is deliberately flat and explicit:
8
+
9
+ - **sequences are barriers.** They run in order; the next begins only when every
10
+ action in the current one has succeeded.
11
+ - **batches are target-bound.** A batch names exactly one target, so a physical
12
+ destination appears once per batch rather than being repeated inside actions.
13
+ - **actions are independent units.** Each has its own payload (where one is
14
+ required), and is reported on its own.
15
+
16
+ Every type serialises to and from plain mappings, which is what the canonical
17
+ ``plan.yml`` and the ``bundle_id`` hash are built from — see :mod:`weaver.build_bundle.bundle`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Mapping
24
+
25
+ from .targets import BoundTarget
26
+ from .changes import TargetChange
27
+ from .incremental import BuildSelection
28
+
29
+ #: Action kinds. Create kinds build structure; prune kinds reconcile the target.
30
+ CREATE_SCHEMA = "create_schema"
31
+ CREATE_ALIAS = "create_alias"
32
+ BUILD_FOLDER = "build_folder"
33
+ BUILD_TABLE = "build_table"
34
+ BUILD_VIEW = "build_view"
35
+
36
+ #: One Lakehouse's SQL analytics endpoint catching up with the Delta mutations
37
+ #: just made in it. It closes an item's physical work: a dependent item's
38
+ #: Warehouse view or OneLake shortcut reads that endpoint's metadata, so it must
39
+ #: not be created while the endpoint still describes the previous shape.
40
+ REFRESH_SQL_ENDPOINT = "refresh_sql_endpoint"
41
+
42
+ #: Load kinds — what an item's final layer installs. ``write_file`` puts one
43
+ #: deployed module or generated statement into the runtime tree;
44
+ #: ``build_procedure`` creates or replaces one generated load procedure.
45
+ WRITE_FILE = "write_file"
46
+ BUILD_PROCEDURE = "build_procedure"
47
+
48
+ #: Managed rebuild drops. They are deliberately distinct from prune: these
49
+ #: objects remain desired and are removed only so a selected definition can be
50
+ #: recreated.
51
+ DROP_FOLDER = "drop_folder"
52
+ DROP_TABLE = "drop_table"
53
+ DROP_VIEW = "drop_view"
54
+
55
+ #: Removals of load artefacts whose source has stopped claiming them. Distinct
56
+ #: from prune because they come from the *catalogue* rather than from a diff
57
+ #: against the target: the previous Registry row says what was installed and
58
+ #: where, so a deleted or renamed source produces the removal without anything
59
+ #: having to enumerate the runtime tree.
60
+ DELETE_FILE = "delete_file"
61
+ DROP_PROCEDURE = "drop_procedure"
62
+
63
+ #: Prune kinds. Each names one frozen drop the build computed against the target:
64
+ #: a Spark SQL DROP for a table/view/schema, a directory removal for a folder.
65
+ PRUNE_TABLE = "prune_table"
66
+ PRUNE_VIEW = "prune_view"
67
+ PRUNE_SCHEMA = "prune_schema"
68
+ PRUNE_FOLDER = "prune_folder"
69
+
70
+ #: Refresh the SQL analytics endpoint for one Lakehouse after its Delta tables
71
+ #: have changed. The action is target-bound and payloadless: the planner decides
72
+ #: which Lakehouses need it, while the executor performs or explicitly skips it
73
+ #: for the current host.
74
+ REFRESH_SQL_ENDPOINT = "refresh_sql_endpoint"
75
+
76
+ #: Catalogue kinds. These write the central catalogue in the Weaver Lakehouse
77
+ #: rather than the destination. Claim deletion leads physical work; batched
78
+ #: publication concludes it, with Registry visibly last in the manifest.
79
+ DELETE_CATALOGUE_CLAIMS = "delete_catalogue_claims"
80
+ PUBLISH_CATALOGUE = "publish_catalogue"
81
+ PUBLISH_REGISTRY = "publish_registry"
82
+ CATALOGUE_KINDS = frozenset(
83
+ {
84
+ DELETE_CATALOGUE_CLAIMS,
85
+ PUBLISH_CATALOGUE,
86
+ PUBLISH_REGISTRY,
87
+ }
88
+ )
89
+
90
+ #: Reasons a repository node is not in the plan. A missing target is visible,
91
+ #: not a mysterious absence.
92
+ OMIT_TARGET_UNBOUND = "target_unbound"
93
+ OMIT_DEPENDS_ON_OMITTED = "depends_on_omitted_node"
94
+ OMIT_UNSUPPORTED_EXECUTOR = "unsupported_executor"
95
+ #: An alias the current bindings give no physical form. The planner decides this
96
+ #: — never the installer, which may only run an alias action already frozen for
97
+ #: it — and records it so the absence is a stated decision rather than a gap.
98
+ OMIT_ALIAS_UNSUPPORTED = "alias_unsupported"
99
+ OMISSION_REASONS = frozenset(
100
+ {
101
+ OMIT_TARGET_UNBOUND,
102
+ OMIT_DEPENDS_ON_OMITTED,
103
+ OMIT_UNSUPPORTED_EXECUTOR,
104
+ OMIT_ALIAS_UNSUPPORTED,
105
+ }
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class OmittedNode:
111
+ """A repository node the projection left out, and why."""
112
+
113
+ node_id: str
114
+ reason: str
115
+ detail: str | None = None
116
+
117
+ def to_mapping(self) -> dict[str, Any]:
118
+ mapping: dict[str, Any] = {"node_id": self.node_id, "reason": self.reason}
119
+ if self.detail is not None:
120
+ mapping["detail"] = self.detail
121
+ return mapping
122
+
123
+ @classmethod
124
+ def from_mapping(cls, mapping: Mapping[str, Any]) -> "OmittedNode":
125
+ return cls(
126
+ node_id=mapping["node_id"],
127
+ reason=mapping["reason"],
128
+ detail=mapping.get("detail"),
129
+ )
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class BuildAction:
134
+ """One independently executable unit.
135
+
136
+ ``payload`` is a bundle-relative path to the generated definition, or None
137
+ for an action that carries no payload (an explicit no-op). ``payload_sha256``
138
+ hashes that payload so corruption is caught before anything runs.
139
+ """
140
+
141
+ id: str
142
+ kind: str
143
+ resource_node_id: str | None
144
+ executor: str
145
+ payload: str | None
146
+ payload_sha256: str | None
147
+
148
+ def to_mapping(self) -> dict[str, Any]:
149
+ return {
150
+ "id": self.id,
151
+ "kind": self.kind,
152
+ "resource_node_id": self.resource_node_id,
153
+ "executor": self.executor,
154
+ "payload": self.payload,
155
+ "payload_sha256": self.payload_sha256,
156
+ }
157
+
158
+ @classmethod
159
+ def from_mapping(cls, mapping: Mapping[str, Any]) -> "BuildAction":
160
+ return cls(
161
+ id=mapping["id"],
162
+ kind=mapping["kind"],
163
+ resource_node_id=mapping.get("resource_node_id"),
164
+ executor=mapping["executor"],
165
+ payload=mapping.get("payload"),
166
+ payload_sha256=mapping.get("payload_sha256"),
167
+ )
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class BuildBatch:
172
+ """A group of actions against exactly one target."""
173
+
174
+ id: str
175
+ target_id: str
176
+ actions: tuple[BuildAction, ...]
177
+
178
+ def to_mapping(self) -> dict[str, Any]:
179
+ return {
180
+ "id": self.id,
181
+ "target_id": self.target_id,
182
+ "actions": [action.to_mapping() for action in self.actions],
183
+ }
184
+
185
+ @classmethod
186
+ def from_mapping(cls, mapping: Mapping[str, Any]) -> "BuildBatch":
187
+ return cls(
188
+ id=mapping["id"],
189
+ target_id=mapping["target_id"],
190
+ actions=tuple(BuildAction.from_mapping(a) for a in mapping.get("actions", ())),
191
+ )
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class BuildSequence:
196
+ """One barrier. Every batch here completes before the next sequence starts."""
197
+
198
+ number: int
199
+ description: str
200
+ batches: tuple[BuildBatch, ...]
201
+
202
+ def to_mapping(self) -> dict[str, Any]:
203
+ return {
204
+ "number": self.number,
205
+ "description": self.description,
206
+ "batches": [batch.to_mapping() for batch in self.batches],
207
+ }
208
+
209
+ @classmethod
210
+ def from_mapping(cls, mapping: Mapping[str, Any]) -> "BuildSequence":
211
+ return cls(
212
+ number=mapping["number"],
213
+ description=mapping["description"],
214
+ batches=tuple(BuildBatch.from_mapping(b) for b in mapping.get("batches", ())),
215
+ )
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class BuildPlan:
220
+ """A whole deployment, fully bound and ordered."""
221
+
222
+ format_version: int
223
+ bundle_id: str
224
+ repository_name: str
225
+ repository_signature: str
226
+ targets: tuple[BoundTarget, ...]
227
+ sequences: tuple[BuildSequence, ...]
228
+ selection: BuildSelection
229
+ omitted_nodes: tuple[OmittedNode, ...] = ()
230
+ #: What this plan will *mean* for each bound target, keyed by target id —
231
+ #: the objects it adds and removes. Part of the manifest, and therefore of
232
+ #: the bundle identity, so the summary a reviewer reads is the summary the
233
+ #: installation was certified with. A sibling file outside the hash could be
234
+ #: edited after certification, which is the thing frozen payloads exist to
235
+ #: prevent.
236
+ target_changes: Mapping[str, tuple[TargetChange, ...]] = field(
237
+ default_factory=dict
238
+ )
239
+
240
+ def to_mapping(self) -> dict[str, Any]:
241
+ mapping = {
242
+ "format_version": self.format_version,
243
+ "bundle_id": self.bundle_id,
244
+ "repository_name": self.repository_name,
245
+ "repository_signature": self.repository_signature,
246
+ "targets": [target.to_mapping() for target in self.targets],
247
+ "sequences": [sequence.to_mapping() for sequence in self.sequences],
248
+ "omitted_nodes": [node.to_mapping() for node in self.omitted_nodes],
249
+ "target_changes": {
250
+ target_id: [change.to_mapping() for change in changes]
251
+ for target_id, changes in sorted(self.target_changes.items())
252
+ },
253
+ }
254
+ mapping["selection"] = self.selection.to_mapping()
255
+ return mapping
256
+
257
+ @classmethod
258
+ def from_mapping(cls, mapping: Mapping[str, Any]) -> "BuildPlan":
259
+ return cls(
260
+ format_version=mapping["format_version"],
261
+ bundle_id=mapping["bundle_id"],
262
+ repository_name=mapping["repository_name"],
263
+ repository_signature=mapping["repository_signature"],
264
+ targets=tuple(BoundTarget.from_mapping(t) for t in mapping.get("targets", ())),
265
+ sequences=tuple(BuildSequence.from_mapping(s) for s in mapping.get("sequences", ())),
266
+ selection=BuildSelection.from_mapping(mapping["selection"]),
267
+ omitted_nodes=tuple(
268
+ OmittedNode.from_mapping(n) for n in mapping.get("omitted_nodes", ())
269
+ ),
270
+ target_changes={
271
+ target_id: tuple(TargetChange.from_mapping(c) for c in changes)
272
+ for target_id, changes in mapping.get("target_changes", {}).items()
273
+ },
274
+ )
275
+
276
+ # --- convenience views ------------------------------------------------
277
+
278
+ @property
279
+ def target_ids(self) -> frozenset[str]:
280
+ return frozenset(target.id for target in self.targets)
281
+
282
+ def actions(self):
283
+ """Every action, in manifest order."""
284
+
285
+ for sequence in self.sequences:
286
+ for batch in sequence.batches:
287
+ for action in batch.actions:
288
+ yield sequence, batch, action
@@ -0,0 +1,34 @@
1
+ """Payload naming and hashing — where a generated definition lives in a bundle.
2
+
3
+ A bundle's ``payload/`` tree groups definitions by the sequence that runs them,
4
+ so the directory order mirrors the deployment order and a reviewer can read it
5
+ top to bottom. This module owns those names and the payload hash, so the planner
6
+ does not scatter path arithmetic through its logic.
7
+
8
+ There are deliberately no reserved sequence numbers here. A stage's number comes
9
+ from its position in the assembled plan — see :mod:`weaver.build_bundle.stages`
10
+ — so nothing has to leave arithmetic headroom for a repository's dependency depth
11
+ and nothing can collide with a region another phase claimed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+
18
+ PAYLOAD_ROOT = "payload"
19
+
20
+
21
+ def sequence_dir(number: int, slug: str) -> str:
22
+ """The payload subdirectory for one sequence, e.g. ``003-build-objects``."""
23
+
24
+ return f"{number:03d}-{slug}"
25
+
26
+
27
+ def payload_path(number: int, slug: str, filename: str) -> str:
28
+ """A bundle-relative payload path under its sequence directory."""
29
+
30
+ return f"{PAYLOAD_ROOT}/{sequence_dir(number, slug)}/{filename}"
31
+
32
+
33
+ def sha256_hex(data: bytes) -> str:
34
+ return hashlib.sha256(data).hexdigest()