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,240 @@
|
|
|
1
|
+
"""Dependency graph primitives.
|
|
2
|
+
|
|
3
|
+
The graph knows nothing about what an edge *means*. That is deliberate,
|
|
4
|
+
because there is more than one graph over the same objects:
|
|
5
|
+
|
|
6
|
+
**Load order** follows every dependency. To load ``Reporting.OrderReport`` you
|
|
7
|
+
first need the rows in ``Sales.Order``.
|
|
8
|
+
|
|
9
|
+
**Build order** is nearly flat. Building a Folder is a directory; building a
|
|
10
|
+
Delta table is a ``CREATE`` from its declared ``Schema`` — neither needs a
|
|
11
|
+
single upstream object to exist. Only a Warehouse object has build
|
|
12
|
+
dependencies, because its shape is inferred from its query. So a build is every
|
|
13
|
+
Folder and every Delta table in one parallel wave, then the Warehouse objects in
|
|
14
|
+
order, with a SQL endpoint refresh where the first of them reads Delta.
|
|
15
|
+
|
|
16
|
+
Both are the same machinery over different edge sets, so this module takes
|
|
17
|
+
nodes and edges and answers ordering questions about them.
|
|
18
|
+
|
|
19
|
+
Order is deterministic: ties are broken by name, so the same repository always
|
|
20
|
+
produces the same plan and two plans can be diffed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Iterable, Mapping
|
|
28
|
+
|
|
29
|
+
from ..errors import GraphError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Edge:
|
|
34
|
+
"""``upstream`` must happen before ``downstream``."""
|
|
35
|
+
|
|
36
|
+
upstream: str
|
|
37
|
+
downstream: str
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return f"{self.upstream} -> {self.downstream}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Graph:
|
|
44
|
+
"""A directed acyclic graph over named nodes."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, nodes: Iterable[str], edges: Iterable[tuple[str, str]] = ()) -> None:
|
|
47
|
+
self._nodes = tuple(sorted(set(nodes)))
|
|
48
|
+
known = set(self._nodes)
|
|
49
|
+
|
|
50
|
+
seen: set[tuple[str, str]] = set()
|
|
51
|
+
collected: list[Edge] = []
|
|
52
|
+
for upstream, downstream in edges:
|
|
53
|
+
if upstream not in known:
|
|
54
|
+
raise GraphError(f"edge from unknown node {upstream!r}")
|
|
55
|
+
if downstream not in known:
|
|
56
|
+
raise GraphError(f"edge to unknown node {downstream!r}")
|
|
57
|
+
if upstream == downstream:
|
|
58
|
+
raise GraphError(f"{upstream} depends on itself")
|
|
59
|
+
if (upstream, downstream) in seen:
|
|
60
|
+
continue
|
|
61
|
+
seen.add((upstream, downstream))
|
|
62
|
+
collected.append(Edge(upstream=upstream, downstream=downstream))
|
|
63
|
+
self._edges = tuple(sorted(collected, key=lambda edge: (edge.upstream, edge.downstream)))
|
|
64
|
+
|
|
65
|
+
self._downstream: Mapping[str, list[str]] = defaultdict(list)
|
|
66
|
+
self._upstream: Mapping[str, list[str]] = defaultdict(list)
|
|
67
|
+
for edge in self._edges:
|
|
68
|
+
self._downstream[edge.upstream].append(edge.downstream)
|
|
69
|
+
self._upstream[edge.downstream].append(edge.upstream)
|
|
70
|
+
|
|
71
|
+
# Fail on construction: an unorderable graph is not a graph worth holding.
|
|
72
|
+
self._order = self._topological_order()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def nodes(self) -> tuple[str, ...]:
|
|
76
|
+
return self._nodes
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def edges(self) -> tuple[Edge, ...]:
|
|
80
|
+
return self._edges
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return len(self._nodes)
|
|
84
|
+
|
|
85
|
+
def __contains__(self, node: str) -> bool:
|
|
86
|
+
return node in set(self._nodes)
|
|
87
|
+
|
|
88
|
+
def _require(self, node: str) -> None:
|
|
89
|
+
if node not in set(self._nodes):
|
|
90
|
+
raise GraphError(f"unknown node: {node!r}")
|
|
91
|
+
|
|
92
|
+
def upstream_of(self, node: str) -> tuple[str, ...]:
|
|
93
|
+
"""What this node depends on directly."""
|
|
94
|
+
|
|
95
|
+
self._require(node)
|
|
96
|
+
return tuple(sorted(self._upstream[node]))
|
|
97
|
+
|
|
98
|
+
def downstream_of(self, node: str) -> tuple[str, ...]:
|
|
99
|
+
"""What depends on this node directly."""
|
|
100
|
+
|
|
101
|
+
self._require(node)
|
|
102
|
+
return tuple(sorted(self._downstream[node]))
|
|
103
|
+
|
|
104
|
+
def roots(self) -> tuple[str, ...]:
|
|
105
|
+
"""Nodes that depend on nothing."""
|
|
106
|
+
|
|
107
|
+
return tuple(node for node in self._nodes if not self._upstream[node])
|
|
108
|
+
|
|
109
|
+
def leaves(self) -> tuple[str, ...]:
|
|
110
|
+
"""Nodes nothing depends on."""
|
|
111
|
+
|
|
112
|
+
return tuple(node for node in self._nodes if not self._downstream[node])
|
|
113
|
+
|
|
114
|
+
# --- ordering ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def order(self) -> tuple[str, ...]:
|
|
117
|
+
"""Every node, upstream before downstream, ties broken by name."""
|
|
118
|
+
|
|
119
|
+
return self._order
|
|
120
|
+
|
|
121
|
+
def layers(self) -> tuple[tuple[str, ...], ...]:
|
|
122
|
+
"""Waves that may run in parallel.
|
|
123
|
+
|
|
124
|
+
Everything in a layer depends only on earlier layers, so a layer can be
|
|
125
|
+
dispatched together and joined before the next begins.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
depth: dict[str, int] = {}
|
|
129
|
+
for node in self._order:
|
|
130
|
+
parents = self._upstream[node]
|
|
131
|
+
depth[node] = max((depth[parent] + 1 for parent in parents), default=0)
|
|
132
|
+
|
|
133
|
+
grouped: dict[int, list[str]] = defaultdict(list)
|
|
134
|
+
for node, level in depth.items():
|
|
135
|
+
grouped[level].append(node)
|
|
136
|
+
return tuple(tuple(sorted(grouped[level])) for level in sorted(grouped))
|
|
137
|
+
|
|
138
|
+
def _topological_order(self) -> tuple[str, ...]:
|
|
139
|
+
remaining = {node: len(self._upstream[node]) for node in self._nodes}
|
|
140
|
+
ready = sorted(node for node, count in remaining.items() if count == 0)
|
|
141
|
+
ordered: list[str] = []
|
|
142
|
+
|
|
143
|
+
while ready:
|
|
144
|
+
node = ready.pop(0)
|
|
145
|
+
ordered.append(node)
|
|
146
|
+
for child in sorted(self._downstream[node]):
|
|
147
|
+
remaining[child] -= 1
|
|
148
|
+
if remaining[child] == 0:
|
|
149
|
+
ready.append(child)
|
|
150
|
+
ready.sort()
|
|
151
|
+
|
|
152
|
+
if len(ordered) != len(self._nodes):
|
|
153
|
+
cycle = self._find_cycle(set(self._nodes) - set(ordered))
|
|
154
|
+
raise GraphError("dependency cycle: " + " -> ".join(cycle))
|
|
155
|
+
return tuple(ordered)
|
|
156
|
+
|
|
157
|
+
def _find_cycle(self, candidates: set[str]) -> list[str]:
|
|
158
|
+
"""One concrete cycle, so the message names the objects involved."""
|
|
159
|
+
|
|
160
|
+
path: list[str] = []
|
|
161
|
+
on_path: set[str] = set()
|
|
162
|
+
seen: set[str] = set()
|
|
163
|
+
|
|
164
|
+
def walk(node: str) -> list[str] | None:
|
|
165
|
+
if node in on_path:
|
|
166
|
+
return path[path.index(node):] + [node]
|
|
167
|
+
if node in seen:
|
|
168
|
+
return None
|
|
169
|
+
seen.add(node)
|
|
170
|
+
on_path.add(node)
|
|
171
|
+
path.append(node)
|
|
172
|
+
for child in sorted(self._downstream[node]):
|
|
173
|
+
if child not in candidates:
|
|
174
|
+
continue
|
|
175
|
+
found = walk(child)
|
|
176
|
+
if found is not None:
|
|
177
|
+
return found
|
|
178
|
+
path.pop()
|
|
179
|
+
on_path.discard(node)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
for start in sorted(candidates):
|
|
183
|
+
found = walk(start)
|
|
184
|
+
if found is not None:
|
|
185
|
+
return found
|
|
186
|
+
return sorted(candidates)
|
|
187
|
+
|
|
188
|
+
# --- traversal --------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def descendants(self, node: str) -> tuple[str, ...]:
|
|
191
|
+
"""Everything reachable downstream, in dependency order.
|
|
192
|
+
|
|
193
|
+
This is what a rebuild must uncertify: an object whose upstream
|
|
194
|
+
definition is being rebuilt cannot stay certified.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
return self._reach(node, self._downstream)
|
|
198
|
+
|
|
199
|
+
def ancestors(self, node: str) -> tuple[str, ...]:
|
|
200
|
+
"""Everything reachable upstream, in dependency order."""
|
|
201
|
+
|
|
202
|
+
return self._reach(node, self._upstream)
|
|
203
|
+
|
|
204
|
+
def _reach(self, node: str, adjacency: Mapping[str, list[str]]) -> tuple[str, ...]:
|
|
205
|
+
self._require(node)
|
|
206
|
+
found: set[str] = set()
|
|
207
|
+
pending = list(adjacency[node])
|
|
208
|
+
while pending:
|
|
209
|
+
current = pending.pop()
|
|
210
|
+
if current in found:
|
|
211
|
+
continue
|
|
212
|
+
found.add(current)
|
|
213
|
+
pending.extend(adjacency[current])
|
|
214
|
+
return tuple(candidate for candidate in self._order if candidate in found)
|
|
215
|
+
|
|
216
|
+
def subgraph(
|
|
217
|
+
self,
|
|
218
|
+
selection: Iterable[str],
|
|
219
|
+
*,
|
|
220
|
+
with_ancestors: bool = False,
|
|
221
|
+
with_descendants: bool = False,
|
|
222
|
+
) -> "Graph":
|
|
223
|
+
"""A graph over a selection, optionally expanded along dependencies."""
|
|
224
|
+
|
|
225
|
+
chosen: set[str] = set()
|
|
226
|
+
for node in selection:
|
|
227
|
+
self._require(node)
|
|
228
|
+
chosen.add(node)
|
|
229
|
+
if with_ancestors:
|
|
230
|
+
chosen.update(self.ancestors(node))
|
|
231
|
+
if with_descendants:
|
|
232
|
+
chosen.update(self.descendants(node))
|
|
233
|
+
return Graph(
|
|
234
|
+
chosen,
|
|
235
|
+
[
|
|
236
|
+
(edge.upstream, edge.downstream)
|
|
237
|
+
for edge in self._edges
|
|
238
|
+
if edge.upstream in chosen and edge.downstream in chosen
|
|
239
|
+
],
|
|
240
|
+
)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Item-owned dependency resolution and sparse logical projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Iterable, Mapping
|
|
7
|
+
|
|
8
|
+
from ..errors import BuildError, DiscoveryError, GraphError
|
|
9
|
+
from .graph import Graph
|
|
10
|
+
from .metadata import ObjectId
|
|
11
|
+
from .model import (
|
|
12
|
+
FILES,
|
|
13
|
+
ItemDependency,
|
|
14
|
+
RepositoryAlias,
|
|
15
|
+
WeaverDocumentId,
|
|
16
|
+
WeaverItemId,
|
|
17
|
+
WeaverRepository,
|
|
18
|
+
)
|
|
19
|
+
from .source import SourceDocument
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_item_dependencies(repository: WeaverRepository) -> WeaverRepository:
|
|
23
|
+
"""Return ``repository`` with exact item-owned edges and a global DAG."""
|
|
24
|
+
|
|
25
|
+
native = repository.source_documents
|
|
26
|
+
aliases = {alias.destination: alias.source for alias in repository.aliases}
|
|
27
|
+
folded_native = {str(identity).casefold(): identity for identity in native}
|
|
28
|
+
folded_alias = {str(identity).casefold(): identity for identity in aliases}
|
|
29
|
+
edges: list[ItemDependency] = []
|
|
30
|
+
#: Graph edges, kept separately from ``edges`` because the two answer
|
|
31
|
+
#: different questions — see :func:`_document_graph`.
|
|
32
|
+
graph_edges: set[tuple[str, str]] = set()
|
|
33
|
+
|
|
34
|
+
for consumer, source in native.items():
|
|
35
|
+
if source.document.declares_dependencies:
|
|
36
|
+
references = tuple(
|
|
37
|
+
(dependency.qualified, WeaverDocumentId(consumer.item, dependency))
|
|
38
|
+
for dependency in source.document.dependencies
|
|
39
|
+
)
|
|
40
|
+
elif source.language == "python":
|
|
41
|
+
references = _python_references(source)
|
|
42
|
+
else:
|
|
43
|
+
references = []
|
|
44
|
+
for reference in source.discovered_references:
|
|
45
|
+
if reference.call:
|
|
46
|
+
continue
|
|
47
|
+
if reference.is_qualified:
|
|
48
|
+
edges.append(
|
|
49
|
+
ItemDependency(
|
|
50
|
+
consumer=consumer,
|
|
51
|
+
reference=str(reference),
|
|
52
|
+
resolution_kind="physical",
|
|
53
|
+
is_within_item=False,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
elif reference.object_id is not None:
|
|
57
|
+
references.append(
|
|
58
|
+
(
|
|
59
|
+
str(reference),
|
|
60
|
+
WeaverDocumentId(consumer.item, reference.object_id),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for written, destination in references:
|
|
65
|
+
producer, kind = _resolve_destination(
|
|
66
|
+
destination,
|
|
67
|
+
native=native,
|
|
68
|
+
aliases=aliases,
|
|
69
|
+
folded_native=folded_native,
|
|
70
|
+
folded_alias=folded_alias,
|
|
71
|
+
consumer=consumer,
|
|
72
|
+
written=written,
|
|
73
|
+
)
|
|
74
|
+
edges.append(
|
|
75
|
+
ItemDependency(
|
|
76
|
+
consumer=consumer,
|
|
77
|
+
producer=producer,
|
|
78
|
+
reference=written,
|
|
79
|
+
resolution_kind=kind,
|
|
80
|
+
is_within_item=producer.item == consumer.item,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
# ``destination`` is what the consumer named in its own namespace: the
|
|
84
|
+
# producer itself when that resolved natively, and the item's alias
|
|
85
|
+
# destination when it resolved through one. The edge above records the
|
|
86
|
+
# producer either way; the graph records the hop actually taken.
|
|
87
|
+
graph_edges.add((str(destination), str(consumer)))
|
|
88
|
+
|
|
89
|
+
unique = {
|
|
90
|
+
(edge.consumer, edge.reference, edge.producer, edge.resolution_kind): edge
|
|
91
|
+
for edge in edges
|
|
92
|
+
}
|
|
93
|
+
resolved = tuple(
|
|
94
|
+
sorted(
|
|
95
|
+
unique.values(),
|
|
96
|
+
key=lambda edge: (
|
|
97
|
+
str(edge.consumer),
|
|
98
|
+
edge.reference,
|
|
99
|
+
str(edge.producer) if edge.producer else "",
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
graph = _document_graph(native, aliases, graph_edges)
|
|
104
|
+
item_graph = _item_graph(repository, resolved)
|
|
105
|
+
by_name = {str(item.identity): item.identity for item in repository.items}
|
|
106
|
+
return replace(
|
|
107
|
+
repository,
|
|
108
|
+
dependency_edges=resolved,
|
|
109
|
+
dependency_graph=graph,
|
|
110
|
+
item_graph=item_graph,
|
|
111
|
+
item_layers=tuple(
|
|
112
|
+
tuple(by_name[node] for node in layer) for layer in item_graph.layers()
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _document_graph(
|
|
118
|
+
native: Mapping[WeaverDocumentId, SourceDocument],
|
|
119
|
+
aliases: Mapping[WeaverDocumentId, WeaverDocumentId],
|
|
120
|
+
graph_edges: set[tuple[str, str]],
|
|
121
|
+
) -> Graph:
|
|
122
|
+
"""The graph incremental selection is planned against.
|
|
123
|
+
|
|
124
|
+
**This is deliberately not a projection of** :attr:`dependency_edges`. An
|
|
125
|
+
edge records what the author wrote and where it resolved to, and
|
|
126
|
+
:data:`~weaver.catalogue.tables.DEPENDENCY` publishes exactly that — an
|
|
127
|
+
alias edge names the *source document* as its producer, because that is the
|
|
128
|
+
truth about where the data comes from. The graph answers a different
|
|
129
|
+
question: what must be built, in what order. There the alias destination is
|
|
130
|
+
a thing in its own right — a shortcut or a view that some build has to
|
|
131
|
+
create — so the path is three hops rather than two:
|
|
132
|
+
|
|
133
|
+
.. code-block:: text
|
|
134
|
+
|
|
135
|
+
source document → alias destination → consumer document
|
|
136
|
+
|
|
137
|
+
Representing it that way is what lets impact propagate across items without
|
|
138
|
+
the planner needing a special case: an alias is an ordinary node, rebuilt
|
|
139
|
+
when its source is, and its consumers are ordinary descendants of it.
|
|
140
|
+
|
|
141
|
+
Every alias contributes its ``source → destination`` edge whether or not a
|
|
142
|
+
document consumes it. An alias with no consumer still has to be materialised
|
|
143
|
+
after the thing it points at exists.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
edges = set(graph_edges)
|
|
147
|
+
for destination, source in aliases.items():
|
|
148
|
+
edges.add((str(source), str(destination)))
|
|
149
|
+
nodes = [str(identity) for identity in native]
|
|
150
|
+
nodes.extend(str(destination) for destination in aliases)
|
|
151
|
+
return Graph(nodes, sorted(edges))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _item_graph(repository: WeaverRepository, resolved: tuple[ItemDependency, ...]) -> Graph:
|
|
155
|
+
"""The acyclic item-level graph a multi-item build is planned against.
|
|
156
|
+
|
|
157
|
+
One item depends on another when it reaches into it: either a document
|
|
158
|
+
resolves to a document that other item owns, or this item declares an alias
|
|
159
|
+
whose source lives there. The alias edge matters on its own — an alias with
|
|
160
|
+
no consumer yet still has to be materialised after its source exists — so it
|
|
161
|
+
is not left to be implied by the dependency edges.
|
|
162
|
+
|
|
163
|
+
Within-item edges are absent by construction: the document graph already
|
|
164
|
+
orders those, and an item cannot wait for itself.
|
|
165
|
+
|
|
166
|
+
A circular item graph is a **repository** fault. It is rejected here, while
|
|
167
|
+
the whole declaration is in view, rather than at the point some incremental
|
|
168
|
+
selection happens to exercise it — a repository whose items cannot be
|
|
169
|
+
ordered has no correct build, not merely no correct build today.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
edges: set[tuple[str, str]] = set()
|
|
173
|
+
for edge in resolved:
|
|
174
|
+
if edge.producer is None or edge.producer.item == edge.consumer.item:
|
|
175
|
+
continue
|
|
176
|
+
edges.add((str(edge.producer.item), str(edge.consumer.item)))
|
|
177
|
+
for alias in repository.aliases:
|
|
178
|
+
# Repository parsing rejects a same-item alias, so every alias is an
|
|
179
|
+
# edge between two distinct items.
|
|
180
|
+
edges.add((str(alias.source.item), str(alias.destination.item)))
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
return Graph(
|
|
184
|
+
(str(item.identity) for item in repository.items), sorted(edges)
|
|
185
|
+
)
|
|
186
|
+
except GraphError as exc:
|
|
187
|
+
raise GraphError(f"item {exc}") from exc
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_destination(
|
|
191
|
+
destination: WeaverDocumentId,
|
|
192
|
+
*,
|
|
193
|
+
native: Mapping[WeaverDocumentId, SourceDocument],
|
|
194
|
+
aliases: Mapping[WeaverDocumentId, WeaverDocumentId],
|
|
195
|
+
folded_native: Mapping[str, WeaverDocumentId],
|
|
196
|
+
folded_alias: Mapping[str, WeaverDocumentId],
|
|
197
|
+
consumer: WeaverDocumentId,
|
|
198
|
+
written: str,
|
|
199
|
+
) -> tuple[WeaverDocumentId, str]:
|
|
200
|
+
if destination in native:
|
|
201
|
+
return destination, "native"
|
|
202
|
+
if destination in aliases:
|
|
203
|
+
return aliases[destination], "alias"
|
|
204
|
+
case_match = folded_native.get(str(destination).casefold()) or folded_alias.get(
|
|
205
|
+
str(destination).casefold()
|
|
206
|
+
)
|
|
207
|
+
detail = f"; declared spelling is {case_match}" if case_match else ""
|
|
208
|
+
raise DiscoveryError(
|
|
209
|
+
f"{consumer}: dependency {written!r} does not resolve in item namespace{detail}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _python_references(source: SourceDocument) -> list[tuple[str, WeaverDocumentId]]:
|
|
214
|
+
assert source.logical_id is not None
|
|
215
|
+
references: list[tuple[str, WeaverDocumentId]] = []
|
|
216
|
+
for imported in source.python_imports:
|
|
217
|
+
candidates = _resolved_python_modules(source.logical_id, imported)
|
|
218
|
+
for written, components in candidates:
|
|
219
|
+
if components and components[0] == "lib":
|
|
220
|
+
continue
|
|
221
|
+
is_files = bool(components and components[0] == FILES)
|
|
222
|
+
object_module = components[-1] if components else ""
|
|
223
|
+
parts = object_module.split("__")
|
|
224
|
+
if len(parts) != 2 or not all(parts):
|
|
225
|
+
continue
|
|
226
|
+
if len(components) != (2 if is_files else 1):
|
|
227
|
+
raise DiscoveryError(
|
|
228
|
+
f"{source.node_id}: import {written!r} does not resolve to an "
|
|
229
|
+
"item object or lib module"
|
|
230
|
+
)
|
|
231
|
+
references.append(
|
|
232
|
+
(
|
|
233
|
+
written,
|
|
234
|
+
WeaverDocumentId(
|
|
235
|
+
source.logical_id.item,
|
|
236
|
+
ObjectId(parts[0], parts[1]),
|
|
237
|
+
is_files=is_files,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return references
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolved_python_modules(logical_id: WeaverDocumentId, imported) -> list[tuple[str, tuple[str, ...]]]:
|
|
245
|
+
module = tuple(imported.module.split(".")) if imported.module else ()
|
|
246
|
+
if imported.level:
|
|
247
|
+
base = (FILES,) if logical_id.is_files else ()
|
|
248
|
+
parents = imported.level - 1
|
|
249
|
+
if parents > len(base):
|
|
250
|
+
raise DiscoveryError(
|
|
251
|
+
f"{logical_id}: import {imported} escapes the owning Weaver item"
|
|
252
|
+
)
|
|
253
|
+
resolved = base[: len(base) - parents] + module
|
|
254
|
+
else:
|
|
255
|
+
resolved = module
|
|
256
|
+
|
|
257
|
+
candidates = [(str(imported), resolved)]
|
|
258
|
+
if not module or not any("__" in component for component in module):
|
|
259
|
+
candidates = [
|
|
260
|
+
(f"{imported}.{name}".replace("..", "."), resolved + (name,))
|
|
261
|
+
for name in imported.names
|
|
262
|
+
]
|
|
263
|
+
return candidates
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def project_bound_documents(
|
|
267
|
+
repository: WeaverRepository,
|
|
268
|
+
bound_items: Iterable[WeaverItemId],
|
|
269
|
+
) -> tuple[SourceDocument, ...]:
|
|
270
|
+
"""Select physical work by exact item only; do not pull in unbound ancestors."""
|
|
271
|
+
|
|
272
|
+
selected_items = set(bound_items)
|
|
273
|
+
if not selected_items:
|
|
274
|
+
raise BuildError("at least one Weaver item must be bound")
|
|
275
|
+
known_items = {item.identity for item in repository.items}
|
|
276
|
+
unknown = selected_items - known_items
|
|
277
|
+
if unknown:
|
|
278
|
+
raise BuildError(
|
|
279
|
+
"binding names item(s) absent from the repository: "
|
|
280
|
+
+ ", ".join(sorted(map(str, unknown)))
|
|
281
|
+
)
|
|
282
|
+
selected = {
|
|
283
|
+
str(identity): source
|
|
284
|
+
for identity, source in repository.source_documents.items()
|
|
285
|
+
if identity.item in selected_items
|
|
286
|
+
}
|
|
287
|
+
order = (
|
|
288
|
+
repository.dependency_graph.order()
|
|
289
|
+
if repository.dependency_graph is not None
|
|
290
|
+
else tuple(sorted(selected))
|
|
291
|
+
)
|
|
292
|
+
return tuple(selected[node] for node in order if node in selected)
|