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,585 @@
|
|
|
1
|
+
"""Source preparation, state handover, bundle generation, and installation.
|
|
2
|
+
|
|
3
|
+
A repository source is independent of the target estate. Remote sources are
|
|
4
|
+
materialised once onto the current process's local filesystem; parsing and
|
|
5
|
+
request validation finish before target state is read. Native builds keep all
|
|
6
|
+
four stages in-environment. A desktop Fabric build serialises only ``BuildState``
|
|
7
|
+
out and hands only a completed archive back for execution.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import stat
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from typing import Iterator, Mapping
|
|
20
|
+
|
|
21
|
+
from ..errors import BuildError
|
|
22
|
+
from ..locations import Location
|
|
23
|
+
from ..declaration.model import WeaverItemId, WeaverRepository
|
|
24
|
+
from ..declaration.repository import parse_item_repository
|
|
25
|
+
from ..store import LocalStore, Store
|
|
26
|
+
from .bundle import BuildBundle, load_bundle
|
|
27
|
+
from .installer import InstallationEnvironment, install_bundle
|
|
28
|
+
from .planner import generate_item_build_bundle
|
|
29
|
+
from .models import BuildPlan
|
|
30
|
+
from .report import InstallationReport
|
|
31
|
+
from .targets import ItemBindings, LakehouseBinding
|
|
32
|
+
from .targets import WAREHOUSE_TARGET
|
|
33
|
+
from .prune import (
|
|
34
|
+
TargetInventory,
|
|
35
|
+
read_lakehouse_inventory,
|
|
36
|
+
read_warehouse_inventory,
|
|
37
|
+
)
|
|
38
|
+
from ..catalogue.state import (
|
|
39
|
+
Catalogue,
|
|
40
|
+
Reconciliation,
|
|
41
|
+
read_catalogue_state,
|
|
42
|
+
reconcile_catalogue_state,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
ARCHIVE_SUFFIX = ".weaver.zip"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class MaterialisedTree:
|
|
50
|
+
"""A source tree copied onto the current process's local filesystem."""
|
|
51
|
+
|
|
52
|
+
location: Location
|
|
53
|
+
store: LocalStore
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class PreparedRepository:
|
|
58
|
+
"""A parsed repository and the process-local store that owns its files."""
|
|
59
|
+
|
|
60
|
+
repository: WeaverRepository
|
|
61
|
+
store: LocalStore
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ItemBuildResult:
|
|
66
|
+
"""Durable in-memory result of a temporary in-environment build."""
|
|
67
|
+
|
|
68
|
+
plan: BuildPlan
|
|
69
|
+
report: InstallationReport
|
|
70
|
+
repository_signature: str
|
|
71
|
+
item_signatures: Mapping[WeaverItemId, str]
|
|
72
|
+
archive: Location | None = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def bundle_id(self) -> str:
|
|
76
|
+
return self.plan.bundle_id
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class BuildState:
|
|
81
|
+
"""Authoritative target state handed from Fabric to a local planner."""
|
|
82
|
+
|
|
83
|
+
catalogue: Catalogue
|
|
84
|
+
target_inventories: Mapping[WeaverItemId, TargetInventory]
|
|
85
|
+
|
|
86
|
+
def to_mapping(self) -> dict[str, object]:
|
|
87
|
+
return {
|
|
88
|
+
"format_version": 1,
|
|
89
|
+
"catalogue": self.catalogue.to_mapping(),
|
|
90
|
+
"target_inventories": [
|
|
91
|
+
{
|
|
92
|
+
"item": str(item),
|
|
93
|
+
"inventory": inventory.to_mapping(),
|
|
94
|
+
}
|
|
95
|
+
for item, inventory in sorted(
|
|
96
|
+
self.target_inventories.items(), key=lambda pair: str(pair[0])
|
|
97
|
+
)
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_mapping(cls, mapping) -> "BuildState":
|
|
103
|
+
version = mapping.get("format_version")
|
|
104
|
+
if version != 1:
|
|
105
|
+
raise BuildError(
|
|
106
|
+
f"unsupported build state format_version {version!r}; expected 1"
|
|
107
|
+
)
|
|
108
|
+
return cls(
|
|
109
|
+
catalogue=Catalogue.from_mapping(mapping["catalogue"]),
|
|
110
|
+
target_inventories={
|
|
111
|
+
WeaverItemId.parse(entry["item"]): TargetInventory.from_mapping(
|
|
112
|
+
entry["inventory"]
|
|
113
|
+
)
|
|
114
|
+
for entry in mapping.get("target_inventories", ())
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def catalogue_items_for_build(
|
|
120
|
+
repository: WeaverRepository, bindings: ItemBindings
|
|
121
|
+
) -> tuple[WeaverItemId, ...]:
|
|
122
|
+
"""Catalogue scope needed for selection and cross-item alias freshness."""
|
|
123
|
+
|
|
124
|
+
bound = set(bindings.by_item)
|
|
125
|
+
items = bound | {
|
|
126
|
+
alias.source.item
|
|
127
|
+
for alias in repository.aliases
|
|
128
|
+
if alias.destination.item in bound and alias.source.item not in bound
|
|
129
|
+
}
|
|
130
|
+
return tuple(sorted(items, key=str))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_build_request(
|
|
134
|
+
repository: WeaverRepository,
|
|
135
|
+
bindings: ItemBindings,
|
|
136
|
+
*,
|
|
137
|
+
control_lakehouse: LakehouseBinding,
|
|
138
|
+
) -> tuple[WeaverItemId, ...]:
|
|
139
|
+
"""Validate repository-dependent input before any target is contacted."""
|
|
140
|
+
|
|
141
|
+
if control_lakehouse is None:
|
|
142
|
+
raise BuildError("every build needs an explicit control-plane Lakehouse")
|
|
143
|
+
if not bindings.entries:
|
|
144
|
+
raise BuildError("at least one Weaver item must be bound")
|
|
145
|
+
known = {item.identity for item in repository.items}
|
|
146
|
+
unknown = set(bindings.by_item) - known
|
|
147
|
+
if unknown:
|
|
148
|
+
raise BuildError(
|
|
149
|
+
"binding names item(s) absent from the repository: "
|
|
150
|
+
+ ", ".join(sorted(map(str, unknown)))
|
|
151
|
+
)
|
|
152
|
+
placed = {item for layer in repository.item_layers for item in layer}
|
|
153
|
+
missing = set(bindings.by_item) - placed
|
|
154
|
+
if missing:
|
|
155
|
+
raise BuildError(
|
|
156
|
+
"bound item(s) absent from the repository item graph: "
|
|
157
|
+
+ ", ".join(sorted(map(str, missing)))
|
|
158
|
+
)
|
|
159
|
+
builtin = WeaverItemId.parse("Lakehouse/_weaver")
|
|
160
|
+
binding = bindings.by_item.get(builtin)
|
|
161
|
+
if binding is not None and not isinstance(binding.target, LakehouseBinding):
|
|
162
|
+
raise BuildError("Lakehouse/_weaver requires a Lakehouse binding")
|
|
163
|
+
if (
|
|
164
|
+
binding is not None
|
|
165
|
+
and binding.target.lakehouse.name != control_lakehouse.lakehouse.name
|
|
166
|
+
):
|
|
167
|
+
raise BuildError(
|
|
168
|
+
"Lakehouse/_weaver must be bound to the explicit control-plane Lakehouse"
|
|
169
|
+
)
|
|
170
|
+
return catalogue_items_for_build(repository, bindings)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def read_build_state(
|
|
174
|
+
bindings: ItemBindings,
|
|
175
|
+
*,
|
|
176
|
+
required_catalogue_items,
|
|
177
|
+
environment: InstallationEnvironment,
|
|
178
|
+
sql_by_item=None,
|
|
179
|
+
) -> BuildState:
|
|
180
|
+
"""Read only the authoritative state a source-independent planner needs."""
|
|
181
|
+
|
|
182
|
+
inventories = read_target_inventories(
|
|
183
|
+
bindings, environment=environment, sql_by_item=sql_by_item
|
|
184
|
+
)
|
|
185
|
+
if environment.spark is None:
|
|
186
|
+
raise BuildError("every build needs Spark to read and publish the catalogue")
|
|
187
|
+
workspace = environment.workspace
|
|
188
|
+
if workspace is None or not workspace.weaver_lakehouse:
|
|
189
|
+
raise BuildError("every build needs a Workspace with a Weaver Lakehouse")
|
|
190
|
+
from ..spark import SparkCatalogue
|
|
191
|
+
from ..targets import ItemRef
|
|
192
|
+
|
|
193
|
+
catalogue = SparkCatalogue(
|
|
194
|
+
environment.spark,
|
|
195
|
+
environment.resolver.spark_destination(ItemRef(workspace.weaver_lakehouse)),
|
|
196
|
+
)
|
|
197
|
+
return BuildState(
|
|
198
|
+
catalogue=read_catalogue_state(catalogue, required_catalogue_items),
|
|
199
|
+
target_inventories=inventories,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@contextmanager
|
|
204
|
+
def materialise_tree(
|
|
205
|
+
source: Location,
|
|
206
|
+
*,
|
|
207
|
+
store: Store,
|
|
208
|
+
prefix: str = "weaver-source-",
|
|
209
|
+
) -> Iterator[MaterialisedTree]:
|
|
210
|
+
"""Copy a store tree once to a temporary local directory.
|
|
211
|
+
|
|
212
|
+
FabricStore uses one recursive ``notebookutils.fs.cp`` operation. A generic
|
|
213
|
+
Store falls back to one listing and exactly one read per file, which makes the
|
|
214
|
+
same contract testable without Fabric.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
if not store.exists(source):
|
|
218
|
+
raise BuildError(f"source does not exist: {source.value}")
|
|
219
|
+
if not store.is_directory(source):
|
|
220
|
+
raise BuildError(f"source is not a directory: {source.value}")
|
|
221
|
+
|
|
222
|
+
with tempfile.TemporaryDirectory(prefix=prefix) as temporary:
|
|
223
|
+
destination = Path(temporary) / source.name
|
|
224
|
+
copier = getattr(store, "copy_to_local", None)
|
|
225
|
+
if callable(copier):
|
|
226
|
+
copier(source, destination)
|
|
227
|
+
else:
|
|
228
|
+
_copy_tree_through_store(source, store, destination)
|
|
229
|
+
if not destination.is_dir():
|
|
230
|
+
raise BuildError(
|
|
231
|
+
f"materialising {source.value} did not create {destination}"
|
|
232
|
+
)
|
|
233
|
+
yield MaterialisedTree(Location(destination.as_posix()), LocalStore())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@contextmanager
|
|
237
|
+
def prepare_repository(
|
|
238
|
+
source: Location,
|
|
239
|
+
*,
|
|
240
|
+
source_store: Store,
|
|
241
|
+
) -> Iterator[PreparedRepository]:
|
|
242
|
+
"""Make a repository process-local when needed, then parse it completely."""
|
|
243
|
+
|
|
244
|
+
with _local_tree(source, source_store, prefix="weaver-repository-") as root:
|
|
245
|
+
store = LocalStore()
|
|
246
|
+
repository = parse_item_repository(Location(root.as_posix()), store=store)
|
|
247
|
+
yield PreparedRepository(repository=repository, store=store)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _copy_tree_through_store(source: Location, store: Store, destination: Path) -> None:
|
|
251
|
+
destination.mkdir(parents=True)
|
|
252
|
+
prefix = source.value.rstrip("/") + "/"
|
|
253
|
+
entries = store.list(source, recursive=True)
|
|
254
|
+
for entry in entries:
|
|
255
|
+
relative = entry.location.value[len(prefix) :]
|
|
256
|
+
target = destination.joinpath(*relative.split("/"))
|
|
257
|
+
if entry.is_directory:
|
|
258
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
for entry in entries:
|
|
260
|
+
if entry.is_directory:
|
|
261
|
+
continue
|
|
262
|
+
relative = entry.location.value[len(prefix) :]
|
|
263
|
+
target = destination.joinpath(*relative.split("/"))
|
|
264
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
target.write_bytes(store.read(entry.location))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def timestamped_archive_name(at: datetime | None = None) -> str:
|
|
269
|
+
"""A sortable, collision-resistant physical name for an optional record."""
|
|
270
|
+
|
|
271
|
+
at = at or datetime.now(timezone.utc)
|
|
272
|
+
if at.tzinfo is None:
|
|
273
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
274
|
+
stamp = at.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
275
|
+
return f"{stamp}{ARCHIVE_SUFFIX}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def persist_bundle_archive(
|
|
279
|
+
bundle: BuildBundle,
|
|
280
|
+
destination: Location,
|
|
281
|
+
*,
|
|
282
|
+
store: Store,
|
|
283
|
+
) -> Location:
|
|
284
|
+
"""Persist a complete bundle as one deterministic ZIP file."""
|
|
285
|
+
|
|
286
|
+
if not destination.name.endswith(ARCHIVE_SUFFIX):
|
|
287
|
+
raise BuildError(
|
|
288
|
+
f"bundle archive must end with {ARCHIVE_SUFFIX!r}: {destination.value}"
|
|
289
|
+
)
|
|
290
|
+
bundle_store = bundle.store or store
|
|
291
|
+
with _local_tree(bundle.location, bundle_store, prefix="weaver-bundle-source-") as root:
|
|
292
|
+
with tempfile.TemporaryDirectory(prefix="weaver-bundle-archive-") as temporary:
|
|
293
|
+
archive = Path(temporary) / destination.name
|
|
294
|
+
_write_archive(root, archive)
|
|
295
|
+
parent_value, separator, _ = destination.value.rpartition("/")
|
|
296
|
+
if separator:
|
|
297
|
+
parent = Location(parent_value)
|
|
298
|
+
if not store.exists(parent):
|
|
299
|
+
store.make_directory(parent)
|
|
300
|
+
copier = getattr(store, "copy_from_local", None)
|
|
301
|
+
if callable(copier):
|
|
302
|
+
copier(archive, destination)
|
|
303
|
+
else:
|
|
304
|
+
store.write(destination, archive.read_bytes())
|
|
305
|
+
return destination
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@contextmanager
|
|
309
|
+
def materialise_bundle_archive(
|
|
310
|
+
archive: Location,
|
|
311
|
+
*,
|
|
312
|
+
store: Store,
|
|
313
|
+
) -> Iterator[BuildBundle]:
|
|
314
|
+
"""Copy one archive locally, extract safely, and load its validated bundle."""
|
|
315
|
+
|
|
316
|
+
if not archive.name.endswith(ARCHIVE_SUFFIX):
|
|
317
|
+
raise BuildError(f"not a Weaver bundle archive: {archive.value}")
|
|
318
|
+
with tempfile.TemporaryDirectory(prefix="weaver-bundle-install-") as temporary:
|
|
319
|
+
temporary_path = Path(temporary)
|
|
320
|
+
local_archive = temporary_path / archive.name
|
|
321
|
+
copier = getattr(store, "copy_to_local", None)
|
|
322
|
+
if callable(copier):
|
|
323
|
+
copier(archive, local_archive)
|
|
324
|
+
else:
|
|
325
|
+
local_archive.write_bytes(store.read(archive))
|
|
326
|
+
root = temporary_path / "bundle"
|
|
327
|
+
root.mkdir()
|
|
328
|
+
_extract_archive(local_archive, root)
|
|
329
|
+
local_store = LocalStore()
|
|
330
|
+
yield load_bundle(Location(root.as_posix()), store=local_store)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def install_bundle_archive(
|
|
334
|
+
archive: Location,
|
|
335
|
+
*,
|
|
336
|
+
archive_store: Store,
|
|
337
|
+
environment: InstallationEnvironment,
|
|
338
|
+
) -> InstallationReport:
|
|
339
|
+
"""Install a handover archive entirely from its temporary local extraction."""
|
|
340
|
+
|
|
341
|
+
with materialise_bundle_archive(archive, store=archive_store) as bundle:
|
|
342
|
+
return install_bundle(bundle, environment=environment)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def build_item_repository(
|
|
346
|
+
repository: WeaverRepository,
|
|
347
|
+
*,
|
|
348
|
+
bindings: ItemBindings,
|
|
349
|
+
target_inventories: Mapping[WeaverItemId, TargetInventory],
|
|
350
|
+
reconciliation: Reconciliation,
|
|
351
|
+
environment: InstallationEnvironment,
|
|
352
|
+
source_store: Store,
|
|
353
|
+
control_lakehouse: LakehouseBinding,
|
|
354
|
+
archive: Location | None = None,
|
|
355
|
+
archive_store: Store | None = None,
|
|
356
|
+
) -> ItemBuildResult:
|
|
357
|
+
"""Generate and install from already parsed source and already read state.
|
|
358
|
+
|
|
359
|
+
This is the planner/executor seam. It deliberately cannot materialise or
|
|
360
|
+
parse authored files, inspect a Workspace, or discover target state.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
validate_build_request(
|
|
364
|
+
repository, bindings, control_lakehouse=control_lakehouse
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
with tempfile.TemporaryDirectory(prefix="weaver-build-") as temporary:
|
|
368
|
+
bundle = generate_item_build_bundle(
|
|
369
|
+
repository,
|
|
370
|
+
bindings=bindings,
|
|
371
|
+
output=Location((Path(temporary) / "bundle").as_posix()),
|
|
372
|
+
store=source_store,
|
|
373
|
+
target_inventories=target_inventories,
|
|
374
|
+
catalogue=reconciliation.catalogue,
|
|
375
|
+
stale_claims=reconciliation.stale_claims,
|
|
376
|
+
control_lakehouse=control_lakehouse,
|
|
377
|
+
)
|
|
378
|
+
report = install_bundle(bundle, environment=environment)
|
|
379
|
+
persisted = None
|
|
380
|
+
if archive is not None:
|
|
381
|
+
persisted = persist_bundle_archive(
|
|
382
|
+
bundle,
|
|
383
|
+
archive,
|
|
384
|
+
store=archive_store or environment.store,
|
|
385
|
+
)
|
|
386
|
+
return ItemBuildResult(
|
|
387
|
+
plan=bundle.plan,
|
|
388
|
+
report=report,
|
|
389
|
+
repository_signature=repository.signature,
|
|
390
|
+
item_signatures={item.identity: item.signature for item in repository.items},
|
|
391
|
+
archive=persisted,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def build_uploaded_item_repository(
|
|
396
|
+
repository_root: Location,
|
|
397
|
+
*,
|
|
398
|
+
bindings: ItemBindings,
|
|
399
|
+
environment: InstallationEnvironment,
|
|
400
|
+
control_lakehouse: LakehouseBinding,
|
|
401
|
+
archive: Location | None = None,
|
|
402
|
+
archive_store: Store | None = None,
|
|
403
|
+
sql_by_item=None,
|
|
404
|
+
) -> ItemBuildResult:
|
|
405
|
+
"""Compatibility wrapper for a repository stored with the target estate."""
|
|
406
|
+
|
|
407
|
+
return build_item_repository_source(
|
|
408
|
+
repository_root,
|
|
409
|
+
source_store=environment.store,
|
|
410
|
+
bindings=bindings,
|
|
411
|
+
environment=environment,
|
|
412
|
+
control_lakehouse=control_lakehouse,
|
|
413
|
+
archive=archive,
|
|
414
|
+
archive_store=archive_store,
|
|
415
|
+
sql_by_item=sql_by_item,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def build_item_repository_source(
|
|
420
|
+
source: Location,
|
|
421
|
+
*,
|
|
422
|
+
source_store: Store,
|
|
423
|
+
bindings: ItemBindings,
|
|
424
|
+
environment: InstallationEnvironment,
|
|
425
|
+
control_lakehouse: LakehouseBinding,
|
|
426
|
+
archive: Location | None = None,
|
|
427
|
+
archive_store: Store | None = None,
|
|
428
|
+
sql_by_item=None,
|
|
429
|
+
) -> ItemBuildResult:
|
|
430
|
+
"""Prepare an explicit source independently from the target, then build it."""
|
|
431
|
+
|
|
432
|
+
with prepare_repository(source, source_store=source_store) as prepared:
|
|
433
|
+
repository = prepared.repository
|
|
434
|
+
validate_build_request(
|
|
435
|
+
repository, bindings, control_lakehouse=control_lakehouse
|
|
436
|
+
)
|
|
437
|
+
inventories = read_target_inventories(
|
|
438
|
+
bindings, environment=environment, sql_by_item=sql_by_item
|
|
439
|
+
)
|
|
440
|
+
reconciled = read_reconciled_catalogue(
|
|
441
|
+
bindings,
|
|
442
|
+
inventories=inventories,
|
|
443
|
+
environment=environment,
|
|
444
|
+
repository=repository,
|
|
445
|
+
)
|
|
446
|
+
return build_item_repository(
|
|
447
|
+
repository,
|
|
448
|
+
bindings=bindings,
|
|
449
|
+
target_inventories=inventories,
|
|
450
|
+
reconciliation=reconciled,
|
|
451
|
+
environment=environment,
|
|
452
|
+
source_store=prepared.store,
|
|
453
|
+
control_lakehouse=control_lakehouse,
|
|
454
|
+
archive=archive,
|
|
455
|
+
archive_store=archive_store,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def read_reconciled_catalogue(
|
|
460
|
+
bindings: ItemBindings,
|
|
461
|
+
*,
|
|
462
|
+
inventories,
|
|
463
|
+
environment: InstallationEnvironment,
|
|
464
|
+
repository=None,
|
|
465
|
+
) -> Reconciliation:
|
|
466
|
+
"""Read the Weaver Lakehouse catalogue and prove selected claims physically.
|
|
467
|
+
|
|
468
|
+
The read covers the bound items and, when a ``repository`` is given, the
|
|
469
|
+
items that *produce* what those items alias. Those producers are not being
|
|
470
|
+
built and nothing about them will be written — but their Registry rows carry
|
|
471
|
+
the build that published them, and comparing that against the alias's own row
|
|
472
|
+
is the only way to learn that a producer moved on while this consumer was not
|
|
473
|
+
looking (see
|
|
474
|
+
:func:`~weaver.build_bundle.incremental.stale_alias_destinations`).
|
|
475
|
+
|
|
476
|
+
They are read without an inventory, so nothing about them is reconciled away:
|
|
477
|
+
a build has no business proving claims about a target it was not pointed at.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
items = {binding.item for binding in bindings.entries}
|
|
481
|
+
if repository is not None:
|
|
482
|
+
items |= {
|
|
483
|
+
alias.source.item
|
|
484
|
+
for alias in repository.aliases
|
|
485
|
+
if alias.destination.item in items and alias.source.item not in items
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if environment.spark is None:
|
|
489
|
+
raise BuildError("every build needs Spark to read and publish the catalogue")
|
|
490
|
+
workspace = environment.workspace
|
|
491
|
+
if workspace is None or not workspace.weaver_lakehouse:
|
|
492
|
+
raise BuildError("every build needs a Workspace with a Weaver Lakehouse")
|
|
493
|
+
from ..spark import SparkCatalogue
|
|
494
|
+
from ..targets import ItemRef
|
|
495
|
+
|
|
496
|
+
catalogue = SparkCatalogue(
|
|
497
|
+
environment.spark,
|
|
498
|
+
environment.resolver.spark_destination(ItemRef(workspace.weaver_lakehouse)),
|
|
499
|
+
)
|
|
500
|
+
state = read_catalogue_state(catalogue, sorted(items, key=str))
|
|
501
|
+
return reconcile_catalogue_state(state, inventories=inventories)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def read_target_inventories(
|
|
505
|
+
bindings: ItemBindings,
|
|
506
|
+
*,
|
|
507
|
+
environment: InstallationEnvironment,
|
|
508
|
+
sql_by_item=None,
|
|
509
|
+
) -> dict:
|
|
510
|
+
"""Read every selected physical target before planning begins."""
|
|
511
|
+
|
|
512
|
+
supplied_sql = sql_by_item or {}
|
|
513
|
+
inventories = {}
|
|
514
|
+
owned = []
|
|
515
|
+
try:
|
|
516
|
+
for binding in bindings.entries:
|
|
517
|
+
target = binding.to_bound_target()
|
|
518
|
+
if target.kind == WAREHOUSE_TARGET:
|
|
519
|
+
sql = supplied_sql.get(binding.item)
|
|
520
|
+
if sql is None:
|
|
521
|
+
if environment.workspace is None:
|
|
522
|
+
raise BuildError(
|
|
523
|
+
f"reading Warehouse inventory for {binding.item} needs a Workspace"
|
|
524
|
+
)
|
|
525
|
+
from ..fabric.sql import fabric_sql_executor
|
|
526
|
+
from ..targets import WarehouseTarget
|
|
527
|
+
|
|
528
|
+
sql = fabric_sql_executor(
|
|
529
|
+
WarehouseTarget.parse(target.item_id), environment.workspace
|
|
530
|
+
)
|
|
531
|
+
owned.append(sql)
|
|
532
|
+
inventories[binding.item] = read_warehouse_inventory(target, sql=sql)
|
|
533
|
+
else:
|
|
534
|
+
inventories[binding.item] = read_lakehouse_inventory(
|
|
535
|
+
target,
|
|
536
|
+
resolver=environment.resolver,
|
|
537
|
+
store=environment.store,
|
|
538
|
+
spark=environment.spark,
|
|
539
|
+
)
|
|
540
|
+
return inventories
|
|
541
|
+
finally:
|
|
542
|
+
for sql in owned:
|
|
543
|
+
if hasattr(sql, "close"):
|
|
544
|
+
sql.close()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@contextmanager
|
|
548
|
+
def _local_tree(
|
|
549
|
+
source: Location,
|
|
550
|
+
store: Store,
|
|
551
|
+
*,
|
|
552
|
+
prefix: str,
|
|
553
|
+
) -> Iterator[Path]:
|
|
554
|
+
if isinstance(store, LocalStore) and not source.is_url:
|
|
555
|
+
yield source.path.resolve()
|
|
556
|
+
return
|
|
557
|
+
with materialise_tree(source, store=store, prefix=prefix) as tree:
|
|
558
|
+
yield tree.location.path
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _write_archive(root: Path, destination: Path) -> None:
|
|
562
|
+
with zipfile.ZipFile(
|
|
563
|
+
destination, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
|
|
564
|
+
) as zipped:
|
|
565
|
+
for path in sorted(candidate for candidate in root.rglob("*") if candidate.is_file()):
|
|
566
|
+
relative = path.relative_to(root).as_posix()
|
|
567
|
+
info = zipfile.ZipInfo(relative, date_time=(1980, 1, 1, 0, 0, 0))
|
|
568
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
569
|
+
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
|
570
|
+
zipped.writestr(info, path.read_bytes(), compresslevel=9)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _extract_archive(archive: Path, destination: Path) -> None:
|
|
574
|
+
with zipfile.ZipFile(archive) as zipped:
|
|
575
|
+
for info in zipped.infolist():
|
|
576
|
+
path = PurePosixPath(info.filename)
|
|
577
|
+
mode = info.external_attr >> 16
|
|
578
|
+
if (
|
|
579
|
+
path.is_absolute()
|
|
580
|
+
or not path.parts
|
|
581
|
+
or any(part in ("", ".", "..") for part in path.parts)
|
|
582
|
+
or stat.S_ISLNK(mode)
|
|
583
|
+
):
|
|
584
|
+
raise BuildError(f"unsafe path in bundle archive: {info.filename!r}")
|
|
585
|
+
zipped.extractall(destination)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Weaver's central catalogue, scoped by logical item.
|
|
2
|
+
|
|
3
|
+
An installation is identified by ``(item_type, item_name)`` and an object by
|
|
4
|
+
that item identity plus ``(schema_name, object_name)``. Files objects use
|
|
5
|
+
``Files/<schema>`` as their catalogue schema, so the same four-part identity
|
|
6
|
+
covers them without a namespace column.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .render import (
|
|
12
|
+
InstallationScope,
|
|
13
|
+
Row,
|
|
14
|
+
column_set,
|
|
15
|
+
identifier,
|
|
16
|
+
literal,
|
|
17
|
+
qualified_name,
|
|
18
|
+
render_delete_obsolete,
|
|
19
|
+
render_delete_scope,
|
|
20
|
+
render_merge,
|
|
21
|
+
sorted_rows,
|
|
22
|
+
typed_literal,
|
|
23
|
+
)
|
|
24
|
+
from .tables import (
|
|
25
|
+
ALIAS,
|
|
26
|
+
AUDIT_COLUMN_NAMES,
|
|
27
|
+
CATALOGUE_SCHEMA,
|
|
28
|
+
CATALOGUE_TABLES,
|
|
29
|
+
COLUMN_DICTIONARY,
|
|
30
|
+
DEPENDENCY,
|
|
31
|
+
DICTIONARY_TABLES,
|
|
32
|
+
FOLDER_DICTIONARY,
|
|
33
|
+
FOREIGN_KEY_DICTIONARY,
|
|
34
|
+
INDEX_DICTIONARY,
|
|
35
|
+
INSTALLATION,
|
|
36
|
+
ITEM_SCOPE_COLUMNS,
|
|
37
|
+
KEY_PRIMARY,
|
|
38
|
+
KEY_UNIQUE,
|
|
39
|
+
OBJECT_ROLES,
|
|
40
|
+
OBJECT_TYPES,
|
|
41
|
+
REGISTRY,
|
|
42
|
+
ROLE_DATA,
|
|
43
|
+
ROLE_LOAD,
|
|
44
|
+
SCHEMA_DICTIONARY,
|
|
45
|
+
SIGNATURE,
|
|
46
|
+
TABLE_DICTIONARY,
|
|
47
|
+
CatalogueColumn,
|
|
48
|
+
CatalogueTable,
|
|
49
|
+
table,
|
|
50
|
+
)
|
|
51
|
+
from .state import (
|
|
52
|
+
RegisteredDocument,
|
|
53
|
+
Catalogue,
|
|
54
|
+
Reconciliation,
|
|
55
|
+
for_targets,
|
|
56
|
+
retaining,
|
|
57
|
+
read_catalogue_state,
|
|
58
|
+
reconcile_catalogue_state,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"ALIAS", "AUDIT_COLUMN_NAMES", "CATALOGUE_SCHEMA", "CATALOGUE_TABLES",
|
|
63
|
+
"COLUMN_DICTIONARY", "CatalogueColumn", "CatalogueTable", "DEPENDENCY",
|
|
64
|
+
"DICTIONARY_TABLES", "FOLDER_DICTIONARY", "FOREIGN_KEY_DICTIONARY",
|
|
65
|
+
"INDEX_DICTIONARY", "INSTALLATION", "ITEM_SCOPE_COLUMNS",
|
|
66
|
+
"InstallationScope", "KEY_PRIMARY", "KEY_UNIQUE",
|
|
67
|
+
"OBJECT_ROLES", "OBJECT_TYPES", "REGISTRY", "ROLE_DATA", "ROLE_LOAD", "Row",
|
|
68
|
+
"SCHEMA_DICTIONARY", "SIGNATURE", "TABLE_DICTIONARY", "column_set",
|
|
69
|
+
"identifier", "literal", "qualified_name", "render_delete_obsolete",
|
|
70
|
+
"render_delete_scope", "render_merge", "sorted_rows", "table", "typed_literal",
|
|
71
|
+
"RegisteredDocument", "Catalogue", "Reconciliation", "retaining", "for_targets",
|
|
72
|
+
"read_catalogue_state", "reconcile_catalogue_state",
|
|
73
|
+
]
|