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,359 @@
|
|
|
1
|
+
"""Writing, loading and validating a build bundle on a store.
|
|
2
|
+
|
|
3
|
+
A bundle is a directory:
|
|
4
|
+
|
|
5
|
+
.. code-block:: text
|
|
6
|
+
|
|
7
|
+
<bundle>/
|
|
8
|
+
plan.yml the canonical manifest
|
|
9
|
+
payload/ one generated definition per action
|
|
10
|
+
010-create-schemas/
|
|
11
|
+
create-DWG.spark.sql
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
The manifest is written **last**, so a half-written directory never looks
|
|
15
|
+
installable. Loading validates the whole bundle — structure, target bindings,
|
|
16
|
+
payload presence, and payload hashes — before any action can run, because the
|
|
17
|
+
installer must be able to trust what it is handed without re-reading the source.
|
|
18
|
+
|
|
19
|
+
``bundle_id`` is derived from stable inputs only: the format version, the
|
|
20
|
+
repository signature, the target descriptors and the canonical manifest with
|
|
21
|
+
its payload hashes. No timestamp participates, so the same inputs always yield
|
|
22
|
+
the same identity.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Mapping
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from ..errors import BuildError
|
|
35
|
+
from ..locations import Location
|
|
36
|
+
from ..store import Store
|
|
37
|
+
from .models import DELETE_FILE, OMISSION_REASONS, BuildPlan
|
|
38
|
+
|
|
39
|
+
#: The only bundle format this code writes and accepts.
|
|
40
|
+
SUPPORTED_FORMAT_VERSION = 1
|
|
41
|
+
|
|
42
|
+
PLAN_FILENAME = "plan.yml"
|
|
43
|
+
PAYLOAD_DIR = "payload"
|
|
44
|
+
|
|
45
|
+
SPARK_SQL_EXECUTOR = "spark_sql"
|
|
46
|
+
SPARK_SQL_BATCH_EXECUTOR = "spark_sql_batch"
|
|
47
|
+
SPARK_SCHEMA_EXECUTOR = "spark_schema"
|
|
48
|
+
SPARK_TABLE_EXECUTOR = "spark_table"
|
|
49
|
+
TSQL_EXECUTOR = "tsql"
|
|
50
|
+
TSQL_BATCH_EXECUTOR = "tsql_batch"
|
|
51
|
+
FOLDER_EXECUTOR = "folder"
|
|
52
|
+
ALIAS_EXECUTOR = "alias"
|
|
53
|
+
SQL_ENDPOINT_REFRESH_EXECUTOR = "sql_endpoint_refresh"
|
|
54
|
+
LOAD_FILE_EXECUTOR = "load_file"
|
|
55
|
+
#: Executors a bundle may carry. ``spark_sql`` runs a create or a frozen prune
|
|
56
|
+
#: DROP; ``spark_sql_batch`` runs ordered catalogue DML as one action;
|
|
57
|
+
#: ``spark_schema`` makes one schema in the destination, whose ``LOCATION``
|
|
58
|
+
#: is a resolved path and so cannot be frozen; ``spark_table`` completes a Spark
|
|
59
|
+
#: SQL table's deferred build by running its query and creating the table;
|
|
60
|
+
#: ``tsql`` runs a self-contained Warehouse script and ``tsql_batch`` an
|
|
61
|
+
#: ordered array of them, each as its own batch; ``folder`` makes or removes a
|
|
62
|
+
#: directory; ``alias`` points one Lakehouse name at another item's object
|
|
63
|
+
VALID_EXECUTORS = frozenset(
|
|
64
|
+
{
|
|
65
|
+
SPARK_SQL_EXECUTOR,
|
|
66
|
+
SPARK_SQL_BATCH_EXECUTOR,
|
|
67
|
+
SPARK_SCHEMA_EXECUTOR,
|
|
68
|
+
SPARK_TABLE_EXECUTOR,
|
|
69
|
+
TSQL_EXECUTOR,
|
|
70
|
+
TSQL_BATCH_EXECUTOR,
|
|
71
|
+
FOLDER_EXECUTOR,
|
|
72
|
+
ALIAS_EXECUTOR,
|
|
73
|
+
SQL_ENDPOINT_REFRESH_EXECUTOR,
|
|
74
|
+
LOAD_FILE_EXECUTOR,
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
#: Executors that run a payload, and the extension that payload must carry.
|
|
78
|
+
#: ``folder`` acts on the resolved target and carries none; ``sql_endpoint_refresh`` acts
|
|
79
|
+
#: on the target itself.
|
|
80
|
+
_EXECUTOR_EXTENSION = {
|
|
81
|
+
SPARK_SQL_EXECUTOR: ".spark.sql",
|
|
82
|
+
SPARK_SQL_BATCH_EXECUTOR: ".spark-sql-batch.json",
|
|
83
|
+
SPARK_SCHEMA_EXECUTOR: ".schema.json",
|
|
84
|
+
SPARK_TABLE_EXECUTOR: ".spark-table.json",
|
|
85
|
+
TSQL_EXECUTOR: ".sql",
|
|
86
|
+
TSQL_BATCH_EXECUTOR: ".tsql-batch.json",
|
|
87
|
+
ALIAS_EXECUTOR: ".alias.json",
|
|
88
|
+
# A deployed file's payload is its exact bytes, whatever they are — a Python
|
|
89
|
+
# module, a generated statement — so the extension names the *role* rather
|
|
90
|
+
# than the content, which is the one thing every load file has in common.
|
|
91
|
+
LOAD_FILE_EXECUTOR: ".payload",
|
|
92
|
+
}
|
|
93
|
+
_PAYLOADLESS_EXECUTORS = frozenset(
|
|
94
|
+
{FOLDER_EXECUTOR, SQL_ENDPOINT_REFRESH_EXECUTOR}
|
|
95
|
+
)
|
|
96
|
+
#: Kinds that carry no payload even though their executor usually does. Only
|
|
97
|
+
#: ``delete_file``: removing a deployed file needs the identity and nothing else,
|
|
98
|
+
#: while writing one needs the exact bytes. Expressing it per *kind* keeps the
|
|
99
|
+
#: strict requirement where it matters — a ``write_file`` with no payload is
|
|
100
|
+
#: still rejected here rather than at install time.
|
|
101
|
+
_PAYLOADLESS_KINDS = frozenset({DELETE_FILE})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class BuildBundle:
|
|
106
|
+
"""A validated bundle and the store holding its files.
|
|
107
|
+
|
|
108
|
+
The bundle store is deliberately independent of the target store. Inside
|
|
109
|
+
Fabric, payloads live on the session driver's temporary filesystem while
|
|
110
|
+
target Files mutations still use ``FabricStore``.
|
|
111
|
+
``None`` remains accepted for compatibility with callers that reconstruct a
|
|
112
|
+
lightweight handle and let the installer use its environment store.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
location: Location
|
|
116
|
+
plan: BuildPlan
|
|
117
|
+
store: Store | None = field(default=None, compare=False, repr=False)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def bundle_id(self) -> str:
|
|
121
|
+
return self.plan.bundle_id
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- canonical form and identity --------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _canonical_bytes(mapping) -> bytes:
|
|
128
|
+
"""A byte form that is identical for equal manifests on any platform."""
|
|
129
|
+
|
|
130
|
+
return json.dumps(
|
|
131
|
+
mapping, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
132
|
+
).encode("utf-8")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def compute_bundle_id(plan: BuildPlan) -> str:
|
|
136
|
+
"""The identity of a plan, independent of its stored ``bundle_id`` field.
|
|
137
|
+
|
|
138
|
+
The field is blanked before hashing so a plan's id never depends on itself,
|
|
139
|
+
and everything else — signature, targets, sequences, payload hashes — feeds
|
|
140
|
+
in through the canonical mapping.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
mapping = plan.to_mapping()
|
|
144
|
+
mapping["bundle_id"] = ""
|
|
145
|
+
return hashlib.sha256(_canonical_bytes(mapping)).hexdigest()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def plan_to_yaml(plan: BuildPlan) -> str:
|
|
149
|
+
"""The human-readable canonical manifest."""
|
|
150
|
+
|
|
151
|
+
return yaml.safe_dump(
|
|
152
|
+
plan.to_mapping(), sort_keys=False, default_flow_style=False, allow_unicode=True
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def plan_from_yaml(text: str) -> BuildPlan:
|
|
157
|
+
loaded = yaml.safe_load(text)
|
|
158
|
+
if not isinstance(loaded, dict):
|
|
159
|
+
raise BuildError("plan.yml must be a mapping")
|
|
160
|
+
try:
|
|
161
|
+
return BuildPlan.from_mapping(loaded)
|
|
162
|
+
except KeyError as exc:
|
|
163
|
+
raise BuildError(f"plan.yml is missing a required field: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- writing -----------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def write_bundle(
|
|
170
|
+
location: Location,
|
|
171
|
+
*,
|
|
172
|
+
plan: BuildPlan,
|
|
173
|
+
payloads: Mapping[str, bytes],
|
|
174
|
+
store: Store,
|
|
175
|
+
) -> BuildBundle:
|
|
176
|
+
"""Write a bundle, manifest last, then reload and validate it.
|
|
177
|
+
|
|
178
|
+
``payloads`` is keyed by each action's bundle-relative payload path.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
for _, _, action in plan.actions():
|
|
182
|
+
if action.payload is None:
|
|
183
|
+
continue
|
|
184
|
+
_check_payload_path(action.payload)
|
|
185
|
+
if action.payload not in payloads:
|
|
186
|
+
raise BuildError(
|
|
187
|
+
f"action {action.id!r} references payload {action.payload!r} "
|
|
188
|
+
"but no payload was supplied for it"
|
|
189
|
+
)
|
|
190
|
+
digest = hashlib.sha256(payloads[action.payload]).hexdigest()
|
|
191
|
+
if action.payload_sha256 != digest:
|
|
192
|
+
raise BuildError(
|
|
193
|
+
f"action {action.id!r} payload hash does not match its content "
|
|
194
|
+
f"({action.payload_sha256} vs {digest})"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for relative, data in payloads.items():
|
|
198
|
+
store.write(location.join(*relative.split("/")), data)
|
|
199
|
+
|
|
200
|
+
# The manifest goes last: until it exists, the directory is not a bundle.
|
|
201
|
+
store.write(location.join(PLAN_FILENAME), plan_to_yaml(plan).encode("utf-8"))
|
|
202
|
+
|
|
203
|
+
return load_bundle(location, store=store)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- loading and validation --------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_bundle(location: Location, *, store: Store) -> BuildBundle:
|
|
210
|
+
"""Read a bundle and fully validate it before returning."""
|
|
211
|
+
|
|
212
|
+
plan_location = location.join(PLAN_FILENAME)
|
|
213
|
+
if not store.exists(plan_location):
|
|
214
|
+
raise BuildError(f"no bundle manifest at {plan_location.value}")
|
|
215
|
+
|
|
216
|
+
plan = plan_from_yaml(store.read(plan_location).decode("utf-8"))
|
|
217
|
+
validate_bundle(location, plan, store=store)
|
|
218
|
+
return BuildBundle(location=location, plan=plan, store=store)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_bundle(location: Location, plan: BuildPlan, *, store: Store) -> None:
|
|
222
|
+
"""Reject any structural or integrity fault before an action runs.
|
|
223
|
+
|
|
224
|
+
Structure is checked first and needs no store, so a malformed manifest is
|
|
225
|
+
caught the same way whether or not its payloads happen to exist; payload
|
|
226
|
+
presence and hashes are checked second, against the store.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
validate_plan_structure(plan)
|
|
230
|
+
_validate_payload_integrity(location, plan, store)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def validate_plan_structure(plan: BuildPlan) -> None:
|
|
234
|
+
"""Everything provable from the manifest alone, without reading payloads."""
|
|
235
|
+
|
|
236
|
+
if plan.format_version != SUPPORTED_FORMAT_VERSION:
|
|
237
|
+
raise BuildError(
|
|
238
|
+
f"unsupported bundle format version {plan.format_version} "
|
|
239
|
+
f"(this build supports {SUPPORTED_FORMAT_VERSION})"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
for node in plan.omitted_nodes:
|
|
243
|
+
if node.reason not in OMISSION_REASONS:
|
|
244
|
+
raise BuildError(f"omitted node {node.node_id!r} has unknown reason {node.reason!r}")
|
|
245
|
+
omitted_ids = {node.node_id for node in plan.omitted_nodes}
|
|
246
|
+
|
|
247
|
+
target_ids = plan.target_ids
|
|
248
|
+
if len(target_ids) != len(plan.targets):
|
|
249
|
+
raise BuildError("duplicate target id in plan")
|
|
250
|
+
for target in plan.targets:
|
|
251
|
+
if (target.logical_item_type is None) != (target.logical_item_name is None):
|
|
252
|
+
raise BuildError(
|
|
253
|
+
f"target {target.id!r} carries an incomplete logical item identity"
|
|
254
|
+
)
|
|
255
|
+
if target.logical_item_type is not None:
|
|
256
|
+
expected = {
|
|
257
|
+
"Lakehouse": "lakehouse",
|
|
258
|
+
"Warehouse": "warehouse",
|
|
259
|
+
}.get(target.logical_item_type)
|
|
260
|
+
if expected != target.kind:
|
|
261
|
+
raise BuildError(
|
|
262
|
+
f"target {target.id!r} binds logical {target.logical_item_type} "
|
|
263
|
+
f"to physical kind {target.kind!r}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
seen_numbers: list[int] = []
|
|
267
|
+
batch_ids: set[str] = set()
|
|
268
|
+
action_ids: set[str] = set()
|
|
269
|
+
|
|
270
|
+
for sequence in plan.sequences:
|
|
271
|
+
seen_numbers.append(sequence.number)
|
|
272
|
+
for batch in sequence.batches:
|
|
273
|
+
if not batch.target_id:
|
|
274
|
+
raise BuildError(f"batch {batch.id!r} has no target")
|
|
275
|
+
if batch.target_id not in target_ids:
|
|
276
|
+
raise BuildError(
|
|
277
|
+
f"batch {batch.id!r} names unknown target {batch.target_id!r}"
|
|
278
|
+
)
|
|
279
|
+
if batch.id in batch_ids:
|
|
280
|
+
raise BuildError(f"duplicate batch id {batch.id!r}")
|
|
281
|
+
batch_ids.add(batch.id)
|
|
282
|
+
for action in batch.actions:
|
|
283
|
+
if action.id in action_ids:
|
|
284
|
+
raise BuildError(f"duplicate action id {action.id!r}")
|
|
285
|
+
action_ids.add(action.id)
|
|
286
|
+
_validate_action_shape(action, omitted_ids)
|
|
287
|
+
|
|
288
|
+
if seen_numbers != sorted(set(seen_numbers)) or len(seen_numbers) != len(set(seen_numbers)):
|
|
289
|
+
raise BuildError(
|
|
290
|
+
f"sequence numbers must be unique and ascending, got {seen_numbers}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _validate_action_shape(action, omitted_ids) -> None:
|
|
295
|
+
if action.executor not in VALID_EXECUTORS:
|
|
296
|
+
raise BuildError(
|
|
297
|
+
f"action {action.id!r} uses unsupported executor {action.executor!r}"
|
|
298
|
+
)
|
|
299
|
+
if action.resource_node_id is not None and action.resource_node_id in omitted_ids:
|
|
300
|
+
raise BuildError(
|
|
301
|
+
f"action {action.id!r} targets omitted node {action.resource_node_id!r}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if action.payload is None:
|
|
305
|
+
if action.payload_sha256 is not None:
|
|
306
|
+
raise BuildError(
|
|
307
|
+
f"action {action.id!r} has no payload but carries a payload hash"
|
|
308
|
+
)
|
|
309
|
+
if (
|
|
310
|
+
action.executor not in _PAYLOADLESS_EXECUTORS
|
|
311
|
+
and action.kind not in _PAYLOADLESS_KINDS
|
|
312
|
+
):
|
|
313
|
+
raise BuildError(
|
|
314
|
+
f"action {action.id!r} uses executor {action.executor!r}, which needs a payload"
|
|
315
|
+
)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if action.executor in _PAYLOADLESS_EXECUTORS or action.kind in _PAYLOADLESS_KINDS:
|
|
319
|
+
raise BuildError(
|
|
320
|
+
f"action {action.id!r} is a {action.kind!r}, which takes no payload"
|
|
321
|
+
)
|
|
322
|
+
_check_payload_path(action.payload)
|
|
323
|
+
extension = _EXECUTOR_EXTENSION[action.executor]
|
|
324
|
+
if not action.payload.endswith(extension):
|
|
325
|
+
raise BuildError(
|
|
326
|
+
f"action {action.id!r} payload {action.payload!r} does not match "
|
|
327
|
+
f"executor {action.executor!r} extension {extension!r}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _validate_payload_integrity(location, plan: BuildPlan, store: Store) -> None:
|
|
332
|
+
for _, _, action in plan.actions():
|
|
333
|
+
if action.payload is None:
|
|
334
|
+
continue
|
|
335
|
+
payload_location = location.join(*action.payload.split("/"))
|
|
336
|
+
if not store.exists(payload_location):
|
|
337
|
+
raise BuildError(f"action {action.id!r} payload is missing: {action.payload!r}")
|
|
338
|
+
digest = hashlib.sha256(store.read(payload_location)).hexdigest()
|
|
339
|
+
if digest != action.payload_sha256:
|
|
340
|
+
raise BuildError(
|
|
341
|
+
f"action {action.id!r} payload hash mismatch for {action.payload!r} "
|
|
342
|
+
f"(manifest {action.payload_sha256}, file {digest})"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _check_payload_path(payload: str) -> None:
|
|
347
|
+
_check_relative(payload, what="payload path")
|
|
348
|
+
if not payload.startswith(PAYLOAD_DIR + "/"):
|
|
349
|
+
raise BuildError(
|
|
350
|
+
f"payload {payload!r} must live under {PAYLOAD_DIR!r}/"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _check_relative(path: str, *, what: str) -> None:
|
|
355
|
+
if path.startswith("/") or ":" in path:
|
|
356
|
+
raise BuildError(f"{what} must be relative and stay in the bundle: {path!r}")
|
|
357
|
+
parts = path.split("/")
|
|
358
|
+
if any(part in ("", "..", ".") for part in parts):
|
|
359
|
+
raise BuildError(f"{what} must not be empty or traverse: {path!r}")
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Collect catalogue claims and render the three ordered catalogue barriers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Iterable, Mapping
|
|
8
|
+
|
|
9
|
+
from ..catalogue.claims import CatalogueClaim, claim_rules_for_object_type
|
|
10
|
+
from ..catalogue.state import Catalogue, for_targets, retaining
|
|
11
|
+
from ..catalogue.reconcile import reconcile
|
|
12
|
+
from ..catalogue.render import InstallationScope, identifier, literal
|
|
13
|
+
from ..catalogue.state import Catalogue, for_targets, retaining
|
|
14
|
+
from ..catalogue.tables import DICTIONARY_TABLES, REGISTRY, CatalogueTable
|
|
15
|
+
from ..declaration.model import WeaverDocumentId
|
|
16
|
+
from ..spark.tokens import object_token
|
|
17
|
+
from .models import (
|
|
18
|
+
DELETE_CATALOGUE_CLAIMS,
|
|
19
|
+
PUBLISH_CATALOGUE,
|
|
20
|
+
PUBLISH_REGISTRY,
|
|
21
|
+
REFRESH_SQL_ENDPOINT,
|
|
22
|
+
BuildAction,
|
|
23
|
+
BuildBatch,
|
|
24
|
+
)
|
|
25
|
+
from .payloads import sha256_hex
|
|
26
|
+
from .stages import CATALOGUE, PlannedStage
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def collect_claims(
|
|
30
|
+
catalogue: Catalogue,
|
|
31
|
+
identities: Iterable[WeaverDocumentId],
|
|
32
|
+
*,
|
|
33
|
+
stale_claims: Iterable[CatalogueClaim] = (),
|
|
34
|
+
) -> tuple[CatalogueClaim, ...]:
|
|
35
|
+
"""Every catalogue claim this build must delete before it does physical work.
|
|
36
|
+
|
|
37
|
+
Two sources, and they are symmetric: claims reconciliation already disproved
|
|
38
|
+
against the inventory, and claims held by the objects this build is about to
|
|
39
|
+
drop or remove. Both are passed in rather than one being read off the
|
|
40
|
+
catalogue, because a catalogue describes what is claimed — not which of those
|
|
41
|
+
claims some earlier step decided were wrong.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
claims = list(stale_claims)
|
|
45
|
+
for identity in sorted(set(identities), key=str):
|
|
46
|
+
document = catalogue.registered.get(identity)
|
|
47
|
+
if document is None:
|
|
48
|
+
continue
|
|
49
|
+
tables = catalogue.rows.get(identity.item, {})
|
|
50
|
+
for rule in claim_rules_for_object_type(document.object_type):
|
|
51
|
+
if any(rule.owns(row, identity) for row in tables.get(rule.table.name, ())):
|
|
52
|
+
claims.append(CatalogueClaim(identity, rule))
|
|
53
|
+
return tuple(dict.fromkeys(claims))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _claim_statements(claims: Iterable[CatalogueClaim]) -> tuple[str, ...]:
|
|
57
|
+
grouped: dict[tuple[CatalogueTable, object], set[WeaverDocumentId]] = defaultdict(set)
|
|
58
|
+
for claim in claims:
|
|
59
|
+
grouped[(claim.rule.table, claim.identity.item)].add(claim.identity)
|
|
60
|
+
|
|
61
|
+
table_order = (REGISTRY, *reversed(DICTIONARY_TABLES))
|
|
62
|
+
statements = []
|
|
63
|
+
for table in table_order:
|
|
64
|
+
groups = sorted(
|
|
65
|
+
(
|
|
66
|
+
(item, identities)
|
|
67
|
+
for (claim_table, item), identities in grouped.items()
|
|
68
|
+
if claim_table == table
|
|
69
|
+
),
|
|
70
|
+
key=lambda pair: str(pair[0]),
|
|
71
|
+
)
|
|
72
|
+
for item, identities in groups:
|
|
73
|
+
scope = InstallationScope(item.item_type, item.item_name)
|
|
74
|
+
predicates = []
|
|
75
|
+
for identity in sorted(identities, key=str):
|
|
76
|
+
rule = next(
|
|
77
|
+
claim.rule
|
|
78
|
+
for claim in claims
|
|
79
|
+
if claim.identity == identity and claim.rule.table == table
|
|
80
|
+
)
|
|
81
|
+
values = rule.values(identity)
|
|
82
|
+
predicates.append(
|
|
83
|
+
"("
|
|
84
|
+
+ " AND ".join(
|
|
85
|
+
f"{identifier(column)} = {literal(value)}"
|
|
86
|
+
for column, value in zip(
|
|
87
|
+
rule.predicate_columns, values, strict=True
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
+ ")"
|
|
91
|
+
)
|
|
92
|
+
statements.append(
|
|
93
|
+
f"DELETE FROM {object_token('_', table.name)}\n"
|
|
94
|
+
f"WHERE {scope.predicate}\n AND ("
|
|
95
|
+
+ "\n OR ".join(predicates)
|
|
96
|
+
+ ")"
|
|
97
|
+
)
|
|
98
|
+
return tuple(statements)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _batch_payload(statements: Iterable[str]) -> bytes:
|
|
102
|
+
return (json.dumps(list(statements), indent=2, ensure_ascii=False) + "\n").encode(
|
|
103
|
+
"utf-8"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _stage(
|
|
108
|
+
*,
|
|
109
|
+
index: int,
|
|
110
|
+
slug: str,
|
|
111
|
+
description: str,
|
|
112
|
+
kind: str,
|
|
113
|
+
statements: Iterable[str],
|
|
114
|
+
control_target,
|
|
115
|
+
) -> PlannedStage | None:
|
|
116
|
+
statements = tuple(statements)
|
|
117
|
+
if not statements:
|
|
118
|
+
return None
|
|
119
|
+
content = _batch_payload(statements)
|
|
120
|
+
filename = f"{slug}.spark-sql-batch.json"
|
|
121
|
+
action = BuildAction(
|
|
122
|
+
id=slug,
|
|
123
|
+
kind=kind,
|
|
124
|
+
resource_node_id=None,
|
|
125
|
+
executor="spark_sql_batch",
|
|
126
|
+
payload=filename,
|
|
127
|
+
payload_sha256=sha256_hex(content),
|
|
128
|
+
)
|
|
129
|
+
return PlannedStage(
|
|
130
|
+
phase=CATALOGUE,
|
|
131
|
+
index=index,
|
|
132
|
+
slug=slug,
|
|
133
|
+
description=description,
|
|
134
|
+
payloads={filename: content},
|
|
135
|
+
batches=(
|
|
136
|
+
BuildBatch(id=slug, target_id=control_target.id, actions=(action,)),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_catalogue_before_build(
|
|
142
|
+
catalogue: Catalogue,
|
|
143
|
+
identities: Iterable[WeaverDocumentId],
|
|
144
|
+
*,
|
|
145
|
+
control_target,
|
|
146
|
+
stale_claims: Iterable[CatalogueClaim] = (),
|
|
147
|
+
) -> PlannedStage | None:
|
|
148
|
+
claims = collect_claims(catalogue, identities, stale_claims=stale_claims)
|
|
149
|
+
return _stage(
|
|
150
|
+
index=0,
|
|
151
|
+
slug="catalogue-before-build",
|
|
152
|
+
description="reconcile and remove catalogue claims before physical work",
|
|
153
|
+
kind=DELETE_CATALOGUE_CLAIMS,
|
|
154
|
+
statements=_claim_statements(claims),
|
|
155
|
+
control_target=control_target,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _item_signature(repository, item) -> str:
|
|
160
|
+
"""The item's own signature, which is what an Installation row records."""
|
|
161
|
+
|
|
162
|
+
return next(
|
|
163
|
+
model.signature for model in repository.items if model.identity == item
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render_catalogue_after_build(
|
|
168
|
+
repository,
|
|
169
|
+
selected_ids: Iterable[WeaverDocumentId],
|
|
170
|
+
target_by_item: Mapping,
|
|
171
|
+
*,
|
|
172
|
+
control_target,
|
|
173
|
+
current: Catalogue | None = None,
|
|
174
|
+
) -> tuple[PlannedStage, ...]:
|
|
175
|
+
"""Publish dictionaries and Installation in one batch, Registry last.
|
|
176
|
+
|
|
177
|
+
A final refresh of the Weaver Lakehouse's own SQL analytics endpoint closes
|
|
178
|
+
the build: the catalogue is a set of Delta tables like any other, and the next
|
|
179
|
+
reader of it — a report, a GUI, the next build — reaches it through that
|
|
180
|
+
endpoint.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
from .. import __version__
|
|
184
|
+
|
|
185
|
+
selected_ids = set(selected_ids)
|
|
186
|
+
|
|
187
|
+
# The publication is a diff: what the repository describes, against what is
|
|
188
|
+
# persisted. Only the desired side drives the statements — see
|
|
189
|
+
# `CatalogueChanges` — but routing production through the same call the
|
|
190
|
+
# reporting uses is what stops the two drifting apart.
|
|
191
|
+
# Logical, then narrowed, then bound — in that order and visibly so. The
|
|
192
|
+
# narrowing is what keeps a Registry row meaning "this succeeded"; the
|
|
193
|
+
# binding is what lets an alias be certified as the thing it physically is.
|
|
194
|
+
logical = Catalogue.from_repository(repository)
|
|
195
|
+
certified = retaining(logical, repository, selected_ids)
|
|
196
|
+
desired = for_targets(
|
|
197
|
+
certified,
|
|
198
|
+
repository,
|
|
199
|
+
selected_ids,
|
|
200
|
+
{item: target.kind for item, target in target_by_item.items()},
|
|
201
|
+
)
|
|
202
|
+
binding_rows = {
|
|
203
|
+
item: (
|
|
204
|
+
{
|
|
205
|
+
"item_type": item.item_type,
|
|
206
|
+
"item_name": item.item_name,
|
|
207
|
+
"target_name": target_by_item[item].name,
|
|
208
|
+
"weaver_version": __version__,
|
|
209
|
+
"signature": _item_signature(repository, item),
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
for item in target_by_item
|
|
213
|
+
}
|
|
214
|
+
by_item = (current or Catalogue(rows={})).diff(desired).render_dml(
|
|
215
|
+
installation=binding_rows
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
catalogue_statements: list[str] = []
|
|
219
|
+
registry_statements: list[str] = []
|
|
220
|
+
for item in sorted(target_by_item, key=str):
|
|
221
|
+
result = by_item[item]
|
|
222
|
+
# Registry last, in its own barrier — taken from the structure rather
|
|
223
|
+
# than recovered from the SQL, so the ordering invariant is carried by
|
|
224
|
+
# the type instead of by a string match.
|
|
225
|
+
for table_plan in (*result.dictionaries, result.installation):
|
|
226
|
+
catalogue_statements.extend(table_plan.statements)
|
|
227
|
+
registry_statements.extend(result.registry.statements)
|
|
228
|
+
|
|
229
|
+
rendered = (
|
|
230
|
+
_stage(
|
|
231
|
+
index=1,
|
|
232
|
+
slug="publish-catalogue",
|
|
233
|
+
description="publish catalogue dictionaries and installations",
|
|
234
|
+
kind=PUBLISH_CATALOGUE,
|
|
235
|
+
statements=catalogue_statements,
|
|
236
|
+
control_target=control_target,
|
|
237
|
+
),
|
|
238
|
+
_stage(
|
|
239
|
+
index=2,
|
|
240
|
+
slug="publish-registry",
|
|
241
|
+
description="publish item registry last",
|
|
242
|
+
kind=PUBLISH_REGISTRY,
|
|
243
|
+
statements=registry_statements,
|
|
244
|
+
control_target=control_target,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
published = tuple(stage for stage in rendered if stage is not None)
|
|
248
|
+
if not published:
|
|
249
|
+
return ()
|
|
250
|
+
return published + (_control_refresh_stage(control_target),)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _control_refresh_stage(control_target) -> PlannedStage:
|
|
254
|
+
return PlannedStage(
|
|
255
|
+
phase=CATALOGUE,
|
|
256
|
+
index=3,
|
|
257
|
+
slug="refresh-control-endpoint",
|
|
258
|
+
description="refresh the Weaver Lakehouse SQL endpoint after catalogue DML",
|
|
259
|
+
batches=(
|
|
260
|
+
BuildBatch(
|
|
261
|
+
id="refresh-control-endpoint",
|
|
262
|
+
target_id=control_target.id,
|
|
263
|
+
actions=(
|
|
264
|
+
BuildAction(
|
|
265
|
+
id="refresh-sql-endpoint-control",
|
|
266
|
+
kind=REFRESH_SQL_ENDPOINT,
|
|
267
|
+
resource_node_id=None,
|
|
268
|
+
executor="sql_endpoint_refresh",
|
|
269
|
+
payload=None,
|
|
270
|
+
payload_sha256=None,
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
)
|