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,497 @@
1
+ """Loading a Python-defined Delta table — the mechanics behind ``Table.load()``.
2
+
3
+ The authored class proposes rows; this owns everything that happens to them.
4
+ ``read()`` returns ``(staging, deletes)`` and never touches the target, which is
5
+ the invariant the whole runtime rests on: an object that wrote to its own table
6
+ would make Weaver's accounting a guess.
7
+
8
+ **The first value is staging, not an upsert set.** It has not been validated,
9
+ nothing has been rejected from it, and no row in it has yet been classified as
10
+ new or changed. It goes through the same phases the Warehouse procedure runs::
11
+
12
+ staging
13
+ → validate keys
14
+ → reject invalid rows
15
+ → valid staging
16
+ → compare against target
17
+ → derive the upsert set
18
+ → insert new rows
19
+ → update changed rows
20
+ → apply explicit deletes, separately, by key
21
+ → delete absent rows, for non-incremental loads only
22
+
23
+ **The intermediate tables are real**, as they are in the Warehouse and in the
24
+ generated Spark SQL program: ``<Schema>.<Object>_Staging``, ``_Upsert`` and
25
+ ``_Reject``. That is what makes a load inspectable — what the source produced,
26
+ what was refused, and what Weaver decided to change are all still there
27
+ afterwards, and a run that failed can be understood without re-running the
28
+ authored code that produced it. Temporary views cannot do that: they vanish with
29
+ the session that made them, which is exactly when someone wants to look.
30
+
31
+ **``Incremental`` chooses the delete driver, and there is only ever one.** An
32
+ incremental source is a window on the truth, so absence proves nothing and the
33
+ object must *state* what went — ``read()[1]``. A non-incremental source is the
34
+ whole truth, so absence is the statement and an explicit list would be a second,
35
+ quieter answer to a question already answered; a non-incremental table that
36
+ returns one is refused rather than silently ignored, as a non-incremental folder
37
+ already is.
38
+
39
+ One driver, used for both the stability count and the deletion itself. Adding
40
+ two paths together would let the guard protect against a number the load never
41
+ intended to delete.
42
+
43
+ Two departures from the reference remain, both forced. It is written in SQL
44
+ rather than the DataFrame API, because ``tests/test_core_boundary.py`` forbids
45
+ importing ``pyspark`` or ``delta`` anywhere in ``weaver``. And ``fault_tolerant``
46
+ is Weaver's own addition: an intolerant run returns before any target mutation.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ from ..declaration.spark_load import (
52
+ COLUMN_MAPPING,
53
+ blank_key_predicate,
54
+ changed_predicate,
55
+ delta_audit_names,
56
+ key_join,
57
+ live_delete_literal,
58
+ )
59
+ from ..errors import LoadError
60
+ from .load_contract import (
61
+ REASON_BLANK_PK,
62
+ REASON_DUPLICATE_PK,
63
+ REJECTION_REASON,
64
+ LoadContract,
65
+ )
66
+ from .load_result import LoadResult
67
+
68
+ #: The suffixes of the three artefacts, matching the Warehouse and the generated
69
+ #: Spark program so one vocabulary describes a load whichever engine ran it.
70
+ STAGING_SUFFIX = "_Staging"
71
+ UPSERT_SUFFIX = "_Upsert"
72
+ REJECT_SUFFIX = "_Reject"
73
+
74
+ #: The keys a load will remove, settled before it removes any. Working rather
75
+ #: than evidence — unlike the reject table, it says nothing a failed run needs
76
+ #: explaining — so it is cleared with the rest.
77
+ DELETE_SUFFIX = "_Delete"
78
+
79
+ #: What ranks duplicate keys, and what marks a row of the upsert set as new.
80
+ #: Both are Weaver's, and both sit in a table beside the author's own columns.
81
+ RANK_COLUMN = "__weaver_pk_row_number"
82
+ IS_NEW_COLUMN = "_Is new row"
83
+
84
+ INTOLERANT_MESSAGE = (
85
+ "rows were rejected and fault_tolerant = 0, so the target was not modified"
86
+ )
87
+ TOLERATED_MESSAGE = "rows were rejected and excluded from the load"
88
+
89
+ #: What a run reports when the change is larger than the object allows. Governed
90
+ #: by ``fault_tolerant`` exactly as row rejection is: refused outright at 0, gone
91
+ #: ahead with but still reported as a failure at 1.
92
+ #: A breach never mutates. ``fault_tolerant`` decides only how the refusal is
93
+ #: surfaced — raised, or returned as a failed result — because a change this
94
+ #: large being *tolerated* is what ``ignore_stability_threshold`` is for.
95
+ BREACH_MESSAGE = "{reason}; the target was not modified"
96
+
97
+
98
+ def load_table(
99
+ spark,
100
+ *,
101
+ contract: LoadContract,
102
+ lakehouse,
103
+ staging_frame,
104
+ deletes=None,
105
+ fault_tolerant: bool = False,
106
+ ignore_stability_threshold: bool = False,
107
+ ) -> LoadResult:
108
+ """Load one Delta table from the rows its object staged.
109
+
110
+ The destination is resolved by the caller, never inferred here: a load that
111
+ guessed it from the attached Lakehouse would write to the control plane.
112
+ """
113
+
114
+ schema, name = contract.object_id.schema, contract.object_id.object
115
+ names = {
116
+ "target": lakehouse.qualify(schema, name),
117
+ "staging": lakehouse.qualify(schema, name + STAGING_SUFFIX),
118
+ "upsert": lakehouse.qualify(schema, name + UPSERT_SUFFIX),
119
+ "reject": lakehouse.qualify(schema, name + REJECT_SUFFIX),
120
+ "delete": lakehouse.qualify(schema, name + DELETE_SUFFIX),
121
+ }
122
+ columns = _business_columns(spark, names["target"])
123
+ _require_columns(staging_frame, contract, columns)
124
+ deletes = _delete_driver(contract, deletes)
125
+
126
+ # Every run starts from nothing a previous run left. Otherwise a clean load
127
+ # leaves the last run's reject table standing, and it reads as evidence about
128
+ # the run that just succeeded.
129
+ _clear(spark, names)
130
+
131
+ _materialise_staging(spark, names, staging_frame, contract, columns)
132
+ rows_read = _count(spark, names["staging"])
133
+
134
+ if contract.replaces_wholesale:
135
+ return _full_replace(spark, names, columns, rows_read)
136
+
137
+ rows_rejected = _reject_invalid_keys(spark, names, contract, columns)
138
+ if rows_rejected and not fault_tolerant:
139
+ # Nothing has been written, so refusing is a decision not to start
140
+ # rather than an unwind — and the reject table is the evidence.
141
+ raise LoadError(
142
+ f"{contract.qualified}: {INTOLERANT_MESSAGE}",
143
+ result=LoadResult.failure(
144
+ INTOLERANT_MESSAGE, rows_read=rows_read, rows_rejected=rows_rejected
145
+ ),
146
+ )
147
+
148
+ _derive_upserts(spark, names, contract, columns)
149
+ _derive_deletes(spark, names, contract, deletes)
150
+
151
+ # Everything the load is about to do, counted before it does any of it —
152
+ # which is the whole reason the change set is a table.
153
+ target_before = _count(spark, names["target"])
154
+ breach = None
155
+ if not ignore_stability_threshold:
156
+ breach = contract.breaches(
157
+ target_rows=target_before,
158
+ deleting=_count(spark, names["delete"]),
159
+ updating=_count(spark, names["upsert"], where=f"`{IS_NEW_COLUMN}` = 0"),
160
+ )
161
+ if breach:
162
+ # A breach never writes. Tolerating one would be tolerating exactly the
163
+ # change the threshold was declared to prevent, so what fault_tolerant
164
+ # decides here is only whether the refusal is raised or returned.
165
+ refused = LoadResult.failure(
166
+ BREACH_MESSAGE.format(reason=breach),
167
+ rows_read=rows_read,
168
+ rows_rejected=rows_rejected,
169
+ )
170
+ if not fault_tolerant:
171
+ raise LoadError(f"{contract.qualified}: {breach}", result=refused)
172
+ return refused
173
+
174
+ inserted, updated = _apply_upserts(spark, names, contract, columns)
175
+ _apply_deletes(spark, names, contract)
176
+
177
+ # What the target actually lost, from its own cardinality. The delete driver
178
+ # says what the load *intended*; this says what happened, and the two differ
179
+ # whenever a key named for deletion was not there to begin with.
180
+ deleted = target_before + inserted - _count(spark, names["target"])
181
+
182
+ result = LoadResult(
183
+ succeeded=True,
184
+ rows_read=rows_read,
185
+ rows_inserted=inserted,
186
+ rows_updated=updated,
187
+ rows_deleted=deleted,
188
+ rows_rejected=rows_rejected,
189
+ )
190
+ if rows_rejected:
191
+ # The artefacts stay: a run that refused rows is one someone will want
192
+ # to look at, and the reject table alone does not explain itself.
193
+ return result.rejected(f"{rows_rejected} {TOLERATED_MESSAGE}")
194
+ _clear(spark, names)
195
+ return result
196
+
197
+
198
+ # --- phases ------------------------------------------------------------------
199
+
200
+
201
+ def _materialise_staging(spark, names, frame, contract: LoadContract, columns) -> None:
202
+ """Put what ``read()`` produced into a real table, ranked for duplicates.
203
+
204
+ Materialised before anything else happens, and that ordering is the point:
205
+ the authored query runs exactly once, and a source that fails does so before
206
+ the target has been touched.
207
+ """
208
+
209
+ view = _register(spark, frame, names["target"], "staged")
210
+ named = ", ".join(f"s.`{c}`" for c in columns)
211
+ if not contract.primary_key:
212
+ selected = named
213
+ else:
214
+ partition = ", ".join(f"s.`{c}`" for c in contract.primary_key)
215
+ selected = (
216
+ f"{named}, row_number() OVER ("
217
+ f" PARTITION BY {partition} ORDER BY (SELECT NULL)) AS `{RANK_COLUMN}`"
218
+ )
219
+ spark.sql(
220
+ f"CREATE TABLE {names['staging']} USING delta {COLUMN_MAPPING} AS "
221
+ f"SELECT {selected} FROM {view} AS s"
222
+ )
223
+
224
+
225
+ def _reject_invalid_keys(spark, names, contract: LoadContract, columns) -> int:
226
+ """Move rows Weaver will not load into the reject table, with the reason.
227
+
228
+ A count alone says something went wrong and nothing about what, so the rows
229
+ are kept. They are then removed from staging, which from here on is the
230
+ *valid* staging every later phase reads.
231
+ """
232
+
233
+ blank = blank_key_predicate(contract.primary_key, alias="s")
234
+ rejected = f"({blank} OR s.`{RANK_COLUMN}` > 1)"
235
+ named = ", ".join(f"s.`{c}`" for c in columns)
236
+ spark.sql(
237
+ f"CREATE TABLE {names['reject']} USING delta {COLUMN_MAPPING} AS\n"
238
+ f"SELECT {named},\n"
239
+ f" CASE WHEN {blank} THEN '{REASON_BLANK_PK}'\n"
240
+ f" ELSE '{REASON_DUPLICATE_PK}' END AS `{REJECTION_REASON}`\n"
241
+ f"FROM {names['staging']} AS s WHERE {rejected}"
242
+ )
243
+ count = _count(spark, names["reject"])
244
+ if count:
245
+ unqualified = rejected.replace("s.`", "`")
246
+ spark.sql(f"DELETE FROM {names['staging']} WHERE {unqualified}")
247
+ else:
248
+ # Nothing was refused, so there is no evidence to keep and an empty table
249
+ # standing next to the object would only invite the wrong conclusion.
250
+ spark.sql(f"DROP TABLE IF EXISTS {names['reject']}")
251
+ return count
252
+
253
+
254
+ def _derive_upserts(spark, names, contract: LoadContract, columns) -> None:
255
+ """Record what this load has decided to change, before it changes anything.
256
+
257
+ A table rather than a subquery, so what Weaver decided is inspectable
258
+ afterwards — and so the stability check can read the size of the change
259
+ before a single row has moved.
260
+ """
261
+
262
+ join = key_join("s", "t", contract.primary_key)
263
+ changed = changed_predicate("s", "t", contract)
264
+ missing = f"t.`{contract.primary_key[0]}` IS NULL"
265
+ named = ", ".join(f"s.`{c}`" for c in columns)
266
+ spark.sql(
267
+ f"CREATE TABLE {names['upsert']} USING delta {COLUMN_MAPPING} AS\n"
268
+ f"SELECT {named},\n"
269
+ f" CASE WHEN {missing} THEN 1 ELSE 0 END AS `{IS_NEW_COLUMN}`\n"
270
+ f"FROM {names['staging']} AS s\n"
271
+ f"LEFT JOIN {names['target']} AS t ON {join}\n"
272
+ f"WHERE {missing} OR ({changed})"
273
+ )
274
+
275
+
276
+ def _apply_upserts(spark, names, contract: LoadContract, columns) -> tuple[int, int]:
277
+ """Insert the new rows, then update the changed ones.
278
+
279
+ Two statements over the one materialised set, as the Warehouse does, so both
280
+ counts describe exactly the rows the writes touched.
281
+ """
282
+
283
+ audit = delta_audit_names()
284
+ insert_columns = ", ".join(f"`{c}`" for c in columns)
285
+ audit_columns = ", ".join(f"`{a}`" for a in audit)
286
+ inserted = _count(spark, names["upsert"], where=f"`{IS_NEW_COLUMN}` = 1")
287
+ if inserted:
288
+ spark.sql(
289
+ f"INSERT INTO {names['target']} ({insert_columns}, {audit_columns})\n"
290
+ f"SELECT {insert_columns}, current_timestamp(), current_timestamp(), "
291
+ f"{live_delete_literal()}\n"
292
+ f"FROM {names['upsert']} WHERE `{IS_NEW_COLUMN}` = 1"
293
+ )
294
+
295
+ updated = _count(spark, names["upsert"], where=f"`{IS_NEW_COLUMN}` = 0")
296
+ if updated:
297
+ sets = [
298
+ f"t.`{c}` = u.`{c}`" for c in columns if c not in contract.primary_key
299
+ ] + [
300
+ f"t.`{audit[1]}` = current_timestamp()",
301
+ f"t.`{audit[2]}` = {live_delete_literal()}",
302
+ ]
303
+ # A merge rather than an UPDATE ... FROM, which Delta does not have. The
304
+ # rows were already chosen when the upsert set was built, so this only
305
+ # applies the change it recorded.
306
+ spark.sql(
307
+ f"MERGE INTO {names['target']} AS t\n"
308
+ f"USING (SELECT * FROM {names['upsert']} WHERE `{IS_NEW_COLUMN}` = 0) AS u\n"
309
+ f" ON {key_join('u', 't', contract.primary_key)}\n"
310
+ f"WHEN MATCHED THEN UPDATE SET {', '.join(sets)}"
311
+ )
312
+ return inserted, updated
313
+
314
+
315
+ def _delete_driver(contract: LoadContract, deletes):
316
+ """Which delete claim this object makes, refusing the one it may not.
317
+
318
+ ``Incremental`` decides, and it decides exclusively. A non-incremental table
319
+ that also returns explicit deletes is stating twice, in two ways, and the
320
+ second statement would be applied on top of a reconciliation that already
321
+ accounted for it — so it is refused rather than ignored, exactly as a
322
+ non-incremental folder's explicit deletes are.
323
+ """
324
+
325
+ if contract.incremental:
326
+ return deletes
327
+ if deletes is not None and bool(deletes.take(1)):
328
+ raise LoadError(
329
+ f"{contract.qualified}: a non-incremental table cannot name explicit "
330
+ "deletes — the source is the whole truth, so a row's absence from it "
331
+ "is what retires it. Return an empty frame, or declare "
332
+ "Incremental: true."
333
+ )
334
+ return None
335
+
336
+
337
+ def _derive_deletes(spark, names, contract: LoadContract, deletes) -> None:
338
+ """Materialise the keys this load will remove, before it removes any.
339
+
340
+ One relation from one driver. A number obtained by deleting would be a
341
+ report rather than a check, and the guard's whole purpose is to decide *not*
342
+ to — so the keys are settled first and the same set is then both counted and
343
+ applied.
344
+ """
345
+
346
+ keys = ", ".join(f"`{c}`" for c in contract.primary_key)
347
+ spark.sql(f"DROP TABLE IF EXISTS {names['delete']}")
348
+ if contract.incremental:
349
+ source = (
350
+ f"SELECT DISTINCT {keys} FROM "
351
+ f"{_register(spark, deletes, names['target'], 'delete_keys')}"
352
+ if deletes is not None
353
+ else f"SELECT {keys} FROM {names['target']} WHERE false"
354
+ )
355
+ # Only keys the target actually holds: a delete for a row that was never
356
+ # there is not a deletion, and counting it would make the guard protect
357
+ # against work the load was never going to do.
358
+ join = key_join("d", "t", contract.primary_key)
359
+ spark.sql(
360
+ f"CREATE TABLE {names['delete']} USING delta {COLUMN_MAPPING} AS\n"
361
+ f"SELECT {', '.join(f't.`{c}`' for c in contract.primary_key)}\n"
362
+ f"FROM {names['target']} AS t JOIN ({source}) AS d ON {join}"
363
+ )
364
+ return
365
+
366
+ join = key_join("s", "t", contract.primary_key)
367
+ spark.sql(
368
+ f"CREATE TABLE {names['delete']} USING delta {COLUMN_MAPPING} AS\n"
369
+ f"SELECT {', '.join(f't.`{c}`' for c in contract.primary_key)}\n"
370
+ f"FROM {names['target']} AS t\n"
371
+ f"WHERE NOT EXISTS "
372
+ f"(SELECT 1 FROM {names['staging']} AS s WHERE {join})"
373
+ )
374
+
375
+
376
+ def _apply_deletes(spark, names, contract) -> None:
377
+ """Remove exactly the keys the driver settled on."""
378
+
379
+ join = key_join("d", "t", contract.primary_key)
380
+ spark.sql(
381
+ f"MERGE INTO {names['target']} AS t USING {names['delete']} AS d "
382
+ f"ON {join} WHEN MATCHED THEN DELETE"
383
+ )
384
+
385
+
386
+ def _full_replace(spark, names, columns, rows_read: int) -> LoadResult:
387
+ """No key, so no row can be matched: the target's contents become these.
388
+
389
+ Staging is materialised first and the target emptied only afterwards, which
390
+ is the whole reason staging is a table. Clearing the target and *then*
391
+ evaluating the authored source would leave nothing behind if the source
392
+ failed.
393
+ """
394
+
395
+ audit = delta_audit_names()
396
+ named = ", ".join(f"`{c}`" for c in columns)
397
+ audit_columns = ", ".join(f"`{a}`" for a in audit)
398
+ rows_deleted = _count(spark, names["target"])
399
+ spark.sql(f"DELETE FROM {names['target']}")
400
+ spark.sql(
401
+ f"INSERT INTO {names['target']} ({named}, {audit_columns})\n"
402
+ f"SELECT {named}, current_timestamp(), current_timestamp(), "
403
+ f"{live_delete_literal()} FROM {names['staging']}"
404
+ )
405
+ _clear(spark, names)
406
+ return LoadResult(
407
+ succeeded=True,
408
+ rows_read=rows_read,
409
+ rows_inserted=rows_read,
410
+ rows_deleted=rows_deleted,
411
+ )
412
+
413
+
414
+ # --- helpers -----------------------------------------------------------------
415
+
416
+
417
+ def _clear(spark, names) -> None:
418
+ """Drop the three artefacts, newest dependency first."""
419
+
420
+ for key in ("upsert", "reject", "delete", "staging"):
421
+ spark.sql(f"DROP TABLE IF EXISTS {names[key]}")
422
+
423
+
424
+ def _count(spark, relation: str, *, where: str | None = None) -> int:
425
+ """How many rows. Unfiltered, Delta answers this from its transaction log.
426
+
427
+ Which is why the target's own count is affordable: it is the ``sys.partitions``
428
+ equivalent rather than a scan. A filtered count does read, so the filtered
429
+ ones here are over the upsert set, never over the target.
430
+ """
431
+
432
+ clause = f" WHERE {where}" if where else ""
433
+ return int(
434
+ spark.sql(f"SELECT count(*) AS n FROM {relation}{clause}").collect()[0]["n"]
435
+ )
436
+
437
+
438
+ def _business_columns(spark, target: str) -> tuple[str, ...]:
439
+ """The target's own columns, less the audit ones the load supplies itself.
440
+
441
+ Read from the table rather than from the declaration, for the reason the
442
+ Warehouse installer reads sys.columns: the physical table is what is being
443
+ written to, and a declaration that had drifted from it would produce a
444
+ statement naming a column that is not there.
445
+ """
446
+
447
+ audit = set(delta_audit_names())
448
+ return tuple(
449
+ field.name
450
+ for field in spark.table(target).schema.fields
451
+ if field.name not in audit
452
+ )
453
+
454
+
455
+ def _require_columns(frame, contract: LoadContract, columns) -> None:
456
+ """Every column the target needs must be present, by exact name.
457
+
458
+ Checked before anything is written, and by name rather than by position: a
459
+ frame whose columns happen to line up today would silently load the wrong
460
+ values the day an author reorders a select.
461
+ """
462
+
463
+ produced = set(frame.columns)
464
+ missing = [name for name in columns if name not in produced]
465
+ if missing:
466
+ raise LoadError(
467
+ f"{contract.qualified}: read() did not produce "
468
+ f"{', '.join(repr(name) for name in missing)} — the staged frame "
469
+ "must carry every column the table declares, by exact name"
470
+ )
471
+ key_missing = [name for name in contract.primary_key if name not in produced]
472
+ if key_missing:
473
+ raise LoadError(
474
+ f"{contract.qualified}: the primary key columns "
475
+ f"{', '.join(repr(name) for name in key_missing)} are not in the "
476
+ "staged frame, so no row can be matched"
477
+ )
478
+
479
+
480
+ def _register(spark, frame, target: str, role: str) -> str:
481
+ """A temporary view over one frame, so SQL can name it.
482
+
483
+ The one legitimate use of a temp view here: it names an in-flight
484
+ ``DataFrame`` for a single statement. It is never where a phase's result
485
+ lives — those are tables, because they have to outlive the session.
486
+ """
487
+
488
+ name = "weaver_" + role + "_" + _clean(target)
489
+ frame.createOrReplaceTempView(name)
490
+ return name
491
+
492
+
493
+ def _clean(name: str) -> str:
494
+ return name.replace(".", "_").replace("`", "").replace(" ", "_").replace("-", "_")
495
+
496
+
497
+ __all__ = ["INTOLERANT_MESSAGE", "TOLERATED_MESSAGE", "load_table"]
@@ -0,0 +1,49 @@
1
+ """Addressing and operating a *named* Spark destination.
2
+
3
+ Spark execution can no longer assume one destination. Even the simplest install
4
+ involves two — the Weaver Lakehouse holding the catalogue, and the Lakehouse
5
+ being built — reached from one session, so "where" has to be said rather than
6
+ inherited from whatever the session is attached to.
7
+
8
+ Three pieces, in the order they are used:
9
+
10
+ :mod:`~weaver.spark.destination`
11
+ what a Lakehouse is *called* here — Fabric's four-part name, or the local
12
+ proxy's folded database name.
13
+ :mod:`~weaver.spark.tokens`
14
+ how a frozen payload names an object without naming a destination, so a
15
+ bundle stays comparable between environments.
16
+ :mod:`~weaver.spark.catalogue`
17
+ the operations — create, execute, discover, exists — each against one named
18
+ destination.
19
+
20
+ Nothing here imports PySpark. A session is passed in and used through ``sql``
21
+ and ``catalog``, so the core stays importable without a JVM.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .catalogue import SparkCatalogue, drop_local_destination_catalogue
27
+ from .destination import (
28
+ LOCAL_SEPARATOR,
29
+ SparkDestination,
30
+ fabric_destination,
31
+ identifier,
32
+ local_destination,
33
+ )
34
+ from .tokens import expand, object_token, schema_token
35
+ from .session import local_delta_session
36
+
37
+ __all__ = [
38
+ "LOCAL_SEPARATOR",
39
+ "SparkCatalogue",
40
+ "SparkDestination",
41
+ "drop_local_destination_catalogue",
42
+ "expand",
43
+ "fabric_destination",
44
+ "identifier",
45
+ "local_destination",
46
+ "local_delta_session",
47
+ "object_token",
48
+ "schema_token",
49
+ ]