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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|