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,108 @@
|
|
|
1
|
+
"""The installation report — one result per action, faithfully.
|
|
2
|
+
|
|
3
|
+
An install is judged by its report, so the report must be exact: every planned
|
|
4
|
+
action gets exactly one result, with its status, timing and — on failure — the
|
|
5
|
+
error, and a sequence that never started is recorded as skipped rather than
|
|
6
|
+
omitted. The whole thing serialises so a local run can drop an
|
|
7
|
+
``install-report.yml`` beside the plan; on Fabric the same structure can move to
|
|
8
|
+
control tables later.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
PENDING = "pending"
|
|
18
|
+
RUNNING = "running"
|
|
19
|
+
SUCCEEDED = "succeeded"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
SKIPPED = "skipped"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _iso(value: datetime | None) -> str | None:
|
|
25
|
+
return value.isoformat() if value is not None else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ActionResult:
|
|
30
|
+
"""The outcome of one action."""
|
|
31
|
+
|
|
32
|
+
action_id: str
|
|
33
|
+
resource_node_id: str | None
|
|
34
|
+
target_id: str
|
|
35
|
+
executor: str
|
|
36
|
+
status: str
|
|
37
|
+
started_at: datetime | None = None
|
|
38
|
+
finished_at: datetime | None = None
|
|
39
|
+
duration_seconds: float | None = None
|
|
40
|
+
error_type: str | None = None
|
|
41
|
+
error_message: str | None = None
|
|
42
|
+
details: dict[str, Any] | None = None
|
|
43
|
+
|
|
44
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
45
|
+
mapping: dict[str, Any] = {
|
|
46
|
+
"action_id": self.action_id,
|
|
47
|
+
"resource_node_id": self.resource_node_id,
|
|
48
|
+
"target_id": self.target_id,
|
|
49
|
+
"executor": self.executor,
|
|
50
|
+
"status": self.status,
|
|
51
|
+
"started_at": _iso(self.started_at),
|
|
52
|
+
"finished_at": _iso(self.finished_at),
|
|
53
|
+
"duration_seconds": self.duration_seconds,
|
|
54
|
+
}
|
|
55
|
+
if self.error_type is not None:
|
|
56
|
+
mapping["error_type"] = self.error_type
|
|
57
|
+
mapping["error_message"] = self.error_message
|
|
58
|
+
if self.details:
|
|
59
|
+
mapping["details"] = self.details
|
|
60
|
+
return mapping
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SequenceResult:
|
|
65
|
+
number: int
|
|
66
|
+
description: str
|
|
67
|
+
status: str
|
|
68
|
+
actions: tuple[ActionResult, ...]
|
|
69
|
+
|
|
70
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"number": self.number,
|
|
73
|
+
"description": self.description,
|
|
74
|
+
"status": self.status,
|
|
75
|
+
"actions": [action.to_mapping() for action in self.actions],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class InstallationReport:
|
|
81
|
+
bundle_id: str
|
|
82
|
+
status: str
|
|
83
|
+
started_at: datetime
|
|
84
|
+
finished_at: datetime | None
|
|
85
|
+
sequences: tuple[SequenceResult, ...]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def succeeded(self) -> bool:
|
|
89
|
+
return self.status == SUCCEEDED
|
|
90
|
+
|
|
91
|
+
def action_results(self):
|
|
92
|
+
for sequence in self.sequences:
|
|
93
|
+
for action in sequence.actions:
|
|
94
|
+
yield action
|
|
95
|
+
|
|
96
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"bundle_id": self.bundle_id,
|
|
99
|
+
"status": self.status,
|
|
100
|
+
"started_at": _iso(self.started_at),
|
|
101
|
+
"finished_at": _iso(self.finished_at),
|
|
102
|
+
"sequences": [sequence.to_mapping() for sequence in self.sequences],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def to_yaml(self) -> str:
|
|
106
|
+
import yaml
|
|
107
|
+
|
|
108
|
+
return yaml.safe_dump(self.to_mapping(), sort_keys=False, allow_unicode=True)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Logical stages, and the one place a sequence number is chosen.
|
|
2
|
+
|
|
3
|
+
A planning component answers *what* has to happen and in what order relative to
|
|
4
|
+
its own siblings. It does not answer *which sequence number* that is, because a
|
|
5
|
+
number is a property of the finished plan and nothing else: with one alias, one
|
|
6
|
+
schema and one endpoint-refresh stage per item, arithmetic over reserved regions
|
|
7
|
+
stops describing the plan and starts constraining it.
|
|
8
|
+
|
|
9
|
+
So each component returns :class:`PlannedStage` values — a phase, a description,
|
|
10
|
+
target-bound batches, and the payloads those batches need, keyed by bare
|
|
11
|
+
filename. The top-level planner concatenates the stages in execution order and
|
|
12
|
+
:func:`enumerate_stages` turns them into :class:`~weaver.build_bundle.models.BuildSequence`
|
|
13
|
+
values, numbering them 1, 2, 3 … and rewriting each payload into
|
|
14
|
+
``payload/<number>-<slug>/<filename>`` so the bundle directory still reads top to
|
|
15
|
+
bottom in deployment order.
|
|
16
|
+
|
|
17
|
+
Numbering last also means a stage cannot collide with another stage's region, and
|
|
18
|
+
there is no headroom to run out of: the number *describes* the order the plan
|
|
19
|
+
already has.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
from typing import Iterable, Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
from ..errors import BuildError
|
|
28
|
+
from .changes import TargetChange, merge as merge_changes
|
|
29
|
+
from .models import BuildBatch, BuildSequence
|
|
30
|
+
from .payloads import payload_path
|
|
31
|
+
|
|
32
|
+
#: The phases one item's work is made of, in the order they must run.
|
|
33
|
+
#:
|
|
34
|
+
#: Prune and managed drops come first because they are the destructive
|
|
35
|
+
#: reconciliation of what is already there. Schemas precede aliases so an alias
|
|
36
|
+
#: materialised as a Warehouse view has a schema to be created in, and aliases
|
|
37
|
+
#: precede builds so every document this item declares is built against a
|
|
38
|
+
#: namespace that already holds what the item imports. The refresh closes the
|
|
39
|
+
#: item: until a mutated Lakehouse's SQL endpoint has caught up, a dependent
|
|
40
|
+
#: item's view or shortcut would be built over metadata that does not describe it.
|
|
41
|
+
#: Load closes the item, after the refresh. Its artefacts depend on the item's
|
|
42
|
+
#: structural work being finished and on nothing within their own layer — a
|
|
43
|
+
#: deployed module and a generated procedure have no ordering between them,
|
|
44
|
+
#: because nothing here runs them.
|
|
45
|
+
PRUNE = "prune"
|
|
46
|
+
DROP = "drop"
|
|
47
|
+
SCHEMA = "schema"
|
|
48
|
+
ALIAS = "alias"
|
|
49
|
+
BUILD = "build"
|
|
50
|
+
REFRESH = "refresh"
|
|
51
|
+
LOAD = "load"
|
|
52
|
+
CATALOGUE = "catalogue"
|
|
53
|
+
|
|
54
|
+
_PHASE_ORDER = (PRUNE, DROP, SCHEMA, ALIAS, BUILD, REFRESH, LOAD, CATALOGUE)
|
|
55
|
+
_PHASE_RANK = {phase: rank for rank, phase in enumerate(_PHASE_ORDER)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PlannedStage:
|
|
60
|
+
"""One barrier's worth of work, before it is given a number.
|
|
61
|
+
|
|
62
|
+
``phase`` and ``index`` place the stage among its siblings: ``index``
|
|
63
|
+
separates the dependency layers within a phase, so two items in the same
|
|
64
|
+
topological item layer can have their layer *n* merged into one barrier.
|
|
65
|
+
|
|
66
|
+
``slug`` names the stage's payload directory. ``payloads`` is keyed by bare
|
|
67
|
+
filename within it, because the directory's name is not known until the
|
|
68
|
+
stage has a number.
|
|
69
|
+
|
|
70
|
+
``changes`` is what this stage's actions will *mean* for each target, keyed
|
|
71
|
+
by target id. Rendered beside the actions rather than inferred from them —
|
|
72
|
+
see :mod:`weaver.build_bundle.changes` — so the statement of effect and the
|
|
73
|
+
thing that has the effect are written in one place.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
phase: str
|
|
77
|
+
description: str
|
|
78
|
+
batches: tuple[BuildBatch, ...]
|
|
79
|
+
slug: str = ""
|
|
80
|
+
index: int = 0
|
|
81
|
+
payloads: Mapping[str, bytes] = field(default_factory=dict)
|
|
82
|
+
changes: Mapping[str, tuple[TargetChange, ...]] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
if self.phase not in _PHASE_RANK:
|
|
86
|
+
raise BuildError(f"unknown planned stage phase {self.phase!r}")
|
|
87
|
+
for filename in self.payloads:
|
|
88
|
+
if "/" in filename or not filename:
|
|
89
|
+
raise BuildError(
|
|
90
|
+
f"stage {self.phase!r} payload key must be a bare filename, "
|
|
91
|
+
f"got {filename!r}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def payload_slug(self) -> str:
|
|
96
|
+
return self.slug or self.phase
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def rank(self) -> tuple[int, int]:
|
|
100
|
+
return (_PHASE_RANK[self.phase], self.index)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def merge_layer_stages(stages: Iterable[PlannedStage]) -> tuple[PlannedStage, ...]:
|
|
104
|
+
"""Fold same-phase, same-index stages from one item layer into one barrier.
|
|
105
|
+
|
|
106
|
+
Items in the same topological layer have no ordering between them, so their
|
|
107
|
+
work belongs in the same barriers: one batch per item, exactly as a
|
|
108
|
+
single-layer build already produces. Merging here is what keeps the
|
|
109
|
+
invariant that matters — nothing in a later item layer starts before this
|
|
110
|
+
layer has completed — without serialising items that never needed it.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
grouped: dict[tuple[int, int], list[PlannedStage]] = {}
|
|
114
|
+
for stage in stages:
|
|
115
|
+
grouped.setdefault(stage.rank, []).append(stage)
|
|
116
|
+
|
|
117
|
+
merged: list[PlannedStage] = []
|
|
118
|
+
for rank in sorted(grouped):
|
|
119
|
+
group = grouped[rank]
|
|
120
|
+
first = group[0]
|
|
121
|
+
payloads: dict[str, bytes] = {}
|
|
122
|
+
for stage in group:
|
|
123
|
+
if stage.payload_slug != first.payload_slug:
|
|
124
|
+
raise BuildError(
|
|
125
|
+
f"stages merged into one barrier disagree about their payload "
|
|
126
|
+
f"directory: {first.payload_slug!r} and {stage.payload_slug!r}"
|
|
127
|
+
)
|
|
128
|
+
for filename, content in stage.payloads.items():
|
|
129
|
+
if payloads.setdefault(filename, content) != content:
|
|
130
|
+
raise BuildError(
|
|
131
|
+
f"two merged stages disagree about payload {filename!r}"
|
|
132
|
+
)
|
|
133
|
+
merged.append(
|
|
134
|
+
replace(
|
|
135
|
+
first,
|
|
136
|
+
batches=tuple(batch for stage in group for batch in stage.batches),
|
|
137
|
+
payloads=payloads,
|
|
138
|
+
changes=merge_changes(*(stage.changes for stage in group)),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return tuple(merged)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def enumerate_stages(
|
|
145
|
+
stages: Sequence[PlannedStage],
|
|
146
|
+
) -> tuple[
|
|
147
|
+
tuple[BuildSequence, ...],
|
|
148
|
+
dict[str, bytes],
|
|
149
|
+
dict[str, tuple[TargetChange, ...]],
|
|
150
|
+
]:
|
|
151
|
+
"""Number the assembled plan and resolve every payload path.
|
|
152
|
+
|
|
153
|
+
Batch ids gain the same number prefix, so a batch is still identifiable in a
|
|
154
|
+
report and still unique across the plan without any component having to know
|
|
155
|
+
what else is being planned.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
sequences: list[BuildSequence] = []
|
|
159
|
+
payloads: dict[str, bytes] = {}
|
|
160
|
+
changes: list[Mapping[str, tuple[TargetChange, ...]]] = []
|
|
161
|
+
# An empty stage is not a barrier — it is a phase this build had no work for
|
|
162
|
+
# — so it takes no number and leaves no gap.
|
|
163
|
+
populated = [stage for stage in stages if stage.batches]
|
|
164
|
+
for number, stage in enumerate(populated, start=1):
|
|
165
|
+
resolved = {}
|
|
166
|
+
for filename, content in stage.payloads.items():
|
|
167
|
+
path = payload_path(number, stage.payload_slug, filename)
|
|
168
|
+
resolved[filename] = path
|
|
169
|
+
payloads[path] = content
|
|
170
|
+
changes.append(stage.changes)
|
|
171
|
+
sequences.append(
|
|
172
|
+
BuildSequence(
|
|
173
|
+
number=number,
|
|
174
|
+
description=stage.description,
|
|
175
|
+
batches=tuple(
|
|
176
|
+
_numbered(batch, number, resolved) for batch in stage.batches
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return tuple(sequences), payloads, merge_changes(*changes)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _numbered(batch: BuildBatch, number: int, payloads: Mapping[str, str]) -> BuildBatch:
|
|
184
|
+
actions = []
|
|
185
|
+
for action in batch.actions:
|
|
186
|
+
if action.payload is None:
|
|
187
|
+
actions.append(action)
|
|
188
|
+
continue
|
|
189
|
+
resolved = payloads.get(action.payload)
|
|
190
|
+
if resolved is None:
|
|
191
|
+
raise BuildError(
|
|
192
|
+
f"action {action.id!r} names payload {action.payload!r}, which its "
|
|
193
|
+
"stage did not supply"
|
|
194
|
+
)
|
|
195
|
+
actions.append(replace(action, payload=resolved))
|
|
196
|
+
return replace(batch, id=f"{number:03d}-{batch.id}", actions=tuple(actions))
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Serialisable physical target descriptors.
|
|
2
|
+
|
|
3
|
+
A build request supplies live workspace objects; a bundle must not. The planner
|
|
4
|
+
converts each supplied binding into a :class:`BoundTarget` — a flat, stable
|
|
5
|
+
descriptor carrying exactly what an installer needs to resolve the physical
|
|
6
|
+
destination, and nothing that ties the bundle to the process that wrote it.
|
|
7
|
+
|
|
8
|
+
There is no workspace kind here. Weaver has one real workspace, Fabric; local execution is
|
|
9
|
+
an emulation of it for development, not a second kind the bundle contract records.
|
|
10
|
+
A target names an item — a Lakehouse or a Warehouse — by the identifiers the
|
|
11
|
+
installer resolves it with; where the installer is running (in a Fabric session,
|
|
12
|
+
or in-process locally) is supplied by its environment, not frozen into the bundle.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Mapping
|
|
19
|
+
|
|
20
|
+
from ..targets import ItemRef
|
|
21
|
+
from ..errors import BuildError
|
|
22
|
+
from ..declaration.model import LAKEHOUSE, WAREHOUSE, WeaverItemId
|
|
23
|
+
|
|
24
|
+
#: Target kinds a bound target may name. They mirror the Weaver document target kinds but
|
|
25
|
+
#: live here because a bundle is read without importing the Weaver document vocabulary.
|
|
26
|
+
LAKEHOUSE_TARGET = "lakehouse"
|
|
27
|
+
WAREHOUSE_TARGET = "warehouse"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BoundTarget:
|
|
32
|
+
"""One physical destination, as flat serialisable data.
|
|
33
|
+
|
|
34
|
+
``id`` is the manifest-local identifier a batch names. ``kind`` says whether
|
|
35
|
+
it is a Lakehouse or a Warehouse. ``item_id`` names the item, with the
|
|
36
|
+
optional Fabric identifiers alongside; the installer resolves the item
|
|
37
|
+
through its own environment.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
kind: str
|
|
42
|
+
item_id: str
|
|
43
|
+
#: The item's resolved display name. Carried alongside ``item_id`` because on
|
|
44
|
+
#: Fabric the id is a GUID: the catalogue records which item an installation is
|
|
45
|
+
#: bound to, and a GUID would make that record unreadable. It is a *record*,
|
|
46
|
+
#: never identity — resolution goes through ``item_id``.
|
|
47
|
+
item_name: str | None = None
|
|
48
|
+
workspace_id: str | None = None
|
|
49
|
+
sql_endpoint_id: str | None = None
|
|
50
|
+
logical_item_type: str | None = None
|
|
51
|
+
logical_item_name: str | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
"""The readable name, falling back to the id when none was carried."""
|
|
56
|
+
|
|
57
|
+
return self.item_name or self.item_id
|
|
58
|
+
|
|
59
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
60
|
+
mapping: dict[str, Any] = {
|
|
61
|
+
"id": self.id,
|
|
62
|
+
"kind": self.kind,
|
|
63
|
+
"item_id": self.item_id,
|
|
64
|
+
}
|
|
65
|
+
if self.item_name is not None:
|
|
66
|
+
mapping["item_name"] = self.item_name
|
|
67
|
+
if self.workspace_id is not None:
|
|
68
|
+
mapping["workspace_id"] = self.workspace_id
|
|
69
|
+
if self.sql_endpoint_id is not None:
|
|
70
|
+
mapping["sql_endpoint_id"] = self.sql_endpoint_id
|
|
71
|
+
if self.logical_item_type is not None:
|
|
72
|
+
mapping["logical_item_type"] = self.logical_item_type
|
|
73
|
+
if self.logical_item_name is not None:
|
|
74
|
+
mapping["logical_item_name"] = self.logical_item_name
|
|
75
|
+
return mapping
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_mapping(cls, mapping: Mapping[str, Any]) -> "BoundTarget":
|
|
79
|
+
return cls(
|
|
80
|
+
id=mapping["id"],
|
|
81
|
+
kind=mapping["kind"],
|
|
82
|
+
item_id=mapping["item_id"],
|
|
83
|
+
item_name=mapping.get("item_name"),
|
|
84
|
+
workspace_id=mapping.get("workspace_id"),
|
|
85
|
+
sql_endpoint_id=mapping.get("sql_endpoint_id"),
|
|
86
|
+
logical_item_type=mapping.get("logical_item_type"),
|
|
87
|
+
logical_item_name=mapping.get("logical_item_name"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- input bindings ----------------------------------------------------------
|
|
92
|
+
#
|
|
93
|
+
# What a caller supplies to the planner. These carry a live identity (an
|
|
94
|
+
# ItemRef, and for Fabric the workspace/item ids); the planner converts them into
|
|
95
|
+
# the flat BoundTarget above so no live workspace object is serialised into a bundle.
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class LakehouseBinding:
|
|
100
|
+
"""A bound destination Lakehouse for Folder and Delta materialisation."""
|
|
101
|
+
|
|
102
|
+
lakehouse: ItemRef
|
|
103
|
+
workspace_id: str | None = None
|
|
104
|
+
#: The concrete Fabric item id; locally the logical Lakehouse name serves.
|
|
105
|
+
item_id: str | None = None
|
|
106
|
+
|
|
107
|
+
def to_bound_target(self) -> BoundTarget:
|
|
108
|
+
return BoundTarget(
|
|
109
|
+
id=f"{LAKEHOUSE_TARGET}-{self.lakehouse.name}",
|
|
110
|
+
kind=LAKEHOUSE_TARGET,
|
|
111
|
+
item_id=self.item_id or self.lakehouse.name,
|
|
112
|
+
item_name=self.lakehouse.name,
|
|
113
|
+
workspace_id=self.workspace_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class WarehouseBinding:
|
|
119
|
+
"""A bound destination Warehouse. Present so the boundary is visible; v1
|
|
120
|
+
installation of Warehouse work is not supported and raises."""
|
|
121
|
+
|
|
122
|
+
warehouse: ItemRef
|
|
123
|
+
workspace_id: str | None = None
|
|
124
|
+
item_id: str | None = None
|
|
125
|
+
sql_endpoint_id: str | None = None
|
|
126
|
+
|
|
127
|
+
def to_bound_target(self) -> BoundTarget:
|
|
128
|
+
return BoundTarget(
|
|
129
|
+
id=f"{WAREHOUSE_TARGET}-{self.warehouse.name}",
|
|
130
|
+
kind=WAREHOUSE_TARGET,
|
|
131
|
+
item_id=self.item_id or self.warehouse.name,
|
|
132
|
+
item_name=self.warehouse.name,
|
|
133
|
+
workspace_id=self.workspace_id,
|
|
134
|
+
sql_endpoint_id=self.sql_endpoint_id,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ItemBinding:
|
|
140
|
+
"""One exact logical Weaver item bound to one typed physical item."""
|
|
141
|
+
|
|
142
|
+
item: WeaverItemId
|
|
143
|
+
target: LakehouseBinding | WarehouseBinding
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
expected = LAKEHOUSE if isinstance(self.target, LakehouseBinding) else WAREHOUSE
|
|
147
|
+
if self.item.item_type != expected:
|
|
148
|
+
raise BuildError(
|
|
149
|
+
f"logical item {self.item} requires a {self.item.item_type} binding, "
|
|
150
|
+
f"not {type(self.target).__name__}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def to_bound_target(self) -> BoundTarget:
|
|
154
|
+
physical = self.target.to_bound_target()
|
|
155
|
+
logical_slug = f"{self.item.item_type}-{self.item.item_name}"
|
|
156
|
+
return BoundTarget(
|
|
157
|
+
id=f"{logical_slug}--{physical.id}",
|
|
158
|
+
kind=physical.kind,
|
|
159
|
+
item_id=physical.item_id,
|
|
160
|
+
item_name=physical.item_name,
|
|
161
|
+
workspace_id=physical.workspace_id,
|
|
162
|
+
sql_endpoint_id=physical.sql_endpoint_id,
|
|
163
|
+
logical_item_type=self.item.item_type,
|
|
164
|
+
logical_item_name=self.item.item_name,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class ItemBindings:
|
|
170
|
+
"""The sparse logical-to-physical bindings for one coordinated build."""
|
|
171
|
+
|
|
172
|
+
entries: tuple[ItemBinding, ...]
|
|
173
|
+
|
|
174
|
+
def __post_init__(self) -> None:
|
|
175
|
+
seen: set[WeaverItemId] = set()
|
|
176
|
+
physical: set[tuple[str, str]] = set()
|
|
177
|
+
for binding in self.entries:
|
|
178
|
+
if binding.item in seen:
|
|
179
|
+
raise BuildError(f"logical item is bound more than once: {binding.item}")
|
|
180
|
+
seen.add(binding.item)
|
|
181
|
+
target = binding.target
|
|
182
|
+
key = (
|
|
183
|
+
LAKEHOUSE if isinstance(target, LakehouseBinding) else WAREHOUSE,
|
|
184
|
+
target.lakehouse.name
|
|
185
|
+
if isinstance(target, LakehouseBinding)
|
|
186
|
+
else target.warehouse.name,
|
|
187
|
+
)
|
|
188
|
+
if key in physical:
|
|
189
|
+
raise BuildError(
|
|
190
|
+
f"physical {key[0]} target is bound more than once: {key[1]}"
|
|
191
|
+
)
|
|
192
|
+
physical.add(key)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def by_item(self) -> Mapping[WeaverItemId, ItemBinding]:
|
|
196
|
+
return {binding.item: binding for binding in self.entries}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def effective_item_bindings(
|
|
200
|
+
bindings: ItemBindings, *, weaver_lakehouse: str
|
|
201
|
+
) -> ItemBindings:
|
|
202
|
+
"""Add the mandatory package-owned control item binding."""
|
|
203
|
+
|
|
204
|
+
builtin = WeaverItemId(LAKEHOUSE, "_weaver")
|
|
205
|
+
if builtin in bindings.by_item:
|
|
206
|
+
raise BuildError("Lakehouse/_weaver is bound implicitly and must not be selected")
|
|
207
|
+
return ItemBindings(
|
|
208
|
+
bindings.entries
|
|
209
|
+
+ (
|
|
210
|
+
ItemBinding(
|
|
211
|
+
builtin,
|
|
212
|
+
LakehouseBinding(ItemRef.parse(weaver_lakehouse)),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parse_item_binding(text: str, *, workspace=None) -> ItemBinding:
|
|
219
|
+
"""Parse a typed physical selector with an optional logical override.
|
|
220
|
+
|
|
221
|
+
``Lakehouse/Sales`` uses the configured default. The self-contained form
|
|
222
|
+
``Lakehouse/Sales=Lakehouse/Raw`` needs no configured target declaration.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if not isinstance(text, str) or text.count("=") > 1:
|
|
226
|
+
raise BuildError(
|
|
227
|
+
"a binding must be TypedPhysical/Name or TypedPhysical/Name=Logical/Item"
|
|
228
|
+
)
|
|
229
|
+
physical_text, separator, logical_text = text.partition("=")
|
|
230
|
+
physical_text = physical_text.strip()
|
|
231
|
+
logical_text = logical_text.strip()
|
|
232
|
+
if not physical_text or (separator and not logical_text):
|
|
233
|
+
raise BuildError(
|
|
234
|
+
"a binding must be TypedPhysical/Name or TypedPhysical/Name=Logical/Item"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
physical_type, physical = _parse_physical_item(physical_text)
|
|
238
|
+
if separator:
|
|
239
|
+
item = WeaverItemId.parse(logical_text)
|
|
240
|
+
else:
|
|
241
|
+
if workspace is None:
|
|
242
|
+
raise BuildError(
|
|
243
|
+
f"binding {physical_text!r} needs a Workspace configuration default "
|
|
244
|
+
"or an explicit =Logical/Item"
|
|
245
|
+
)
|
|
246
|
+
item = workspace.declaration_for(physical_type, physical.name).item
|
|
247
|
+
if item.item_type != physical_type:
|
|
248
|
+
raise BuildError(
|
|
249
|
+
f"physical {physical_text} cannot be bound to logical {item}; "
|
|
250
|
+
f"both must be {physical_type}"
|
|
251
|
+
)
|
|
252
|
+
target = (
|
|
253
|
+
LakehouseBinding(physical)
|
|
254
|
+
if physical_type == LAKEHOUSE
|
|
255
|
+
else WarehouseBinding(physical)
|
|
256
|
+
)
|
|
257
|
+
return ItemBinding(item, target)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _parse_physical_item(text: str) -> tuple[str, ItemRef]:
|
|
261
|
+
"""The binding's physical half, through the grammar every operation shares.
|
|
262
|
+
|
|
263
|
+
The logical item types and the grammar's spellings happen to be the same two
|
|
264
|
+
words, so the kind is used directly rather than translated.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
from ..targets import parse_physical_target, physical_item, physical_kind
|
|
268
|
+
|
|
269
|
+
target = parse_physical_target(
|
|
270
|
+
text, what="binding physical target", error=BuildError
|
|
271
|
+
)
|
|
272
|
+
return physical_kind(target), physical_item(target)
|