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,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)