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,213 @@
1
+ """Spark SQL table build — inference and creation in one self-contained action.
2
+
3
+ A Spark SQL table's shape is only settled by running its query in the session, so
4
+ its payload is not finished SQL. It is a JSON instruction (built by
5
+ :func:`weaver.ses.ddl._spark_table_ddl`) that this executor completes in a single
6
+ pass, the Spark counterpart of the old T-SQL self-contained script
7
+ (how-does-build-work §2):
8
+
9
+ 1. run the query and read the resulting ``DataFrame`` schema — Spark resolves the
10
+ column names and types from the logical plan without running a job, so no rows
11
+ are read;
12
+ 2. validate the columns with the same guards a declared schema passes at parse
13
+ (:func:`weaver.ses.columns.validate_build_columns`), driven entirely by the
14
+ frozen payload — the Weaver document source is never reopened;
15
+ 3. choose the physical business columns — declared types when declared, the
16
+ query's inferred types otherwise;
17
+ 4. append Weaver's audit columns;
18
+ 5. create the table with strict ``CREATE TABLE``.
19
+
20
+ A Delta table has no identity column, so nothing here handles one. Native
21
+ identity is what makes the column worth having, and no Delta version Weaver
22
+ runs on generates it, so the ``Identity`` header is a Warehouse-only
23
+ declaration the parser refuses elsewhere (:data:`weaver.declaration.metadata.IDENTITY_LANGUAGES`)
24
+ rather than something accepted here and quietly not materialised.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from typing import Any
31
+
32
+ from ...errors import InstallError
33
+ from ...declaration.columns import validate_build_columns
34
+ from ...declaration.metadata import AUDIT_COLUMNS, audit_column_name, PYTHON
35
+ from ...spark import tokens
36
+ from ..models import BuildAction
37
+ from .base import InstallationContext
38
+ from .spark_case import exact_identifier_case
39
+
40
+ #: Reserved audit names, in the Delta (underscored) spelling, for collision
41
+ #: detection against an inferred query's own output columns.
42
+ _AUDIT_NAMES = {audit_column_name(logical, PYTHON).lower() for logical in AUDIT_COLUMNS}
43
+
44
+ class SparkTableExecutor:
45
+ name = "spark_table"
46
+
47
+ def execute(
48
+ self,
49
+ action: BuildAction,
50
+ payload: bytes | None,
51
+ context: InstallationContext,
52
+ ) -> dict[str, Any] | None:
53
+ if payload is None:
54
+ raise InstallError(f"spark_table action {action.id!r} has no payload")
55
+ if context.spark is None:
56
+ raise InstallError(
57
+ f"spark_table action {action.id!r} needs a Spark session but none "
58
+ "was provided"
59
+ )
60
+
61
+ instruction = json.loads(payload.decode("utf-8"))
62
+ catalogue = context.catalogue
63
+ # Both sides are resolved against the batch's destination: the table this
64
+ # creates, and every managed object its query reads. Inferring the shape
65
+ # from a query that resolved through the session's own catalogue would
66
+ # read some other Lakehouse's table of that name — and then create a table
67
+ # of that shape, silently, in the right place.
68
+ qualified = catalogue.expand(instruction["object"])
69
+ query = catalogue.expand(instruction["source_query"])
70
+
71
+ # Fabric defaults case-sensitive analysis off. Weaver identities are exact,
72
+ # so the source query and the resulting DDL must share one exact-case scope:
73
+ # otherwise a table created as ``CustomerEnriched`` cannot be consumed by
74
+ # the next action in the same coordinated build.
75
+ with exact_identifier_case(
76
+ context.spark,
77
+ enabled=catalogue.destination.preserve_table_identifier_case,
78
+ ):
79
+ frame = context.spark.sql(query)
80
+ query_columns = tuple(field.name for field in frame.schema.fields)
81
+ query_types = {
82
+ field.name: field.dataType.simpleString() for field in frame.schema.fields
83
+ }
84
+
85
+ declared = instruction["declared_columns"]
86
+ declared_names = (
87
+ tuple(name for name, _type, _nn in declared)
88
+ if declared is not None
89
+ else None
90
+ )
91
+ references = tuple(
92
+ (label, column) for label, column in instruction["references"]
93
+ )
94
+ business_columns = validate_build_columns(
95
+ qualified,
96
+ query_columns,
97
+ declared_columns=declared_names,
98
+ references=references,
99
+ )
100
+
101
+ business = self._physical_columns(
102
+ qualified, business_columns, declared, query_types, references
103
+ )
104
+ physical = business + [
105
+ tuple(entry) for entry in instruction["audit_columns"]
106
+ ]
107
+
108
+ statement = _create_table_sql(
109
+ qualified,
110
+ physical,
111
+ column_mapping=instruction.get("column_mapping", True),
112
+ )
113
+ _create_preserving_identifier_case(
114
+ context.spark,
115
+ statement,
116
+ catalogue=catalogue,
117
+ logical_object=instruction["object"],
118
+ )
119
+ return {
120
+ "object": qualified,
121
+ "schema_mode": instruction["schema_mode"],
122
+ "columns": [name for name, _type, _nn in physical],
123
+ }
124
+
125
+ def _physical_columns(
126
+ self,
127
+ qualified: str,
128
+ business_columns: tuple[str, ...],
129
+ declared: list | None,
130
+ query_types: dict[str, str],
131
+ references: tuple[tuple[str, str], ...],
132
+ ) -> list[tuple[str, str, bool]]:
133
+ """The business columns as ``(name, type, not_null)``.
134
+
135
+ Declared columns carry their declared type and not-null. Inferred columns
136
+ take the query's type and are not null when the primary key or a
137
+ ``Not null`` names them — the same loading contract, applied to a shape
138
+ the query supplied rather than a declaration.
139
+ """
140
+
141
+ collisions = [name for name in business_columns if name.lower() in _AUDIT_NAMES]
142
+ if collisions:
143
+ raise InstallError(
144
+ f"{qualified}: the query produces column(s) reserved for Weaver's "
145
+ "audit columns: " + ", ".join(collisions)
146
+ )
147
+
148
+ if declared is not None:
149
+ declared_by_name = {name: (type_, nn) for name, type_, nn in declared}
150
+ return [(name, *declared_by_name[name]) for name in business_columns]
151
+
152
+ not_null_names = {
153
+ column for label, column in references if label in ("Primary key", "Not null")
154
+ }
155
+ return [
156
+ (name, query_types[name], name in not_null_names)
157
+ for name in business_columns
158
+ ]
159
+
160
+
161
+ def _create_table_sql(
162
+ qualified: str, columns: list[tuple[str, str, bool]], *, column_mapping: bool
163
+ ) -> str:
164
+ column_lines = ",\n".join(
165
+ f" {_ident(name)} {type_}{' NOT NULL' if not_null else ''}"
166
+ for name, type_, not_null in columns
167
+ )
168
+ mapping = (
169
+ "\nTBLPROPERTIES ('delta.columnMapping.mode' = 'name')" if column_mapping else ""
170
+ )
171
+ return (
172
+ f"CREATE TABLE {qualified} (\n"
173
+ f"{column_lines}\n"
174
+ ")\n"
175
+ "USING delta"
176
+ f"{mapping}\n"
177
+ )
178
+
179
+
180
+ def _create_preserving_identifier_case(
181
+ spark,
182
+ statement: str,
183
+ *,
184
+ catalogue,
185
+ logical_object: str,
186
+ ) -> None:
187
+ """Create one exact-case table without leaking session configuration."""
188
+
189
+ spark.sql(statement)
190
+
191
+
192
+ def _drop_case_variant(catalogue, logical_object: str) -> None:
193
+ match = tokens.OBJECT.fullmatch(logical_object)
194
+ if match is None: # expand() reports the useful token error on the main path
195
+ return
196
+ schema, declared = match.groups()
197
+ matches = [
198
+ existing
199
+ for existing in catalogue.tables(schema)
200
+ if existing.casefold() == declared.casefold()
201
+ ]
202
+ if not matches or matches == [declared]:
203
+ return
204
+ if len(matches) != 1:
205
+ raise InstallError(
206
+ f"{schema}.{declared}: target contains case-colliding tables: "
207
+ + ", ".join(sorted(matches))
208
+ )
209
+ catalogue.spark.sql(f"DROP TABLE {catalogue.qualify(schema, matches[0])}")
210
+
211
+
212
+ def _ident(name: str) -> str:
213
+ return "`" + name.replace("`", "``") + "`"
@@ -0,0 +1,34 @@
1
+ """Refresh a target Lakehouse's SQL analytics endpoint where supported."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...errors import InstallError
6
+ from ..models import REFRESH_SQL_ENDPOINT
7
+ from .base import InstallationContext, SkippedExecution
8
+
9
+
10
+ class SqlEndpointRefreshExecutor:
11
+ """Perform the planned refresh, or explicitly skip an unsupported host."""
12
+
13
+ name = "sql_endpoint_refresh"
14
+
15
+ def execute(self, action, payload, context: InstallationContext):
16
+ if action.kind != REFRESH_SQL_ENDPOINT:
17
+ raise InstallError(
18
+ f"SQL endpoint refresh action {action.id!r} has unknown kind "
19
+ f"{action.kind!r}"
20
+ )
21
+ if payload is not None:
22
+ raise InstallError(
23
+ f"SQL endpoint refresh action {action.id!r} must not carry a payload"
24
+ )
25
+
26
+ refresh = getattr(context.resolver, "refresh_sql_endpoint", None)
27
+ if refresh is None:
28
+ return SkippedExecution(
29
+ {
30
+ "reason": "SQL endpoint refresh is unsupported in this environment",
31
+ }
32
+ )
33
+ details = refresh(context.target.lakehouse)
34
+ return details or {"lakehouse": context.target.bound.name}
@@ -0,0 +1,81 @@
1
+ """T-SQL execution — run a generated Warehouse script through the SQL stack.
2
+
3
+ The payload is a finished, self-contained T-SQL script (built by
4
+ :mod:`weaver.declaration.tsql_ddl`): a table build materialises and inspects its own
5
+ query shape server-side and creates only its main table; a view is a
6
+ strict ``CREATE VIEW``. The executor runs it as one multi-statement script
7
+ through the pooled SQL executor the environment supplies — it adds no logic of
8
+ its own, exactly the mechanical executor the build philosophy calls for.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ from ...errors import InstallError
17
+ from ..models import BuildAction
18
+ from .base import InstallationContext
19
+
20
+
21
+ class TSqlExecutor:
22
+ name = "tsql"
23
+
24
+ def execute(
25
+ self,
26
+ action: BuildAction,
27
+ payload: bytes | None,
28
+ context: InstallationContext,
29
+ ) -> dict[str, Any] | None:
30
+ if payload is None:
31
+ raise InstallError(f"tsql action {action.id!r} has no payload")
32
+ if context.sql is None:
33
+ raise InstallError(
34
+ f"tsql action {action.id!r} needs a SQL executor but none was "
35
+ "provided — a Warehouse install must supply one"
36
+ )
37
+ script = payload.decode("utf-8")
38
+ context.sql.execute_script(script)
39
+ return {
40
+ "statement_first_line": script.splitlines()[0] if script.strip() else ""
41
+ }
42
+
43
+
44
+ class TSqlBatchExecutor:
45
+ """Ordered T-SQL statements, each run as its own batch, as one action.
46
+
47
+ The T-SQL twin of ``spark_sql_batch``, and it exists for a reason the Spark
48
+ side does not have: several statements cannot share a batch when any of them
49
+ is a ``CREATE VIEW``, because T-SQL requires that to be the first statement in
50
+ its batch. ``execute_script`` sends what it is given as one batch, so a script
51
+ holding two ``CREATE OR ALTER VIEW`` statements is rejected outright —
52
+ *Incorrect syntax near the keyword 'create'*.
53
+
54
+ So the payload is an ordered array rather than one script, and each element is
55
+ submitted on its own. The action stays one action: it is one decision, reported
56
+ once, and the batching is transport.
57
+ """
58
+
59
+ name = "tsql_batch"
60
+
61
+ def execute(
62
+ self,
63
+ action: BuildAction,
64
+ payload: bytes | None,
65
+ context: InstallationContext,
66
+ ) -> dict[str, Any] | None:
67
+ if payload is None:
68
+ raise InstallError(f"tsql_batch action {action.id!r} has no payload")
69
+ if context.sql is None:
70
+ raise InstallError(
71
+ f"tsql_batch action {action.id!r} needs a SQL executor but none was "
72
+ "provided — a Warehouse install must supply one"
73
+ )
74
+ statements = json.loads(payload.decode("utf-8"))
75
+ if not isinstance(statements, list):
76
+ raise InstallError(
77
+ f"tsql_batch action {action.id!r} payload must be an array of statements"
78
+ )
79
+ for statement in statements:
80
+ context.sql.execute_script(statement)
81
+ return {"statements": len(statements)}
@@ -0,0 +1,288 @@
1
+ """Pure incremental selection from a prepared repository and trusted catalogue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Iterable, Mapping
8
+
9
+ from ..catalogue.state import RegisteredDocument
10
+ from ..declaration.model import WeaverDocumentId, WeaverItemId, WeaverRepository
11
+
12
+
13
+ def _ordered(values: Iterable[WeaverDocumentId]) -> tuple[WeaverDocumentId, ...]:
14
+ return tuple(sorted(set(values), key=str))
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Impact:
19
+ """Signature classification plus existing descendants affected by changes."""
20
+
21
+ new: tuple[WeaverDocumentId, ...]
22
+ changed: tuple[WeaverDocumentId, ...]
23
+ impacted_descendants: tuple[WeaverDocumentId, ...]
24
+
25
+ @property
26
+ def impacted(self) -> tuple[WeaverDocumentId, ...]:
27
+ return _ordered(set(self.changed) | set(self.impacted_descendants))
28
+
29
+ def to_mapping(self) -> dict[str, list[str]]:
30
+ return {
31
+ "new": [str(value) for value in self.new],
32
+ "changed": [str(value) for value in self.changed],
33
+ "impacted_descendants": [
34
+ str(value) for value in self.impacted_descendants
35
+ ],
36
+ }
37
+
38
+ @classmethod
39
+ def from_mapping(cls, mapping) -> "Impact":
40
+ changed = tuple(
41
+ WeaverDocumentId.parse(value) for value in mapping.get("changed", ())
42
+ )
43
+ descendants = mapping.get("impacted_descendants")
44
+ if descendants is None:
45
+ descendants = tuple(
46
+ value
47
+ for value in mapping.get("impacted", ())
48
+ if WeaverDocumentId.parse(value) not in set(changed)
49
+ )
50
+ return cls(
51
+ new=tuple(WeaverDocumentId.parse(value) for value in mapping.get("new", ())),
52
+ changed=changed,
53
+ impacted_descendants=tuple(
54
+ WeaverDocumentId.parse(value) for value in descendants
55
+ ),
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class BuildSelection:
61
+ """The complete, inspectable decision before bundle actions are rendered."""
62
+
63
+ impact: Impact
64
+ prohibited: tuple[WeaverDocumentId, ...]
65
+ selected_for_drop: tuple[WeaverDocumentId, ...]
66
+ selected_for_build: tuple[WeaverDocumentId, ...]
67
+
68
+ def to_mapping(self) -> dict:
69
+ return {
70
+ "impact": self.impact.to_mapping(),
71
+ "prohibited": [str(value) for value in self.prohibited],
72
+ "selected_for_drop": [str(value) for value in self.selected_for_drop],
73
+ "selected_for_build": [str(value) for value in self.selected_for_build],
74
+ }
75
+
76
+ @classmethod
77
+ def from_mapping(cls, mapping) -> "BuildSelection":
78
+ return cls(
79
+ impact=Impact.from_mapping(mapping.get("impact", {})),
80
+ prohibited=tuple(
81
+ WeaverDocumentId.parse(value)
82
+ for value in mapping.get("prohibited", ())
83
+ ),
84
+ selected_for_drop=tuple(
85
+ WeaverDocumentId.parse(value)
86
+ for value in mapping.get("selected_for_drop", ())
87
+ ),
88
+ selected_for_build=tuple(
89
+ WeaverDocumentId.parse(value)
90
+ for value in mapping.get("selected_for_build", ())
91
+ ),
92
+ )
93
+
94
+
95
+ def _as_instant(value) -> datetime | None:
96
+ """One build epoch as something comparable, whatever the reader returned.
97
+
98
+ Spark hands back a ``datetime``; a hand-built catalogue or a JSON round trip
99
+ may hand back the string that was written. Anything else is treated as no
100
+ epoch at all rather than guessed at — a wrong comparison here would rebuild
101
+ the estate or fail to.
102
+ """
103
+
104
+ if isinstance(value, datetime):
105
+ return value
106
+ if isinstance(value, str) and value:
107
+ try:
108
+ return datetime.fromisoformat(value)
109
+ except ValueError:
110
+ return None
111
+ return None
112
+
113
+
114
+ def stale_alias_destinations(
115
+ repository: WeaverRepository,
116
+ registered: Mapping[WeaverDocumentId, RegisteredDocument],
117
+ *,
118
+ bound_items: Iterable[WeaverItemId],
119
+ ) -> tuple[WeaverDocumentId, ...]:
120
+ """Aliases whose source has been rebuilt since the alias was last published.
121
+
122
+ This is the half of cross-item freshness the graph cannot answer, and it is
123
+ needed *whether or not* the producer is in this build. The descendant walk
124
+ only carries impact from a producer whose declaration changed; a producer
125
+ rebuilt by some earlier build is, to this one, entirely unchanged. Nothing in
126
+ the repository records that it moved — the only surviving evidence is in the
127
+ catalogue.
128
+
129
+ So the catalogue is asked directly: the producer's Registry row and the
130
+ alias's Registry row each carry the build that published them, and a producer
131
+ published later than the alias over it means the alias's consumers were built
132
+ against something that has since moved on. Naming it here lets it join the
133
+ ordinary changed roots, and its consumers are picked up by the ordinary walk.
134
+
135
+ ``bound_items`` scopes it to aliases this build could act on. A consumer item
136
+ that is not being built keeps its stale alias — that is the deferral, and it
137
+ is why the comparison is worth recording rather than acting on immediately.
138
+
139
+ Deliberately silent when either row is absent: that is not staleness but a
140
+ missing installation, which signature classification already calls new.
141
+ """
142
+
143
+ bound = set(bound_items)
144
+ stale = []
145
+ for alias in repository.aliases:
146
+ if alias.destination.item not in bound:
147
+ continue
148
+ destination = registered.get(alias.destination)
149
+ source = registered.get(alias.source)
150
+ if destination is None or source is None:
151
+ continue
152
+ source_epoch = _as_instant(source.build_epoch)
153
+ if source_epoch is None:
154
+ continue
155
+ destination_epoch = _as_instant(destination.build_epoch)
156
+ if destination_epoch is None or source_epoch > destination_epoch:
157
+ stale.append(alias.destination)
158
+ return _ordered(stale)
159
+
160
+
161
+ def declared_signatures(
162
+ repository: WeaverRepository,
163
+ selected: Iterable[WeaverDocumentId],
164
+ ) -> dict[WeaverDocumentId, str]:
165
+ """What each selected node's Registry signature should be, from the source.
166
+
167
+ Three kinds of node are selectable and they are signed differently. A
168
+ document is signed by its source file. An alias destination is signed by the
169
+ pair it declares — this destination, that source — because that is the whole
170
+ of what an alias *is* (see
171
+ :attr:`~weaver.declaration.model.RepositoryAlias.signature`). A load artefact
172
+ is signed by what it is rendered from: a deployed module by its own bytes, a
173
+ generated body by the document it renders plus the version of the generator
174
+ that rendered it (see :mod:`weaver.etl`).
175
+ """
176
+
177
+ from ..etl import load_artefacts, load_artefacts_by_identity
178
+
179
+ aliases = {alias.destination: alias for alias in repository.aliases}
180
+ loads = load_artefacts_by_identity(load_artefacts(repository))
181
+ signatures: dict[WeaverDocumentId, str] = {}
182
+ for identity in selected:
183
+ alias = aliases.get(identity)
184
+ artefact = loads.get(identity)
185
+ if alias is not None:
186
+ signatures[identity] = alias.signature
187
+ elif artefact is not None:
188
+ signatures[identity] = artefact.signature
189
+ else:
190
+ signatures[identity] = repository.source_documents[
191
+ identity
192
+ ].effective_signature
193
+ return signatures
194
+
195
+
196
+ def determine_impact(
197
+ repository: WeaverRepository,
198
+ registered: Mapping[WeaverDocumentId, RegisteredDocument],
199
+ *,
200
+ selected: Iterable[WeaverDocumentId],
201
+ stale_aliases: Iterable[WeaverDocumentId] = (),
202
+ ) -> Impact:
203
+ """Classify bound nodes and expand changed roots across the whole graph.
204
+
205
+ Propagation is no longer confined to one item. The graph carries alias
206
+ destinations as nodes, so the path from a producer to another item's consumer
207
+ is an ordinary walk — ``source → alias destination → consumer`` — and a
208
+ coordinated bundle that binds both items propagates across it exactly as it
209
+ does within one. Items *not* in the build are still deferred, but by
210
+ construction rather than by rule: they are not in ``selected``, so nothing
211
+ reaches them.
212
+
213
+ ``stale_aliases`` are destinations the catalogue already proved out of date —
214
+ their source was rebuilt by some earlier build that did not include this item
215
+ (see :func:`weaver.build_bundle.workflow.stale_alias_destinations`). They join
216
+ the changed roots, so their consumers are picked up by the same walk.
217
+ """
218
+
219
+ selected_set = set(selected)
220
+ installed = {
221
+ identity: document.signature
222
+ for identity, document in registered.items()
223
+ if identity in selected_set
224
+ }
225
+ declared = declared_signatures(repository, selected_set)
226
+
227
+ new: set[WeaverDocumentId] = set()
228
+ changed: set[WeaverDocumentId] = set()
229
+ for identity in selected_set:
230
+ signature = installed.get(identity)
231
+ if signature is None:
232
+ new.add(identity)
233
+ elif signature != declared[identity]:
234
+ changed.add(identity)
235
+ changed |= {
236
+ identity for identity in stale_aliases if identity in installed
237
+ }
238
+
239
+ existing = set(installed)
240
+ impacted = set(changed)
241
+ graph = repository.dependency_graph
242
+ if graph is not None:
243
+ by_text = {str(identity): identity for identity in selected_set}
244
+ for root in changed:
245
+ # A load artefact is not a node in the authored graph, and that is
246
+ # the design rather than an omission: nothing depends on a deployed
247
+ # module, and it depends on nothing — its signature is its own
248
+ # content. So a changed one is the end of a walk, not the start.
249
+ if root.is_load_artefact:
250
+ continue
251
+ for node in graph.descendants(str(root)):
252
+ descendant = by_text.get(node)
253
+ if descendant is not None and descendant in existing:
254
+ impacted.add(descendant)
255
+
256
+ return Impact(
257
+ new=_ordered(new),
258
+ changed=_ordered(changed),
259
+ impacted_descendants=_ordered(impacted - changed),
260
+ )
261
+
262
+
263
+ def select_build(
264
+ repository: WeaverRepository,
265
+ registered: Mapping[WeaverDocumentId, RegisteredDocument],
266
+ *,
267
+ selected: Iterable[WeaverDocumentId],
268
+ stale_aliases: Iterable[WeaverDocumentId] = (),
269
+ ) -> BuildSelection:
270
+ impact = determine_impact(
271
+ repository, registered, selected=selected, stale_aliases=stale_aliases
272
+ )
273
+ # An alias destination has no source document and therefore no
274
+ # ``prohibit_rebuild``: nothing an author writes can forbid replacing a
275
+ # pointer, because replacing one destroys nothing.
276
+ prohibited = {
277
+ identity
278
+ for identity in impact.impacted
279
+ if identity in repository.source_documents
280
+ and repository.source_documents[identity].document.prohibit_rebuild
281
+ }
282
+ selected_for_drop = set(impact.impacted) - prohibited
283
+ return BuildSelection(
284
+ impact=impact,
285
+ prohibited=_ordered(prohibited),
286
+ selected_for_drop=_ordered(selected_for_drop),
287
+ selected_for_build=_ordered(set(impact.new) | selected_for_drop),
288
+ )