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,386 @@
1
+ """Locating what each planned node would dispatch, without dispatching it.
2
+
3
+ The seam between "what should run" and "run it", and it exists as its own layer
4
+ because the two questions fail for entirely different reasons. A planning fault
5
+ is a wrong graph; a resolution fault is a graph that is right about an estate
6
+ that is not there — a procedure that was never installed, a module deleted from
7
+ the runtime tree, a Warehouse that has been wiped. Keeping them apart means a
8
+ failure names which of the two it is.
9
+
10
+ The question this layer answers is exactly:
11
+
12
+ Can the orchestrator locate what it would dispatch?
13
+
14
+ and deliberately not *does the primitive work*. Each primitive is independently
15
+ runnable and independently tested; re-proving that here would make orchestration
16
+ the owner of behaviour it does not implement.
17
+
18
+ **Physical state arrives as an inventory, not as a live connection.** The same
19
+ :class:`~weaver.build_bundle.prune.TargetInventory` a build reads before planning
20
+ answers every existence question here — is the target there, is the procedure
21
+ there, is the deployed file there, does the table it loads into exist. So this
22
+ module is pure: a graph and some observed state in, a resolved plan out. The
23
+ reading happens once, above, where the session already is.
24
+
25
+ Dry run is this layer plus nothing. §11's list — read the catalogue, reverse the
26
+ bindings, build the graph, check it is acyclic, order it, resolve every node,
27
+ verify targets, procedures, files, modules and refresh capability — is the whole
28
+ orchestration path with dispatch removed, which is why a dry run can be complete
29
+ and still touch nothing.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Mapping
36
+
37
+ from .build_bundle.prune import TargetInventory
38
+ from .etl import load_procedure_name
39
+ from .load_plan import (
40
+ ENDPOINT_REFRESH,
41
+ PYTHON_FOLDER,
42
+ PYTHON_TABLE,
43
+ SPARK_SQL_FILE,
44
+ WAREHOUSE_PROCEDURE,
45
+ LoadDag,
46
+ LoadNode,
47
+ PhysicalTargetRef,
48
+ )
49
+ from .load_report import (
50
+ BLOCKED,
51
+ DEPENDENCY_BLOCKED,
52
+ DISPATCH_LOCATION_MISSING,
53
+ INVALID,
54
+ MODULE_IMPORT_FAILURE,
55
+ TARGET_MISSING,
56
+ VALIDATED,
57
+ LoadMessage,
58
+ LoadNodeReport,
59
+ error,
60
+ warning,
61
+ )
62
+ from .targets import ItemRef
63
+
64
+ #: What a refresh resolves to when the host can perform one. Not a physical
65
+ #: object — a Lakehouse's SQL analytics endpoint is a capability of the item, so
66
+ #: the location names the item and the capability rather than a path.
67
+ ENDPOINT_SUFFIX = "sql_endpoint"
68
+
69
+ #: Why a refresh could not be resolved on this host. The emulator has no SQL
70
+ #: analytics endpoint at all, which is an honest absence rather than a fault —
71
+ #: the build's own executor skips for the same reason.
72
+ REFRESH_UNSUPPORTED = "SQL endpoint refresh is unsupported in this environment"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class LoadEnvironment:
77
+ """Runtime services and the observed physical state of one load run.
78
+
79
+ ``inventories`` is keyed by the physical target's public spelling —
80
+ ``Lakehouse/Raw_LH`` — because that is what a caller wrote and what a report
81
+ prints, so a key that could not be read back would put a second vocabulary
82
+ between the request and the answer. A target with no entry is a target that
83
+ is not there.
84
+
85
+ ``sql`` is per Warehouse rather than one connection, because a run may span
86
+ two of them and a Warehouse is reached over its own TDS endpoint.
87
+ """
88
+
89
+ resolver: Any = None
90
+ inventories: Mapping[str, TargetInventory] = field(default_factory=dict)
91
+ store: Any = None
92
+ spark: Any = None
93
+ sql: Mapping[str, Any] = field(default_factory=dict)
94
+ workspace: Any = None
95
+
96
+ def inventory(self, target: PhysicalTargetRef) -> TargetInventory | None:
97
+ return self.inventories.get(str(target))
98
+
99
+ def sql_for(self, target: PhysicalTargetRef) -> Any:
100
+ return self.sql.get(target.name)
101
+
102
+ def can_refresh(self) -> bool:
103
+ return callable(getattr(self.resolver, "refresh_sql_endpoint", None))
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class ResolvedLoadNode:
108
+ """One node, and what the orchestrator would actually reach for.
109
+
110
+ ``target_exists`` and ``primitive_exists`` are separate answers because they
111
+ are separate failures: a Warehouse that has been wiped and a procedure that
112
+ was never generated both stop this node, and telling a reader which one it
113
+ was is the whole value of resolving ahead of dispatching.
114
+ """
115
+
116
+ node: LoadNode
117
+ dispatch_location: str | None = None
118
+ target_exists: bool = False
119
+ primitive_exists: bool = False
120
+ #: The class a deployed Python module must define, for the two Python kinds.
121
+ expected_class: str | None = None
122
+ validation_messages: tuple[LoadMessage, ...] = ()
123
+ #: A capability this host does not have, so the node is omitted rather than
124
+ #: failed. Only an endpoint refresh in an environment with no endpoint.
125
+ unsupported: bool = False
126
+
127
+ @property
128
+ def node_id(self) -> str:
129
+ return self.node.node_id
130
+
131
+ @property
132
+ def valid(self) -> bool:
133
+ return not any(
134
+ message.severity == "error" for message in self.validation_messages
135
+ )
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ResolvedLoadPlan:
140
+ """A whole graph resolved against one environment."""
141
+
142
+ dag: LoadDag
143
+ nodes: tuple[ResolvedLoadNode, ...]
144
+
145
+ @property
146
+ def by_id(self) -> Mapping[str, ResolvedLoadNode]:
147
+ return {node.node_id: node for node in self.nodes}
148
+
149
+ @property
150
+ def order(self) -> tuple[ResolvedLoadNode, ...]:
151
+ resolved = self.by_id
152
+ return tuple(resolved[node.node_id] for node in self.dag.order())
153
+
154
+ @property
155
+ def blocked(self) -> Mapping[str, frozenset[str]]:
156
+ """Which invalid node blocks each node that may not run because of it."""
157
+
158
+ blocked: dict[str, set[str]] = {}
159
+ for resolved in self.nodes:
160
+ if resolved.valid:
161
+ continue
162
+ for downstream in self.dag.descendants(resolved.node_id):
163
+ blocked.setdefault(downstream, set()).add(resolved.node_id)
164
+ return {node: frozenset(causes) for node, causes in blocked.items()}
165
+
166
+
167
+ def resolve_load_plan(
168
+ dag: LoadDag, *, environment: LoadEnvironment
169
+ ) -> ResolvedLoadPlan:
170
+ """Resolve every node in ``dag`` to the installed primitive it would run."""
171
+
172
+ return ResolvedLoadPlan(
173
+ dag=dag,
174
+ nodes=tuple(_resolve_node(node, environment) for node in dag.order()),
175
+ )
176
+
177
+
178
+ def _resolve_node(node: LoadNode, environment: LoadEnvironment) -> ResolvedLoadNode:
179
+ inventory = environment.inventory(node.physical_target)
180
+ if node.primitive_kind == ENDPOINT_REFRESH:
181
+ return _resolve_refresh(node, environment, inventory)
182
+ messages: list[LoadMessage] = []
183
+ if inventory is None:
184
+ messages.append(
185
+ error(
186
+ TARGET_MISSING,
187
+ f"{node.physical_target} is not present, so {node.node_id} has "
188
+ "nowhere to run",
189
+ source="load_resolution",
190
+ )
191
+ )
192
+ location, expected_class = _dispatch_location(node, environment)
193
+ if location is None:
194
+ messages.append(
195
+ error(
196
+ DISPATCH_LOCATION_MISSING,
197
+ f"{node.node_id} names primitive kind {node.primitive_kind!r}, "
198
+ "which this environment cannot address",
199
+ source="load_resolution",
200
+ )
201
+ )
202
+ primitive_exists = _holds(inventory, node.primitive_object)
203
+ if inventory is not None and not primitive_exists:
204
+ messages.append(
205
+ error(
206
+ DISPATCH_LOCATION_MISSING,
207
+ f"{node.node_id} would dispatch {location}, which is not installed "
208
+ f"in {node.physical_target}",
209
+ source="load_resolution",
210
+ )
211
+ )
212
+ if inventory is not None and not _holds(inventory, node.physical_object):
213
+ messages.append(
214
+ error(
215
+ TARGET_MISSING,
216
+ f"{node.physical_target} does not hold {node.physical_object}, "
217
+ "which this node loads into",
218
+ source="load_resolution",
219
+ )
220
+ )
221
+ if expected_class is None and node.primitive_kind in (PYTHON_TABLE, PYTHON_FOLDER):
222
+ messages.append(
223
+ error(
224
+ MODULE_IMPORT_FAILURE,
225
+ f"{node.node_id} names a deployed module whose expected class "
226
+ "cannot be derived from its filename",
227
+ source="load_resolution",
228
+ )
229
+ )
230
+ return ResolvedLoadNode(
231
+ node=node,
232
+ dispatch_location=location,
233
+ target_exists=inventory is not None,
234
+ primitive_exists=primitive_exists,
235
+ expected_class=expected_class,
236
+ validation_messages=tuple(messages),
237
+ )
238
+
239
+
240
+ def _resolve_refresh(
241
+ node: LoadNode, environment: LoadEnvironment, inventory
242
+ ) -> ResolvedLoadNode:
243
+ """A barrier resolves to a capability, and its absence is not a failure."""
244
+
245
+ messages: list[LoadMessage] = []
246
+ if inventory is None:
247
+ messages.append(
248
+ error(
249
+ TARGET_MISSING,
250
+ f"{node.physical_target} is not present, so its SQL endpoint "
251
+ "cannot be refreshed",
252
+ source="load_resolution",
253
+ )
254
+ )
255
+ supported = environment.can_refresh()
256
+ if not supported:
257
+ messages.append(
258
+ warning(
259
+ DISPATCH_LOCATION_MISSING,
260
+ f"{REFRESH_UNSUPPORTED}; {node.node_id} will be skipped",
261
+ source="load_resolution",
262
+ )
263
+ )
264
+ return ResolvedLoadNode(
265
+ node=node,
266
+ dispatch_location=f"{node.physical_target}/{ENDPOINT_SUFFIX}",
267
+ target_exists=inventory is not None,
268
+ primitive_exists=supported,
269
+ validation_messages=tuple(messages),
270
+ unsupported=not supported,
271
+ )
272
+
273
+
274
+ def _dispatch_location(
275
+ node: LoadNode, environment: LoadEnvironment
276
+ ) -> tuple[str | None, str | None]:
277
+ """Where the primitive is, and the class a deployed module must define."""
278
+
279
+ if node.primitive_kind == WAREHOUSE_PROCEDURE:
280
+ procedure = load_procedure_name(node.logical_id.object_id)
281
+ return f"{node.physical_target}/{procedure}", None
282
+ if node.primitive_kind in (SPARK_SQL_FILE, PYTHON_TABLE, PYTHON_FOLDER):
283
+ location = installed_file_location(node, environment)
284
+ expected = (
285
+ _module_class(node.primitive_object.object)
286
+ if node.primitive_kind in (PYTHON_TABLE, PYTHON_FOLDER)
287
+ else None
288
+ )
289
+ return location, expected
290
+ return None, None
291
+
292
+
293
+ def installed_file_location(node: LoadNode, environment: LoadEnvironment) -> str | None:
294
+ """The deployed artefact's location, as this environment addresses it.
295
+
296
+ Resolved rather than composed, so the desktop, the emulator and a Fabric
297
+ session each get their own spelling of the same file from the one place that
298
+ knows how a name becomes a location.
299
+ """
300
+
301
+ resolver = environment.resolver
302
+ files_root = getattr(resolver, "files_root", None)
303
+ if files_root is None or node.primitive_object is None:
304
+ return None
305
+ root = files_root(ItemRef(node.physical_target.name))
306
+ relative = f"{node.primitive_object.schema}/{node.primitive_object.object}"
307
+ return root.join(*relative.split("/")).value
308
+
309
+
310
+ def _module_class(filename: str) -> str | None:
311
+ """``Sales__Order.py`` names class ``Sales__Order``.
312
+
313
+ The same rule the authoring surface applies to a class name and the
314
+ repository parser applies to a filename, so a deployed module's class is
315
+ found by the rule that put it there rather than by importing and looking.
316
+ """
317
+
318
+ if not filename.endswith(".py"):
319
+ return None
320
+ stem = filename[: -len(".py")]
321
+ return stem or None
322
+
323
+
324
+ def _holds(inventory: TargetInventory | None, where) -> bool:
325
+ if inventory is None or where is None:
326
+ return False
327
+ return inventory.has_object(where.schema, where.object, where.object_type)
328
+
329
+
330
+ # --- the dry-run report -------------------------------------------------------
331
+
332
+
333
+ def dry_run_reports(plan: ResolvedLoadPlan) -> tuple[LoadNodeReport, ...]:
334
+ """A resolved plan as node reports, with nothing executed.
335
+
336
+ A validation status is never an execution status: a node that resolved is
337
+ ``validated``, not ``succeeded``, and ``executed`` is false on every one of
338
+ them. Reporting a dry run as a successful load would put a claim in a report
339
+ that nothing performed — and the report shape is otherwise identical to a
340
+ real run's, which is precisely what makes a dry run worth inspecting.
341
+ """
342
+
343
+ blocked = plan.blocked
344
+ reports = []
345
+ for resolved in plan.order:
346
+ node = resolved.node
347
+ causes = blocked.get(node.node_id, frozenset())
348
+ messages = list(resolved.validation_messages)
349
+ if resolved.valid and causes:
350
+ status = BLOCKED
351
+ messages.append(
352
+ error(
353
+ DEPENDENCY_BLOCKED,
354
+ f"{node.node_id} cannot run: "
355
+ + ", ".join(sorted(causes))
356
+ + " did not validate",
357
+ source="load_resolution",
358
+ )
359
+ )
360
+ else:
361
+ status = VALIDATED if resolved.valid else INVALID
362
+ reports.append(
363
+ LoadNodeReport(
364
+ node_id=node.node_id,
365
+ logical_id=str(node.logical_id) if node.logical_id else None,
366
+ physical_target=str(node.physical_target),
367
+ primitive_kind=node.primitive_kind,
368
+ dispatch_location=resolved.dispatch_location,
369
+ status=status,
370
+ executed=False,
371
+ messages=tuple(messages),
372
+ )
373
+ )
374
+ return tuple(reports)
375
+
376
+
377
+ __all__ = [
378
+ "ENDPOINT_SUFFIX",
379
+ "LoadEnvironment",
380
+ "REFRESH_UNSUPPORTED",
381
+ "ResolvedLoadNode",
382
+ "ResolvedLoadPlan",
383
+ "dry_run_reports",
384
+ "installed_file_location",
385
+ "resolve_load_plan",
386
+ ]
weaver/locations.py ADDED
@@ -0,0 +1,164 @@
1
+ """Resolved physical locations.
2
+
3
+ A :class:`Location` is what a workspace resolution produces: the concrete place an
4
+ item, folder or table lives. It exists because ``pathlib.Path`` cannot be the
5
+ common currency — ``Path("abfss://ws@onelake.dfs.fabric.microsoft.com/lh")``
6
+ silently collapses the double slash and yields a broken root with no error.
7
+
8
+ So a location always carries a string and always joins by string. ``.path`` is
9
+ available when, and only when, the location is a filesystem path; asking a URL
10
+ location for a ``Path`` is a mistake worth raising on rather than corrupting.
11
+
12
+ Local resolution (checkpoint 2) produces filesystem locations. Fabric
13
+ resolution (checkpoint 7) will produce ``abfss://`` and OneLake URL locations
14
+ through the same type, so everything downstream is written once.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .errors import IdentityError
23
+
24
+ _URL_MARKER = "://"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Location:
29
+ """One resolved location — a filesystem path or a URL."""
30
+
31
+ value: str
32
+
33
+ def __post_init__(self) -> None:
34
+ if not isinstance(self.value, str):
35
+ raise IdentityError(f"location must be a string, got {type(self.value).__name__}")
36
+ value = self.value.strip()
37
+ if not value:
38
+ raise IdentityError("location must not be empty")
39
+ # One separator, everywhere. A Windows caller reaches this with
40
+ # backslashes — `LocalWorkspace` normalises its root through `Path`, and
41
+ # `str()` of a `WindowsPath` uses them — while everything downstream
42
+ # treats "/" as the only separator: `join`, `name`, and the segment
43
+ # splitting in the Weaver document reader. Left alone, a repository read from a
44
+ # Windows checkout takes its whole path as its catalogue name.
45
+ #
46
+ # Safe against real names: "\" is rejected in object and schema names
47
+ # (see targets._ILLEGAL_IN_NAME), so a backslash here is always a
48
+ # separator. URLs never carry one either.
49
+ value = value.replace("\\", "/")
50
+ if len(value) > 1:
51
+ value = value.rstrip("/")
52
+ object.__setattr__(self, "value", value)
53
+
54
+ @property
55
+ def is_url(self) -> bool:
56
+ return _URL_MARKER in self.value
57
+
58
+ @property
59
+ def path(self) -> Path:
60
+ """The filesystem path. Raises for URL locations."""
61
+
62
+ if self.is_url:
63
+ raise IdentityError(
64
+ f"{self.value!r} is a URL location and has no filesystem path — "
65
+ "use a Store to read or write it"
66
+ )
67
+ return Path(self.value)
68
+
69
+ def join(self, *parts: str) -> "Location":
70
+ """Append path segments. Always a string join, never ``Path``."""
71
+
72
+ joined = self.value
73
+ for part in parts:
74
+ if not isinstance(part, str):
75
+ raise IdentityError(f"location segment must be a string, got {part!r}")
76
+ segment = part.strip().strip("/")
77
+ if not segment:
78
+ raise IdentityError(f"location segment must not be empty: {part!r}")
79
+ joined = f"{joined.rstrip('/')}/{segment}"
80
+ return Location(joined)
81
+
82
+ def __truediv__(self, part: str) -> "Location":
83
+ return self.join(part)
84
+
85
+ @property
86
+ def name(self) -> str:
87
+ """The final segment."""
88
+
89
+ return self.value.rstrip("/").rsplit("/", 1)[-1]
90
+
91
+ def __str__(self) -> str:
92
+ return self.value
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class LakehouseSparkLocation:
97
+ """One destination Lakehouse's physical roots, resolved once.
98
+
99
+ The Spark session is attached to the **Weaver Lakehouse** — that is the fixed
100
+ control-plane context, and it is why Weaver's own catalogue tables are reached
101
+ as ordinary two-part names in schema ``_``. Destination Lakehouses are the
102
+ variable data plane, so they are reached through explicit roots instead, and
103
+ never by making the session point somewhere else.
104
+
105
+ That distinction is what lets one session build several Lakehouses in one
106
+ invocation. Switching the current catalogue between targets would make two
107
+ destinations that share a schema name indistinguishable; resolving each
108
+ target's roots keeps them separate by construction::
109
+
110
+ locations = {
111
+ target: resolver.lakehouse_spark_location(target)
112
+ for target in bound_lakehouse_targets
113
+ }
114
+
115
+ Roots are plain strings rather than :class:`Location` values because this is
116
+ what *Spark* addresses: an ``abfss://`` URL on Fabric, a filesystem path in
117
+ the local emulator. Same contract, different transport.
118
+
119
+ A resolved location is deliberately **not** carried in a build bundle. It is
120
+ derived from the item at install time, because on Fabric it embeds workspace
121
+ and item ids and locally it embeds a temporary directory — and a bundle whose
122
+ identity moved with a temporary path would not be comparable between
123
+ environments (how-does-build-work §15).
124
+ """
125
+
126
+ #: The Lakehouse this resolves, by its logical name.
127
+ item: str
128
+ tables_root: str
129
+ files_root: str
130
+
131
+ def schema_root(self, schema: str) -> str:
132
+ """Where a schema's managed tables live."""
133
+
134
+ return f"{self.tables_root.rstrip('/')}/{_segment(schema)}"
135
+
136
+ def table_path(self, schema: str, name: str) -> str:
137
+ """Where one managed Delta table lives."""
138
+
139
+ return f"{self.schema_root(schema)}/{_segment(name)}"
140
+
141
+ def folder_path(self, schema: str, name: str) -> str:
142
+ """Where one managed folder lives, under the Files area."""
143
+
144
+ return (
145
+ f"{self.files_root.rstrip('/')}/{_segment(schema)}/{_segment(name)}"
146
+ )
147
+
148
+ def __str__(self) -> str:
149
+ return f"{self.item} (tables={self.tables_root}, files={self.files_root})"
150
+
151
+
152
+ def _segment(value: str) -> str:
153
+ """One path segment, checked rather than trusted.
154
+
155
+ These strings are concatenated into paths Spark writes through, so a segment
156
+ that escaped its parent would write outside the Lakehouse it names.
157
+ """
158
+
159
+ segment = value.strip().strip("/")
160
+ if not segment or segment in (".", ".."):
161
+ raise IdentityError(f"path segment must be a real name, got {value!r}")
162
+ if "/" in segment or "\\" in segment:
163
+ raise IdentityError(f"path segment must not contain a separator: {value!r}")
164
+ return segment