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