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,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
+ )