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