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,620 @@
1
+ """Reconciling a bound physical target against what an item declares.
2
+
3
+ Building says what must exist. Pruning says what must stop existing, and it is
4
+ the half that can destroy data, so it is deliberately narrow: only objects the
5
+ target already holds, only in schemas the bound item declares, and only after
6
+ the inventory has been frozen at plan time rather than re-read at install time.
7
+
8
+ The Delta side reads the Spark catalogue for the bound Lakehouse and the Files
9
+ area for its Folders. The Warehouse side reads the target's own SQL catalogue,
10
+ in the environment the build is running in.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Iterable, Mapping
17
+
18
+ from ..catalogue.tables import CATALOGUE_SCHEMA
19
+ from ..etl import LOAD_ROOT
20
+ from ..workspaces import BUILD_BUNDLES_AREA, CLI_AREA
21
+ from ..spark import SparkCatalogue, object_token, schema_token
22
+ from ..declaration.metadata import DELTA_TARGET, FOLDER_TARGET, SQL_TARGET, TABLE, VIEW
23
+ from ..declaration.model import PROCEDURE_SHAPE, WeaverDocumentId
24
+ from ..declaration.source import SourceDocument
25
+ from ..errors import BuildError
26
+ from ..store import Store
27
+ from ..targets import ItemRef
28
+ from .models import (
29
+ PRUNE_FOLDER,
30
+ PRUNE_SCHEMA,
31
+ PRUNE_TABLE,
32
+ PRUNE_VIEW,
33
+ BuildAction,
34
+ )
35
+ from .changes import (
36
+ FOLDER as FOLDER_KIND,
37
+ FOLDER_SCHEMA as FOLDER_SCHEMA_KIND,
38
+ SCHEMA as SCHEMA_KIND,
39
+ TABLE as TABLE_KIND,
40
+ VIEW as VIEW_KIND,
41
+ TargetChange,
42
+ removed,
43
+ )
44
+ from .payloads import sha256_hex
45
+ from .targets import BoundTarget
46
+
47
+ #: Files areas a prune never touches: they are Weaver's own, not an item's
48
+ #: materialised output.
49
+ _RESERVED_FILES_AREAS = frozenset({BUILD_BUNDLES_AREA, CLI_AREA})
50
+
51
+ #: *Delta* schemas a prune never touches. A schema-enabled Fabric Lakehouse has a
52
+ #: default ``dbo`` schema that cannot be dropped and that Weaver does not manage;
53
+ #: ``_`` holds Weaver's own catalogue, which no item owns. A build normally cannot
54
+ #: see `_` at all — it lives in the Weaver Lakehouse and prune is scoped to the
55
+ #: bound destination's own storage — but an item built *into* the Weaver Lakehouse
56
+ #: would, and a prune that dropped the catalogue would take the record of every
57
+ #: installation with it.
58
+ #:
59
+ #: The load layer's ``_`` is a different object wearing the same name: a *folder*
60
+ #: under Files, and a *Warehouse* schema. Neither is listed here, and neither
61
+ #: should be — both are generated, projected and pruned like any other managed
62
+ #: object, which is exactly how they go when an item stops declaring load code.
63
+ _RESERVED_SCHEMAS = frozenset({"dbo", CATALOGUE_SCHEMA})
64
+
65
+ #: Warehouse schemas that belong to the engine rather than to any item.
66
+ _RESERVED_SQL_SCHEMAS = frozenset(
67
+ {"dbo", "guest", "information_schema", "sys", "queryinsights", "_rsc"}
68
+ )
69
+ @dataclass(frozen=True)
70
+ class _Managed:
71
+ """The keep-set the build diffs the target against, folded for comparison."""
72
+
73
+ schemas: frozenset[str]
74
+ folder_schemas: frozenset[str]
75
+ folders: frozenset[str]
76
+ tables: frozenset[str]
77
+ views: frozenset[str]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class TargetInventory:
82
+ """Transport-neutral physical state prepared before bundle generation.
83
+
84
+ ``files`` and ``procedures`` are what a load layer installs, and they are
85
+ read like everything else here. A type the inventory cannot see would be
86
+ disproved by every reconciliation — the claim tested against nothing and
87
+ found missing — so an artefact would be rebuilt on every build, silently.
88
+ Observing them is what lets the ordinary machinery answer presence, physical
89
+ deletion and drift for load artefacts too.
90
+ """
91
+
92
+ target_id: str
93
+ kind: str
94
+ target_name: str
95
+ schemas: tuple[str, ...] = ()
96
+ folder_schemas: tuple[str, ...] = ()
97
+ folders: tuple[str, ...] = ()
98
+ tables: tuple[str, ...] = ()
99
+ views: tuple[str, ...] = ()
100
+ #: Deployed load files, as ``<path beneath Files>/<filename>``.
101
+ files: tuple[str, ...] = ()
102
+ #: Generated load procedures, as ``<schema>.<name>``.
103
+ procedures: tuple[str, ...] = ()
104
+
105
+ def to_mapping(self) -> dict[str, object]:
106
+ """A versioned JSON-safe representation for remote state handover."""
107
+
108
+ return {
109
+ "format_version": 1,
110
+ "target_id": self.target_id,
111
+ "kind": self.kind,
112
+ "target_name": self.target_name,
113
+ "schemas": list(self.schemas),
114
+ "folder_schemas": list(self.folder_schemas),
115
+ "folders": list(self.folders),
116
+ "tables": list(self.tables),
117
+ "views": list(self.views),
118
+ "files": list(self.files),
119
+ "procedures": list(self.procedures),
120
+ }
121
+
122
+ @classmethod
123
+ def from_mapping(cls, mapping) -> "TargetInventory":
124
+ """Reconstruct an inventory returned by an in-Fabric state read."""
125
+
126
+ version = mapping.get("format_version")
127
+ if version != 1:
128
+ raise BuildError(
129
+ f"unsupported target inventory format_version {version!r}; expected 1"
130
+ )
131
+ return cls(
132
+ target_id=mapping["target_id"],
133
+ kind=mapping["kind"],
134
+ target_name=mapping["target_name"],
135
+ schemas=tuple(mapping.get("schemas", ())),
136
+ folder_schemas=tuple(mapping.get("folder_schemas", ())),
137
+ folders=tuple(mapping.get("folders", ())),
138
+ tables=tuple(mapping.get("tables", ())),
139
+ views=tuple(mapping.get("views", ())),
140
+ files=tuple(mapping.get("files", ())),
141
+ procedures=tuple(mapping.get("procedures", ())),
142
+ )
143
+
144
+ def update_using(self, plan) -> "TargetInventory":
145
+ """This target as the plan intends to leave it.
146
+
147
+ The build's declared effect on this target, applied. What it gives is a
148
+ *prediction*, and the value of a prediction is that it can be wrong: an
149
+ estate built from a repository and read back should equal the same
150
+ repository's declared inventory, and if applying a build's own summary to
151
+ the state it was planned against does not reach that, the build does not
152
+ converge.
153
+
154
+ Reads the summary rather than inferring one from the actions, because an
155
+ inference would be a model of what executors do living where no executor
156
+ could correct it. The summary is held to the actions separately, by
157
+ bijection over action ids.
158
+ """
159
+
160
+ from .changes import apply_to
161
+
162
+ return apply_to(self, plan.target_changes.get(self.target_id, ()))
163
+
164
+ def has_object(self, schema: str, name: str, object_type: str) -> bool:
165
+ """Whether the target holds this object, asked of the right collection.
166
+
167
+ Branching on the type is not a convenience: falling through to ``tables``
168
+ for a type this did not know about would answer *no* for something that
169
+ is plainly there, and reconciliation reads a *no* as proof the claim is
170
+ stale.
171
+ """
172
+
173
+ if object_type == "file":
174
+ # A file is addressed by path, and its schema already *is* the path
175
+ # beneath Files — so the two halves join with a separator rather than
176
+ # the dot a two-part object name uses.
177
+ return _holds(self.files, f"{schema}/{name}")
178
+ if object_type == "stored_procedure":
179
+ return _holds(self.procedures, f"{schema}.{name}")
180
+ physical_schema = schema
181
+ values = self.views if object_type == "view" else self.tables
182
+ if object_type == "folder":
183
+ prefix = "Files/"
184
+ if schema.casefold().startswith(prefix.casefold()):
185
+ physical_schema = schema[len(prefix) :]
186
+ values = self.folders
187
+ return _holds(values, f"{physical_schema}.{name}")
188
+
189
+
190
+ def _holds(values: Iterable[str], qualified: str) -> bool:
191
+ return qualified.casefold() in {value.casefold() for value in values}
192
+
193
+
194
+ def read_lakehouse_inventory(
195
+ target: BoundTarget, *, resolver, store: Store, spark=None
196
+ ) -> TargetInventory:
197
+ """Read every Weaver-manageable object in one Lakehouse."""
198
+
199
+ lakehouse = ItemRef(target.item_id)
200
+ tables_root = resolver.tables_root(lakehouse)
201
+ files_root = resolver.files_root(lakehouse)
202
+ control_item = target.logical_item_name == "_weaver"
203
+ reserved_schemas = set(_RESERVED_SCHEMAS)
204
+ if control_item:
205
+ reserved_schemas.discard(CATALOGUE_SCHEMA)
206
+ schemas = tuple(
207
+ entry.name
208
+ for entry in _child_dirs(store, tables_root)
209
+ if (
210
+ entry.name.casefold() == CATALOGUE_SCHEMA.casefold()
211
+ if control_item
212
+ else entry.name.casefold() not in reserved_schemas
213
+ )
214
+ )
215
+ catalogue = _catalogue_for(resolver, lakehouse, spark)
216
+ if (
217
+ control_item
218
+ and catalogue is not None
219
+ and catalogue.schema_exists(CATALOGUE_SCHEMA)
220
+ and CATALOGUE_SCHEMA.casefold()
221
+ not in {schema.casefold() for schema in schemas}
222
+ ):
223
+ # The empty catalogue schema is catalogue state, not storage state: until
224
+ # its first table exists there is no Tables/_ directory for the store to
225
+ # discover. The package-owned control item is the one safe exception to
226
+ # storage-only schema discovery because it is the attached Lakehouse.
227
+ schemas += (CATALOGUE_SCHEMA,)
228
+ tables = tuple(
229
+ f"{schema}.{entry.name}"
230
+ for schema in schemas
231
+ for entry in _child_dirs(store, tables_root / schema)
232
+ )
233
+ # The same narrowing the Delta side uses, and for the same reason. The
234
+ # control item's Files area holds Weaver's own working directories — the
235
+ # declaration, retained bundles, CLI handover — none of which is a Folder
236
+ # object; what it *does* declare is the task log, under the reserved schema.
237
+ # Excluding the whole area instead left that folder unobservable, so every
238
+ # build concluded it was absent and tried to create it again.
239
+ folder_schema_entries = tuple(
240
+ entry
241
+ for entry in _child_dirs(store, files_root)
242
+ if (
243
+ entry.name.casefold() == CATALOGUE_SCHEMA.casefold()
244
+ if control_item
245
+ else entry.name not in _RESERVED_FILES_AREAS
246
+ )
247
+ )
248
+ folders = tuple(
249
+ f"{entry.name}.{child.name}"
250
+ for entry in folder_schema_entries
251
+ for child in _child_dirs(store, entry.location)
252
+ )
253
+ views: tuple[str, ...] = ()
254
+ if catalogue is not None:
255
+ views = tuple(
256
+ f"{schema}.{view}"
257
+ for schema in schemas
258
+ for view in catalogue.views(schema)
259
+ )
260
+ files = () if control_item else _load_files(store, files_root)
261
+ return TargetInventory(
262
+ target_id=target.id,
263
+ kind=target.kind,
264
+ target_name=target.name,
265
+ schemas=tuple(sorted(schemas, key=str.casefold)),
266
+ folder_schemas=tuple(
267
+ sorted((entry.name for entry in folder_schema_entries), key=str.casefold)
268
+ ),
269
+ folders=tuple(sorted(folders, key=str.casefold)),
270
+ tables=tuple(sorted(tables, key=str.casefold)),
271
+ views=tuple(sorted(views, key=str.casefold)),
272
+ files=files,
273
+ )
274
+
275
+
276
+ def _load_files(store: Store, files_root) -> tuple[str, ...]:
277
+ """Every deployed load file, as the path beneath ``Files`` that names it.
278
+
279
+ Scoped to the runtime tree rather than the whole Files area, and deliberately:
280
+ a Folder object's *contents* are data an item loaded, not objects Weaver
281
+ installed, so walking all of Files would inventory rows as though they were
282
+ artefacts. The load tree is the one place a build puts individual files it
283
+ claims one by one.
284
+ """
285
+
286
+ root = files_root / LOAD_ROOT.split("/")[0]
287
+ if not store.exists(root) or not store.is_directory(root):
288
+ return ()
289
+ prefix = files_root.value.rstrip("/") + "/"
290
+ return tuple(
291
+ sorted(
292
+ (
293
+ entry.location.value[len(prefix) :]
294
+ for entry in store.list(root, recursive=True)
295
+ if not entry.is_directory
296
+ ),
297
+ key=str.casefold,
298
+ )
299
+ )
300
+
301
+
302
+ def read_warehouse_inventory(target: BoundTarget, *, sql) -> TargetInventory:
303
+ """Read every Weaver-manageable schema, table, view and procedure."""
304
+
305
+ rows = sql.query(
306
+ """
307
+ select schema_name(objects.schema_id) as schema_name,
308
+ objects.name as object_name,
309
+ objects.type as object_type
310
+ from sys.objects as objects
311
+ where objects.is_ms_shipped = 0
312
+ and objects.type in (N'U', N'V', N'P')
313
+ order by schema_name(objects.schema_id), objects.name
314
+ """
315
+ )
316
+ objects = [
317
+ (
318
+ str(row["schema_name"]),
319
+ str(row["object_name"]),
320
+ str(row["object_type"]).strip(),
321
+ )
322
+ for row in rows
323
+ if str(row["schema_name"]).casefold() not in _RESERVED_SQL_SCHEMAS
324
+ ]
325
+ schema_rows = sql.query(
326
+ """
327
+ select schemas.name as name
328
+ from sys.schemas as schemas
329
+ left join sys.database_principals as owners
330
+ on owners.principal_id = schemas.principal_id
331
+ where owners.is_fixed_role is null or owners.is_fixed_role = 0
332
+ """
333
+ )
334
+ schemas = tuple(
335
+ sorted(
336
+ {
337
+ str(row["name"])
338
+ for row in schema_rows
339
+ if str(row["name"]).casefold() not in _RESERVED_SQL_SCHEMAS
340
+ },
341
+ key=str.casefold,
342
+ )
343
+ )
344
+ return TargetInventory(
345
+ target_id=target.id,
346
+ kind=target.kind,
347
+ target_name=target.name,
348
+ schemas=schemas,
349
+ tables=tuple(
350
+ sorted(
351
+ (f"{schema}.{name}" for schema, name, kind in objects if kind == "U"),
352
+ key=str.casefold,
353
+ )
354
+ ),
355
+ views=tuple(
356
+ sorted(
357
+ (f"{schema}.{name}" for schema, name, kind in objects if kind == "V"),
358
+ key=str.casefold,
359
+ )
360
+ ),
361
+ procedures=tuple(
362
+ sorted(
363
+ (f"{schema}.{name}" for schema, name, kind in objects if kind == "P"),
364
+ key=str.casefold,
365
+ )
366
+ ),
367
+ )
368
+
369
+
370
+ def render_inventory_prune(
371
+ target: BoundTarget,
372
+ inventory: TargetInventory,
373
+ managed: _Managed,
374
+ payloads: dict[str, bytes],
375
+ ) -> tuple[tuple[BuildAction, ...], tuple[TargetChange, ...]]:
376
+ """Purely render prune actions from one already-read inventory.
377
+
378
+ ``payloads`` is filled with the frozen drops, keyed by bare filename: the
379
+ caller owns which sequence these actions land in and therefore which payload
380
+ directory they live under.
381
+
382
+ Returns the changes alongside, and this is the one place they are not merely
383
+ convenient. A prune action carries no ``resource_node_id`` — the object it
384
+ removes has no node in the repository, which is why it is being pruned — so
385
+ what a prune destroys is otherwise recorded nowhere a reader or a test can
386
+ reach without parsing SQL.
387
+ """
388
+
389
+ actions: list[BuildAction] = []
390
+ changes: list[TargetChange] = []
391
+ if target.kind == "warehouse":
392
+ for qualified in inventory.views:
393
+ if qualified.casefold() not in managed.views:
394
+ schema, name = qualified.split(".", 1)
395
+ actions.append(
396
+ _drop_action(
397
+ target,
398
+ PRUNE_VIEW,
399
+ "view",
400
+ qualified,
401
+ f"drop view if exists {_tsql_ident(schema)}.{_tsql_ident(name)};",
402
+ payloads,
403
+ executor="tsql",
404
+ extension=".sql",
405
+ )
406
+ )
407
+ changes.append(removed(VIEW_KIND, qualified, actions[-1].id))
408
+ for qualified in inventory.tables:
409
+ if qualified.casefold() not in managed.tables:
410
+ schema, name = qualified.split(".", 1)
411
+ actions.append(
412
+ _drop_action(
413
+ target,
414
+ PRUNE_TABLE,
415
+ "table",
416
+ qualified,
417
+ f"drop table if exists {_tsql_ident(schema)}.{_tsql_ident(name)};",
418
+ payloads,
419
+ executor="tsql",
420
+ extension=".sql",
421
+ )
422
+ )
423
+ changes.append(removed(TABLE_KIND, qualified, actions[-1].id))
424
+ for schema in inventory.schemas:
425
+ if schema.casefold() not in managed.schemas:
426
+ actions.append(
427
+ _drop_action(
428
+ target,
429
+ PRUNE_SCHEMA,
430
+ "schema",
431
+ schema,
432
+ f"drop schema if exists {_tsql_ident(schema)};",
433
+ payloads,
434
+ executor="tsql",
435
+ extension=".sql",
436
+ )
437
+ )
438
+ changes.append(removed(SCHEMA_KIND, schema, actions[-1].id))
439
+ else:
440
+ orphan_schemas = {
441
+ schema.casefold()
442
+ for schema in inventory.schemas
443
+ if schema.casefold() not in managed.schemas
444
+ }
445
+ for qualified in inventory.views:
446
+ schema, name = qualified.split(".", 1)
447
+ if (
448
+ schema.casefold() not in orphan_schemas
449
+ and qualified.casefold() not in managed.views
450
+ ):
451
+ actions.append(
452
+ _drop_action(
453
+ target,
454
+ PRUNE_VIEW,
455
+ "view",
456
+ qualified,
457
+ f"DROP VIEW IF EXISTS {object_token(schema, name)}",
458
+ payloads,
459
+ )
460
+ )
461
+ changes.append(removed(VIEW_KIND, qualified, actions[-1].id))
462
+ for qualified in inventory.tables:
463
+ schema, name = qualified.split(".", 1)
464
+ if (
465
+ schema.casefold() not in orphan_schemas
466
+ and qualified.casefold() not in managed.tables
467
+ ):
468
+ actions.append(
469
+ _drop_action(
470
+ target,
471
+ PRUNE_TABLE,
472
+ "table",
473
+ qualified,
474
+ f"DROP TABLE IF EXISTS {object_token(schema, name)}",
475
+ payloads,
476
+ )
477
+ )
478
+ changes.append(removed(TABLE_KIND, qualified, actions[-1].id))
479
+ for schema in inventory.folder_schemas:
480
+ if schema.casefold() not in managed.folder_schemas:
481
+ actions.append(_prune_folder_action(target, f"folder:{schema}"))
482
+ changes.append(removed(FOLDER_SCHEMA_KIND, schema, actions[-1].id))
483
+ for qualified in inventory.folders:
484
+ schema, _name = qualified.split(".", 1)
485
+ if (
486
+ schema.casefold() in managed.folder_schemas
487
+ and qualified.casefold() not in managed.folders
488
+ ):
489
+ actions.append(_prune_folder_action(target, f"folder:{qualified}"))
490
+ changes.append(removed(FOLDER_KIND, qualified, actions[-1].id))
491
+ for schema in inventory.schemas:
492
+ if schema.casefold() in orphan_schemas:
493
+ actions.append(
494
+ _drop_action(
495
+ target,
496
+ PRUNE_SCHEMA,
497
+ "schema",
498
+ schema,
499
+ f"DROP SCHEMA IF EXISTS {schema_token(schema)} CASCADE",
500
+ payloads,
501
+ )
502
+ )
503
+ changes.append(removed(SCHEMA_KIND, schema, actions[-1].id))
504
+ return tuple(actions), tuple(changes)
505
+
506
+
507
+ def managed_sets(
508
+ documents: Mapping[str, SourceDocument],
509
+ object_target_kind: str = DELTA_TARGET,
510
+ *,
511
+ alias_destinations: Iterable[WeaverDocumentId] = (),
512
+ load_identities: Iterable[WeaverDocumentId] = (),
513
+ ) -> _Managed:
514
+ """The keep-set for one physical side: Delta objects, or Warehouse ones.
515
+
516
+ ``alias_destinations`` are the item's alias destinations. They belong in the
517
+ keep-set because they are desired state in this item exactly as a declared
518
+ document is — merely produced somewhere else — and a build that pruned the
519
+ shortcut or view it was about to create would be both destructive and
520
+ pointless. Which set an alias joins follows its physical form: a folder under
521
+ Files, a view in a Warehouse, a table directory in a Lakehouse.
522
+
523
+ ``load_identities`` contribute the one namespace a document cannot: the ``_``
524
+ schema a Warehouse's generated load procedures live in. Nothing *declares* a
525
+ document there, so without this the schema would be an orphan and every build
526
+ would drop the schema it had just created. It is derived from the artefacts
527
+ rather than added unconditionally, which is what lets the schema go when the
528
+ last procedure does. On the Lakehouse side the runtime tree needs nothing
529
+ here — it is a declared folder, and is spared as one.
530
+ """
531
+
532
+ tables = {d.qualified for d in documents.values() if d.target_kind == object_target_kind and d.kind == TABLE}
533
+ views = {d.qualified for d in documents.values() if d.target_kind == object_target_kind and d.kind == VIEW}
534
+ folders = {d.qualified for d in documents.values() if d.target_kind == FOLDER_TARGET}
535
+ for destination in alias_destinations:
536
+ qualified = destination.object_id.qualified
537
+ if destination.is_files:
538
+ folders.add(qualified)
539
+ elif object_target_kind == SQL_TARGET:
540
+ views.add(qualified)
541
+ else:
542
+ tables.add(qualified)
543
+ schemas = {name.split(".", 1)[0].lower() for name in tables | views}
544
+ schemas.update(
545
+ identity.object_id.schema.lower()
546
+ for identity in load_identities
547
+ if identity.shape == PROCEDURE_SHAPE
548
+ )
549
+ return _Managed(
550
+ schemas=frozenset(schemas),
551
+ folder_schemas=frozenset(name.split(".", 1)[0].lower() for name in folders),
552
+ folders=frozenset(name.lower() for name in folders),
553
+ tables=frozenset(name.lower() for name in tables),
554
+ views=frozenset(name.lower() for name in views),
555
+ )
556
+
557
+
558
+ def _drop_action(
559
+ target,
560
+ kind,
561
+ slug,
562
+ name,
563
+ statement,
564
+ payloads,
565
+ *,
566
+ executor: str = "spark_sql",
567
+ extension: str = ".spark.sql",
568
+ ) -> BuildAction:
569
+ content = (statement + "\n").encode("utf-8")
570
+ filename = f"{slug}-{name}{extension}"
571
+ payloads[filename] = content
572
+ return BuildAction(
573
+ id=f"prune-{slug}-{name}",
574
+ kind=kind,
575
+ resource_node_id=None,
576
+ executor=executor,
577
+ payload=filename,
578
+ payload_sha256=sha256_hex(content),
579
+ )
580
+
581
+
582
+ def _prune_folder_action(target, resource: str) -> BuildAction:
583
+ return BuildAction(
584
+ id=f"prune-{resource}",
585
+ kind=PRUNE_FOLDER,
586
+ resource_node_id=resource,
587
+ executor="folder",
588
+ payload=None,
589
+ payload_sha256=None,
590
+ )
591
+
592
+
593
+ def _child_dirs(store: Store, root) -> list:
594
+ if not store.exists(root) or not store.is_directory(root):
595
+ return []
596
+ return sorted(
597
+ (entry for entry in store.list(root) if entry.is_directory), key=lambda e: e.name
598
+ )
599
+
600
+
601
+ def _catalogue_for(resolver, lakehouse: ItemRef, spark) -> "SparkCatalogue | None":
602
+ """Catalogue operations against the Lakehouse being reconciled.
603
+
604
+ None without a session — prune still reconciles tables, folders and schemas
605
+ from storage, and simply cannot see views, which is the documented cost of
606
+ generating without one.
607
+ """
608
+
609
+ if spark is None:
610
+ return None
611
+ resolve = getattr(resolver, "spark_destination", None)
612
+ if resolve is None: # pragma: no cover - both shipped resolvers provide it
613
+ return None
614
+ return SparkCatalogue(spark, resolve(lakehouse))
615
+
616
+
617
+ def _tsql_ident(name: str) -> str:
618
+ """A bracket-quoted T-SQL identifier."""
619
+
620
+ return "[" + name.replace("]", "]]") + "]"