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
weaver/load_plan.py ADDED
@@ -0,0 +1,912 @@
1
+ """The physical load graph, derived from the installed catalogue alone.
2
+
3
+ This is where load orchestration decides *what runs and in what order*, and it is
4
+ pure Python: a catalogue in, a graph out. No session, no SQL connection, no
5
+ target and no repository. That is not an economy — it is the claim. The
6
+ repository is the *source*; once an estate is installed, the catalogue is the
7
+ authority on what exists, and an orchestrator that reopened the source would be
8
+ loading what somebody meant to install rather than what is there.
9
+
10
+ The direction of travel is the interesting part. A build establishes
11
+
12
+ .. code-block:: text
13
+
14
+ logical identity → physical target and physical object
15
+
16
+ and load orchestration needs the reverse, because a caller names physical
17
+ targets. So the first thing that happens is that the Installation and Registry
18
+ rows are turned inside out into :class:`InstalledEstate`.
19
+
20
+ Two logical objects resolving to one physical object would make "load
21
+ Warehouse/Reporting" a request with two possible meanings, and no such request is
22
+ carried out. But the *reading* records that finding rather than raising on it, and
23
+ :func:`load_dag` refuses when it touches what was asked for — because an estate
24
+ accumulates a Registry row for every item ever bound to a target, and a stale
25
+ duplicate in one Warehouse must not stop a load of an unrelated Lakehouse.
26
+
27
+ **The graph is physical, and the alias is why that matters.** A logical
28
+ dependency that crosses items is not an edge between two objects; it is a
29
+ publication path, and the path has a barrier in it:
30
+
31
+ .. code-block:: text
32
+
33
+ Lakehouse/Raw/Sales.Order
34
+ → published through a Warehouse-facing alias
35
+ → Warehouse/Reporting/Sales.Order
36
+ → consumed by Warehouse/Reporting/Sales.Summary
37
+
38
+ becomes
39
+
40
+ .. code-block:: text
41
+
42
+ load Raw Sales.Order → refresh Raw SQL endpoint → load Reporting Sales.Summary
43
+
44
+ The refresh is a node, not something dispatch does quietly on the way past. A
45
+ Lakehouse presents its Delta tables to SQL through an endpoint whose metadata
46
+ lags the write, so a consumer reading across that boundary before the refresh
47
+ reads the previous shape — and a barrier that lives inside dispatch cannot be
48
+ seen in a plan, cannot be ordered against anything, and cannot be asserted.
49
+
50
+ **Views are conduits, not nodes.** A view owns no load work, so it is never
51
+ dispatched; but a table depending on a view depends on whatever the view reads,
52
+ so the traversal passes *through* it to the loadable ancestors behind it. An
53
+ object with no installed load primitive is treated the same way.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ from dataclasses import dataclass, field, replace
59
+ from types import MappingProxyType
60
+ from typing import Mapping, Sequence
61
+
62
+ from .catalogue.state import Catalogue
63
+ from .catalogue.tables import ALIAS, DEPENDENCY, INSTALLATION
64
+ from .declaration.metadata import ObjectId
65
+ from .declaration.model import (
66
+ FILE_SHAPE,
67
+ LAKEHOUSE,
68
+ WAREHOUSE,
69
+ WeaverDocumentId,
70
+ WeaverItemId,
71
+ )
72
+ from .errors import LoadError
73
+ from .etl import LOAD_ROOT, load_procedure_id
74
+ from .load_report import DEPENDENCY_EXTERNAL, LoadMessage, info
75
+ from .targets import LAKEHOUSE_KIND, WAREHOUSE_KIND
76
+
77
+ # --- the primitive kinds ------------------------------------------------------
78
+ #
79
+ # What an installed load *is*, in the vocabulary dispatch branches on. Five
80
+ # values, four of them a real installed artefact and one a barrier the planner
81
+ # inserts. They are strings rather than a class hierarchy because they cross into
82
+ # a plan file and a task log, where a reader needs to see the word.
83
+
84
+ WAREHOUSE_PROCEDURE = "warehouse_procedure"
85
+ SPARK_SQL_FILE = "spark_sql_file"
86
+ PYTHON_TABLE = "python_table"
87
+ PYTHON_FOLDER = "python_folder"
88
+ ENDPOINT_REFRESH = "endpoint_refresh"
89
+
90
+ PRIMITIVE_KINDS = (
91
+ WAREHOUSE_PROCEDURE,
92
+ SPARK_SQL_FILE,
93
+ PYTHON_TABLE,
94
+ PYTHON_FOLDER,
95
+ ENDPOINT_REFRESH,
96
+ )
97
+
98
+ #: What the catalogue calls each physical target kind. The same two words the
99
+ #: build's :mod:`weaver.build_bundle.targets` uses, because a load plan and a
100
+ #: build bundle describe the same estate.
101
+ LAKEHOUSE_TARGET = "lakehouse"
102
+ WAREHOUSE_TARGET = "warehouse"
103
+
104
+ _TARGET_KIND_FOR_ITEM = {LAKEHOUSE: LAKEHOUSE_TARGET, WAREHOUSE: WAREHOUSE_TARGET}
105
+ _GRAMMAR_KIND = {LAKEHOUSE_TARGET: LAKEHOUSE_KIND, WAREHOUSE_TARGET: WAREHOUSE_KIND}
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class PhysicalTargetRef:
110
+ """One physical item, as the public grammar names it."""
111
+
112
+ kind: str
113
+ name: str
114
+
115
+ def __str__(self) -> str:
116
+ return f"{_GRAMMAR_KIND[self.kind]}/{self.name}"
117
+
118
+ @property
119
+ def is_lakehouse(self) -> bool:
120
+ return self.kind == LAKEHOUSE_TARGET
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class PhysicalObjectRef:
125
+ """One installed object, addressed as its physical target holds it.
126
+
127
+ ``schema`` is the catalogue's ``schema_name`` unchanged — which for a folder
128
+ carries its ``Files/`` prefix and for a deployed file is the path beneath
129
+ ``Files``. Keeping the stored spelling means a reference can be handed
130
+ straight to :meth:`weaver.build_bundle.prune.TargetInventory.has_object`
131
+ without a translation that could disagree with the one the catalogue used.
132
+ """
133
+
134
+ target_id: str
135
+ target_kind: str
136
+ schema: str
137
+ object: str
138
+ object_type: str
139
+ shape: str | None = None
140
+
141
+ def __str__(self) -> str:
142
+ return f"{self.schema}.{self.object}"
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class InstalledObject:
147
+ """One certified Registry row, as load planning reads it."""
148
+
149
+ identity: WeaverDocumentId
150
+ object_type: str
151
+ target: PhysicalTargetRef
152
+
153
+ @property
154
+ def physical(self) -> PhysicalObjectRef:
155
+ from .catalogue.claims import catalogue_schema
156
+
157
+ return PhysicalObjectRef(
158
+ target_id=self.target.name,
159
+ target_kind=self.target.kind,
160
+ schema=catalogue_schema(self.identity),
161
+ object=self.identity.object_id.object,
162
+ object_type=self.object_type,
163
+ shape=self.identity.shape,
164
+ )
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class InstalledAlias:
169
+ """One name a consuming item presents for another item's document."""
170
+
171
+ destination: WeaverDocumentId
172
+ source: WeaverDocumentId
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class InstalledDependency:
177
+ """One dependency edge, with the reference exactly as its author wrote it."""
178
+
179
+ consumer: WeaverDocumentId
180
+ reference: str
181
+ is_within_item: bool
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class InstalledEstate:
186
+ """The installed catalogue, reversed into what load planning asks of it.
187
+
188
+ Transport-neutral by construction: it is built from a :class:`Catalogue`,
189
+ which a test can hand-write and production reads over Spark. Everything below
190
+ this class is arithmetic on these five mappings.
191
+ """
192
+
193
+ installations: Mapping[WeaverItemId, PhysicalTargetRef]
194
+ objects: Mapping[WeaverDocumentId, InstalledObject]
195
+ primitives: Mapping[WeaverDocumentId, InstalledObject]
196
+ dependencies: tuple[InstalledDependency, ...]
197
+ aliases: tuple[InstalledAlias, ...]
198
+ #: Physical addresses two logical objects both claim, by the target they are
199
+ #: in. Recorded rather than raised — see :meth:`from_catalogue`.
200
+ ambiguous: Mapping[PhysicalTargetRef, tuple[str, ...]] = field(
201
+ default_factory=dict
202
+ )
203
+
204
+ @classmethod
205
+ def from_catalogue(cls, catalogue: Catalogue) -> "InstalledEstate":
206
+ """Reverse the whole catalogue, recording ambiguity rather than refusing it.
207
+
208
+ Two logical objects at one physical address is a real fault and load
209
+ planning must not proceed through one — but *where* it stops matters. An
210
+ estate accumulates Registry rows for every item ever bound to a target, so
211
+ a Warehouse that was rebound years ago can carry a duplicate claim
212
+ indefinitely; refusing here would make that stale row stop a load of an
213
+ unrelated Lakehouse, which is a fault report about the wrong thing.
214
+
215
+ So the finding is kept and :func:`load_dag` refuses when it touches the
216
+ request — which is exactly when the request is genuinely ambiguous.
217
+ """
218
+
219
+ installations = _installations(catalogue)
220
+ objects: dict[WeaverDocumentId, InstalledObject] = {}
221
+ primitives: dict[WeaverDocumentId, InstalledObject] = {}
222
+ physical_owner: dict[tuple, WeaverDocumentId] = {}
223
+ ambiguous: dict[PhysicalTargetRef, list[str]] = {}
224
+ for identity, document in sorted(
225
+ catalogue.registered.items(), key=lambda pair: str(pair[0])
226
+ ):
227
+ target = installations.get(identity.item)
228
+ if target is None:
229
+ # Registry without Installation: the estate says an object is
230
+ # certified but not where it lives. Refused here rather than
231
+ # skipped, because skipping it would silently shrink the graph.
232
+ raise LoadError(
233
+ f"{identity} is registered but {identity.item} has no "
234
+ "installation row, so its physical target is unknown"
235
+ )
236
+ installed = InstalledObject(identity, document.object_type, target)
237
+ where = installed.physical
238
+ key = (
239
+ where.target_kind,
240
+ where.target_id.casefold(),
241
+ where.schema.casefold(),
242
+ where.object.casefold(),
243
+ where.object_type,
244
+ )
245
+ owner = physical_owner.get(key)
246
+ if owner is not None:
247
+ ambiguous.setdefault(target, []).append(
248
+ f"{owner} and {identity} both resolve to {where}"
249
+ )
250
+ else:
251
+ physical_owner[key] = identity
252
+ if identity.is_load_artefact:
253
+ primitives[identity] = installed
254
+ else:
255
+ objects[identity] = installed
256
+ return cls(
257
+ installations=MappingProxyType(installations),
258
+ objects=MappingProxyType(objects),
259
+ primitives=MappingProxyType(primitives),
260
+ dependencies=_dependencies(catalogue),
261
+ aliases=_aliases(catalogue),
262
+ ambiguous=MappingProxyType(
263
+ {target: tuple(found) for target, found in ambiguous.items()}
264
+ ),
265
+ )
266
+
267
+ def target_for(self, item: WeaverItemId) -> PhysicalTargetRef:
268
+ target = self.installations.get(item)
269
+ if target is None:
270
+ raise LoadError(f"{item} has no installation row in the catalogue")
271
+ return target
272
+
273
+ @property
274
+ def targets(self) -> tuple[PhysicalTargetRef, ...]:
275
+ return tuple(
276
+ sorted(set(self.installations.values()), key=lambda ref: (ref.kind, ref.name))
277
+ )
278
+
279
+
280
+ def _installations(catalogue: Catalogue) -> dict[WeaverItemId, PhysicalTargetRef]:
281
+ """Each logical item's bound physical target, keyed for reverse lookup.
282
+
283
+ Several logical items may name one physical target, and that is not an error
284
+ to catch here. A request names a *target*, and what it means is "everything
285
+ installed there" — which is answerable whoever installed it, as long as no
286
+ two objects claim one address. That narrower question is the one ambiguity
287
+ actually turns on, and :meth:`InstalledEstate.from_catalogue` asks it per
288
+ object.
289
+
290
+ Refusing at the item level instead looked equivalent and was not: an estate
291
+ accumulates Installation rows from every item ever bound to a target, so a
292
+ binding that has since moved on would stop a load of a target it no longer
293
+ has a single object in.
294
+ """
295
+
296
+ bound: dict[WeaverItemId, PhysicalTargetRef] = {}
297
+ for item, tables in catalogue.rows.items():
298
+ for row in tables.get(INSTALLATION.name, ()):
299
+ name = str(row.get("target_name") or "")
300
+ if not name:
301
+ raise LoadError(
302
+ f"the installation row for {item} names no physical target"
303
+ )
304
+ kind = _TARGET_KIND_FOR_ITEM.get(item.item_type)
305
+ if kind is None:
306
+ raise LoadError(
307
+ f"{item} has item type {item.item_type!r}, which names no "
308
+ "physical target kind"
309
+ )
310
+ bound[item] = PhysicalTargetRef(kind=kind, name=name)
311
+ return bound
312
+
313
+
314
+ def _dependencies(catalogue: Catalogue) -> tuple[InstalledDependency, ...]:
315
+ found = []
316
+ for item, tables in catalogue.rows.items():
317
+ for row in tables.get(DEPENDENCY.name, ()):
318
+ consumer = _registry_identity(catalogue, item, row)
319
+ if consumer is None:
320
+ continue
321
+ found.append(
322
+ InstalledDependency(
323
+ consumer=consumer,
324
+ reference=str(row.get("dependency_name") or ""),
325
+ is_within_item=bool(row.get("is_within_item")),
326
+ )
327
+ )
328
+ return tuple(sorted(found, key=lambda edge: (str(edge.consumer), edge.reference)))
329
+
330
+
331
+ def _aliases(catalogue: Catalogue) -> tuple[InstalledAlias, ...]:
332
+ found = []
333
+ for item, tables in catalogue.rows.items():
334
+ for row in tables.get(ALIAS.name, ()):
335
+ destination = _document_id(
336
+ item,
337
+ str(row.get("destination_schema_name") or ""),
338
+ str(row.get("destination_object_name") or ""),
339
+ )
340
+ source_item = WeaverItemId(
341
+ str(row.get("source_item_type") or ""),
342
+ str(row.get("source_item_name") or ""),
343
+ )
344
+ source = _document_id(
345
+ source_item,
346
+ str(row.get("source_schema_name") or ""),
347
+ str(row.get("source_object_name") or ""),
348
+ )
349
+ found.append(InstalledAlias(destination=destination, source=source))
350
+ return tuple(sorted(found, key=lambda alias: str(alias.destination)))
351
+
352
+
353
+ _FILES_PREFIX = "Files/"
354
+
355
+
356
+ def _document_id(item: WeaverItemId, schema: str, name: str) -> WeaverDocumentId:
357
+ """One stored ``schema_name``/``object_name`` pair back as an identity."""
358
+
359
+ is_files = schema.startswith(_FILES_PREFIX)
360
+ return WeaverDocumentId(
361
+ item,
362
+ ObjectId(schema[len(_FILES_PREFIX) :] if is_files else schema, name),
363
+ is_files=is_files,
364
+ )
365
+
366
+
367
+ #: What separates schema from object in a Python module name. A module name
368
+ #: cannot carry a dot, so ``Sales.Seed`` is spelled ``Sales__Seed``.
369
+ _PYTHON_ID_SEPARATOR = "__"
370
+
371
+ #: The one directory beneath an item that holds runtime source rather than
372
+ #: declarations. An import of it names a helper, never a Weaver object.
373
+ _LIB = "lib"
374
+
375
+ #: The item-relative directory a Folder document lives in.
376
+ _FILES = "Files"
377
+
378
+
379
+ def _is_python_module_reference(reference: str) -> bool:
380
+ """Whether a stored dependency names a Python module rather than an object.
381
+
382
+ The catalogue records a dependency *exactly as its author wrote it*, and for
383
+ a Python object the author wrote an import — ``.Files.Sales__Seed``, or
384
+ ``Files.Sales__Seed``. So reversing the graph means reapplying the rule that
385
+ turned one into an identity, and telling the two spellings apart first.
386
+
387
+ A leading dot is a relative import and can be nothing else. Otherwise the
388
+ tell is the separator: a module name cannot carry a dot, so a Python object
389
+ module spells ``Schema.Object`` as ``Schema__Object`` — which a
390
+ ``Schema.Object`` reference never does.
391
+ """
392
+
393
+ if not reference:
394
+ return False
395
+ if reference.startswith("."):
396
+ return True
397
+ return _PYTHON_ID_SEPARATOR in reference.rsplit(".", 1)[-1]
398
+
399
+
400
+ def _python_module_identity(
401
+ item: WeaverItemId, reference: str
402
+ ) -> WeaverDocumentId | None:
403
+ """The object one written import names, or ``None`` if it names none.
404
+
405
+ The mirror of :func:`weaver.declaration.item_dependencies._python_references`,
406
+ and it has to be: what the build resolved on the way in is what this resolves
407
+ on the way back out, so the two use one rule for the ``__`` split — including
408
+ the case where the schema is itself underscores.
409
+ """
410
+
411
+ from .declaration.source import python_id_parts
412
+
413
+ components = [part for part in reference.split(".") if part]
414
+ if not components or components[0] == _LIB:
415
+ return None
416
+ parts = python_id_parts(components[-1])
417
+ if len(parts) != 2 or not all(part.strip() for part in parts):
418
+ return None
419
+ return WeaverDocumentId(
420
+ item,
421
+ ObjectId(parts[0].strip(), parts[1].strip()),
422
+ is_files=components[0] == _FILES,
423
+ )
424
+
425
+
426
+ def _registry_identity(catalogue, item, row) -> WeaverDocumentId | None:
427
+ """The document a dictionary row describes, when the Registry certifies it.
428
+
429
+ A dictionary row for something no Registry row certifies describes an object
430
+ that is declared and not installed, so it contributes no edge — the graph is
431
+ of what is *there*.
432
+ """
433
+
434
+ identity = _document_id(
435
+ item, str(row.get("schema_name") or ""), str(row.get("object_name") or "")
436
+ )
437
+ return identity if identity in catalogue.registered else None
438
+
439
+
440
+ # --- primitives ---------------------------------------------------------------
441
+
442
+
443
+ def primitive_candidates(
444
+ identity: WeaverDocumentId, object_type: str
445
+ ) -> tuple[tuple[str, WeaverDocumentId], ...]:
446
+ """Where an object's installed load primitive would be, and what kind it is.
447
+
448
+ Derived from identity and object type alone, because that is all a build has
449
+ when it decides where to put one — the naming is the contract, not a lookup
450
+ table. Returning candidates rather than an answer is what lets the Registry
451
+ settle it: a Lakehouse table's load is either a deployed module or a
452
+ generated Spark SQL file depending on what its author wrote, and the estate
453
+ knows which was installed.
454
+ """
455
+
456
+ item = identity.item
457
+ schema, name = identity.object_id.schema, identity.object_id.object
458
+ if item.item_type == WAREHOUSE:
459
+ if object_type != "table":
460
+ return ()
461
+ return ((WAREHOUSE_PROCEDURE, load_procedure_id(item, identity.object_id)),)
462
+ if object_type == "folder":
463
+ return (
464
+ (
465
+ PYTHON_FOLDER,
466
+ _deployed_file(item, f"{_FILES_PREFIX}{schema}__{name}.py"),
467
+ ),
468
+ )
469
+ if object_type != "table":
470
+ return ()
471
+ return (
472
+ (PYTHON_TABLE, _deployed_file(item, f"{schema}__{name}.py")),
473
+ (SPARK_SQL_FILE, _deployed_file(item, f"{schema}.{name}.sql")),
474
+ )
475
+
476
+
477
+ def _deployed_file(item: WeaverItemId, relative: str) -> WeaverDocumentId:
478
+ """A file in the deployed runtime tree, as the Registry stores it."""
479
+
480
+ path = f"{LOAD_ROOT}/{relative}"
481
+ directory, _, name = path.rpartition("/")
482
+ return WeaverDocumentId(
483
+ item, ObjectId(schema=directory, object=name), shape=FILE_SHAPE
484
+ )
485
+
486
+
487
+ # --- the graph ----------------------------------------------------------------
488
+
489
+
490
+ @dataclass(frozen=True)
491
+ class LoadNode:
492
+ """One unit of physical load work, or the barrier between two of them."""
493
+
494
+ node_id: str
495
+ logical_id: WeaverDocumentId | None
496
+ physical_target: PhysicalTargetRef
497
+ primitive_kind: str
498
+ physical_object: PhysicalObjectRef | None = None
499
+ #: The installed primitive itself — the procedure or the deployed file.
500
+ #: ``None`` for a refresh, which is a capability rather than an artefact.
501
+ primitive_id: WeaverDocumentId | None = None
502
+ primitive_object: PhysicalObjectRef | None = None
503
+
504
+ @property
505
+ def sort_key(self) -> tuple[str, str, str, str]:
506
+ """What orders two nodes that became ready at the same moment.
507
+
508
+ Target kind, then target, then logical identity, then primitive kind —
509
+ so the order a plan prints is a property of the estate rather than of
510
+ the dictionary iteration that happened to build it.
511
+ """
512
+
513
+ return (
514
+ self.physical_target.kind,
515
+ self.physical_target.name,
516
+ str(self.logical_id or ""),
517
+ self.primitive_kind,
518
+ )
519
+
520
+
521
+ @dataclass(frozen=True)
522
+ class LoadDag:
523
+ """The selected physical load graph: nodes, edges and what was requested.
524
+
525
+ An edge means *the upstream node must complete successfully before the
526
+ downstream node may execute*, and nothing else. It is not a data-flow
527
+ statement and not a claim about what the downstream node reads.
528
+ """
529
+
530
+ nodes: tuple[LoadNode, ...]
531
+ edges: tuple[tuple[str, str], ...]
532
+ requested: tuple[PhysicalTargetRef, ...] = ()
533
+ messages: tuple[LoadMessage, ...] = ()
534
+
535
+ @classmethod
536
+ def from_catalogue(
537
+ cls, catalogue: Catalogue, *, targets: Sequence[PhysicalTargetRef]
538
+ ) -> "LoadDag":
539
+ return load_dag(InstalledEstate.from_catalogue(catalogue), targets=targets)
540
+
541
+ @property
542
+ def by_id(self) -> Mapping[str, LoadNode]:
543
+ return {node.node_id: node for node in self.nodes}
544
+
545
+ def upstream(self, node_id: str) -> frozenset[str]:
546
+ return frozenset(
547
+ upstream for upstream, downstream in self.edges if downstream == node_id
548
+ )
549
+
550
+ def descendants(self, node_id: str) -> frozenset[str]:
551
+ """Every node that may not run once ``node_id`` has failed."""
552
+
553
+ reached: set[str] = set()
554
+ frontier = [node_id]
555
+ while frontier:
556
+ current = frontier.pop()
557
+ for upstream, downstream in self.edges:
558
+ if upstream == current and downstream not in reached:
559
+ reached.add(downstream)
560
+ frontier.append(downstream)
561
+ return frozenset(reached)
562
+
563
+ def order(self) -> tuple[LoadNode, ...]:
564
+ """The deterministic topological order, or a refusal if there is a cycle.
565
+
566
+ Ready nodes are sorted rather than taken as they come, which is what
567
+ makes a dry run inspectable, a log reproducible and a test stable. The
568
+ cycle check is here rather than in a validator because the sort is the
569
+ one place that can see one.
570
+ """
571
+
572
+ remaining = {node.node_id: node for node in self.nodes}
573
+ pending = {
574
+ node_id: set(self.upstream(node_id)) & set(remaining)
575
+ for node_id in remaining
576
+ }
577
+ ordered: list[LoadNode] = []
578
+ while pending:
579
+ ready = sorted(
580
+ (remaining[node_id] for node_id, waiting in pending.items() if not waiting),
581
+ key=lambda node: node.sort_key,
582
+ )
583
+ if not ready:
584
+ cycle = ", ".join(sorted(pending))
585
+ raise LoadError(
586
+ f"the load graph contains a cycle among: {cycle}",
587
+ )
588
+ for node in ready:
589
+ ordered.append(node)
590
+ del pending[node.node_id]
591
+ done = {node.node_id for node in ready}
592
+ for waiting in pending.values():
593
+ waiting -= done
594
+ return tuple(ordered)
595
+
596
+
597
+ def load_dag(
598
+ estate: InstalledEstate, *, targets: Sequence[PhysicalTargetRef]
599
+ ) -> LoadDag:
600
+ """The physical load graph for one set of requested physical targets.
601
+
602
+ *Load every installed loadable object physically hosted in the requested
603
+ targets, plus every upstream dependency required to load them* — and nothing
604
+ else. Unrelated downstream objects are outside the request; unrelated objects
605
+ in upstream targets are not required by it.
606
+ """
607
+
608
+ requested = tuple(dict.fromkeys(targets))
609
+ planner = _Planner(estate)
610
+ return planner.plan(requested)
611
+
612
+
613
+ class _Planner:
614
+ """One planning run's working state.
615
+
616
+ A class rather than a fold of functions because the traversal, the barrier
617
+ placement and the message stream all read the same three lookups, and passing
618
+ them through six signatures obscured what each step actually decided.
619
+ """
620
+
621
+ def __init__(self, estate: InstalledEstate) -> None:
622
+ self.estate = estate
623
+ self.messages: list[LoadMessage] = []
624
+ self.nodes: dict[str, LoadNode] = {}
625
+ self.edges: set[tuple[str, str]] = set()
626
+ self.refresh_nodes: dict[str, LoadNode] = {}
627
+ #: Which physical targets a refresh barrier must wait for, by refresh id.
628
+ self.refresh_sources: dict[str, PhysicalTargetRef] = {}
629
+ self._alias_by_destination = {
630
+ alias.destination: alias for alias in estate.aliases
631
+ }
632
+ self._dependencies: dict[WeaverDocumentId, list[InstalledDependency]] = {}
633
+ for edge in estate.dependencies:
634
+ self._dependencies.setdefault(edge.consumer, []).append(edge)
635
+ self._loadable = self._installed_primitives()
636
+
637
+ # --- what owns load work --------------------------------------------------
638
+
639
+ def _installed_primitives(self) -> dict[WeaverDocumentId, tuple[str, InstalledObject]]:
640
+ """Every data object the estate installed a load primitive for."""
641
+
642
+ found: dict[WeaverDocumentId, tuple[str, InstalledObject]] = {}
643
+ for identity, installed in self.estate.objects.items():
644
+ for kind, primitive_id in primitive_candidates(
645
+ identity, installed.object_type
646
+ ):
647
+ primitive = self.estate.primitives.get(primitive_id)
648
+ if primitive is not None:
649
+ found[identity] = (kind, primitive)
650
+ break
651
+ return found
652
+
653
+ # --- planning -------------------------------------------------------------
654
+
655
+ def plan(self, requested: tuple[PhysicalTargetRef, ...]) -> LoadDag:
656
+ self._refuse_ambiguity(requested)
657
+ seeds = sorted(
658
+ (
659
+ identity
660
+ for identity in self._loadable
661
+ if self.estate.objects[identity].target in requested
662
+ ),
663
+ key=str,
664
+ )
665
+ visited: set[WeaverDocumentId] = set()
666
+ for identity in seeds:
667
+ self._select(identity, visited)
668
+ self._place_refresh_barriers()
669
+ dag = LoadDag(
670
+ nodes=tuple(sorted(self.nodes.values(), key=lambda node: node.sort_key)),
671
+ edges=tuple(sorted(self.edges)),
672
+ requested=requested,
673
+ messages=tuple(self.messages),
674
+ )
675
+ # Ordering is what proves acyclicity, so it is done here rather than left
676
+ # to whoever consumes the graph — a planner that returned a cyclic graph
677
+ # would have made a decision it could not defend.
678
+ dag.order()
679
+ self._refuse_ambiguity(
680
+ tuple(dict.fromkeys(node.physical_target for node in dag.nodes))
681
+ )
682
+ return dag
683
+
684
+ def _refuse_ambiguity(self, targets: tuple[PhysicalTargetRef, ...]) -> None:
685
+ """Stop if any target this request touches holds a duplicated address.
686
+
687
+ Asked twice — of what was requested, and of what the upstream closure
688
+ walked into — because both are targets this run would dispatch against,
689
+ and neither is known at the moment the other is.
690
+ """
691
+
692
+ for target in targets:
693
+ found = self.estate.ambiguous.get(target)
694
+ if found:
695
+ raise LoadError(
696
+ f"{target} holds two logical objects at one physical "
697
+ f"address, so a load of it is ambiguous: {found[0]}"
698
+ )
699
+
700
+ def _select(self, identity: WeaverDocumentId, visited: set) -> str:
701
+ """Add this loadable object and everything it needs, returning its node id."""
702
+
703
+ node = self._load_node(identity)
704
+ if identity in visited:
705
+ return node.node_id
706
+ visited.add(identity)
707
+ for producer, crossed in self._upstream_loadable(identity):
708
+ upstream_id = self._select(producer, visited)
709
+ if crossed is None:
710
+ self.edges.add((upstream_id, node.node_id))
711
+ else:
712
+ # An alias read as SQL: the producer's endpoint has to catch up
713
+ # before the consumer can see it, so the barrier replaces the
714
+ # direct edge rather than sitting beside it.
715
+ refresh_id = self._refresh_node(crossed).node_id
716
+ self.edges.add((refresh_id, node.node_id))
717
+ return node.node_id
718
+
719
+ def _load_node(self, identity: WeaverDocumentId) -> LoadNode:
720
+ kind, primitive = self._loadable[identity]
721
+ installed = self.estate.objects[identity]
722
+ node_id = f"load:{installed.target}/{identity.object_id.qualified}"
723
+ node = self.nodes.get(node_id)
724
+ if node is None:
725
+ node = LoadNode(
726
+ node_id=node_id,
727
+ logical_id=identity,
728
+ physical_target=installed.target,
729
+ primitive_kind=kind,
730
+ physical_object=installed.physical,
731
+ primitive_id=primitive.identity,
732
+ primitive_object=primitive.physical,
733
+ )
734
+ self.nodes[node_id] = node
735
+ return node
736
+
737
+ def _refresh_node(self, target: PhysicalTargetRef) -> LoadNode:
738
+ """The one refresh barrier for this Lakehouse, made once per run."""
739
+
740
+ node_id = f"refresh:{target}"
741
+ node = self.refresh_nodes.get(node_id)
742
+ if node is None:
743
+ node = LoadNode(
744
+ node_id=node_id,
745
+ logical_id=None,
746
+ physical_target=target,
747
+ primitive_kind=ENDPOINT_REFRESH,
748
+ )
749
+ self.refresh_nodes[node_id] = node
750
+ self.nodes[node_id] = node
751
+ self.refresh_sources[node_id] = target
752
+ return node
753
+
754
+ def _place_refresh_barriers(self) -> None:
755
+ """Every selected load in a refreshed Lakehouse runs before its barrier.
756
+
757
+ Deliberately broad: one barrier per affected Lakehouse, behind *all* of
758
+ that Lakehouse's selected loads rather than only the ones an alias names.
759
+ A narrower placement would have to know which tables a consumer's query
760
+ actually touches, and the catalogue records the alias, not the shape of
761
+ the read.
762
+ """
763
+
764
+ for node_id, target in self.refresh_sources.items():
765
+ for node in list(self.nodes.values()):
766
+ if node.primitive_kind == ENDPOINT_REFRESH:
767
+ continue
768
+ if node.physical_target == target:
769
+ self.edges.add((node.node_id, node_id))
770
+
771
+ # --- dependency resolution -------------------------------------------------
772
+
773
+ def _upstream_loadable(
774
+ self, identity: WeaverDocumentId
775
+ ) -> tuple[tuple[WeaverDocumentId, PhysicalTargetRef | None], ...]:
776
+ """The loadable ancestors of one object, and where each hop crossed.
777
+
778
+ Passing through non-loadable producers is what makes a view a conduit:
779
+ it owns no load work, so it is not a node, but a consumer of it still
780
+ depends on whatever fills the tables behind it.
781
+ """
782
+
783
+ found: dict[WeaverDocumentId, PhysicalTargetRef | None] = {}
784
+ seen: set[WeaverDocumentId] = set()
785
+ frontier: list[tuple[WeaverDocumentId, PhysicalTargetRef | None]] = [
786
+ (identity, None)
787
+ ]
788
+ while frontier:
789
+ current, crossing = frontier.pop()
790
+ for producer, hop in self._direct_producers(current):
791
+ crossed = crossing or hop
792
+ if producer in self._loadable:
793
+ # A closer crossing wins: the barrier belongs to the hop that
794
+ # actually left the consumer's engine.
795
+ if producer not in found or found[producer] is None:
796
+ found[producer] = crossed
797
+ continue
798
+ if (producer, crossed) in seen:
799
+ continue
800
+ seen.add((producer, crossed))
801
+ frontier.append((producer, crossed))
802
+ return tuple(sorted(found.items(), key=lambda pair: str(pair[0])))
803
+
804
+ def _direct_producers(
805
+ self, consumer: WeaverDocumentId
806
+ ) -> tuple[tuple[WeaverDocumentId, PhysicalTargetRef | None], ...]:
807
+ """What one object reads directly, and the barrier each read crosses."""
808
+
809
+ producers: list[tuple[WeaverDocumentId, PhysicalTargetRef | None]] = []
810
+ consumer_target = self.estate.objects[consumer].target
811
+ for edge in self._dependencies.get(consumer, ()):
812
+ resolved = self._resolve_reference(consumer, edge)
813
+ if resolved is None:
814
+ continue
815
+ producer, through_alias = resolved
816
+ producer_target = self.estate.objects[producer].target
817
+ crossing = None
818
+ if (
819
+ through_alias
820
+ and producer_target.is_lakehouse
821
+ and not consumer_target.is_lakehouse
822
+ ):
823
+ # Lakehouse to Warehouse is the one crossing read through a SQL
824
+ # analytics endpoint. A Lakehouse-to-Lakehouse alias is a OneLake
825
+ # shortcut — Delta on both sides, and nothing to synchronise.
826
+ crossing = producer_target
827
+ producers.append((producer, crossing))
828
+ return tuple(producers)
829
+
830
+ def _resolve_reference(
831
+ self, consumer: WeaverDocumentId, edge: InstalledDependency
832
+ ) -> tuple[WeaverDocumentId, bool] | None:
833
+ """What one written reference names, in the consumer's own namespace.
834
+
835
+ Aliases are consulted *before* native objects, and the order is not a
836
+ preference. An alias destination is registered in the consuming item like
837
+ any other object — that is what makes it addressable — so a native lookup
838
+ would find it and stop there, and the crossing would disappear.
839
+ """
840
+
841
+ reference = edge.reference
842
+ if _is_python_module_reference(reference):
843
+ producer = _python_module_identity(consumer.item, reference)
844
+ if producer is None:
845
+ # A `lib/` helper, or an import that names no object at all. It
846
+ # is real source and it is not a Weaver object, so it orders
847
+ # nothing.
848
+ return None
849
+ if producer not in self.estate.objects:
850
+ raise LoadError(
851
+ f"{consumer} imports {reference!r}, which resolves to "
852
+ f"{producer} — not an installed object"
853
+ )
854
+ return producer, False
855
+ parts = reference.split(".")
856
+ if len(parts) > 2:
857
+ # A fully qualified physical read. It names something outside the
858
+ # estate's logical graph, so there is nothing here to order against.
859
+ self.messages.append(
860
+ info(
861
+ DEPENDENCY_EXTERNAL,
862
+ f"{consumer} reads {reference}, which names a physical object "
863
+ "directly and is not part of the load graph",
864
+ source="load_plan",
865
+ )
866
+ )
867
+ return None
868
+ if len(parts) != 2:
869
+ raise LoadError(
870
+ f"{consumer} declares dependency {reference!r}, which is not a "
871
+ "Schema.Object reference"
872
+ )
873
+ candidate = WeaverDocumentId(consumer.item, ObjectId(parts[0], parts[1]))
874
+ alias = self._alias_by_destination.get(candidate)
875
+ if alias is not None:
876
+ if alias.source not in self.estate.objects:
877
+ raise LoadError(
878
+ f"{consumer} reads alias {reference}, which points at "
879
+ f"{alias.source} — not an installed object"
880
+ )
881
+ return alias.source, True
882
+ if candidate in self.estate.objects:
883
+ return candidate, False
884
+ folder = replace(candidate, is_files=True)
885
+ if folder in self.estate.objects:
886
+ return folder, False
887
+ raise LoadError(
888
+ f"{consumer} declares dependency {reference!r}, which resolves to "
889
+ "neither an installed object nor an alias in its own item"
890
+ )
891
+
892
+
893
+ __all__ = [
894
+ "ENDPOINT_REFRESH",
895
+ "InstalledAlias",
896
+ "InstalledDependency",
897
+ "InstalledEstate",
898
+ "InstalledObject",
899
+ "LAKEHOUSE_TARGET",
900
+ "LoadDag",
901
+ "LoadNode",
902
+ "PRIMITIVE_KINDS",
903
+ "PYTHON_FOLDER",
904
+ "PYTHON_TABLE",
905
+ "PhysicalObjectRef",
906
+ "PhysicalTargetRef",
907
+ "SPARK_SQL_FILE",
908
+ "WAREHOUSE_PROCEDURE",
909
+ "WAREHOUSE_TARGET",
910
+ "load_dag",
911
+ "primitive_candidates",
912
+ ]