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,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)