weaverstack 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -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()
|