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,959 @@
1
+ """The workspace declaration — a tree of Weaver items, read and checked whole.
2
+
3
+ The first directory level is the item type and the second is the logical item
4
+ name. Everything below belongs to exactly one item.
5
+
6
+ ::
7
+
8
+ repository/
9
+ ├── Lakehouse/
10
+ │ └── Raw/
11
+ │ ├── schemas/Sales.yml
12
+ │ ├── Sales__Order.py Delta table, Python
13
+ │ ├── Sales.OrderSummary.sql Delta table, Spark SQL
14
+ │ ├── Files/Sales__Export.py Folder
15
+ │ └── lib/dates.py
16
+ └── Warehouse/
17
+ └── Reporting/
18
+ ├── schemas/Sales.yml
19
+ ├── alias.yml
20
+ └── Sales.OrderReport.sql Warehouse table, T-SQL
21
+
22
+ The owning item chooses the SQL dialect, so no document carries one. Reading
23
+ goes through a :class:`~weaver.store.Store`, so the same reader serves a local
24
+ checkout, Notebook Resources, and an accessible OneLake source.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import ast
30
+ import hashlib
31
+ from dataclasses import dataclass, replace
32
+ from typing import Iterable, Mapping
33
+
34
+ import yaml
35
+
36
+ from ..errors import DiscoveryError, IdentityError, MetadataError
37
+ from ..locations import Location
38
+ from ..store import LocalStore, Store
39
+ from .graph import Graph
40
+ from .metadata import (
41
+ DELTA_TARGET,
42
+ FOLDER_TARGET,
43
+ LAKEHOUSE_NAMESPACE,
44
+ PYTHON,
45
+ SQL_TARGET,
46
+ WAREHOUSE_NAMESPACE,
47
+ ObjectId,
48
+ )
49
+ from .model import (
50
+ FILES,
51
+ ITEM_TYPES,
52
+ LAKEHOUSE,
53
+ WAREHOUSE,
54
+ RepositoryAlias,
55
+ WeaverDocumentId,
56
+ WeaverItem,
57
+ WeaverItemId,
58
+ WeaverRepository,
59
+ WeaverSchemaId,
60
+ )
61
+ from .metadata import _UniqueKeyLoader
62
+ from .schemas import SchemaSes, is_schema_file, read_schema_document
63
+ from .source import (
64
+ PYTHON_ID_SEPARATOR,
65
+ SourceDocument,
66
+ language_for_filename,
67
+ object_id_for_filename,
68
+ read_source_document,
69
+ )
70
+ from .dependencies import PythonImport
71
+ from .references import validate_repository_metadata
72
+ from .item_dependencies import resolve_item_dependencies
73
+
74
+ #: Never read, never installed.
75
+ IGNORED_DIRECTORIES = frozenset(
76
+ {"__pycache__", ".git", ".venv", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".idea"}
77
+ )
78
+ IGNORED_FILENAMES = frozenset({".DS_Store", "Thumbs.db"})
79
+ IGNORED_SUFFIXES = (".pyc", ".pyo", ".swp", ".orig", ".rej")
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+ def parse_item_repository(
89
+ root: Location,
90
+ *,
91
+ store: Store | None = None,
92
+ ) -> WeaverRepository:
93
+ """Read the workspace declaration without executing authored code."""
94
+
95
+ store = store or LocalStore()
96
+ if not store.exists(root):
97
+ raise DiscoveryError(f"repository root does not exist: {root}")
98
+ if not store.is_directory(root):
99
+ raise DiscoveryError(f"repository root is not a directory: {root}")
100
+
101
+ prefix = root.value.rstrip("/") + "/"
102
+ entries: list[tuple[str, bool]] = []
103
+ for entry in store.list(root, recursive=True):
104
+ relative = entry.location.value[len(prefix):]
105
+ parts = relative.split("/")
106
+ if (
107
+ "_ignore" in parts
108
+ or any(part in IGNORED_DIRECTORIES for part in parts)
109
+ or parts[-1] in IGNORED_FILENAMES
110
+ or parts[-1].endswith(IGNORED_SUFFIXES)
111
+ ):
112
+ continue
113
+ entries.append((relative, entry.is_directory))
114
+
115
+ from ..catalogue.builtin import item_repository_files
116
+
117
+ generated_files = item_repository_files()
118
+ builtin_prefix = "Lakehouse/_weaver"
119
+ authored_builtin = sorted(
120
+ relative
121
+ for relative, _is_directory in entries
122
+ if relative == builtin_prefix or relative.startswith(builtin_prefix + "/")
123
+ )
124
+ if authored_builtin:
125
+ raise DiscoveryError(
126
+ f"{authored_builtin[0]}: Lakehouse/_weaver is package-owned and must "
127
+ "not be authored"
128
+ )
129
+
130
+ for relative, is_directory in entries:
131
+ if not is_directory and relative.rsplit("/", 1)[-1] == "__init__.py":
132
+ raise DiscoveryError(
133
+ f"{relative}: user-authored __init__.py is not allowed; "
134
+ "Weaver supplies package loading"
135
+ )
136
+
137
+ if any(relative == "alias.yml" for relative, _ in entries):
138
+ raise DiscoveryError(
139
+ "alias.yml: an alias belongs to the item that consumes it — declare it "
140
+ "in <ItemType>/<ItemName>/alias.yml, keyed by that item's own "
141
+ "Schema.Object"
142
+ )
143
+
144
+ invalid_roots = sorted(
145
+ {
146
+ relative.split("/", 1)[0]
147
+ for relative, _ in entries
148
+ if relative.split("/", 1)[0] not in ITEM_TYPES
149
+ }
150
+ )
151
+ if invalid_roots:
152
+ raise DiscoveryError(
153
+ f"{invalid_roots[0]}: first directory must be exactly one of "
154
+ + ", ".join(sorted(ITEM_TYPES))
155
+ )
156
+
157
+ for relative, is_directory in entries:
158
+ if not is_directory:
159
+ continue
160
+ parts = relative.split("/")
161
+ if len(parts) <= 2:
162
+ continue
163
+ item = WeaverItemId(parts[0], parts[1])
164
+ within = parts[2:]
165
+ if within == ["schemas"]:
166
+ continue
167
+ if within == [FILES] and item.item_type == LAKEHOUSE:
168
+ continue
169
+ if within[0] == "lib" and item.item_type == LAKEHOUSE:
170
+ continue
171
+ raise DiscoveryError(
172
+ f"{relative}: only schemas/, lib/ and Lakehouse Files/ are authored "
173
+ "subdirectories of an item"
174
+ )
175
+
176
+ item_ids: set[WeaverItemId] = set()
177
+ files: list[str] = []
178
+ for relative, is_directory in entries:
179
+ parts = relative.split("/")
180
+ if len(parts) == 1:
181
+ if is_directory and parts[0] in ITEM_TYPES:
182
+ continue
183
+ raise DiscoveryError(
184
+ f"{relative}: the declaration root may contain only item type "
185
+ "directories and _ignore/"
186
+ )
187
+ if parts[0] not in ITEM_TYPES:
188
+ raise DiscoveryError(
189
+ f"{relative}: first directory must be exactly one of "
190
+ + ", ".join(sorted(ITEM_TYPES))
191
+ )
192
+ item = WeaverItemId(parts[0], parts[1])
193
+ item_ids.add(item)
194
+ if len(parts) == 2:
195
+ if not is_directory:
196
+ raise DiscoveryError(f"{relative}: an item must be a directory")
197
+ continue
198
+ if not is_directory:
199
+ files.append(relative)
200
+
201
+ source_documents: dict[WeaverDocumentId, SourceDocument] = {}
202
+ schema_documents: dict[WeaverSchemaId, SchemaSes] = {}
203
+ support_files: list[str] = []
204
+ documents_by_item: dict[WeaverItemId, list[WeaverDocumentId]] = {
205
+ item: [] for item in item_ids
206
+ }
207
+ schemas_by_item: dict[WeaverItemId, list[WeaverSchemaId]] = {
208
+ item: [] for item in item_ids
209
+ }
210
+
211
+ alias_files: dict[WeaverItemId, str] = {}
212
+ for relative in sorted(files):
213
+ parts = relative.split("/")
214
+ item = WeaverItemId(parts[0], parts[1])
215
+ within = parts[2:]
216
+
217
+ if within == ["alias.yml"]:
218
+ # The item owns its aliases, so the file travels and certifies with
219
+ # the rest of the item's source rather than as a shared root file.
220
+ alias_files[item] = relative
221
+ support_files.append(relative)
222
+ continue
223
+
224
+ if within[0] == "lib":
225
+ if item.item_type != LAKEHOUSE:
226
+ raise DiscoveryError(f"{relative}: lib/ belongs to a Lakehouse item")
227
+ if len(within) == 1:
228
+ raise DiscoveryError(f"{relative}: lib must be a directory")
229
+ support_files.append(relative)
230
+ continue
231
+
232
+ if within[0] == "schemas":
233
+ if len(within) != 2 or not within[1].endswith(".yml"):
234
+ raise DiscoveryError(
235
+ f"{relative}: schema declarations are schemas/<Schema>.yml"
236
+ )
237
+ schema = read_schema_document(
238
+ relative, store.read(root.join(*relative.split("/")))
239
+ )
240
+ identity = WeaverSchemaId(item, schema.schema_id)
241
+ _insert_exact_case(
242
+ schema_documents, identity, schema, relative, what="schema"
243
+ )
244
+ schemas_by_item[item].append(identity)
245
+ continue
246
+
247
+ is_files = within[0] == FILES
248
+ if is_files:
249
+ if item.item_type != LAKEHOUSE:
250
+ raise DiscoveryError(f"{relative}: Files/ belongs to a Lakehouse item")
251
+ if len(within) != 2:
252
+ raise DiscoveryError(
253
+ f"{relative}: Folder documents live directly under Files/"
254
+ )
255
+ elif len(within) != 1:
256
+ raise DiscoveryError(
257
+ f"{relative}: only schemas/, lib/ and Lakehouse Files/ are authored "
258
+ "subdirectories of an item"
259
+ )
260
+
261
+ filename = within[-1]
262
+ if language_for_filename(filename, item.item_type) is None:
263
+ raise DiscoveryError(f"{relative}: not a Weaver object file")
264
+ source = read_source_document(
265
+ relative,
266
+ store.read(root.join(*relative.split("/"))),
267
+ item.item_type,
268
+ )
269
+ if source.warehouse_alias is not None or source.lakehouse_alias is not None:
270
+ raise DiscoveryError(
271
+ f"{relative}: document-local Warehouse alias/Lakehouse alias headers "
272
+ "have been replaced by the item's own alias.yml"
273
+ )
274
+ if item.item_type == LAKEHOUSE:
275
+ expected = FOLDER_TARGET if is_files else DELTA_TARGET
276
+ else:
277
+ expected = SQL_TARGET
278
+ if source.target_kind != expected:
279
+ location = "Files/" if is_files else f"{item.item_type} item root"
280
+ raise DiscoveryError(
281
+ f"{relative}: {source.document.kind} in {source.language} does not "
282
+ f"belong at the {location}"
283
+ )
284
+ identity = WeaverDocumentId(item, source.object_id, is_files=is_files)
285
+ source = replace(source, logical_id=identity)
286
+ _insert_exact_case(
287
+ source_documents, identity, source, relative, what="document"
288
+ )
289
+ documents_by_item[item].append(identity)
290
+
291
+ builtin_item = WeaverItemId(LAKEHOUSE, "_weaver")
292
+ item_ids.add(builtin_item)
293
+ documents_by_item[builtin_item] = []
294
+ schemas_by_item[builtin_item] = []
295
+ for relative, data in sorted(generated_files.items()):
296
+ if "/schemas/" in relative:
297
+ schema = read_schema_document(relative, data)
298
+ identity = WeaverSchemaId(builtin_item, schema.schema_id)
299
+ schema_documents[identity] = schema
300
+ schemas_by_item[builtin_item].append(identity)
301
+ continue
302
+ source = read_source_document(relative, data, builtin_item.item_type)
303
+ # The control plane declares a Folder as well as its tables — the task
304
+ # log — and a Folder is a Files document. Read from the same path the
305
+ # authored branch reads it from, so a generated declaration and an
306
+ # authored one of the same kind produce the same identity.
307
+ is_files = f"/{FILES}/" in relative
308
+ identity = WeaverDocumentId(builtin_item, source.object_id, is_files=is_files)
309
+ source_documents[identity] = replace(source, logical_id=identity)
310
+ documents_by_item[builtin_item].append(identity)
311
+
312
+ # An item with load code owns the runtime tree that code is deployed into,
313
+ # and owning it means declaring it. The folder and its schema are generated
314
+ # here rather than reserved somewhere later, so they are ordinary managed
315
+ # objects from the moment the repository is interpreted.
316
+ from ..etl import ETL_SCHEMA, generated_item_files
317
+
318
+ for item in sorted(item_ids):
319
+ if item == builtin_item:
320
+ continue
321
+ authored = [
322
+ str(schema)
323
+ for schema in schemas_by_item[item]
324
+ if schema.schema == ETL_SCHEMA
325
+ ] + [
326
+ source_documents[identity].relative_path
327
+ for identity in documents_by_item[item]
328
+ if identity.object_id.schema == ETL_SCHEMA
329
+ ]
330
+ if authored:
331
+ raise DiscoveryError(
332
+ f"{sorted(authored)[0]}: schema {ETL_SCHEMA!r} is generated Weaver "
333
+ "infrastructure — it holds the runtime tree a load is deployed "
334
+ "into and the schema generated load procedures live in, so an "
335
+ "item may not author into it"
336
+ )
337
+
338
+ for item in sorted(item_ids):
339
+ item_files = generated_item_files(
340
+ item,
341
+ documents=[
342
+ source_documents[identity] for identity in documents_by_item[item]
343
+ ],
344
+ support_paths=support_files,
345
+ )
346
+ if not item_files:
347
+ continue
348
+ generated_files = {**generated_files, **item_files}
349
+ for relative, data in sorted(item_files.items()):
350
+ if "/schemas/" in relative:
351
+ schema = read_schema_document(relative, data)
352
+ identity = WeaverSchemaId(item, schema.schema_id)
353
+ schema_documents[identity] = schema
354
+ schemas_by_item[item].append(identity)
355
+ continue
356
+ source = read_source_document(relative, data, item.item_type)
357
+ identity = WeaverDocumentId(item, source.object_id, is_files=True)
358
+ source_documents[identity] = replace(source, logical_id=identity)
359
+ documents_by_item[item].append(identity)
360
+
361
+ items: list[WeaverItem] = []
362
+ for item_id in sorted(item_ids):
363
+ schemas = tuple(sorted(schemas_by_item[item_id], key=str))
364
+ documents = tuple(sorted(documents_by_item[item_id], key=str))
365
+ declared = {schema.schema for schema in schemas}
366
+ for document_id in documents:
367
+ if document_id.object_id.schema not in declared:
368
+ source = source_documents[document_id]
369
+ raise DiscoveryError(
370
+ f"{source.relative_path}: schema {document_id.object_id.schema!r} "
371
+ f"is not declared by item {item_id}"
372
+ )
373
+ items.append(WeaverItem(item_id, schemas=schemas, documents=documents))
374
+
375
+ aliases = _read_item_aliases(
376
+ root,
377
+ store,
378
+ alias_files,
379
+ source_documents=source_documents,
380
+ schemas_by_item=schemas_by_item,
381
+ )
382
+ validate_repository_metadata(source_documents.values(), aliases=aliases)
383
+
384
+ source_documents = _with_build_signatures(
385
+ source_documents,
386
+ support_files=support_files,
387
+ store=store,
388
+ root=root,
389
+ )
390
+
391
+ items = [
392
+ replace(
393
+ model,
394
+ signature=_item_signature(
395
+ model,
396
+ source_documents=source_documents,
397
+ schema_documents=schema_documents,
398
+ support_files=support_files,
399
+ store=store,
400
+ root=root,
401
+ ),
402
+ )
403
+ for model in items
404
+ ]
405
+
406
+ # Held rather than re-read: a ``lib/`` file is deployed by the load layer, so
407
+ # its bytes have to reach both the signature it is selected by and the
408
+ # payload the bundle carries, and neither may reopen the repository.
409
+ #
410
+ # Every support file, not only the Python ones. A `.py` filter here was
411
+ # reading across from the *top level*, where a Weaver document is python,
412
+ # sql or yml — but `lib/` is an ordinary directory the runtime tree
413
+ # reproduces verbatim, and a module that reads a data file beside it needs
414
+ # that file to have travelled with it.
415
+ support_file_contents = {
416
+ relative: store.read(root.join(*relative.split("/")))
417
+ for relative in sorted(support_files)
418
+ }
419
+
420
+ repository = WeaverRepository(
421
+ name=root.name,
422
+ root=root,
423
+ items=tuple(items),
424
+ source_documents=source_documents,
425
+ schema_documents=schema_documents,
426
+ support_files=tuple(sorted(support_files)),
427
+ support_file_contents=support_file_contents,
428
+ signature=_item_repository_signature(
429
+ files, store, root, generated=generated_files
430
+ ),
431
+ aliases=aliases,
432
+ generated_files=generated_files,
433
+ )
434
+ return resolve_item_dependencies(repository)
435
+
436
+
437
+ def _with_build_signatures(
438
+ documents: Mapping[WeaverDocumentId, SourceDocument],
439
+ *,
440
+ support_files: Iterable[str],
441
+ store: Store,
442
+ root: Location,
443
+ ) -> dict[WeaverDocumentId, SourceDocument]:
444
+ """Attach each document's own, statically reachable implementation hash.
445
+
446
+ ``lib/`` is item-owned source code, but hashing the whole directory into
447
+ every object would make an unrelated helper rebuild the item. Instead each
448
+ Python document carries the transitive closure of the helper modules it can
449
+ import. Discovery remains static: helper modules are parsed, never imported
450
+ or executed.
451
+ """
452
+
453
+ from .source import PYTHON, content_hash
454
+
455
+ helper_paths: dict[WeaverItemId, dict[tuple[str, ...], str]] = {}
456
+ helper_hashes: dict[str, str] = {}
457
+ for relative in support_files:
458
+ parts = relative.split("/")
459
+ if len(parts) < 4 or parts[2] != "lib" or not relative.endswith(".py"):
460
+ continue
461
+ item = WeaverItemId(parts[0], parts[1])
462
+ module = tuple(parts[2:-1] + [parts[-1][:-3]])
463
+ helper_paths.setdefault(item, {})[module] = relative
464
+ helper_hashes[relative] = content_hash(
465
+ store.read(root.join(*relative.split("/")))
466
+ )
467
+
468
+ parsed_imports: dict[str, tuple[PythonImport, ...]] = {}
469
+
470
+ def imports_for(relative: str) -> tuple[PythonImport, ...]:
471
+ if relative in parsed_imports:
472
+ return parsed_imports[relative]
473
+ data = store.read(root.join(*relative.split("/")))
474
+ try:
475
+ text = data.decode("utf-8-sig")
476
+ except UnicodeDecodeError as exc:
477
+ raise DiscoveryError(f"{relative}: must be UTF-8 text ({exc})") from exc
478
+ try:
479
+ module = ast.parse(text)
480
+ except SyntaxError as exc:
481
+ raise DiscoveryError(f"{relative}: invalid imported helper Python: {exc}") from exc
482
+ parsed_imports[relative] = _python_imports(module)
483
+ return parsed_imports[relative]
484
+
485
+ resolved: dict[WeaverDocumentId, SourceDocument] = {}
486
+ for identity, source in documents.items():
487
+ if source.language != PYTHON:
488
+ resolved[identity] = replace(source, build_signature=source.source_hash)
489
+ continue
490
+
491
+ available = helper_paths.get(identity.item, {})
492
+ current = _module_within_item(source.relative_path)
493
+ pending = list(_helper_targets(source.python_imports, current, available))
494
+ reached: set[tuple[str, ...]] = set()
495
+ while pending:
496
+ helper = pending.pop()
497
+ if helper in reached:
498
+ continue
499
+ reached.add(helper)
500
+ relative = available[helper]
501
+ pending.extend(
502
+ target
503
+ for target in _helper_targets(imports_for(relative), helper, available)
504
+ if target not in reached
505
+ )
506
+
507
+ if not reached:
508
+ resolved[identity] = replace(source, build_signature=source.source_hash)
509
+ continue
510
+ digest = hashlib.sha256()
511
+ entries = [(source.relative_path, source.source_hash)] + [
512
+ (available[module], helper_hashes[available[module]])
513
+ for module in sorted(reached)
514
+ ]
515
+ for relative, signature in entries:
516
+ digest.update(relative.encode("utf-8"))
517
+ digest.update(b"\0")
518
+ digest.update(signature.encode("ascii"))
519
+ digest.update(b"\n")
520
+ resolved[identity] = replace(source, build_signature=digest.hexdigest())
521
+ return resolved
522
+
523
+
524
+ def _module_within_item(relative: str) -> tuple[str, ...]:
525
+ parts = relative.split("/")[2:]
526
+ return tuple(parts[:-1] + [parts[-1][:-3]])
527
+
528
+
529
+ def _helper_targets(
530
+ imports: Iterable[PythonImport],
531
+ current: tuple[str, ...],
532
+ available: Mapping[tuple[str, ...], str],
533
+ ) -> tuple[tuple[str, ...], ...]:
534
+ found: set[tuple[str, ...]] = set()
535
+ package = current[:-1]
536
+ for imported in imports:
537
+ module = tuple(imported.module.split(".")) if imported.module else ()
538
+ if imported.level:
539
+ parents = imported.level - 1
540
+ if parents > len(package):
541
+ continue
542
+ base = package[: len(package) - parents] + module
543
+ else:
544
+ base = module
545
+ if base in available:
546
+ found.add(base)
547
+ for name in imported.names:
548
+ candidate = base + tuple(name.split("."))
549
+ if candidate in available:
550
+ found.add(candidate)
551
+ return tuple(sorted(found))
552
+
553
+
554
+ def _python_imports(module: ast.Module) -> tuple[PythonImport, ...]:
555
+ imports: list[PythonImport] = []
556
+ for node in ast.walk(module):
557
+ if isinstance(node, ast.ImportFrom):
558
+ imports.append(
559
+ PythonImport(
560
+ module=node.module,
561
+ level=node.level,
562
+ names=tuple(alias.name for alias in node.names),
563
+ )
564
+ )
565
+ elif isinstance(node, ast.Import):
566
+ imports.extend(
567
+ PythonImport(module=alias.name, names=(alias.name,))
568
+ for alias in node.names
569
+ )
570
+ return tuple(imports)
571
+
572
+
573
+ def _item_signature(
574
+ item: WeaverItem,
575
+ *,
576
+ source_documents: Mapping[WeaverDocumentId, SourceDocument],
577
+ schema_documents: Mapping[WeaverSchemaId, SchemaSes],
578
+ support_files: Iterable[str],
579
+ store: Store,
580
+ root: Location,
581
+ ) -> str:
582
+ """Certify exactly one logical item's authored and generated inputs.
583
+
584
+ An item's ``alias.yml`` sits under its own prefix and is certified with its
585
+ other support files. The producer's content deliberately does not
586
+ participate: a logical dependency does not make an independently installed
587
+ producer part of the consumer's source item.
588
+ """
589
+
590
+ from .source import content_hash
591
+
592
+ entries: list[tuple[str, str]] = []
593
+ for identity in item.documents:
594
+ source = source_documents[identity]
595
+ entries.append((source.relative_path, source.source_hash))
596
+ for identity in item.schemas:
597
+ schema = schema_documents[identity]
598
+ entries.append((schema.relative_path, schema.source_hash))
599
+
600
+ prefix = f"{item.identity.item_type}/{item.identity.item_name}/"
601
+ for relative in support_files:
602
+ if relative.startswith(prefix):
603
+ entries.append(
604
+ (
605
+ relative,
606
+ content_hash(store.read(root.join(*relative.split("/")))),
607
+ )
608
+ )
609
+ digest = hashlib.sha256()
610
+ digest.update(str(item.identity).encode("utf-8"))
611
+ digest.update(b"\n")
612
+ for relative, source_hash in sorted(entries):
613
+ digest.update(relative.encode("utf-8"))
614
+ digest.update(b"\0")
615
+ digest.update(source_hash.encode("ascii"))
616
+ digest.update(b"\n")
617
+ return digest.hexdigest()
618
+
619
+
620
+ def _insert_exact_case(
621
+ destination: dict,
622
+ identity,
623
+ value,
624
+ relative: str,
625
+ *,
626
+ what: str,
627
+ ) -> None:
628
+ rendered = str(identity)
629
+ for existing, existing_value in destination.items():
630
+ if str(existing) == rendered:
631
+ prior = getattr(existing_value, "relative_path", str(existing))
632
+ raise DiscoveryError(
633
+ f"{rendered} is declared twice: {prior} and {relative}"
634
+ )
635
+ if str(existing).casefold() == rendered.casefold():
636
+ raise DiscoveryError(
637
+ f"{rendered} and {existing} differ only by case and cannot coexist"
638
+ )
639
+ destination[identity] = value
640
+
641
+
642
+ def _item_repository_signature(
643
+ paths: Iterable[str],
644
+ store: Store,
645
+ root: Location,
646
+ *,
647
+ generated: Mapping[str, bytes] | None = None,
648
+ ) -> str:
649
+ """Hash included item-oriented files; `_ignore/` never reaches this list."""
650
+
651
+ from .source import content_hash
652
+
653
+ digest = hashlib.sha256()
654
+ generated = generated or {}
655
+ for relative in sorted(set(paths) | set(generated)):
656
+ digest.update(relative.encode("utf-8"))
657
+ digest.update(b"\0")
658
+ digest.update(
659
+ content_hash(
660
+ generated.get(relative)
661
+ if relative in generated
662
+ else store.read(root.join(*relative.split("/")))
663
+ ).encode("ascii")
664
+ )
665
+ digest.update(b"\n")
666
+ return digest.hexdigest()
667
+
668
+
669
+ def _read_item_aliases(
670
+ root: Location,
671
+ store: Store,
672
+ alias_files: Mapping[WeaverItemId, str],
673
+ *,
674
+ source_documents: Mapping[WeaverDocumentId, SourceDocument],
675
+ schemas_by_item: Mapping[WeaverItemId, list[WeaverSchemaId]],
676
+ ) -> tuple[RepositoryAlias, ...]:
677
+ """Read each item's own ``alias.yml``.
678
+
679
+ The file's location names the consuming item, so a declaration maps that
680
+ item's local ``Schema.Object`` to a full four-part source elsewhere in the
681
+ workspace. Nothing in the file repeats what the directory already says.
682
+ """
683
+
684
+ aliases: list[RepositoryAlias] = []
685
+ native_folded = {str(identity).casefold(): identity for identity in source_documents}
686
+ destination_folded: dict[str, WeaverDocumentId] = {}
687
+
688
+ for item in sorted(alias_files):
689
+ relative = alias_files[item]
690
+ try:
691
+ text = store.read(root.join(*relative.split("/"))).decode("utf-8-sig")
692
+ except UnicodeDecodeError as exc:
693
+ raise DiscoveryError(f"{relative}: must be UTF-8 text ({exc})") from exc
694
+ try:
695
+ loaded = yaml.load(text, Loader=_UniqueKeyLoader)
696
+ except MetadataError:
697
+ raise
698
+ except yaml.YAMLError as exc:
699
+ raise DiscoveryError(f"{relative}: invalid YAML: {exc}") from exc
700
+ if not isinstance(loaded, dict) or set(loaded) != {"aliases"}:
701
+ raise DiscoveryError(
702
+ f"{relative} must contain exactly one 'aliases' mapping"
703
+ )
704
+ declarations = loaded["aliases"]
705
+ if not isinstance(declarations, dict):
706
+ raise DiscoveryError(f"{relative}: 'aliases' must be a mapping")
707
+
708
+ declared_schemas = {schema.schema for schema in schemas_by_item[item]}
709
+ for raw_destination, raw_source in declarations.items():
710
+ if not isinstance(raw_destination, str) or not isinstance(raw_source, str):
711
+ raise DiscoveryError(
712
+ f"{relative}: destinations and sources must be strings"
713
+ )
714
+ try:
715
+ destination = WeaverDocumentId.parse_local(item, raw_destination)
716
+ except IdentityError as exc:
717
+ raise DiscoveryError(
718
+ f"{relative}: an alias destination is this item's own "
719
+ f"Schema.Object — the item is already known ({exc})"
720
+ ) from exc
721
+ try:
722
+ source = WeaverDocumentId.parse(raw_source)
723
+ except IdentityError as exc:
724
+ raise DiscoveryError(f"{relative}: {exc}") from exc
725
+ local = destination.object_id
726
+ if source not in source_documents:
727
+ case_match = native_folded.get(str(source).casefold())
728
+ detail = f"; declared spelling is {case_match}" if case_match else ""
729
+ raise DiscoveryError(
730
+ f"{relative}: alias source {source} is not a document{detail}"
731
+ )
732
+ if source.item == item:
733
+ # An alias exists to cross an item boundary. Within one item the
734
+ # document graph already orders producer before consumer, and a
735
+ # document can name its own item's object directly — so a
736
+ # same-item alias would be a second name for something already
737
+ # reachable, ordered by an alias stage that runs before every
738
+ # document the item declares and therefore before its own source.
739
+ raise DiscoveryError(
740
+ f"{relative}: alias destination {destination} and its source "
741
+ f"{source} are both owned by {item} — an alias crosses items, "
742
+ "so reference the source directly instead"
743
+ )
744
+ if local.schema not in declared_schemas:
745
+ raise DiscoveryError(
746
+ f"{relative}: alias destination schema {local.schema!r} is not "
747
+ f"declared by item {item}"
748
+ )
749
+ folded = str(destination).casefold()
750
+ native = native_folded.get(folded)
751
+ if native is not None:
752
+ raise DiscoveryError(
753
+ f"{relative}: alias destination {destination} collides with "
754
+ f"native document {native}"
755
+ )
756
+ prior = destination_folded.get(folded)
757
+ if prior is not None:
758
+ raise DiscoveryError(
759
+ f"{relative}: alias destinations {destination} and {prior} "
760
+ "differ only by case"
761
+ )
762
+ destination_folded[folded] = destination
763
+ aliases.append(RepositoryAlias(destination=destination, source=source))
764
+ return tuple(aliases)
765
+
766
+
767
+
768
+
769
+ def _repository_files(store: Store, root: Location) -> list[str]:
770
+ prefix = root.value.rstrip("/") + "/"
771
+ relatives: list[str] = []
772
+ for entry in store.list(root, recursive=True):
773
+ if entry.is_directory:
774
+ continue
775
+ relative = entry.location.value[len(prefix):]
776
+ if _ignored(relative):
777
+ continue
778
+ relatives.append(relative)
779
+ return sorted(relatives)
780
+
781
+
782
+ def _ignored(relative: str) -> bool:
783
+ parts = relative.split("/")
784
+ if any(part in IGNORED_DIRECTORIES for part in parts[:-1]):
785
+ return True
786
+ filename = parts[-1]
787
+ return filename in IGNORED_FILENAMES or filename.endswith(IGNORED_SUFFIXES)
788
+
789
+
790
+
791
+
792
+ def importable_module_name(relative_path: str) -> str | None:
793
+ """The full dotted module a repository-relative path is importable as.
794
+
795
+ ``_helpers/dates.py`` is ``_helpers.dates``, not ``dates`` — a nested module
796
+ lives in its package's namespace and cannot shadow a top-level one.
797
+ ``_helpers/__init__.py`` is the package itself, ``_helpers``.
798
+ """
799
+
800
+ if not relative_path.endswith(".py"):
801
+ return None
802
+ stem = relative_path[: -len(".py")]
803
+ if stem.endswith("/__init__"):
804
+ stem = stem[: -len("/__init__")]
805
+ return stem.replace("/", ".")
806
+
807
+
808
+
809
+
810
+
811
+
812
+ # --- schema, namespace and alias resolution ---------------------------------
813
+
814
+
815
+
816
+
817
+
818
+
819
+
820
+
821
+
822
+
823
+
824
+
825
+
826
+
827
+
828
+
829
+
830
+
831
+
832
+
833
+
834
+
835
+ # --- the internal dependency graph -------------------------------------------
836
+
837
+
838
+ def _canonical(qualified: str) -> str:
839
+ """Object identities are compared without regard to case.
840
+
841
+ A developer may write `sales__order` where the house style is
842
+ `Sales__Order`, and SQL is case-insensitive by nature. Two objects whose
843
+ IDs differ only by case are refused, so the folding is unambiguous.
844
+ """
845
+
846
+ return qualified.lower()
847
+
848
+
849
+ def effective_dependencies(document: SourceDocument) -> tuple[ObjectId, ...]:
850
+ """What this object depends on: declared if declared, else discovered.
851
+
852
+ A declaration replaces discovery rather than adding to it, so an author can
853
+ remove an edge as well as add one — the phantom dependency an unused import
854
+ creates has no other cure. ``Dependencies: []`` is such a declaration, so an
855
+ explicit none suppresses discovery rather than falling back to it.
856
+ """
857
+
858
+ if document.document.declares_dependencies:
859
+ return document.declared_dependencies
860
+ return document.referenced_object_ids
861
+
862
+
863
+ def _resolve(
864
+ dependency: ObjectId,
865
+ by_id: Mapping[str, list[SourceDocument]],
866
+ referrer: SourceDocument,
867
+ ) -> SourceDocument | None:
868
+ """The object a two-part reference names, when that is unambiguous.
869
+
870
+ A two-part name resolves in the namespace of whoever wrote it: T-SQL
871
+ resolves inside the Warehouse, Spark SQL inside the Lakehouse. So the
872
+ referrer's own target wins when it has a candidate — `join Sales.Customer`
873
+ in a Warehouse query means the Warehouse's Sales.Customer, because that is
874
+ what the SQL would actually bind to.
875
+
876
+ Failing that, a single candidate anywhere is the answer, and it may cross a
877
+ boundary: a Warehouse query reading a Delta table is the ordinary case, and
878
+ the one the SQL endpoint and the shortcuts exist to bridge.
879
+
880
+ Two candidates in neither of those positions is genuinely ambiguous and is
881
+ left for the build, which has the targets and the shortcut bindings.
882
+ """
883
+
884
+ candidates = by_id.get(_canonical(dependency.qualified), [])
885
+ if not candidates:
886
+ return None
887
+ own_target = [
888
+ candidate for candidate in candidates
889
+ if candidate.target_kind == referrer.target_kind
890
+ and candidate.node_id != referrer.node_id
891
+ ]
892
+ if len(own_target) == 1:
893
+ return own_target[0]
894
+ elsewhere = [
895
+ candidate for candidate in candidates if candidate.node_id != referrer.node_id
896
+ ]
897
+ return elsewhere[0] if len(elsewhere) == 1 else None
898
+
899
+
900
+ def _by_id(documents: Iterable[SourceDocument]) -> Mapping[str, list[SourceDocument]]:
901
+ grouped: dict[str, list[SourceDocument]] = {}
902
+ for document in documents:
903
+ grouped.setdefault(_canonical(document.qualified), []).append(document)
904
+ return grouped
905
+
906
+
907
+ def build_internal_graph(
908
+ documents: Iterable[SourceDocument], *, external_names: Iterable[str] = ()
909
+ ) -> Graph:
910
+ """The graph over references that resolve within this repository.
911
+
912
+ Nodes are ``target:Schema.Object``, because an ID alone is not unique.
913
+ References resolving to nothing here — or to more than one thing — are left
914
+ out entirely. They may be shortcuts, objects of another repository, or
915
+ mistakes, and telling those apart needs the external-dependency
916
+ configuration supplied at build.
917
+ """
918
+
919
+ documents = list(documents)
920
+ by_id = _by_id(documents)
921
+ known_external = {_canonical(name) for name in external_names}
922
+
923
+ edges: list[tuple[str, str]] = []
924
+ for document in documents:
925
+ for dependency in effective_dependencies(document):
926
+ if _canonical(dependency.qualified) in known_external:
927
+ # Provided from outside — a boundary, not an edge within this graph.
928
+ continue
929
+ upstream = _resolve(dependency, by_id, document)
930
+ if upstream is not None and upstream.node_id != document.node_id:
931
+ edges.append((upstream.node_id, document.node_id))
932
+
933
+ return Graph((document.node_id for document in documents), edges)
934
+
935
+
936
+ def unresolved_references(
937
+ documents: Iterable[SourceDocument], *, external_names: Iterable[str] = ()
938
+ ) -> dict[str, tuple[str, ...]]:
939
+ """Per object, the references naming nothing in this repository.
940
+
941
+ Recorded rather than refused: resolution needs the external-dependency
942
+ configuration, and that is a build concern.
943
+ """
944
+
945
+ documents = list(documents)
946
+ by_id = _by_id(documents)
947
+ known_external = {_canonical(name) for name in external_names}
948
+ unresolved: dict[str, tuple[str, ...]] = {}
949
+ for document in documents:
950
+ outside = tuple(
951
+ dependency.qualified
952
+ for dependency in effective_dependencies(document)
953
+ if _canonical(dependency.qualified) not in known_external
954
+ and _resolve(dependency, by_id, document) is None
955
+ )
956
+ physical = tuple(str(reference) for reference in document.qualified_references)
957
+ if outside or physical:
958
+ unresolved[document.node_id] = outside + physical
959
+ return unresolved