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,437 @@
1
+ """Projecting one logical item's catalogue rows from a validated declaration.
2
+
3
+ This is the boundary the whole design turns on. On one side is a declaration
4
+ that has already been read, validated and closed, and a build that has already
5
+ decided which items it is installing. On the other side are rows. Nothing here
6
+ re-reads a source file, imports an object module, or asks a physical table what
7
+ shape it is — every value comes from the validated declaration or from the
8
+ declaration's own resolved graph.
9
+
10
+ **Only bound items are projected.** Objects owned by unbound items are *out of
11
+ scope*, not deleted: a build has no opinion about an item it was not asked to
12
+ install, and projecting them would invite a comparison that removed them.
13
+
14
+ **Every row is stamped with the same item scope.** The scope is passed in once
15
+ and applied to every row, rather than each projector deriving it — a projector
16
+ that derived it differently would silently write into the wrong installation,
17
+ which the renderer then refuses.
18
+
19
+ **An alias is not a dependency.** A dependency row records the reference exactly
20
+ as the author wrote it, and :data:`~weaver.catalogue.tables.ALIAS` records what
21
+ the consuming item's alias points at. Joining Dependency, Alias and Registry is
22
+ what yields the estate's whole graph; keeping them apart is what stops one item
23
+ appearing to depend directly on another's physical object.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from typing import Iterable, Mapping
30
+
31
+ from ..declaration.metadata import FOLDER, TABLE, VIEW, ObjectId, Reference
32
+ from ..declaration.model import WeaverDocumentId, WeaverItemId, WeaverRepository
33
+ from ..declaration.references import declared_column_notes, resolve_text
34
+ from ..etl import PROCEDURE_TYPE, item_load_artefacts, load_artefacts_by_identity
35
+ from .claims import catalogue_schema
36
+ from .render import InstallationScope, Row, column_set
37
+ from .tables import (
38
+ ALIAS,
39
+ CATALOGUE_TABLES,
40
+ COLUMN_DICTIONARY,
41
+ DEPENDENCY,
42
+ FOLDER_DICTIONARY,
43
+ FOREIGN_KEY_DICTIONARY,
44
+ INDEX_DICTIONARY,
45
+ INSTALLATION,
46
+ KEY_PRIMARY,
47
+ KEY_UNIQUE,
48
+ REGISTRY,
49
+ ROLE_DATA,
50
+ ROLE_LOAD,
51
+ SCHEMA_DICTIONARY,
52
+ TABLE_DICTIONARY,
53
+ CatalogueTable,
54
+ )
55
+
56
+ # Catalogue projection consumes the stable persisted target-kind vocabulary; it
57
+ # does not depend on build-package binding classes. Keeping these values here
58
+ # also prevents importing the build package while catalogue reconciliation is
59
+ # still initialising.
60
+ LAKEHOUSE_TARGET = "lakehouse"
61
+ WAREHOUSE_TARGET = "warehouse"
62
+
63
+ #: How an Weaver document kind names itself in the catalogue. Deliberately a translation
64
+ #: rather than a reuse: Weaver document kinds are title case and the catalogue's vocabulary
65
+ #: is lower case, and pinning the mapping here means a new Weaver document kind must be given
66
+ #: a catalogue meaning rather than leaking one.
67
+ OBJECT_TYPE_FOR_KIND = {FOLDER: "folder", TABLE: "table", VIEW: "view"}
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class CatalogueProjection:
72
+ """Every catalogue row one build invocation wants, for one installation."""
73
+
74
+ scope: InstallationScope
75
+ rows: Mapping[str, tuple[Row, ...]]
76
+
77
+ def for_table(self, table: CatalogueTable) -> tuple[Row, ...]:
78
+ return self.rows.get(table.name, ())
79
+
80
+ @property
81
+ def total(self) -> int:
82
+ return sum(len(rows) for rows in self.rows.values())
83
+
84
+
85
+ def project_item_catalogue(
86
+ repository: WeaverRepository,
87
+ *,
88
+ item: WeaverItemId,
89
+ retained: Iterable[WeaverDocumentId],
90
+ ) -> CatalogueProjection:
91
+ """One item's catalogue rows, from the declaration and nothing else.
92
+
93
+ Every value here is a function of *source*: what the item declares, what it
94
+ aliases, what its documents describe. Nothing about a build, a binding or a
95
+ target reaches it. That is what makes the projection something a developer
96
+ keeps correct by adding a declaration, rather than a fixture someone has to
97
+ remember to update alongside one.
98
+
99
+ Not an *installation* projection, despite what this was once called. It says
100
+ what the repository declares; whether any of it has been installed, where,
101
+ and by which Weaver are separate facts composed at publication.
102
+
103
+ **No Registry row is written for an alias destination.** The Alias row —
104
+ this name points at that object — is a declaration and belongs here. The
105
+ Registry row is a certification that a physical object exists at that name
106
+ *and what it is*, and an alias is a view in a Warehouse and a table in a
107
+ Lakehouse. That cannot be answered without a binding, so it is not answered
108
+ here; see :func:`project_alias_registry`.
109
+ """
110
+
111
+ scope = InstallationScope(item.item_type, item.item_name)
112
+ retained = tuple(sorted(set(retained), key=str))
113
+ if any(identity.item != item for identity in retained):
114
+ raise ValueError(f"item projection {item} received a document owned elsewhere")
115
+
116
+ # ``retained`` carries every kind of registered object. Splitting them here
117
+ # rather than at the call site keeps the caller from having to know which is
118
+ # which — the repository already does.
119
+ alias_by_destination = {
120
+ alias.destination: alias
121
+ for alias in repository.aliases
122
+ if alias.destination.item == item
123
+ }
124
+ retained_aliases = tuple(
125
+ alias_by_destination[identity]
126
+ for identity in retained
127
+ if identity in alias_by_destination
128
+ )
129
+ loads = load_artefacts_by_identity(item_load_artefacts(repository, item=item))
130
+ retained_loads = tuple(
131
+ loads[identity] for identity in retained if identity in loads
132
+ )
133
+ retained = tuple(
134
+ identity
135
+ for identity in retained
136
+ if identity not in alias_by_destination and identity not in loads
137
+ )
138
+ documents = [repository.source_documents[identity] for identity in retained]
139
+ all_documents = tuple(repository.source_documents.values())
140
+ rows: dict[str, list[dict]] = {table.name: [] for table in CATALOGUE_TABLES}
141
+
142
+ for identity, source in zip(retained, documents):
143
+ common = _identity(scope, identity)
144
+ signature = source.effective_signature
145
+ rows[REGISTRY.name].append(
146
+ {
147
+ **common,
148
+ "object_type": OBJECT_TYPE_FOR_KIND[source.kind],
149
+ "object_role": ROLE_DATA,
150
+ "signature": signature,
151
+ }
152
+ )
153
+ described = _described(
154
+ source,
155
+ all_documents,
156
+ repository,
157
+ )
158
+ if source.kind == FOLDER:
159
+ rows[FOLDER_DICTIONARY.name].append(
160
+ {
161
+ **common,
162
+ **described,
163
+ "file_key": column_set(source.document.file_keys),
164
+ **_behaviour(source),
165
+ "signature": signature,
166
+ }
167
+ )
168
+ else:
169
+ rows[TABLE_DICTIONARY.name].append(
170
+ {
171
+ **common,
172
+ "object_type": OBJECT_TYPE_FOR_KIND[source.kind],
173
+ **described,
174
+ "primary_key": column_set(source.document.primary_key),
175
+ "not_null_columns": column_set(source.document.declared_not_null),
176
+ "identity_column": source.document.identity,
177
+ "comparison_columns": column_set(source.document.comparison_columns),
178
+ **_behaviour(source),
179
+ "signature": signature,
180
+ }
181
+ )
182
+ for column_name, note in declared_column_notes(source):
183
+ resolved = resolve_text(
184
+ note,
185
+ owner=source,
186
+ documents=all_documents,
187
+ aliases=repository.aliases,
188
+ )
189
+ rows[COLUMN_DICTIONARY.name].append(
190
+ {
191
+ **common,
192
+ "column_name": column_name,
193
+ "description": resolved.literal,
194
+ "description_reference": resolved.reference,
195
+ "is_identity": column_name == source.document.identity,
196
+ "signature": signature,
197
+ }
198
+ )
199
+ if source.document.primary_key:
200
+ rows[INDEX_DICTIONARY.name].append(
201
+ {
202
+ **common,
203
+ "index_type": KEY_PRIMARY,
204
+ "column_set": column_set(source.document.primary_key),
205
+ "signature": signature,
206
+ }
207
+ )
208
+ for unique in source.document.unique_keys:
209
+ rows[INDEX_DICTIONARY.name].append(
210
+ {
211
+ **common,
212
+ "index_type": KEY_UNIQUE,
213
+ "column_set": column_set(unique),
214
+ "signature": signature,
215
+ }
216
+ )
217
+ rows[FOREIGN_KEY_DICTIONARY.name].extend(
218
+ _foreign_keys(source, identity, common, signature)
219
+ )
220
+
221
+ # A load artefact claims the Registry and nothing else. It has no columns to
222
+ # describe, no keys to record and no dependencies to keep — it is a deployed
223
+ # module or a generated statement, and what the catalogue knows about it is
224
+ # that Weaver installed it and at what signature.
225
+ for artefact in retained_loads:
226
+ rows[REGISTRY.name].append(
227
+ {
228
+ **_identity(scope, artefact.identity),
229
+ "object_type": artefact.object_type,
230
+ "object_role": ROLE_LOAD,
231
+ "signature": artefact.signature,
232
+ }
233
+ )
234
+
235
+ retained_set = set(retained)
236
+ for edge in repository.dependency_edges:
237
+ if edge.consumer not in retained_set:
238
+ continue
239
+ source = repository.source_documents[edge.consumer]
240
+ rows[DEPENDENCY.name].append(
241
+ {
242
+ **_identity(scope, edge.consumer),
243
+ "dependency_name": edge.reference,
244
+ "is_within_item": edge.is_within_item,
245
+ "signature": source.effective_signature,
246
+ }
247
+ )
248
+
249
+ for alias in repository.aliases:
250
+ if alias.destination.item != item:
251
+ continue
252
+ rows[ALIAS.name].append(
253
+ {
254
+ **_scope(scope),
255
+ "destination_schema_name": _catalogue_schema(alias.destination),
256
+ "destination_object_name": alias.destination.object_id.object,
257
+ "source_item_type": alias.source.item.item_type,
258
+ "source_item_name": alias.source.item.item_name,
259
+ "source_schema_name": _catalogue_schema(alias.source),
260
+ "source_object_name": alias.source.object_id.object,
261
+ "signature": alias.signature,
262
+ }
263
+ )
264
+
265
+ used_schemas = sorted(
266
+ {
267
+ (_catalogue_schema(identity), identity.object_id.schema)
268
+ for identity in retained
269
+ }
270
+ | {
271
+ (_catalogue_schema(alias.destination), alias.destination.object_id.schema)
272
+ for alias in retained_aliases
273
+ }
274
+ # A generated load procedure puts a schema into use that no document
275
+ # declares an object in, so it would otherwise be a schema the
276
+ # installation uses and does not describe. A deployed file contributes
277
+ # nothing here: its schema half is a path, and the namespace it sits in
278
+ # is described by the folder document that owns the tree.
279
+ | {
280
+ (artefact.identity.object_id.schema, artefact.identity.object_id.schema)
281
+ for artefact in retained_loads
282
+ if artefact.object_type == PROCEDURE_TYPE
283
+ }
284
+ )
285
+ item_model = next(model for model in repository.items if model.identity == item)
286
+ schemas = {identity.schema: identity for identity in item_model.schemas}
287
+ for catalogue_name, declared_name in used_schemas:
288
+ schema = repository.schema_documents[schemas[declared_name]]
289
+ rows[SCHEMA_DICTIONARY.name].append(
290
+ {
291
+ **_scope(scope),
292
+ "schema_name": catalogue_name,
293
+ "description": schema.description,
294
+ "description_reference": None,
295
+ "signature": schema.source_hash,
296
+ }
297
+ )
298
+
299
+ return CatalogueProjection(
300
+ scope=scope,
301
+ rows={name: tuple(values) for name, values in rows.items()},
302
+ )
303
+
304
+
305
+ def project_alias_registry(
306
+ repository: WeaverRepository,
307
+ *,
308
+ item: WeaverItemId,
309
+ retained: Iterable[WeaverDocumentId],
310
+ target_kind: str,
311
+ ) -> tuple[Row, ...]:
312
+ """Registry rows certifying this item's alias destinations, given a binding.
313
+
314
+ Separate from :func:`project_item_catalogue` because it is the one part of an
315
+ item's catalogue that cannot be derived from source: an alias is registered
316
+ as the thing it physically *is*, and that depends on what it was bound to.
317
+
318
+ Requiring the kind rather than defaulting it is deliberate. A default would
319
+ write a *wrong* Registry row quietly — a Warehouse alias recorded as a table —
320
+ and a wrong certification is the one failure the catalogue must never produce
321
+ on its own.
322
+ """
323
+
324
+ scope = InstallationScope(item.item_type, item.item_name)
325
+ wanted = set(retained)
326
+ return tuple(
327
+ {
328
+ **_identity(scope, alias.destination),
329
+ "object_type": _alias_object_type(alias.destination, target_kind),
330
+ "object_role": ROLE_DATA,
331
+ "signature": alias.signature,
332
+ }
333
+ for alias in sorted(
334
+ (
335
+ alias
336
+ for alias in repository.aliases
337
+ if alias.destination.item == item and alias.destination in wanted
338
+ ),
339
+ key=lambda alias: str(alias.destination),
340
+ )
341
+ )
342
+
343
+
344
+ def _alias_object_type(destination: WeaverDocumentId, target_kind: str) -> str:
345
+ """What an alias destination physically *is*, in the catalogue's vocabulary.
346
+
347
+ Not a type of its own. An alias is registered as the thing it actually is —
348
+ a folder under ``Files``, a view in a Warehouse, a table in a Lakehouse —
349
+ because to every reader of the catalogue that is what it is, and because the
350
+ operations that matter (does it exist, how is it addressed, how is it
351
+ dropped) are the ordinary ones for that type. That a Lakehouse table alias
352
+ happens to be implemented as a OneLake shortcut is execution detail, the way
353
+ a managed table's storage layout is.
354
+
355
+ Its *alias-ness* is not lost: :data:`~weaver.catalogue.tables.ALIAS` records
356
+ it, and that is the only place that does.
357
+ """
358
+
359
+ if destination.is_files:
360
+ return OBJECT_TYPE_FOR_KIND[FOLDER]
361
+ if target_kind == WAREHOUSE_TARGET:
362
+ return OBJECT_TYPE_FOR_KIND[VIEW]
363
+ return OBJECT_TYPE_FOR_KIND[TABLE]
364
+
365
+
366
+ def _scope(scope: InstallationScope) -> dict[str, str]:
367
+ return dict(scope.values)
368
+
369
+
370
+ def _catalogue_schema(identity: WeaverDocumentId) -> str:
371
+ return catalogue_schema(identity)
372
+
373
+
374
+ def _identity(scope: InstallationScope, identity: WeaverDocumentId) -> dict:
375
+ return {
376
+ **_scope(scope),
377
+ "schema_name": _catalogue_schema(identity),
378
+ "object_name": identity.object_id.object,
379
+ }
380
+
381
+
382
+ def _described(source, all_documents, repository) -> dict:
383
+ description = resolve_text(
384
+ source.document.description,
385
+ owner=source,
386
+ documents=all_documents,
387
+ aliases=repository.aliases,
388
+ )
389
+ lineage = resolve_text(
390
+ source.document.lineage,
391
+ owner=source,
392
+ documents=all_documents,
393
+ aliases=repository.aliases,
394
+ )
395
+ return {
396
+ "description": description.literal,
397
+ "description_reference": description.reference,
398
+ "lineage": lineage.literal,
399
+ "lineage_reference": lineage.reference,
400
+ }
401
+
402
+
403
+ def _behaviour(source) -> dict:
404
+ return {
405
+ "is_incremental": source.document.is_incremental,
406
+ "is_static": source.document.static,
407
+ "prohibit_rebuild": source.document.prohibit_rebuild,
408
+ }
409
+
410
+
411
+ def _foreign_keys(source, identity, common, signature) -> list[dict]:
412
+ rows = []
413
+ for key in source.document.foreign_keys:
414
+ reference = key.logical_reference or Reference(
415
+ schema=key.reference.schema,
416
+ object=key.reference.object,
417
+ )
418
+ parent_item = (
419
+ WeaverItemId(reference.item_type, reference.item_name)
420
+ if reference.is_item_qualified
421
+ else identity.item
422
+ )
423
+ rows.append(
424
+ {
425
+ **common,
426
+ "column_set": column_set(key.columns),
427
+ "reference_item_type": parent_item.item_type,
428
+ "reference_item_name": parent_item.item_name,
429
+ "reference_schema_name": (
430
+ f"Files/{reference.schema}" if reference.is_files else reference.schema
431
+ ),
432
+ "reference_object_name": reference.object,
433
+ "reference_column_set": column_set(key.reference_columns),
434
+ "signature": signature,
435
+ }
436
+ )
437
+ return rows
@@ -0,0 +1,152 @@
1
+ """Reading the catalogue through its fixed expected schema.
2
+
3
+ A build compares what the catalogue holds with what the repository now declares,
4
+ so it has to read tables that may not be there yet and may be an older shape than
5
+ this Weaver knows about. Both are ordinary states, not faults:
6
+
7
+ **Bootstrap.** The very first build has no catalogue at all — the tables are
8
+ created by the build that then writes to them. A missing table reads as no rows.
9
+
10
+ **Upgrade.** A Weaver that adds a column runs against a catalogue built by an
11
+ older one. A missing column reads as a typed null, so newer code compares
12
+ successfully against an older shape and the next build repairs it. An unexpected
13
+ extra column is ignored, which is the other half: an older Weaver must not choke
14
+ on a catalogue a newer one extended.
15
+
16
+ What must **not** happen is a genuine failure being read as an empty catalogue. A
17
+ permission error, a corrupt Delta log, an unavailable store or a broken session
18
+ that returned "no rows" would make the next build's comparison conclude that
19
+ everything is new — and, once drop policy lands, that everything the catalogue no
20
+ longer mentions may be removed. So the absence check is deliberately narrow: only
21
+ Spark's own ``TABLE_OR_VIEW_NOT_FOUND`` is absence. Everything else propagates.
22
+
23
+ That asymmetry is the whole design of this module. Tolerance is cheap when it is
24
+ specific and dangerous when it is a bare ``except``.
25
+
26
+ **A read names the Lakehouse it reads from.** The catalogue lives in the Weaver
27
+ Lakehouse; a build's other work is aimed at a destination Lakehouse; one session
28
+ serves both. So a read takes a :class:`~weaver.spark.catalogue.SparkCatalogue`
29
+ rather than a bare session — asking "the catalogue" of whatever the session is
30
+ attached to would answer for the wrong Lakehouse, and answer *plausibly*, which
31
+ is the failure mode this whole area exists to remove.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import Any
37
+
38
+ from .render import InstallationScope, Row, identifier, qualified_name
39
+ from .tables import CatalogueTable
40
+
41
+ #: Spark's error class for a table or view that is not registered. A missing
42
+ #: *schema* reports the same class, which is what we want — an installation whose
43
+ #: schema `_` has never been created is as absent as one whose table has not.
44
+ _ABSENT = frozenset({"TABLE_OR_VIEW_NOT_FOUND"})
45
+
46
+
47
+ def _is_absent(exception: Exception) -> bool:
48
+ """Whether this exception means "not created yet" rather than "went wrong".
49
+
50
+ Keyed on Spark's error class rather than on message text, so a reworded
51
+ message cannot silently turn an infrastructure failure into an empty read. The
52
+ message is consulted only when no class is available, which is the case for a
53
+ session or connector that raises a plain error.
54
+ """
55
+
56
+ error_class = getattr(exception, "getErrorClass", None)
57
+ if callable(error_class):
58
+ try:
59
+ found = error_class()
60
+ except Exception: # pragma: no cover - defensive; a broken accessor is not absence
61
+ found = None
62
+ if found:
63
+ return found in _ABSENT
64
+ return False
65
+
66
+
67
+ def read_table(
68
+ catalogue: Any,
69
+ table: CatalogueTable,
70
+ *,
71
+ scope: InstallationScope | None = None,
72
+ ) -> tuple[Row, ...]:
73
+ """Every row of one catalogue table, projected through its expected schema.
74
+
75
+ ``catalogue`` is a :class:`~weaver.spark.catalogue.SparkCatalogue` bound to
76
+ the Weaver Lakehouse — the read has to say where the catalogue is, because
77
+ the session is not necessarily pointed at it.
78
+
79
+ ``scope`` narrows the read to one installation, which is what a build wants:
80
+ it compares and writes within one ``(repository, target_type)`` and has no
81
+ business seeing another's rows.
82
+
83
+ Returns plain dictionaries of ``str``/``bool``/``None`` — the same shape the
84
+ projection produces, so the two can be compared directly.
85
+ """
86
+
87
+ if catalogue is None:
88
+ raise ValueError(
89
+ f"reading {table.qualified} needs a Spark catalogue bound to the Weaver "
90
+ "Lakehouse — the catalogue lives there, and a session alone does not "
91
+ "say which Lakehouse that is"
92
+ )
93
+
94
+ name = catalogue.expand(qualified_name(table))
95
+ try:
96
+ existing = catalogue.spark.table(name).columns
97
+ except Exception as exception:
98
+ if _is_absent(exception):
99
+ return ()
100
+ raise
101
+
102
+ # Case-folded, because the local metastore lowercases column names where
103
+ # Fabric preserves them, and a column's presence must not depend on that.
104
+ present = {column.lower(): column for column in existing}
105
+
106
+ projected = ", ".join(
107
+ _projected_column(column, present.get(column.name.lower()))
108
+ for column in table.columns
109
+ )
110
+ where = ""
111
+ if scope is not None:
112
+ where = f" WHERE {scope.predicate}"
113
+
114
+ rows = catalogue.spark.sql(f"SELECT {projected} FROM {name}{where}").collect()
115
+ return tuple(row.asDict() for row in rows)
116
+
117
+
118
+ def _projected_column(column, actual: str | None) -> str:
119
+ """One column of the expected schema, as a select expression.
120
+
121
+ Rendered as SQL rather than built with ``pyspark.sql.functions`` so this module
122
+ names no Spark API — the core stays importable without PySpark, and a session
123
+ is only ever duck-typed. It also keeps the projection inspectable as text.
124
+ """
125
+
126
+ if actual is None:
127
+ # Older shape: give this Weaver the column it expects, as a typed null.
128
+ return f"CAST(NULL AS {column.type.upper()}) AS {identifier(column.name)}"
129
+ # Cast even when present: an older catalogue may have stored a boolean as a
130
+ # string, and a comparison against a projected boolean would then differ for a
131
+ # row that has not actually changed.
132
+ return (
133
+ f"CAST({identifier(actual)} AS {column.type.upper()}) "
134
+ f"AS {identifier(column.name)}"
135
+ )
136
+
137
+
138
+ def read_installation(
139
+ catalogue: Any, *, scope: InstallationScope, tables=None
140
+ ) -> dict[str, tuple[Row, ...]]:
141
+ """Every catalogue table, read for one installation.
142
+
143
+ Keyed by table name, so a caller compares table by table against the
144
+ projection without repeating the scope — which it could otherwise forget.
145
+ """
146
+
147
+ from .tables import CATALOGUE_TABLES
148
+
149
+ return {
150
+ table.name: read_table(catalogue, table, scope=scope)
151
+ for table in (tables if tables is not None else CATALOGUE_TABLES)
152
+ }