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,386 @@
|
|
|
1
|
+
"""Locating what each planned node would dispatch, without dispatching it.
|
|
2
|
+
|
|
3
|
+
The seam between "what should run" and "run it", and it exists as its own layer
|
|
4
|
+
because the two questions fail for entirely different reasons. A planning fault
|
|
5
|
+
is a wrong graph; a resolution fault is a graph that is right about an estate
|
|
6
|
+
that is not there — a procedure that was never installed, a module deleted from
|
|
7
|
+
the runtime tree, a Warehouse that has been wiped. Keeping them apart means a
|
|
8
|
+
failure names which of the two it is.
|
|
9
|
+
|
|
10
|
+
The question this layer answers is exactly:
|
|
11
|
+
|
|
12
|
+
Can the orchestrator locate what it would dispatch?
|
|
13
|
+
|
|
14
|
+
and deliberately not *does the primitive work*. Each primitive is independently
|
|
15
|
+
runnable and independently tested; re-proving that here would make orchestration
|
|
16
|
+
the owner of behaviour it does not implement.
|
|
17
|
+
|
|
18
|
+
**Physical state arrives as an inventory, not as a live connection.** The same
|
|
19
|
+
:class:`~weaver.build_bundle.prune.TargetInventory` a build reads before planning
|
|
20
|
+
answers every existence question here — is the target there, is the procedure
|
|
21
|
+
there, is the deployed file there, does the table it loads into exist. So this
|
|
22
|
+
module is pure: a graph and some observed state in, a resolved plan out. The
|
|
23
|
+
reading happens once, above, where the session already is.
|
|
24
|
+
|
|
25
|
+
Dry run is this layer plus nothing. §11's list — read the catalogue, reverse the
|
|
26
|
+
bindings, build the graph, check it is acyclic, order it, resolve every node,
|
|
27
|
+
verify targets, procedures, files, modules and refresh capability — is the whole
|
|
28
|
+
orchestration path with dispatch removed, which is why a dry run can be complete
|
|
29
|
+
and still touch nothing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any, Mapping
|
|
36
|
+
|
|
37
|
+
from .build_bundle.prune import TargetInventory
|
|
38
|
+
from .etl import load_procedure_name
|
|
39
|
+
from .load_plan import (
|
|
40
|
+
ENDPOINT_REFRESH,
|
|
41
|
+
PYTHON_FOLDER,
|
|
42
|
+
PYTHON_TABLE,
|
|
43
|
+
SPARK_SQL_FILE,
|
|
44
|
+
WAREHOUSE_PROCEDURE,
|
|
45
|
+
LoadDag,
|
|
46
|
+
LoadNode,
|
|
47
|
+
PhysicalTargetRef,
|
|
48
|
+
)
|
|
49
|
+
from .load_report import (
|
|
50
|
+
BLOCKED,
|
|
51
|
+
DEPENDENCY_BLOCKED,
|
|
52
|
+
DISPATCH_LOCATION_MISSING,
|
|
53
|
+
INVALID,
|
|
54
|
+
MODULE_IMPORT_FAILURE,
|
|
55
|
+
TARGET_MISSING,
|
|
56
|
+
VALIDATED,
|
|
57
|
+
LoadMessage,
|
|
58
|
+
LoadNodeReport,
|
|
59
|
+
error,
|
|
60
|
+
warning,
|
|
61
|
+
)
|
|
62
|
+
from .targets import ItemRef
|
|
63
|
+
|
|
64
|
+
#: What a refresh resolves to when the host can perform one. Not a physical
|
|
65
|
+
#: object — a Lakehouse's SQL analytics endpoint is a capability of the item, so
|
|
66
|
+
#: the location names the item and the capability rather than a path.
|
|
67
|
+
ENDPOINT_SUFFIX = "sql_endpoint"
|
|
68
|
+
|
|
69
|
+
#: Why a refresh could not be resolved on this host. The emulator has no SQL
|
|
70
|
+
#: analytics endpoint at all, which is an honest absence rather than a fault —
|
|
71
|
+
#: the build's own executor skips for the same reason.
|
|
72
|
+
REFRESH_UNSUPPORTED = "SQL endpoint refresh is unsupported in this environment"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class LoadEnvironment:
|
|
77
|
+
"""Runtime services and the observed physical state of one load run.
|
|
78
|
+
|
|
79
|
+
``inventories`` is keyed by the physical target's public spelling —
|
|
80
|
+
``Lakehouse/Raw_LH`` — because that is what a caller wrote and what a report
|
|
81
|
+
prints, so a key that could not be read back would put a second vocabulary
|
|
82
|
+
between the request and the answer. A target with no entry is a target that
|
|
83
|
+
is not there.
|
|
84
|
+
|
|
85
|
+
``sql`` is per Warehouse rather than one connection, because a run may span
|
|
86
|
+
two of them and a Warehouse is reached over its own TDS endpoint.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
resolver: Any = None
|
|
90
|
+
inventories: Mapping[str, TargetInventory] = field(default_factory=dict)
|
|
91
|
+
store: Any = None
|
|
92
|
+
spark: Any = None
|
|
93
|
+
sql: Mapping[str, Any] = field(default_factory=dict)
|
|
94
|
+
workspace: Any = None
|
|
95
|
+
|
|
96
|
+
def inventory(self, target: PhysicalTargetRef) -> TargetInventory | None:
|
|
97
|
+
return self.inventories.get(str(target))
|
|
98
|
+
|
|
99
|
+
def sql_for(self, target: PhysicalTargetRef) -> Any:
|
|
100
|
+
return self.sql.get(target.name)
|
|
101
|
+
|
|
102
|
+
def can_refresh(self) -> bool:
|
|
103
|
+
return callable(getattr(self.resolver, "refresh_sql_endpoint", None))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class ResolvedLoadNode:
|
|
108
|
+
"""One node, and what the orchestrator would actually reach for.
|
|
109
|
+
|
|
110
|
+
``target_exists`` and ``primitive_exists`` are separate answers because they
|
|
111
|
+
are separate failures: a Warehouse that has been wiped and a procedure that
|
|
112
|
+
was never generated both stop this node, and telling a reader which one it
|
|
113
|
+
was is the whole value of resolving ahead of dispatching.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
node: LoadNode
|
|
117
|
+
dispatch_location: str | None = None
|
|
118
|
+
target_exists: bool = False
|
|
119
|
+
primitive_exists: bool = False
|
|
120
|
+
#: The class a deployed Python module must define, for the two Python kinds.
|
|
121
|
+
expected_class: str | None = None
|
|
122
|
+
validation_messages: tuple[LoadMessage, ...] = ()
|
|
123
|
+
#: A capability this host does not have, so the node is omitted rather than
|
|
124
|
+
#: failed. Only an endpoint refresh in an environment with no endpoint.
|
|
125
|
+
unsupported: bool = False
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def node_id(self) -> str:
|
|
129
|
+
return self.node.node_id
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def valid(self) -> bool:
|
|
133
|
+
return not any(
|
|
134
|
+
message.severity == "error" for message in self.validation_messages
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ResolvedLoadPlan:
|
|
140
|
+
"""A whole graph resolved against one environment."""
|
|
141
|
+
|
|
142
|
+
dag: LoadDag
|
|
143
|
+
nodes: tuple[ResolvedLoadNode, ...]
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def by_id(self) -> Mapping[str, ResolvedLoadNode]:
|
|
147
|
+
return {node.node_id: node for node in self.nodes}
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def order(self) -> tuple[ResolvedLoadNode, ...]:
|
|
151
|
+
resolved = self.by_id
|
|
152
|
+
return tuple(resolved[node.node_id] for node in self.dag.order())
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def blocked(self) -> Mapping[str, frozenset[str]]:
|
|
156
|
+
"""Which invalid node blocks each node that may not run because of it."""
|
|
157
|
+
|
|
158
|
+
blocked: dict[str, set[str]] = {}
|
|
159
|
+
for resolved in self.nodes:
|
|
160
|
+
if resolved.valid:
|
|
161
|
+
continue
|
|
162
|
+
for downstream in self.dag.descendants(resolved.node_id):
|
|
163
|
+
blocked.setdefault(downstream, set()).add(resolved.node_id)
|
|
164
|
+
return {node: frozenset(causes) for node, causes in blocked.items()}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_load_plan(
|
|
168
|
+
dag: LoadDag, *, environment: LoadEnvironment
|
|
169
|
+
) -> ResolvedLoadPlan:
|
|
170
|
+
"""Resolve every node in ``dag`` to the installed primitive it would run."""
|
|
171
|
+
|
|
172
|
+
return ResolvedLoadPlan(
|
|
173
|
+
dag=dag,
|
|
174
|
+
nodes=tuple(_resolve_node(node, environment) for node in dag.order()),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _resolve_node(node: LoadNode, environment: LoadEnvironment) -> ResolvedLoadNode:
|
|
179
|
+
inventory = environment.inventory(node.physical_target)
|
|
180
|
+
if node.primitive_kind == ENDPOINT_REFRESH:
|
|
181
|
+
return _resolve_refresh(node, environment, inventory)
|
|
182
|
+
messages: list[LoadMessage] = []
|
|
183
|
+
if inventory is None:
|
|
184
|
+
messages.append(
|
|
185
|
+
error(
|
|
186
|
+
TARGET_MISSING,
|
|
187
|
+
f"{node.physical_target} is not present, so {node.node_id} has "
|
|
188
|
+
"nowhere to run",
|
|
189
|
+
source="load_resolution",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
location, expected_class = _dispatch_location(node, environment)
|
|
193
|
+
if location is None:
|
|
194
|
+
messages.append(
|
|
195
|
+
error(
|
|
196
|
+
DISPATCH_LOCATION_MISSING,
|
|
197
|
+
f"{node.node_id} names primitive kind {node.primitive_kind!r}, "
|
|
198
|
+
"which this environment cannot address",
|
|
199
|
+
source="load_resolution",
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
primitive_exists = _holds(inventory, node.primitive_object)
|
|
203
|
+
if inventory is not None and not primitive_exists:
|
|
204
|
+
messages.append(
|
|
205
|
+
error(
|
|
206
|
+
DISPATCH_LOCATION_MISSING,
|
|
207
|
+
f"{node.node_id} would dispatch {location}, which is not installed "
|
|
208
|
+
f"in {node.physical_target}",
|
|
209
|
+
source="load_resolution",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
if inventory is not None and not _holds(inventory, node.physical_object):
|
|
213
|
+
messages.append(
|
|
214
|
+
error(
|
|
215
|
+
TARGET_MISSING,
|
|
216
|
+
f"{node.physical_target} does not hold {node.physical_object}, "
|
|
217
|
+
"which this node loads into",
|
|
218
|
+
source="load_resolution",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
if expected_class is None and node.primitive_kind in (PYTHON_TABLE, PYTHON_FOLDER):
|
|
222
|
+
messages.append(
|
|
223
|
+
error(
|
|
224
|
+
MODULE_IMPORT_FAILURE,
|
|
225
|
+
f"{node.node_id} names a deployed module whose expected class "
|
|
226
|
+
"cannot be derived from its filename",
|
|
227
|
+
source="load_resolution",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
return ResolvedLoadNode(
|
|
231
|
+
node=node,
|
|
232
|
+
dispatch_location=location,
|
|
233
|
+
target_exists=inventory is not None,
|
|
234
|
+
primitive_exists=primitive_exists,
|
|
235
|
+
expected_class=expected_class,
|
|
236
|
+
validation_messages=tuple(messages),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _resolve_refresh(
|
|
241
|
+
node: LoadNode, environment: LoadEnvironment, inventory
|
|
242
|
+
) -> ResolvedLoadNode:
|
|
243
|
+
"""A barrier resolves to a capability, and its absence is not a failure."""
|
|
244
|
+
|
|
245
|
+
messages: list[LoadMessage] = []
|
|
246
|
+
if inventory is None:
|
|
247
|
+
messages.append(
|
|
248
|
+
error(
|
|
249
|
+
TARGET_MISSING,
|
|
250
|
+
f"{node.physical_target} is not present, so its SQL endpoint "
|
|
251
|
+
"cannot be refreshed",
|
|
252
|
+
source="load_resolution",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
supported = environment.can_refresh()
|
|
256
|
+
if not supported:
|
|
257
|
+
messages.append(
|
|
258
|
+
warning(
|
|
259
|
+
DISPATCH_LOCATION_MISSING,
|
|
260
|
+
f"{REFRESH_UNSUPPORTED}; {node.node_id} will be skipped",
|
|
261
|
+
source="load_resolution",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return ResolvedLoadNode(
|
|
265
|
+
node=node,
|
|
266
|
+
dispatch_location=f"{node.physical_target}/{ENDPOINT_SUFFIX}",
|
|
267
|
+
target_exists=inventory is not None,
|
|
268
|
+
primitive_exists=supported,
|
|
269
|
+
validation_messages=tuple(messages),
|
|
270
|
+
unsupported=not supported,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _dispatch_location(
|
|
275
|
+
node: LoadNode, environment: LoadEnvironment
|
|
276
|
+
) -> tuple[str | None, str | None]:
|
|
277
|
+
"""Where the primitive is, and the class a deployed module must define."""
|
|
278
|
+
|
|
279
|
+
if node.primitive_kind == WAREHOUSE_PROCEDURE:
|
|
280
|
+
procedure = load_procedure_name(node.logical_id.object_id)
|
|
281
|
+
return f"{node.physical_target}/{procedure}", None
|
|
282
|
+
if node.primitive_kind in (SPARK_SQL_FILE, PYTHON_TABLE, PYTHON_FOLDER):
|
|
283
|
+
location = installed_file_location(node, environment)
|
|
284
|
+
expected = (
|
|
285
|
+
_module_class(node.primitive_object.object)
|
|
286
|
+
if node.primitive_kind in (PYTHON_TABLE, PYTHON_FOLDER)
|
|
287
|
+
else None
|
|
288
|
+
)
|
|
289
|
+
return location, expected
|
|
290
|
+
return None, None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def installed_file_location(node: LoadNode, environment: LoadEnvironment) -> str | None:
|
|
294
|
+
"""The deployed artefact's location, as this environment addresses it.
|
|
295
|
+
|
|
296
|
+
Resolved rather than composed, so the desktop, the emulator and a Fabric
|
|
297
|
+
session each get their own spelling of the same file from the one place that
|
|
298
|
+
knows how a name becomes a location.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
resolver = environment.resolver
|
|
302
|
+
files_root = getattr(resolver, "files_root", None)
|
|
303
|
+
if files_root is None or node.primitive_object is None:
|
|
304
|
+
return None
|
|
305
|
+
root = files_root(ItemRef(node.physical_target.name))
|
|
306
|
+
relative = f"{node.primitive_object.schema}/{node.primitive_object.object}"
|
|
307
|
+
return root.join(*relative.split("/")).value
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _module_class(filename: str) -> str | None:
|
|
311
|
+
"""``Sales__Order.py`` names class ``Sales__Order``.
|
|
312
|
+
|
|
313
|
+
The same rule the authoring surface applies to a class name and the
|
|
314
|
+
repository parser applies to a filename, so a deployed module's class is
|
|
315
|
+
found by the rule that put it there rather than by importing and looking.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
if not filename.endswith(".py"):
|
|
319
|
+
return None
|
|
320
|
+
stem = filename[: -len(".py")]
|
|
321
|
+
return stem or None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _holds(inventory: TargetInventory | None, where) -> bool:
|
|
325
|
+
if inventory is None or where is None:
|
|
326
|
+
return False
|
|
327
|
+
return inventory.has_object(where.schema, where.object, where.object_type)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# --- the dry-run report -------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def dry_run_reports(plan: ResolvedLoadPlan) -> tuple[LoadNodeReport, ...]:
|
|
334
|
+
"""A resolved plan as node reports, with nothing executed.
|
|
335
|
+
|
|
336
|
+
A validation status is never an execution status: a node that resolved is
|
|
337
|
+
``validated``, not ``succeeded``, and ``executed`` is false on every one of
|
|
338
|
+
them. Reporting a dry run as a successful load would put a claim in a report
|
|
339
|
+
that nothing performed — and the report shape is otherwise identical to a
|
|
340
|
+
real run's, which is precisely what makes a dry run worth inspecting.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
blocked = plan.blocked
|
|
344
|
+
reports = []
|
|
345
|
+
for resolved in plan.order:
|
|
346
|
+
node = resolved.node
|
|
347
|
+
causes = blocked.get(node.node_id, frozenset())
|
|
348
|
+
messages = list(resolved.validation_messages)
|
|
349
|
+
if resolved.valid and causes:
|
|
350
|
+
status = BLOCKED
|
|
351
|
+
messages.append(
|
|
352
|
+
error(
|
|
353
|
+
DEPENDENCY_BLOCKED,
|
|
354
|
+
f"{node.node_id} cannot run: "
|
|
355
|
+
+ ", ".join(sorted(causes))
|
|
356
|
+
+ " did not validate",
|
|
357
|
+
source="load_resolution",
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
status = VALIDATED if resolved.valid else INVALID
|
|
362
|
+
reports.append(
|
|
363
|
+
LoadNodeReport(
|
|
364
|
+
node_id=node.node_id,
|
|
365
|
+
logical_id=str(node.logical_id) if node.logical_id else None,
|
|
366
|
+
physical_target=str(node.physical_target),
|
|
367
|
+
primitive_kind=node.primitive_kind,
|
|
368
|
+
dispatch_location=resolved.dispatch_location,
|
|
369
|
+
status=status,
|
|
370
|
+
executed=False,
|
|
371
|
+
messages=tuple(messages),
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
return tuple(reports)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
__all__ = [
|
|
378
|
+
"ENDPOINT_SUFFIX",
|
|
379
|
+
"LoadEnvironment",
|
|
380
|
+
"REFRESH_UNSUPPORTED",
|
|
381
|
+
"ResolvedLoadNode",
|
|
382
|
+
"ResolvedLoadPlan",
|
|
383
|
+
"dry_run_reports",
|
|
384
|
+
"installed_file_location",
|
|
385
|
+
"resolve_load_plan",
|
|
386
|
+
]
|
weaver/locations.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Resolved physical locations.
|
|
2
|
+
|
|
3
|
+
A :class:`Location` is what a workspace resolution produces: the concrete place an
|
|
4
|
+
item, folder or table lives. It exists because ``pathlib.Path`` cannot be the
|
|
5
|
+
common currency — ``Path("abfss://ws@onelake.dfs.fabric.microsoft.com/lh")``
|
|
6
|
+
silently collapses the double slash and yields a broken root with no error.
|
|
7
|
+
|
|
8
|
+
So a location always carries a string and always joins by string. ``.path`` is
|
|
9
|
+
available when, and only when, the location is a filesystem path; asking a URL
|
|
10
|
+
location for a ``Path`` is a mistake worth raising on rather than corrupting.
|
|
11
|
+
|
|
12
|
+
Local resolution (checkpoint 2) produces filesystem locations. Fabric
|
|
13
|
+
resolution (checkpoint 7) will produce ``abfss://`` and OneLake URL locations
|
|
14
|
+
through the same type, so everything downstream is written once.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .errors import IdentityError
|
|
23
|
+
|
|
24
|
+
_URL_MARKER = "://"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Location:
|
|
29
|
+
"""One resolved location — a filesystem path or a URL."""
|
|
30
|
+
|
|
31
|
+
value: str
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not isinstance(self.value, str):
|
|
35
|
+
raise IdentityError(f"location must be a string, got {type(self.value).__name__}")
|
|
36
|
+
value = self.value.strip()
|
|
37
|
+
if not value:
|
|
38
|
+
raise IdentityError("location must not be empty")
|
|
39
|
+
# One separator, everywhere. A Windows caller reaches this with
|
|
40
|
+
# backslashes — `LocalWorkspace` normalises its root through `Path`, and
|
|
41
|
+
# `str()` of a `WindowsPath` uses them — while everything downstream
|
|
42
|
+
# treats "/" as the only separator: `join`, `name`, and the segment
|
|
43
|
+
# splitting in the Weaver document reader. Left alone, a repository read from a
|
|
44
|
+
# Windows checkout takes its whole path as its catalogue name.
|
|
45
|
+
#
|
|
46
|
+
# Safe against real names: "\" is rejected in object and schema names
|
|
47
|
+
# (see targets._ILLEGAL_IN_NAME), so a backslash here is always a
|
|
48
|
+
# separator. URLs never carry one either.
|
|
49
|
+
value = value.replace("\\", "/")
|
|
50
|
+
if len(value) > 1:
|
|
51
|
+
value = value.rstrip("/")
|
|
52
|
+
object.__setattr__(self, "value", value)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_url(self) -> bool:
|
|
56
|
+
return _URL_MARKER in self.value
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def path(self) -> Path:
|
|
60
|
+
"""The filesystem path. Raises for URL locations."""
|
|
61
|
+
|
|
62
|
+
if self.is_url:
|
|
63
|
+
raise IdentityError(
|
|
64
|
+
f"{self.value!r} is a URL location and has no filesystem path — "
|
|
65
|
+
"use a Store to read or write it"
|
|
66
|
+
)
|
|
67
|
+
return Path(self.value)
|
|
68
|
+
|
|
69
|
+
def join(self, *parts: str) -> "Location":
|
|
70
|
+
"""Append path segments. Always a string join, never ``Path``."""
|
|
71
|
+
|
|
72
|
+
joined = self.value
|
|
73
|
+
for part in parts:
|
|
74
|
+
if not isinstance(part, str):
|
|
75
|
+
raise IdentityError(f"location segment must be a string, got {part!r}")
|
|
76
|
+
segment = part.strip().strip("/")
|
|
77
|
+
if not segment:
|
|
78
|
+
raise IdentityError(f"location segment must not be empty: {part!r}")
|
|
79
|
+
joined = f"{joined.rstrip('/')}/{segment}"
|
|
80
|
+
return Location(joined)
|
|
81
|
+
|
|
82
|
+
def __truediv__(self, part: str) -> "Location":
|
|
83
|
+
return self.join(part)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def name(self) -> str:
|
|
87
|
+
"""The final segment."""
|
|
88
|
+
|
|
89
|
+
return self.value.rstrip("/").rsplit("/", 1)[-1]
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
return self.value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class LakehouseSparkLocation:
|
|
97
|
+
"""One destination Lakehouse's physical roots, resolved once.
|
|
98
|
+
|
|
99
|
+
The Spark session is attached to the **Weaver Lakehouse** — that is the fixed
|
|
100
|
+
control-plane context, and it is why Weaver's own catalogue tables are reached
|
|
101
|
+
as ordinary two-part names in schema ``_``. Destination Lakehouses are the
|
|
102
|
+
variable data plane, so they are reached through explicit roots instead, and
|
|
103
|
+
never by making the session point somewhere else.
|
|
104
|
+
|
|
105
|
+
That distinction is what lets one session build several Lakehouses in one
|
|
106
|
+
invocation. Switching the current catalogue between targets would make two
|
|
107
|
+
destinations that share a schema name indistinguishable; resolving each
|
|
108
|
+
target's roots keeps them separate by construction::
|
|
109
|
+
|
|
110
|
+
locations = {
|
|
111
|
+
target: resolver.lakehouse_spark_location(target)
|
|
112
|
+
for target in bound_lakehouse_targets
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
Roots are plain strings rather than :class:`Location` values because this is
|
|
116
|
+
what *Spark* addresses: an ``abfss://`` URL on Fabric, a filesystem path in
|
|
117
|
+
the local emulator. Same contract, different transport.
|
|
118
|
+
|
|
119
|
+
A resolved location is deliberately **not** carried in a build bundle. It is
|
|
120
|
+
derived from the item at install time, because on Fabric it embeds workspace
|
|
121
|
+
and item ids and locally it embeds a temporary directory — and a bundle whose
|
|
122
|
+
identity moved with a temporary path would not be comparable between
|
|
123
|
+
environments (how-does-build-work §15).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
#: The Lakehouse this resolves, by its logical name.
|
|
127
|
+
item: str
|
|
128
|
+
tables_root: str
|
|
129
|
+
files_root: str
|
|
130
|
+
|
|
131
|
+
def schema_root(self, schema: str) -> str:
|
|
132
|
+
"""Where a schema's managed tables live."""
|
|
133
|
+
|
|
134
|
+
return f"{self.tables_root.rstrip('/')}/{_segment(schema)}"
|
|
135
|
+
|
|
136
|
+
def table_path(self, schema: str, name: str) -> str:
|
|
137
|
+
"""Where one managed Delta table lives."""
|
|
138
|
+
|
|
139
|
+
return f"{self.schema_root(schema)}/{_segment(name)}"
|
|
140
|
+
|
|
141
|
+
def folder_path(self, schema: str, name: str) -> str:
|
|
142
|
+
"""Where one managed folder lives, under the Files area."""
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
f"{self.files_root.rstrip('/')}/{_segment(schema)}/{_segment(name)}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def __str__(self) -> str:
|
|
149
|
+
return f"{self.item} (tables={self.tables_root}, files={self.files_root})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _segment(value: str) -> str:
|
|
153
|
+
"""One path segment, checked rather than trusted.
|
|
154
|
+
|
|
155
|
+
These strings are concatenated into paths Spark writes through, so a segment
|
|
156
|
+
that escaped its parent would write outside the Lakehouse it names.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
segment = value.strip().strip("/")
|
|
160
|
+
if not segment or segment in (".", ".."):
|
|
161
|
+
raise IdentityError(f"path segment must be a real name, got {value!r}")
|
|
162
|
+
if "/" in segment or "\\" in segment:
|
|
163
|
+
raise IdentityError(f"path segment must not contain a separator: {value!r}")
|
|
164
|
+
return segment
|