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,71 @@
|
|
|
1
|
+
"""Folder execution — strictly create/drop a managed folder, or prune one.
|
|
2
|
+
|
|
3
|
+
Building a Folder is creating its directory in the Lakehouse Files area; there is
|
|
4
|
+
no data (staging files into it is *load*). Pruning one is removing a directory
|
|
5
|
+
the build already decided, at freeze time, is unmanaged. Both resolve their path
|
|
6
|
+
from the action's resource id and the bound target and touch no catalog:
|
|
7
|
+
|
|
8
|
+
- ``build_folder`` make ``Files/<schema>/<object>`` and fail on collision;
|
|
9
|
+
- ``drop_folder`` remove a selected managed object and fail if absent;
|
|
10
|
+
- ``prune_folder`` remove ``Files/<schema>/<object>`` (an object), or
|
|
11
|
+
``Files/<schema>`` when the whole schema is unmanaged (resource ``folder:<schema>``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ...errors import InstallError
|
|
19
|
+
from ...targets import FolderTarget
|
|
20
|
+
from ..models import BUILD_FOLDER, DROP_FOLDER, PRUNE_FOLDER, BuildAction
|
|
21
|
+
from .base import InstallationContext
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FolderExecutor:
|
|
25
|
+
name = "folder"
|
|
26
|
+
|
|
27
|
+
def execute(
|
|
28
|
+
self,
|
|
29
|
+
action: BuildAction,
|
|
30
|
+
payload: bytes | None,
|
|
31
|
+
context: InstallationContext,
|
|
32
|
+
) -> dict[str, Any] | None:
|
|
33
|
+
if action.resource_node_id is None:
|
|
34
|
+
raise InstallError(f"folder action {action.id!r} names no resource")
|
|
35
|
+
location = self._location(action.resource_node_id, context)
|
|
36
|
+
if action.kind == BUILD_FOLDER:
|
|
37
|
+
if context.store.exists(location):
|
|
38
|
+
raise InstallError(
|
|
39
|
+
f"cannot create managed folder because it already exists: "
|
|
40
|
+
f"{location.value}"
|
|
41
|
+
)
|
|
42
|
+
context.store.make_directory(location)
|
|
43
|
+
return {"created": location.value}
|
|
44
|
+
if action.kind == DROP_FOLDER:
|
|
45
|
+
if not context.store.exists(location):
|
|
46
|
+
raise InstallError(
|
|
47
|
+
f"cannot drop managed folder because it does not exist: "
|
|
48
|
+
f"{location.value}"
|
|
49
|
+
)
|
|
50
|
+
context.store.delete(location, recursive=True)
|
|
51
|
+
return {"dropped": location.value}
|
|
52
|
+
if action.kind == PRUNE_FOLDER:
|
|
53
|
+
if context.store.exists(location):
|
|
54
|
+
context.store.delete(location, recursive=True)
|
|
55
|
+
return {"pruned": location.value}
|
|
56
|
+
raise InstallError(f"folder action {action.id!r} has unknown kind {action.kind!r}")
|
|
57
|
+
|
|
58
|
+
def _location(self, node_id: str, context: InstallationContext):
|
|
59
|
+
target = FolderTarget(lakehouse=context.target.lakehouse)
|
|
60
|
+
if "/Files/" in node_id:
|
|
61
|
+
# Item-oriented canonical identity. The batch already carries the
|
|
62
|
+
# physical Lakehouse binding, so the logical item prefix is only
|
|
63
|
+
# identity and is not reinterpreted here.
|
|
64
|
+
qualified = node_id.split("/Files/", 1)[1]
|
|
65
|
+
else:
|
|
66
|
+
qualified = node_id.split(":", 1)[1]
|
|
67
|
+
if "." in qualified: # a specific folder object
|
|
68
|
+
schema, name = qualified.split(".", 1)
|
|
69
|
+
return context.resolver.folder_object(target, schema, name)
|
|
70
|
+
# a whole unmanaged folder schema: the schema directory itself
|
|
71
|
+
return context.resolver.files_root(context.target.lakehouse).join(qualified)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Writing and removing one file of a Lakehouse item's deployed runtime tree.
|
|
2
|
+
|
|
3
|
+
The load layer's file half — and for one kind of file, its second phase.
|
|
4
|
+
|
|
5
|
+
A ``write_file`` action usually carries the exact bytes to put down: a deployed
|
|
6
|
+
Python module is authored source and travels verbatim. A **generated Spark SQL
|
|
7
|
+
load does not**. Its payload is an instruction, and finishing it is this
|
|
8
|
+
executor's job:
|
|
9
|
+
|
|
10
|
+
.. code-block:: text
|
|
11
|
+
|
|
12
|
+
read the built target's schema, through Spark
|
|
13
|
+
take its physical business columns
|
|
14
|
+
render the executable program from the instruction
|
|
15
|
+
resolve the destination tokens
|
|
16
|
+
write the file
|
|
17
|
+
|
|
18
|
+
That two-phase shape is the same one the Warehouse load uses, which ships a
|
|
19
|
+
script that reads ``sys.columns`` and assembles the procedure server-side. The
|
|
20
|
+
reason is the same too: what a load writes are the *physical* target's columns,
|
|
21
|
+
and a Spark SQL table may leave its schema to be inferred at build — so the
|
|
22
|
+
program cannot be finished while the table is still a declaration. Writing a
|
|
23
|
+
file up front would be writing down a guess.
|
|
24
|
+
|
|
25
|
+
A consequence worth stating: installing a generated load therefore **needs a
|
|
26
|
+
Spark session**, where deploying a module needs only the store.
|
|
27
|
+
|
|
28
|
+
A ``delete_file`` action removes a file whose source has stopped claiming it.
|
|
29
|
+
|
|
30
|
+
Both derive their location the same way every other executor does: from the
|
|
31
|
+
action's resource id and the target the batch names. The identity says where the
|
|
32
|
+
file goes (``_/Load/lib/dates.py`` beneath ``Files``) and the bound target says
|
|
33
|
+
which Lakehouse, so nothing here decides placement — that was settled when the
|
|
34
|
+
artefact was claimed.
|
|
35
|
+
|
|
36
|
+
Directories are the store's business on the way down, and nobody's on the way
|
|
37
|
+
back up. The tree is owned by a declared folder, so when the last artefact goes
|
|
38
|
+
the folder stops being projected and ordinary folder prune removes the whole
|
|
39
|
+
subtree — an executor walking upward deleting empty parents would be a second,
|
|
40
|
+
quieter answer to a question already answered.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from ...declaration.load import TSQL_LOAD_EXTENSION as SQL_EXTENSION
|
|
48
|
+
from ...declaration.spark_load import GENERATED_LOAD_MARKER
|
|
49
|
+
from ...spark import tokens
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _generated_load(text: str) -> dict | None:
|
|
53
|
+
"""The instruction this payload carries, or ``None`` if it is not one."""
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
|
|
57
|
+
from ...declaration.spark_load import GENERATED_LOAD_INSTRUCTION
|
|
58
|
+
|
|
59
|
+
stripped = text.lstrip()
|
|
60
|
+
if not stripped.startswith("{"):
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
payload = json.loads(stripped)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
return None
|
|
66
|
+
if not isinstance(payload, dict):
|
|
67
|
+
return None
|
|
68
|
+
return payload if payload.get("weaver") == GENERATED_LOAD_INSTRUCTION else None
|
|
69
|
+
from ...errors import InstallError
|
|
70
|
+
from ...targets import FolderTarget
|
|
71
|
+
from ..models import DELETE_FILE, WRITE_FILE, BuildAction
|
|
72
|
+
from .base import InstallationContext
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LoadFileExecutor:
|
|
76
|
+
name = "load_file"
|
|
77
|
+
|
|
78
|
+
def execute(
|
|
79
|
+
self,
|
|
80
|
+
action: BuildAction,
|
|
81
|
+
payload: bytes | None,
|
|
82
|
+
context: InstallationContext,
|
|
83
|
+
) -> dict[str, Any] | None:
|
|
84
|
+
if action.resource_node_id is None:
|
|
85
|
+
raise InstallError(f"load file action {action.id!r} names no resource")
|
|
86
|
+
location = self._location(action.resource_node_id, context)
|
|
87
|
+
if action.kind == WRITE_FILE:
|
|
88
|
+
if payload is None:
|
|
89
|
+
raise InstallError(f"load file action {action.id!r} has no payload")
|
|
90
|
+
payload = self._addressed(location.value, payload, context)
|
|
91
|
+
context.store.write(location, payload)
|
|
92
|
+
return {"written": location.value, "bytes": len(payload)}
|
|
93
|
+
if action.kind == DELETE_FILE:
|
|
94
|
+
# Tolerant of absence, and only here. A delete is reconciliation
|
|
95
|
+
# toward "this must not exist", and something else having already
|
|
96
|
+
# removed it is that state reached — unlike a create, where a
|
|
97
|
+
# collision means two things believe they own one name.
|
|
98
|
+
if context.store.exists(location):
|
|
99
|
+
context.store.delete(location)
|
|
100
|
+
return {"deleted": location.value}
|
|
101
|
+
return {"absent": location.value}
|
|
102
|
+
raise InstallError(
|
|
103
|
+
f"load file action {action.id!r} has unknown kind {action.kind!r}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _addressed(
|
|
107
|
+
self, path: str, payload: bytes, context: InstallationContext
|
|
108
|
+
) -> bytes:
|
|
109
|
+
"""Finish a generated load, and address it, on the way down.
|
|
110
|
+
|
|
111
|
+
Two steps, not one. An instruction is first *rendered* into a program
|
|
112
|
+
against the built target's columns (:meth:`_render`); whatever program
|
|
113
|
+
results then has its object tokens resolved. A payload that is already a
|
|
114
|
+
program — one written by an older bundle — skips straight to the second.
|
|
115
|
+
|
|
116
|
+
The bundle stays destination-free, which is what lets one repository
|
|
117
|
+
generate the same bytes everywhere and two bundles be diffed for what
|
|
118
|
+
actually differs. The *installed file* cannot be: it has to be runnable
|
|
119
|
+
by anyone who opens it, and by then the destination is known, so this is
|
|
120
|
+
the moment the two requirements stop conflicting.
|
|
121
|
+
|
|
122
|
+
Deployed Python is left exactly as authored. A module is source code, not
|
|
123
|
+
a statement, and it addresses its target through the resolved Lakehouse
|
|
124
|
+
it is constructed with.
|
|
125
|
+
|
|
126
|
+
**Decided by what the payload is, not by what it is called.** Keying on a
|
|
127
|
+
``.spark.sql`` suffix looked right and was wrong: a generated load keeps
|
|
128
|
+
its *authored* name, ``Sales.OrderSummary.sql``, so the suffix never
|
|
129
|
+
matched and every installed program shipped with its tokens intact and
|
|
130
|
+
could not run. A generated Spark program announces itself in its first
|
|
131
|
+
line, which is a fact about the file rather than about its name.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
if not path.endswith(SQL_EXTENSION):
|
|
135
|
+
return payload
|
|
136
|
+
text = payload.decode("utf-8")
|
|
137
|
+
instruction = _generated_load(text)
|
|
138
|
+
if instruction is not None:
|
|
139
|
+
text = self._render(instruction, context)
|
|
140
|
+
elif not text.lstrip().startswith(GENERATED_LOAD_MARKER):
|
|
141
|
+
return payload
|
|
142
|
+
destination = context.target.destination
|
|
143
|
+
if destination is None:
|
|
144
|
+
raise InstallError(
|
|
145
|
+
f"a generated load lands in {context.target.bound.id!r}, which "
|
|
146
|
+
"resolved to no Spark destination, so its object names cannot be "
|
|
147
|
+
"addressed"
|
|
148
|
+
)
|
|
149
|
+
# Resolved against the destination directly rather than through the
|
|
150
|
+
# catalogue: writing a file needs no Spark session, and asking for one
|
|
151
|
+
# would make installing a load depend on a capability it never uses.
|
|
152
|
+
return tokens.expand(text, destination).encode("utf-8")
|
|
153
|
+
|
|
154
|
+
def _render(self, instruction: dict, context: InstallationContext) -> str:
|
|
155
|
+
"""Finish a generated load from the table it will write to.
|
|
156
|
+
|
|
157
|
+
The columns are the one thing generation cannot know: a Spark SQL table
|
|
158
|
+
may leave its schema to be inferred at build, and even a declared one is
|
|
159
|
+
materialised as the physical table the program has to name. So the
|
|
160
|
+
bundle carries an instruction and this reads the built table — the same
|
|
161
|
+
two-phase shape the Warehouse load uses with ``sys.columns``, rather
|
|
162
|
+
than writing a file from a guess and hoping the guess held.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
from ...declaration.spark_load import render_installed_program
|
|
166
|
+
# The module rather than the name. `tests/test_core_boundary.py` matches
|
|
167
|
+
# raw source text, so pulling in a symbol whose name begins with the
|
|
168
|
+
# engine's reads to it as the forbidden dependency.
|
|
169
|
+
from ...runtime import load_contract
|
|
170
|
+
|
|
171
|
+
if context.spark is None:
|
|
172
|
+
raise InstallError(
|
|
173
|
+
f"a generated load for {instruction['qualified']} must read its "
|
|
174
|
+
"target's columns, and no Spark session was provided"
|
|
175
|
+
)
|
|
176
|
+
target = context.catalogue.expand(instruction["object"])
|
|
177
|
+
audit = set(load_contract.delta_audit_columns())
|
|
178
|
+
columns = tuple(
|
|
179
|
+
field.name
|
|
180
|
+
for field in context.spark.table(target).schema.fields
|
|
181
|
+
if field.name not in audit
|
|
182
|
+
)
|
|
183
|
+
if not columns:
|
|
184
|
+
raise InstallError(
|
|
185
|
+
f"{target} has no loadable columns; build the table before "
|
|
186
|
+
"installing its load"
|
|
187
|
+
)
|
|
188
|
+
return render_installed_program(instruction, columns)
|
|
189
|
+
|
|
190
|
+
def _location(self, node_id: str, context: InstallationContext):
|
|
191
|
+
"""``Lakehouse/Sales/file:_/Load/lib/dates.py`` under this batch's target.
|
|
192
|
+
|
|
193
|
+
The logical item prefix is identity only: the batch already carries the
|
|
194
|
+
physical binding, so the path beneath ``Files`` is what is used and the
|
|
195
|
+
item it names is not reinterpreted here.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
marker = "/file:"
|
|
199
|
+
if marker not in node_id:
|
|
200
|
+
raise InstallError(
|
|
201
|
+
f"load file action names {node_id!r}, which is not a file identity"
|
|
202
|
+
)
|
|
203
|
+
relative = node_id.split(marker, 1)[1]
|
|
204
|
+
target = FolderTarget(lakehouse=context.target.lakehouse)
|
|
205
|
+
return context.resolver.folder_root(target).join(*relative.split("/"))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exact-case Spark analysis scoped to one executor call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
_CASE_SENSITIVE = "spark.sql.caseSensitive"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def exact_identifier_case(spark, *, enabled: bool) -> Iterator[None]:
|
|
13
|
+
"""Temporarily make both analysis and DDL honour Weaver identifier case."""
|
|
14
|
+
|
|
15
|
+
if not enabled:
|
|
16
|
+
yield
|
|
17
|
+
return
|
|
18
|
+
previous = spark.conf.get(_CASE_SENSITIVE)
|
|
19
|
+
if str(previous).lower() == "true":
|
|
20
|
+
yield
|
|
21
|
+
return
|
|
22
|
+
spark.conf.set(_CASE_SENSITIVE, "true")
|
|
23
|
+
try:
|
|
24
|
+
yield
|
|
25
|
+
finally:
|
|
26
|
+
spark.conf.set(_CASE_SENSITIVE, previous)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Creating one schema in the destination the batch names.
|
|
2
|
+
|
|
3
|
+
A schema create is the one piece of build DDL that cannot be a frozen SQL
|
|
4
|
+
payload, and the reason is instructive: on local Spark it needs a ``LOCATION``,
|
|
5
|
+
and a ``LOCATION`` is a *resolved path*.
|
|
6
|
+
|
|
7
|
+
Freezing it meant a bundle generated on a laptop carried
|
|
8
|
+
|
|
9
|
+
.. code-block:: sql
|
|
10
|
+
|
|
11
|
+
CREATE SCHEMA IF NOT EXISTS `Sales` LOCATION '/var/folders/…/T/pytest-42/Sales_LH/Tables/Sales'
|
|
12
|
+
|
|
13
|
+
— a temporary directory, in the hashed plan, deciding where a managed table
|
|
14
|
+
lands. Two runs of the same repository produced different bundles, and a bundle
|
|
15
|
+
kept overnight named a path that no longer existed (how-does-build-work §15). On
|
|
16
|
+
Fabric it froze the opposite mistake: no clause at all, and a bare two-part name,
|
|
17
|
+
so the schema was created in whatever Lakehouse the session was attached to
|
|
18
|
+
rather than in the destination.
|
|
19
|
+
|
|
20
|
+
So the action names the schema and nothing else, and the destination decides how
|
|
21
|
+
to make one. That is not an installer filling in a semantic decision — which
|
|
22
|
+
schema, in which Lakehouse, is settled and in the manifest — it is the same
|
|
23
|
+
transport-level resolution every other action gets, applied to a clause that is
|
|
24
|
+
purely about storage.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ...errors import InstallError
|
|
33
|
+
from ..models import BuildAction
|
|
34
|
+
from .base import InstallationContext
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SparkSchemaExecutor:
|
|
38
|
+
name = "spark_schema"
|
|
39
|
+
|
|
40
|
+
def execute(
|
|
41
|
+
self,
|
|
42
|
+
action: BuildAction,
|
|
43
|
+
payload: bytes | None,
|
|
44
|
+
context: InstallationContext,
|
|
45
|
+
) -> dict[str, Any] | None:
|
|
46
|
+
if payload is None:
|
|
47
|
+
raise InstallError(f"spark_schema action {action.id!r} has no payload")
|
|
48
|
+
if context.spark is None:
|
|
49
|
+
raise InstallError(
|
|
50
|
+
f"spark_schema action {action.id!r} needs a Spark session but none "
|
|
51
|
+
"was provided"
|
|
52
|
+
)
|
|
53
|
+
schema = json.loads(payload.decode("utf-8"))["schema"]
|
|
54
|
+
catalogue = context.catalogue
|
|
55
|
+
statement = catalogue.create_schema(schema, if_not_exists=False)
|
|
56
|
+
return {
|
|
57
|
+
"destination": catalogue.destination.item,
|
|
58
|
+
"schema": catalogue.qualified_schema(schema),
|
|
59
|
+
"statement": statement,
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Spark SQL execution — run the generated statement against the batch's target.
|
|
2
|
+
|
|
3
|
+
The payload is the single executable unit ``create_ddl`` produced: a ``CREATE OR
|
|
4
|
+
REPLACE VIEW``/``TABLE``, or a frozen prune ``DROP``. It names its objects
|
|
5
|
+
logically — ``{{object:Sales.Customer}}`` — and this resolves those names against
|
|
6
|
+
the destination the batch is bound to before running the statement.
|
|
7
|
+
|
|
8
|
+
That resolution is the whole difference between a build that works and one that
|
|
9
|
+
looks like it does. A two-part name resolves through the session's *current*
|
|
10
|
+
catalogue, and the session is attached to the Weaver Lakehouse, so every
|
|
11
|
+
destination statement would have landed in the control plane. On Fabric the
|
|
12
|
+
object would have been created in the wrong Lakehouse and then read back from the
|
|
13
|
+
wrong Lakehouse, and the assertion would have passed.
|
|
14
|
+
|
|
15
|
+
The same session runs every sequence, so a view registered earlier is in the
|
|
16
|
+
catalogue for a later one — now under a name that says which Lakehouse it is in.
|
|
17
|
+
The SQL analytics endpoint is never used; Spark views are Spark-catalogue objects
|
|
18
|
+
and resolve there.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ...errors import InstallError
|
|
26
|
+
from ..models import BuildAction
|
|
27
|
+
from .base import InstallationContext
|
|
28
|
+
from .spark_case import exact_identifier_case
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SparkSqlExecutor:
|
|
32
|
+
name = "spark_sql"
|
|
33
|
+
|
|
34
|
+
def execute(
|
|
35
|
+
self,
|
|
36
|
+
action: BuildAction,
|
|
37
|
+
payload: bytes | None,
|
|
38
|
+
context: InstallationContext,
|
|
39
|
+
) -> dict[str, Any] | None:
|
|
40
|
+
if payload is None:
|
|
41
|
+
raise InstallError(f"spark_sql action {action.id!r} has no payload")
|
|
42
|
+
if context.spark is None:
|
|
43
|
+
raise InstallError(
|
|
44
|
+
f"spark_sql action {action.id!r} needs a Spark session but none was provided"
|
|
45
|
+
)
|
|
46
|
+
catalogue = context.catalogue
|
|
47
|
+
statement = catalogue.expand(payload.decode("utf-8").strip())
|
|
48
|
+
with exact_identifier_case(
|
|
49
|
+
context.spark,
|
|
50
|
+
enabled=catalogue.destination.preserve_table_identifier_case,
|
|
51
|
+
):
|
|
52
|
+
context.spark.sql(statement)
|
|
53
|
+
# The destination is reported, not just used: an install report that says
|
|
54
|
+
# which Lakehouse each statement ran against is the record a reviewer needs
|
|
55
|
+
# when the answer used to depend on what the session was attached to.
|
|
56
|
+
return {
|
|
57
|
+
"destination": catalogue.destination.item,
|
|
58
|
+
"statement_first_line": statement.splitlines()[0] if statement else "",
|
|
59
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Execute one ordered batch of Spark SQL statements from a JSON payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ...errors import InstallError
|
|
9
|
+
from ...spark.tokens import substitute_epoch
|
|
10
|
+
from ..models import BuildAction
|
|
11
|
+
from .base import InstallationContext
|
|
12
|
+
from .spark_case import exact_identifier_case
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SparkSqlBatchExecutor:
|
|
16
|
+
name = "spark_sql_batch"
|
|
17
|
+
|
|
18
|
+
def execute(
|
|
19
|
+
self,
|
|
20
|
+
action: BuildAction,
|
|
21
|
+
payload: bytes | None,
|
|
22
|
+
context: InstallationContext,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
if payload is None:
|
|
25
|
+
raise InstallError(f"spark_sql_batch action {action.id!r} has no payload")
|
|
26
|
+
if context.spark is None:
|
|
27
|
+
raise InstallError(
|
|
28
|
+
f"spark_sql_batch action {action.id!r} needs a Spark session"
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
statements = json.loads(payload.decode("utf-8"))
|
|
32
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
33
|
+
raise InstallError(
|
|
34
|
+
f"spark_sql_batch action {action.id!r} has an invalid JSON payload"
|
|
35
|
+
) from exc
|
|
36
|
+
if not isinstance(statements, list) or not all(
|
|
37
|
+
isinstance(statement, str) and statement.strip()
|
|
38
|
+
for statement in statements
|
|
39
|
+
):
|
|
40
|
+
raise InstallError(
|
|
41
|
+
f"spark_sql_batch action {action.id!r} must contain SQL strings"
|
|
42
|
+
)
|
|
43
|
+
with exact_identifier_case(
|
|
44
|
+
context.spark,
|
|
45
|
+
enabled=context.catalogue.destination.preserve_table_identifier_case,
|
|
46
|
+
):
|
|
47
|
+
for statement in statements:
|
|
48
|
+
# The epoch first: it is scoped to this installation rather than
|
|
49
|
+
# to a destination, and ``expand`` rejects every token it does
|
|
50
|
+
# not itself resolve — so one left behind here would be reported
|
|
51
|
+
# as an unresolvable name instead of quietly reaching the engine.
|
|
52
|
+
dated = substitute_epoch(statement.strip(), context.epoch)
|
|
53
|
+
context.spark.sql(context.catalogue.expand(dated))
|
|
54
|
+
return {
|
|
55
|
+
"destination": context.catalogue.destination.item,
|
|
56
|
+
"statement_count": len(statements),
|
|
57
|
+
}
|