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,660 @@
1
+ """Read and reconcile catalogue claims before bundle generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from types import MappingProxyType
8
+ from typing import Any, Mapping
9
+
10
+ from ..declaration.metadata import ObjectId
11
+ from ..declaration.model import (
12
+ FILE_SHAPE,
13
+ PROCEDURE_SHAPE,
14
+ WeaverDocumentId,
15
+ WeaverItemId,
16
+ )
17
+ from ..errors import BuildError
18
+ from ..spark.tokens import object_token
19
+ from .claims import CatalogueClaim, catalogue_schema, claim_rules_for_object_type
20
+ from .reader import _is_absent, read_installation, read_table
21
+ from .render import InstallationScope
22
+ from .tables import (
23
+ BUILD_EPOCH,
24
+ CATALOGUE_TABLES,
25
+ INSTALLATION,
26
+ OBJECT_TYPES,
27
+ REGISTRY,
28
+ SCOPE_ITEM_NAME,
29
+ SCOPE_ITEM_TYPE,
30
+ )
31
+
32
+ #: What a Folder's stored schema carries, so a table and a folder of the same
33
+ #: name stay apart. Only the object shape uses it — see :func:`catalogue_schema`.
34
+ _FILES_PREFIX = "Files/"
35
+
36
+
37
+ @dataclass(frozen=True, init=False)
38
+ class Catalogue:
39
+ """The catalogue the build reads and reasons about.
40
+
41
+ One class, whatever produced it. In production it is read from the Weaver
42
+ Lakehouse over Spark; a test may build one directly from Registry rows, or
43
+ from a repository — the state a successful build of that repository would
44
+ have left. That is not a fake: it is the same class the build consumes, begun
45
+ further along, exactly as installing a frozen bundle begins further along
46
+ than building one from a repository.
47
+
48
+ Everything the build's own logic needs is here and nothing else. Incremental
49
+ selection, alias staleness and claim collection all work from ``registered``
50
+ and ``rows``, so they are pure Python and can be proven without standing up a
51
+ Lakehouse to seed a signature.
52
+
53
+ ``rows`` is the catalogue's own row data, by item and table. ``registered`` is
54
+ the certified documents derived from the Registry rows — identity, type,
55
+ signature and publication epoch, and no audit columns, because none of the
56
+ build's decisions depend on who wrote a row or when it was touched.
57
+
58
+ ``present_tables`` records which catalogue tables physically exist. One line
59
+ of the reconciler needs it and the rule it encodes is not optional: a claim
60
+ can only be raised against a table that is actually there, or reconciliation
61
+ would emit deletes against tables that are not.
62
+ """
63
+
64
+ rows: Mapping[WeaverItemId, Mapping[str, tuple[Mapping[str, object], ...]]]
65
+ registered: Mapping[WeaverDocumentId, "RegisteredDocument"]
66
+ present_tables: frozenset[str]
67
+
68
+ def __init__(
69
+ self,
70
+ rows: Mapping[WeaverItemId, Mapping[str, tuple[Mapping[str, object], ...]]],
71
+ *,
72
+ registered: Mapping[WeaverDocumentId, "RegisteredDocument"] | None = None,
73
+ present_tables: frozenset[str] | None = None,
74
+ ) -> None:
75
+ frozen_rows = MappingProxyType(dict(rows))
76
+ object.__setattr__(self, "rows", frozen_rows)
77
+
78
+ object.__setattr__(
79
+ self,
80
+ "registered",
81
+ _registered_documents(frozen_rows)
82
+ if registered is None
83
+ else MappingProxyType(dict(registered)),
84
+ )
85
+ # Defaulting to "every table this catalogue carries rows for" keeps a
86
+ # hand-built catalogue honest without making every caller state it.
87
+ object.__setattr__(
88
+ self,
89
+ "present_tables",
90
+ frozenset(
91
+ present_tables
92
+ if present_tables is not None
93
+ else {
94
+ table
95
+ for tables in frozen_rows.values()
96
+ for table in tables
97
+ }
98
+ ),
99
+ )
100
+
101
+ def to_mapping(self) -> dict[str, object]:
102
+ """A versioned JSON-safe representation for a remote state boundary."""
103
+
104
+ return {
105
+ "format_version": 1,
106
+ "items": [
107
+ {
108
+ "item": str(item),
109
+ "tables": {
110
+ table: [
111
+ {key: _encode_json_value(value) for key, value in row.items()}
112
+ for row in rows
113
+ ]
114
+ for table, rows in sorted(tables.items())
115
+ },
116
+ }
117
+ for item, tables in sorted(self.rows.items(), key=lambda pair: str(pair[0]))
118
+ ],
119
+ "present_tables": sorted(self.present_tables),
120
+ }
121
+
122
+ @classmethod
123
+ def from_mapping(cls, mapping) -> "Catalogue":
124
+ """Reconstruct catalogue state without querying a target locally."""
125
+
126
+ version = mapping.get("format_version")
127
+ if version != 1:
128
+ raise BuildError(
129
+ f"unsupported catalogue format_version {version!r}; expected 1"
130
+ )
131
+ rows = {
132
+ WeaverItemId.parse(entry["item"]): MappingProxyType(
133
+ {
134
+ table: tuple(
135
+ {
136
+ key: _decode_json_value(value)
137
+ for key, value in row.items()
138
+ }
139
+ for row in table_rows
140
+ )
141
+ for table, table_rows in entry.get("tables", {}).items()
142
+ }
143
+ )
144
+ for entry in mapping.get("items", ())
145
+ }
146
+ return cls(
147
+ rows=MappingProxyType(rows),
148
+ present_tables=frozenset(mapping.get("present_tables", ())),
149
+ )
150
+
151
+ # --- constructors ---------------------------------------------------------
152
+ #
153
+ # Symmetrical on purpose. One reads what is persisted, the other derives what
154
+ # the source says should be; both produce this class, which is what lets the
155
+ # two be compared at all.
156
+
157
+ @classmethod
158
+ def from_weaver_lakehouse(cls, catalogue: Any, items) -> "Catalogue":
159
+ """The persisted catalogue, read over Spark from the Weaver Lakehouse."""
160
+
161
+ return read_catalogue_state(catalogue, items)
162
+
163
+ @classmethod
164
+ def from_repository(cls, repository) -> "Catalogue":
165
+ """Everything the source declares — the whole logical catalogue.
166
+
167
+ *All* of it, deliberately. Not the subset some build is ready to certify:
168
+ making selection an input would mean the caller had to know what was
169
+ certifiable before the desired state could be described, and the desired
170
+ state would then be a statement about a build rather than about the
171
+ repository. Selection and materialisation *transform* this later — see
172
+ :meth:`retaining` and :meth:`for_targets`.
173
+
174
+ It carries no binding: no target name, no Weaver version, no Installation
175
+ row, no publication epoch, and no Registry certification for an alias
176
+ destination, because an alias is a view in a Warehouse and a table in a
177
+ Lakehouse and this does not know which.
178
+ """
179
+
180
+ from ..etl import item_load_artefacts
181
+ from .projection import project_item_catalogue
182
+
183
+ rows = {}
184
+ for model in repository.items:
185
+ item = model.identity
186
+ declared = {
187
+ identity
188
+ for identity in repository.source_documents
189
+ if identity.item == item
190
+ }
191
+ # A load artefact is declared by the source exactly as a document is
192
+ # — derived from it, but a target in its own right — so it belongs in
193
+ # what the repository says should exist.
194
+ declared.update(
195
+ artefact.identity
196
+ for artefact in item_load_artefacts(repository, item=item)
197
+ )
198
+ projection = project_item_catalogue(
199
+ repository, item=item, retained=declared
200
+ )
201
+ rows[item] = MappingProxyType(dict(projection.rows))
202
+ return cls(rows=MappingProxyType(rows))
203
+
204
+ # --- transformations ------------------------------------------------------
205
+
206
+ def diff(self, desired: "Catalogue") -> "CatalogueChanges":
207
+ """How this catalogue would move toward the one ``desired`` describes.
208
+
209
+ Read it as: *persisted* `.diff(` *derived from source* `)`. The result
210
+ reports from both sides and renders statements from ``desired`` alone —
211
+ see :class:`CatalogueChanges` for why that asymmetry is deliberate.
212
+ """
213
+
214
+ return CatalogueChanges(current=self, desired=desired)
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class CatalogueChanges:
219
+ """How a persisted catalogue would move toward the one a repository describes.
220
+
221
+ Carries both sides, and uses them for different things — which is the whole
222
+ of the design and the part worth not getting wrong.
223
+
224
+ ``current`` informs *reporting*: how many rows are new, changed, unchanged
225
+ and removed, so a reviewer can see what a bundle will do before it runs.
226
+
227
+ ``desired`` alone drives the *statements*. The delete keeps exactly the keys
228
+ the desired catalogue claims and the merge is idempotent, so the pair is
229
+ correct against any prior state — including one the reader never saw. Deriving
230
+ the delete from the row-level difference instead would look equivalent and
231
+ would not be: a partial or scoped-wrong read returns fewer rows in
232
+ ``current``, so the diff would emit fewer deletes and obsolete claims would
233
+ survive indefinitely, with nothing to notice. As it stands a bad read costs a
234
+ misleading *report* and cannot corrupt the catalogue.
235
+ """
236
+
237
+ current: "Catalogue"
238
+ desired: "Catalogue"
239
+
240
+ def per_table(self):
241
+ """``{item: (TableChanges, ...)}`` — reporting only, never statements."""
242
+
243
+ from .reconcile import compare
244
+ from .tables import DICTIONARY_TABLES, INSTALLATION, REGISTRY
245
+
246
+ tables = (*DICTIONARY_TABLES, INSTALLATION, REGISTRY)
247
+ report = {}
248
+ for item, wanted in self.desired.rows.items():
249
+ found = self.current.rows.get(item, {})
250
+ report[item] = tuple(
251
+ compare(table, wanted.get(table.name, ()), found.get(table.name, ()))
252
+ for table in tables
253
+ )
254
+ return report
255
+
256
+ @property
257
+ def is_noop(self) -> bool:
258
+ return all(
259
+ change.is_noop
260
+ for changes in self.per_table().values()
261
+ for change in changes
262
+ )
263
+
264
+ def render_dml(self, *, installation=None):
265
+ """``{item: CatalogueReconciliation}`` making the catalogue match ``desired``.
266
+
267
+ A structured result rather than flat statements, and deliberately: the
268
+ caller needs the dictionaries, the Installation row and the Registry
269
+ separated, because Registry is written last in its own barrier so a row
270
+ certifying an object cannot outrun the work it attests to. Handing back
271
+ one list would leave the caller to recover that ordering by inspecting
272
+ the SQL, which is a guess dressed as a grouping.
273
+
274
+ ``installation`` supplies the binding facts per item — which target, which
275
+ Weaver — because a repository-derived catalogue does not know them and
276
+ must not invent them.
277
+ """
278
+
279
+ from .projection import CatalogueProjection
280
+ from .reconcile import reconcile
281
+
282
+ installation = dict(installation or {})
283
+ rendered = {}
284
+ for item, wanted in self.desired.rows.items():
285
+ rows = dict(wanted)
286
+ binding = installation.get(item)
287
+ if binding is not None:
288
+ rows[INSTALLATION.name] = tuple(binding)
289
+ projection = CatalogueProjection(
290
+ scope=InstallationScope(item.item_type, item.item_name),
291
+ rows=rows,
292
+ )
293
+ rendered[item] = reconcile(projection)
294
+ return rendered
295
+
296
+
297
+ def retaining(catalogue: Catalogue, repository, identities) -> Catalogue:
298
+ """Narrow a desired catalogue to what a build actually certified.
299
+
300
+ The step that keeps a Registry row meaning *this succeeded*. Publishing the
301
+ whole logical catalogue would claim every declared object as installed,
302
+ including the ones a build omitted or failed to materialise — which is what
303
+ the planner's uncertified set exists to prevent.
304
+
305
+ A function rather than a method, and ``repository`` passed rather than
306
+ remembered, because a catalogue is rows: making it carry the repository it
307
+ came from would give a *persisted* one two fields that mean nothing and two
308
+ methods that refuse. The dependency is real, so it is visible.
309
+ """
310
+
311
+ from .projection import project_item_catalogue
312
+
313
+ wanted = set(identities)
314
+ rows = {}
315
+ for item in catalogue.rows:
316
+ kept = {identity for identity in wanted if identity.item == item}
317
+ if not kept:
318
+ # An item this build retains nothing of is out of scope, not empty.
319
+ # Keeping it would publish a scope that deletes everything the item
320
+ # has, and would demand a binding for an item that has none.
321
+ continue
322
+ projection = project_item_catalogue(repository, item=item, retained=kept)
323
+ rows[item] = MappingProxyType(dict(projection.rows))
324
+ return Catalogue(rows=MappingProxyType(rows))
325
+
326
+
327
+ def for_targets(
328
+ catalogue: Catalogue,
329
+ repository,
330
+ identities,
331
+ target_kinds: Mapping[WeaverItemId, str],
332
+ ) -> Catalogue:
333
+ """Bind to targets: certify alias destinations, and scope to what is bound.
334
+
335
+ ``target_kinds`` names the items being published *and* what each is bound to,
336
+ and those are one decision rather than two. An item not named is not
337
+ published — so an alias can never be certified against a guessed kind,
338
+ because there is no path that reaches the certification without stating the
339
+ binding. A default would have written a Warehouse alias into the Registry as
340
+ a table, quietly, in the authoritative record.
341
+
342
+ ``identities`` is what the build certified, and it is passed rather than read
343
+ off the rows because the two differ on purpose: an alias whose source item is
344
+ unbound still has its *declaration* published — the name does point there —
345
+ while a Registry row would claim work that never happened.
346
+
347
+ An item named here but retaining nothing is still published, and must be: its
348
+ scope's rows are all obsolete and the publication is what says so.
349
+ """
350
+
351
+ from .projection import project_alias_registry
352
+
353
+ certified = set(identities)
354
+ rows = {}
355
+ for item, kind in target_kinds.items():
356
+ tables = dict(catalogue.rows.get(item, {}))
357
+ certifiable = {
358
+ alias.destination
359
+ for alias in repository.aliases
360
+ if alias.destination.item == item and alias.destination in certified
361
+ }
362
+ if certifiable:
363
+ tables[REGISTRY.name] = tuple(
364
+ tables.get(REGISTRY.name, ())
365
+ ) + project_alias_registry(
366
+ repository, item=item, retained=certifiable, target_kind=kind
367
+ )
368
+ rows[item] = MappingProxyType(tables)
369
+ return Catalogue(rows=MappingProxyType(rows))
370
+
371
+
372
+ @dataclass(frozen=True)
373
+ class Reconciliation:
374
+ """What reconciling a catalogue against prepared inventories produced.
375
+
376
+ Two things, and they are genuinely two: the catalogue with disproved claims
377
+ removed, and the claims that were removed — which the build turns into delete
378
+ DML. Carrying the second on the catalogue itself made it look like catalogue
379
+ state, when it is a *finding about* the catalogue, and left claim collection
380
+ reading one of its two claim sources off an object and computing the other.
381
+ """
382
+
383
+ catalogue: Catalogue
384
+ #: Claims disproved by the inventory, to be deleted before physical work.
385
+ stale_claims: tuple[CatalogueClaim, ...]
386
+ #: The disproved objects, as readable labels. Nothing in the build consumes
387
+ #: this — it exists so a reconciliation decision can be seen and asserted
388
+ #: rather than inferred from the DML it eventually produces.
389
+ stale_objects: tuple[str, ...] = ()
390
+
391
+
392
+ @dataclass(frozen=True)
393
+ class RegisteredDocument:
394
+ """One validated Registry row, parsed once at the catalogue boundary."""
395
+
396
+ identity: WeaverDocumentId
397
+ object_type: str
398
+ signature: str
399
+ #: When the build that last certified this object published it. ``None`` for
400
+ #: a row written before epochs existed, which orders as older than any epoch.
401
+ build_epoch: object = None
402
+
403
+
404
+ def _encode_json_value(value):
405
+ if isinstance(value, datetime):
406
+ return {"$weaver_type": "datetime", "value": value.isoformat()}
407
+ if value is None or isinstance(value, (str, bool, int, float)):
408
+ return value
409
+ raise BuildError(
410
+ f"catalogue state contains a non-JSON value: {type(value).__name__}"
411
+ )
412
+
413
+
414
+ def _decode_json_value(value):
415
+ if isinstance(value, dict) and value.get("$weaver_type") == "datetime":
416
+ return datetime.fromisoformat(value["value"])
417
+ return value
418
+
419
+
420
+ def _registered_documents(
421
+ rows: Mapping[WeaverItemId, Mapping[str, tuple[Mapping[str, object], ...]]]
422
+ ) -> Mapping[WeaverDocumentId, RegisteredDocument]:
423
+ registered: dict[WeaverDocumentId, RegisteredDocument] = {}
424
+ for item, tables in rows.items():
425
+ for row in tables.get(REGISTRY.name, ()):
426
+ # The type is read first because it is what says how the other two
427
+ # columns are shaped: ``_/Load/lib`` and ``dates.py`` are a schema
428
+ # and an object only once the row has said it describes a file.
429
+ object_type = str(row.get("object_type") or "")
430
+ if object_type not in OBJECT_TYPES:
431
+ expected = ", ".join(OBJECT_TYPES)
432
+ raise BuildError(
433
+ f"Registry row for {item}/{row.get('schema_name')}."
434
+ f"{row.get('object_name')} has unsupported object_type "
435
+ f"{object_type!r}; expected one of {expected}"
436
+ )
437
+ identity = _row_identity(item, row, object_type)
438
+ signature = str(row.get("signature") or "")
439
+ if not signature:
440
+ raise BuildError(f"Registry row for {identity} has no signature")
441
+ document = RegisteredDocument(
442
+ identity, object_type, signature, row.get(BUILD_EPOCH)
443
+ )
444
+ prior = registered.get(identity)
445
+ if prior is not None and prior != document:
446
+ raise BuildError(f"Registry contains conflicting rows for {identity}")
447
+ registered[identity] = document
448
+ return MappingProxyType(registered)
449
+
450
+
451
+ def read_catalogue_state(catalogue: Any, items) -> Catalogue:
452
+ """Read the catalogue over Spark — the production way to populate one.
453
+
454
+ The one place a catalogue meets a session. Everything downstream of it is
455
+ pure, so this is the boundary whose *fidelity* is worth a Spark test: does a
456
+ real catalogue read back into the same class a fixture builds directly.
457
+
458
+ The shape check is deliberately strict and must stay so. A physically
459
+ incomplete catalogue is rejected here rather than tolerated, because tests
460
+ wanting a Registry-only catalogue can construct one directly — weakening this
461
+ to accommodate them would trade a real production guarantee for a fixture's
462
+ convenience.
463
+ """
464
+
465
+ present: set[str] = set()
466
+ missing: set[str] = set()
467
+ incompatible: list[str] = []
468
+ for table in CATALOGUE_TABLES:
469
+ name = catalogue.expand(object_token("_", table.name))
470
+ try:
471
+ columns = catalogue.spark.table(name).columns
472
+ except Exception as exc:
473
+ if _is_absent(exc):
474
+ missing.add(table.name)
475
+ continue
476
+ raise
477
+ present.add(table.name)
478
+ folded = {column.casefold() for column in columns}
479
+ # Published columns are required too, and deliberately: the merge writes
480
+ # one on every insert, so a catalogue without it can be *read* but not
481
+ # *written*. Exempting it here would let planning succeed and push the
482
+ # failure into the install, where it surfaces as an engine complaint
483
+ # about an unknown column rather than as a statement about the
484
+ # catalogue's shape. The reader's null tolerance answers a different
485
+ # question — a column that exists but predates some rows — and both hold
486
+ # at once: require the column, tolerate the value.
487
+ required = {
488
+ name.casefold()
489
+ for name in table.column_names + table.published_column_names
490
+ }
491
+ absent_columns = sorted(required - folded)
492
+ if absent_columns:
493
+ incompatible.append(f"{table.name}.{absent_columns[0]}")
494
+ if incompatible:
495
+ raise BuildError(
496
+ "catalogue schema is incompatible; missing required column(s): "
497
+ + ", ".join(incompatible)
498
+ )
499
+ rows = {
500
+ item: MappingProxyType(
501
+ read_installation(
502
+ catalogue,
503
+ scope=InstallationScope(item.item_type, item.item_name),
504
+ )
505
+ )
506
+ for item in items
507
+ }
508
+ return Catalogue(
509
+ rows=MappingProxyType(rows),
510
+ present_tables=frozenset(present),
511
+ )
512
+
513
+
514
+ def read_installed_catalogue(catalogue: Any) -> Catalogue:
515
+ """Read the whole installed catalogue, without being told what is in it.
516
+
517
+ The sibling of :func:`read_catalogue_state`, and the difference is the one
518
+ that matters to an operation which runs *after* a build. A build knows its
519
+ items — it is holding the repository that declares them — and reads each
520
+ installation's scope. Load orchestration knows only physical targets, and
521
+ has to discover which logical items are installed and where they are bound
522
+ before it can decide anything at all. So this reads unscoped and groups the
523
+ rows by the installation scope they carry.
524
+
525
+ The shape check is deliberately weaker than the build's: a table that does
526
+ not exist reads as no rows rather than as a fault, because an estate with no
527
+ aliases has never had an Alias table written and that is an ordinary state
528
+ for something that only reads. Nothing here writes, so nothing here needs
529
+ the guarantee that the catalogue can *be* written.
530
+ """
531
+
532
+ rows: dict[WeaverItemId, dict[str, list[Mapping[str, object]]]] = {}
533
+ present: set[str] = set()
534
+ for table in CATALOGUE_TABLES:
535
+ table_rows = read_table(catalogue, table)
536
+ if table_rows:
537
+ present.add(table.name)
538
+ for row in table_rows:
539
+ item_type = str(row.get(SCOPE_ITEM_TYPE) or "")
540
+ item_name = str(row.get(SCOPE_ITEM_NAME) or "")
541
+ if not item_type or not item_name:
542
+ raise BuildError(
543
+ f"{table.qualified} holds a row with no installation scope; "
544
+ "every catalogue row names the logical item it belongs to"
545
+ )
546
+ item = WeaverItemId(item_type, item_name)
547
+ rows.setdefault(item, {}).setdefault(table.name, []).append(row)
548
+ return Catalogue(
549
+ rows=MappingProxyType(
550
+ {
551
+ item: MappingProxyType(
552
+ {name: tuple(table_rows) for name, table_rows in tables.items()}
553
+ )
554
+ for item, tables in rows.items()
555
+ }
556
+ ),
557
+ present_tables=frozenset(present),
558
+ )
559
+
560
+
561
+ def reconcile_catalogue_state(
562
+ state: Catalogue, *, inventories: Mapping[WeaverItemId, Any]
563
+ ) -> Reconciliation:
564
+ """Discard catalogue claims the prepared inventories physically disprove.
565
+
566
+ Pure: a catalogue in, an inventory in, a catalogue and its stale claims out.
567
+ Both inputs can be built directly, so what a registered object does when the
568
+ thing it claims is *not there* needs no Lakehouse to demonstrate.
569
+ """
570
+
571
+ registered = state.registered
572
+ reconciled = {}
573
+ stale_claims: list[CatalogueClaim] = []
574
+ stale_labels: list[str] = []
575
+ for item, tables in state.rows.items():
576
+ inventory = inventories.get(item)
577
+ stale: dict[WeaverDocumentId, RegisteredDocument] = {}
578
+ if inventory is not None:
579
+ for identity, document in registered.items():
580
+ if identity.item != item:
581
+ continue
582
+ if not inventory.has_object(
583
+ catalogue_schema(identity),
584
+ identity.object_id.object,
585
+ document.object_type,
586
+ ):
587
+ stale[identity] = document
588
+ filtered = {}
589
+ for table in CATALOGUE_TABLES:
590
+ rows = tables.get(table.name, ())
591
+ rules = {
592
+ rule
593
+ for document in stale.values()
594
+ for rule in claim_rules_for_object_type(document.object_type)
595
+ if rule.table == table
596
+ }
597
+ if not rules:
598
+ filtered[table.name] = tuple(rows)
599
+ continue
600
+ filtered[table.name] = tuple(
601
+ row
602
+ for row in rows
603
+ if not any(
604
+ rule.owns(row, identity)
605
+ for identity, document in stale.items()
606
+ for rule in claim_rules_for_object_type(document.object_type)
607
+ if rule.table == table
608
+ )
609
+ )
610
+ if table.name in state.present_tables:
611
+ stale_claims.extend(
612
+ CatalogueClaim(identity, rule)
613
+ for identity, document in stale.items()
614
+ for rule in claim_rules_for_object_type(document.object_type)
615
+ if rule.table == table
616
+ )
617
+ reconciled[item] = MappingProxyType(filtered)
618
+ stale_labels.extend(str(identity) for identity in stale)
619
+ retained = {
620
+ identity: document
621
+ for identity, document in registered.items()
622
+ if identity not in {claim.identity for claim in stale_claims}
623
+ }
624
+ return Reconciliation(
625
+ catalogue=Catalogue(
626
+ rows=MappingProxyType(reconciled),
627
+ registered=retained,
628
+ present_tables=state.present_tables,
629
+ ),
630
+ stale_claims=tuple(dict.fromkeys(stale_claims)),
631
+ stale_objects=tuple(sorted(stale_labels)),
632
+ )
633
+
634
+
635
+ def _row_identity(
636
+ item: WeaverItemId, row: Mapping[str, object], object_type: str
637
+ ) -> WeaverDocumentId:
638
+ """One Registry row's identity, built from its own columns.
639
+
640
+ The row already *is* the identity: item, schema, object and what kind of
641
+ object it is are four stored fields. Composing them into a line and handing
642
+ it to a parser was a round trip through a grammar built for table-style
643
+ ``Schema.Object`` names, and it could not express the two the load layer
644
+ installs — a path and a filename, or a procedure named for what it loads.
645
+ Constructing directly means the stored name is the real name, in both
646
+ directions.
647
+ """
648
+
649
+ schema = str(row.get("schema_name") or "")
650
+ name = str(row.get("object_name") or "")
651
+ if object_type == "file":
652
+ return WeaverDocumentId(item, ObjectId(schema, name), shape=FILE_SHAPE)
653
+ if object_type == "stored_procedure":
654
+ return WeaverDocumentId(item, ObjectId(schema, name), shape=PROCEDURE_SHAPE)
655
+ is_files = schema.startswith(_FILES_PREFIX)
656
+ return WeaverDocumentId(
657
+ item,
658
+ ObjectId(schema[len(_FILES_PREFIX) :] if is_files else schema, name),
659
+ is_files=is_files,
660
+ )