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,448 @@
|
|
|
1
|
+
"""Pure logical repository, item and document identities.
|
|
2
|
+
|
|
3
|
+
These values describe authored Weaver structure. They deliberately know
|
|
4
|
+
nothing about Fabric item names, workspaces, stores or build execution: those are
|
|
5
|
+
physical bindings applied later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Mapping
|
|
13
|
+
|
|
14
|
+
from ..errors import DiscoveryError, IdentityError
|
|
15
|
+
from ..locations import Location
|
|
16
|
+
from .metadata import ObjectId
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .schemas import SchemaSes
|
|
20
|
+
from .source import SourceDocument
|
|
21
|
+
|
|
22
|
+
LAKEHOUSE = "Lakehouse"
|
|
23
|
+
WAREHOUSE = "Warehouse"
|
|
24
|
+
ITEM_TYPES = frozenset({LAKEHOUSE, WAREHOUSE})
|
|
25
|
+
FILES = "Files"
|
|
26
|
+
|
|
27
|
+
#: What an identity's two parts *mean*, which is what decides how they are
|
|
28
|
+
#: validated and spelled. Weaver has one identity — a schema and an object
|
|
29
|
+
#: within an item — and three kinds of target wear it differently:
|
|
30
|
+
#:
|
|
31
|
+
#: ``OBJECT`` ``Sales`` + ``Customer``: a table, view or folder.
|
|
32
|
+
#: ``FILE`` ``_/Load/lib`` + ``dates.py``: the containing path is the
|
|
33
|
+
#: schema and the complete leaf filename is the object.
|
|
34
|
+
#: ``PROCEDURE`` ``_`` + ``Load Sales.Customer``: an ordinary schema, and an
|
|
35
|
+
#: object name that carries the dot and space of the object it
|
|
36
|
+
#: loads.
|
|
37
|
+
#:
|
|
38
|
+
#: Validation branches on this rather than assuming table-style naming
|
|
39
|
+
#: everywhere, and the Registry stores the real logical target name rather than
|
|
40
|
+
#: something encoded to fit one validator.
|
|
41
|
+
OBJECT_SHAPE = "object"
|
|
42
|
+
FILE_SHAPE = "file"
|
|
43
|
+
PROCEDURE_SHAPE = "procedure"
|
|
44
|
+
SHAPES = (OBJECT_SHAPE, FILE_SHAPE, PROCEDURE_SHAPE)
|
|
45
|
+
|
|
46
|
+
#: How a non-object shape marks itself in the one-line spelling. A file's schema
|
|
47
|
+
#: is a path and its object carries an extension, so ``Schema.Object`` cannot
|
|
48
|
+
#: tell the two halves apart without being told which shape it is reading.
|
|
49
|
+
_SHAPE_MARKERS = {FILE_SHAPE: "file:", PROCEDURE_SHAPE: "procedure:"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _logical_name(value: object, *, what: str) -> str:
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
|
|
55
|
+
if not value or value != value.strip():
|
|
56
|
+
raise IdentityError(f"{what} must be a non-empty name without surrounding whitespace")
|
|
57
|
+
if any(character in value for character in ("/", "\\", ".", ":")):
|
|
58
|
+
raise IdentityError(f"{what} must be one logical name, got {value!r}")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _relative_path(value: object, *, what: str) -> str:
|
|
63
|
+
"""One relative, canonical path — a file identity's schema half.
|
|
64
|
+
|
|
65
|
+
A file's schema is where it sits, so it may contain ``/``. Everything that
|
|
66
|
+
would make it ambiguous or let it escape its root may not: an absolute path,
|
|
67
|
+
a backslash, an empty component, or a ``.``/``..`` component. The result is
|
|
68
|
+
a path that joins onto a root exactly once and means the same thing however
|
|
69
|
+
it is read back.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
if not isinstance(value, str):
|
|
73
|
+
raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
|
|
74
|
+
if not value or value != value.strip():
|
|
75
|
+
raise IdentityError(f"{what} must be a non-empty path without surrounding whitespace")
|
|
76
|
+
if "\\" in value:
|
|
77
|
+
raise IdentityError(f"{what} must use '/' between components, got {value!r}")
|
|
78
|
+
components = value.split("/")
|
|
79
|
+
if any(not component for component in components):
|
|
80
|
+
raise IdentityError(f"{what} must be relative and canonical, got {value!r}")
|
|
81
|
+
if any(component in (".", "..") for component in components):
|
|
82
|
+
raise IdentityError(f"{what} must not contain '.' or '..', got {value!r}")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _file_name(value: object, *, what: str) -> str:
|
|
87
|
+
"""One complete leaf filename, extension included — a file identity's object."""
|
|
88
|
+
|
|
89
|
+
if not isinstance(value, str):
|
|
90
|
+
raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
|
|
91
|
+
if not value or value != value.strip():
|
|
92
|
+
raise IdentityError(f"{what} must be a non-empty name without surrounding whitespace")
|
|
93
|
+
if "/" in value or "\\" in value:
|
|
94
|
+
raise IdentityError(f"{what} must be one filename, got {value!r}")
|
|
95
|
+
if value in (".", ".."):
|
|
96
|
+
raise IdentityError(f"{what} must name a file, got {value!r}")
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _procedure_name(value: object, *, what: str) -> str:
|
|
101
|
+
"""One procedure name, which carries the identity of what it loads.
|
|
102
|
+
|
|
103
|
+
``Load Sales.Customer`` is the real name of the real object, so the dot and
|
|
104
|
+
the space are part of it rather than something to encode away. It is still
|
|
105
|
+
one name in one schema, so a path separator is refused.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
if not isinstance(value, str):
|
|
109
|
+
raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
|
|
110
|
+
if not value or value != value.strip():
|
|
111
|
+
raise IdentityError(f"{what} must be a non-empty name without surrounding whitespace")
|
|
112
|
+
if any(character in value for character in ("/", "\\")):
|
|
113
|
+
raise IdentityError(f"{what} must be one object name, got {value!r}")
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _item_type(value: object) -> str:
|
|
118
|
+
if not isinstance(value, str) or value not in ITEM_TYPES:
|
|
119
|
+
expected = ", ".join(sorted(ITEM_TYPES))
|
|
120
|
+
raise IdentityError(f"item type must be exactly one of {expected}, got {value!r}")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _split(text: object, *, what: str) -> tuple[str, ...]:
|
|
125
|
+
if not isinstance(text, str):
|
|
126
|
+
raise IdentityError(f"{what} must be a string, got {type(text).__name__}")
|
|
127
|
+
if not text or text != text.strip():
|
|
128
|
+
raise IdentityError(f"{what} must not be empty or padded with whitespace")
|
|
129
|
+
return tuple(text.split("/"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _object_id(text: str) -> ObjectId:
|
|
133
|
+
if text.count(".") != 1:
|
|
134
|
+
raise IdentityError(f"object identity must be Schema.Object, got {text!r}")
|
|
135
|
+
schema, object_name = text.split(".")
|
|
136
|
+
return ObjectId(
|
|
137
|
+
schema=_logical_name(schema, what="schema name"),
|
|
138
|
+
object=_logical_name(object_name, what="object name"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, order=True)
|
|
143
|
+
class WeaverItemId:
|
|
144
|
+
"""An exact-case logical item identity: ``ItemType/ItemName``."""
|
|
145
|
+
|
|
146
|
+
item_type: str
|
|
147
|
+
item_name: str
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
object.__setattr__(self, "item_type", _item_type(self.item_type))
|
|
151
|
+
object.__setattr__(
|
|
152
|
+
self, "item_name", _logical_name(self.item_name, what="item name")
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def parse(cls, text: str) -> "WeaverItemId":
|
|
157
|
+
parts = _split(text, what="item identity")
|
|
158
|
+
if len(parts) != 2:
|
|
159
|
+
raise IdentityError(
|
|
160
|
+
f"item identity must be ItemType/ItemName, got {text!r}"
|
|
161
|
+
)
|
|
162
|
+
return cls(parts[0], parts[1])
|
|
163
|
+
|
|
164
|
+
def __str__(self) -> str:
|
|
165
|
+
return f"{self.item_type}/{self.item_name}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, order=True)
|
|
169
|
+
class WeaverSchemaId:
|
|
170
|
+
"""An item-owned schema identity."""
|
|
171
|
+
|
|
172
|
+
item: WeaverItemId
|
|
173
|
+
schema: str
|
|
174
|
+
|
|
175
|
+
def __post_init__(self) -> None:
|
|
176
|
+
object.__setattr__(self, "schema", _logical_name(self.schema, what="schema name"))
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def parse(cls, text: str) -> "WeaverSchemaId":
|
|
180
|
+
parts = _split(text, what="schema identity")
|
|
181
|
+
if len(parts) != 3:
|
|
182
|
+
raise IdentityError(
|
|
183
|
+
f"schema identity must be ItemType/ItemName/Schema, got {text!r}"
|
|
184
|
+
)
|
|
185
|
+
return cls(WeaverItemId(parts[0], parts[1]), parts[2])
|
|
186
|
+
|
|
187
|
+
def __str__(self) -> str:
|
|
188
|
+
return f"{self.item}/{self.schema}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, order=True)
|
|
192
|
+
class WeaverDocumentId:
|
|
193
|
+
"""An item-qualified target identity: one schema and one object, in one item.
|
|
194
|
+
|
|
195
|
+
One identity model for everything Weaver builds. A table, a deployed Python
|
|
196
|
+
module and a generated stored procedure are all *a schema and an object
|
|
197
|
+
inside an item* — what differs is only the shape of those two parts, which
|
|
198
|
+
:data:`SHAPES` names and which decides both how they are validated and how
|
|
199
|
+
they are spelled on one line. The Registry stores the two real parts, so
|
|
200
|
+
nothing is encoded to fit a validator and nothing has to be decoded to be
|
|
201
|
+
used.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
item: WeaverItemId
|
|
205
|
+
object_id: ObjectId
|
|
206
|
+
is_files: bool = False
|
|
207
|
+
shape: str = OBJECT_SHAPE
|
|
208
|
+
|
|
209
|
+
def __post_init__(self) -> None:
|
|
210
|
+
if self.shape not in SHAPES:
|
|
211
|
+
expected = ", ".join(SHAPES)
|
|
212
|
+
raise IdentityError(
|
|
213
|
+
f"identity shape must be one of {expected}, got {self.shape!r}"
|
|
214
|
+
)
|
|
215
|
+
if self.shape == FILE_SHAPE:
|
|
216
|
+
schema = _relative_path(self.object_id.schema, what="file path")
|
|
217
|
+
name = _file_name(self.object_id.object, what="file name")
|
|
218
|
+
elif self.shape == PROCEDURE_SHAPE:
|
|
219
|
+
schema = _logical_name(self.object_id.schema, what="schema name")
|
|
220
|
+
name = _procedure_name(self.object_id.object, what="object name")
|
|
221
|
+
else:
|
|
222
|
+
schema = _logical_name(self.object_id.schema, what="schema name")
|
|
223
|
+
name = _logical_name(self.object_id.object, what="object name")
|
|
224
|
+
object.__setattr__(self, "object_id", ObjectId(schema=schema, object=name))
|
|
225
|
+
if self.is_files and self.shape != OBJECT_SHAPE:
|
|
226
|
+
raise IdentityError(
|
|
227
|
+
"the Files/ prefix belongs to a Folder document; a "
|
|
228
|
+
f"{self.shape} identity carries its own location"
|
|
229
|
+
)
|
|
230
|
+
if self.is_files and self.item.item_type != LAKEHOUSE:
|
|
231
|
+
raise IdentityError("Files documents may only belong to a Lakehouse item")
|
|
232
|
+
if self.shape == FILE_SHAPE and self.item.item_type != LAKEHOUSE:
|
|
233
|
+
raise IdentityError("a file identity may only belong to a Lakehouse item")
|
|
234
|
+
if self.shape == PROCEDURE_SHAPE and self.item.item_type != WAREHOUSE:
|
|
235
|
+
raise IdentityError(
|
|
236
|
+
"a stored procedure identity may only belong to a Warehouse item"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def parse(cls, text: str) -> "WeaverDocumentId":
|
|
241
|
+
parts = _split(text, what="document identity")
|
|
242
|
+
if len(parts) >= 4:
|
|
243
|
+
marker = _SHAPE_MARKERS[FILE_SHAPE]
|
|
244
|
+
if parts[2].startswith(marker):
|
|
245
|
+
# ``file:<path>/<name>`` — the last component is the filename and
|
|
246
|
+
# everything before it, marker stripped, is the containing path.
|
|
247
|
+
head = (parts[2][len(marker) :],) + parts[3:-1]
|
|
248
|
+
return cls(
|
|
249
|
+
WeaverItemId(parts[0], parts[1]),
|
|
250
|
+
ObjectId(schema="/".join(head), object=parts[-1]),
|
|
251
|
+
shape=FILE_SHAPE,
|
|
252
|
+
)
|
|
253
|
+
if len(parts) == 4:
|
|
254
|
+
marker = _SHAPE_MARKERS[PROCEDURE_SHAPE]
|
|
255
|
+
if parts[2].startswith(marker):
|
|
256
|
+
return cls(
|
|
257
|
+
WeaverItemId(parts[0], parts[1]),
|
|
258
|
+
ObjectId(schema=parts[2][len(marker) :], object=parts[3]),
|
|
259
|
+
shape=PROCEDURE_SHAPE,
|
|
260
|
+
)
|
|
261
|
+
if parts[2] == FILES:
|
|
262
|
+
return cls(
|
|
263
|
+
WeaverItemId(parts[0], parts[1]),
|
|
264
|
+
_object_id(parts[3]),
|
|
265
|
+
is_files=True,
|
|
266
|
+
)
|
|
267
|
+
if len(parts) == 3:
|
|
268
|
+
return cls(WeaverItemId(parts[0], parts[1]), _object_id(parts[2]))
|
|
269
|
+
raise IdentityError(
|
|
270
|
+
"document identity must be ItemType/ItemName/Schema.Object, "
|
|
271
|
+
"Lakehouse/ItemName/Files/Schema.Object, "
|
|
272
|
+
"Lakehouse/ItemName/file:Path/Name.ext or "
|
|
273
|
+
f"Warehouse/ItemName/procedure:Schema/Object, got {text!r}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def parse_local(cls, item: "WeaverItemId", text: str) -> "WeaverDocumentId":
|
|
278
|
+
"""Parse the item-relative spelling — the inverse of :attr:`relative`.
|
|
279
|
+
|
|
280
|
+
Used where the item is already known from context, such as an item's own
|
|
281
|
+
``alias.yml``, so the declaration does not repeat it.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
parts = _split(text, what="document identity")
|
|
285
|
+
if len(parts) == 1:
|
|
286
|
+
return cls(item, _object_id(parts[0]))
|
|
287
|
+
if len(parts) == 2 and parts[0] == FILES:
|
|
288
|
+
return cls(item, _object_id(parts[1]), is_files=True)
|
|
289
|
+
raise IdentityError(
|
|
290
|
+
"an item-relative document identity must be Schema.Object or "
|
|
291
|
+
f"Files/Schema.Object, got {text!r}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def relative(self) -> str:
|
|
296
|
+
if self.shape == FILE_SHAPE:
|
|
297
|
+
marker = _SHAPE_MARKERS[FILE_SHAPE]
|
|
298
|
+
return f"{marker}{self.object_id.schema}/{self.object_id.object}"
|
|
299
|
+
if self.shape == PROCEDURE_SHAPE:
|
|
300
|
+
marker = _SHAPE_MARKERS[PROCEDURE_SHAPE]
|
|
301
|
+
return f"{marker}{self.object_id.schema}/{self.object_id.object}"
|
|
302
|
+
prefix = f"{FILES}/" if self.is_files else ""
|
|
303
|
+
return f"{prefix}{self.object_id.qualified}"
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def is_load_artefact(self) -> bool:
|
|
307
|
+
"""Whether this identity names something a load layer produces.
|
|
308
|
+
|
|
309
|
+
The two load shapes against the one structural shape. Asked wherever a
|
|
310
|
+
selection has to be partitioned, so that the question is answered from
|
|
311
|
+
the identity rather than by each caller keeping its own set.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
return self.shape in (FILE_SHAPE, PROCEDURE_SHAPE)
|
|
315
|
+
|
|
316
|
+
def __str__(self) -> str:
|
|
317
|
+
return f"{self.item}/{self.relative}"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True, order=True)
|
|
321
|
+
class RepositoryAlias:
|
|
322
|
+
"""One destination-keyed logical alias from ``alias.yml``."""
|
|
323
|
+
|
|
324
|
+
destination: WeaverDocumentId
|
|
325
|
+
source: WeaverDocumentId
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def signature(self) -> str:
|
|
329
|
+
"""What this alias *is*, hashed — and nothing about what it points to.
|
|
330
|
+
|
|
331
|
+
An alias declares one thing: this destination stands for that source. So
|
|
332
|
+
its signature is the pair, and only the pair. The source document's own
|
|
333
|
+
content is deliberately absent: a rebuilt source does not redefine the
|
|
334
|
+
alias, and treating it as a change would replace every downstream
|
|
335
|
+
shortcut whenever a table was reloaded.
|
|
336
|
+
|
|
337
|
+
A source that was rebuilt is still a reason to remake the alias — but
|
|
338
|
+
that is freshness, answered by comparing build epochs in the Registry,
|
|
339
|
+
not by this signature. Keeping the two apart is what lets an unchanged
|
|
340
|
+
alias over an unchanged source be left alone.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
declaration = f"{self.destination}\0{self.source}".encode("utf-8")
|
|
344
|
+
return hashlib.sha256(declaration).hexdigest()
|
|
345
|
+
|
|
346
|
+
def __str__(self) -> str:
|
|
347
|
+
return f"{self.destination}: {self.source}"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True, order=True)
|
|
351
|
+
class ItemDependency:
|
|
352
|
+
"""One consumer-owned dependency declaration and its logical resolution."""
|
|
353
|
+
|
|
354
|
+
consumer: WeaverDocumentId
|
|
355
|
+
reference: str
|
|
356
|
+
producer: WeaverDocumentId | None = None
|
|
357
|
+
resolution_kind: str = "native" # native | alias | physical
|
|
358
|
+
is_within_item: bool = False
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def uses_alias(self) -> bool:
|
|
362
|
+
return self.resolution_kind == "alias"
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def is_physical(self) -> bool:
|
|
366
|
+
return self.resolution_kind == "physical"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _reject_duplicates(values: tuple[object, ...], *, what: str) -> None:
|
|
370
|
+
exact: set[str] = set()
|
|
371
|
+
folded: dict[str, str] = {}
|
|
372
|
+
for value in values:
|
|
373
|
+
rendered = str(value)
|
|
374
|
+
if rendered in exact:
|
|
375
|
+
raise DiscoveryError(f"{what} is declared more than once: {rendered}")
|
|
376
|
+
prior = folded.get(rendered.casefold())
|
|
377
|
+
if prior is not None and prior != rendered:
|
|
378
|
+
raise DiscoveryError(
|
|
379
|
+
f"{rendered} and {prior} differ only by case and cannot coexist"
|
|
380
|
+
)
|
|
381
|
+
exact.add(rendered)
|
|
382
|
+
folded[rendered.casefold()] = rendered
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass(frozen=True)
|
|
386
|
+
class WeaverItem:
|
|
387
|
+
"""The pure identity-level contents owned by one logical item."""
|
|
388
|
+
|
|
389
|
+
identity: WeaverItemId
|
|
390
|
+
schemas: tuple[WeaverSchemaId, ...] = ()
|
|
391
|
+
documents: tuple[WeaverDocumentId, ...] = ()
|
|
392
|
+
signature: str = ""
|
|
393
|
+
|
|
394
|
+
def __post_init__(self) -> None:
|
|
395
|
+
if any(schema.item != self.identity for schema in self.schemas):
|
|
396
|
+
raise DiscoveryError(f"every schema must belong to item {self.identity}")
|
|
397
|
+
if any(document.item != self.identity for document in self.documents):
|
|
398
|
+
raise DiscoveryError(f"every document must belong to item {self.identity}")
|
|
399
|
+
_reject_duplicates(self.schemas, what="schema")
|
|
400
|
+
_reject_duplicates(self.documents, what="document")
|
|
401
|
+
|
|
402
|
+
def __getitem__(self, relative: str) -> WeaverDocumentId:
|
|
403
|
+
for document in self.documents:
|
|
404
|
+
if document.relative == relative:
|
|
405
|
+
return document
|
|
406
|
+
raise DiscoveryError(f"{relative!r} is not a document in item {self.identity}")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@dataclass(frozen=True)
|
|
410
|
+
class WeaverRepository:
|
|
411
|
+
"""One exact-case logical repository containing typed Weaver items."""
|
|
412
|
+
|
|
413
|
+
name: str
|
|
414
|
+
items: tuple[WeaverItem, ...]
|
|
415
|
+
root: Location | None = None
|
|
416
|
+
source_documents: Mapping[WeaverDocumentId, "SourceDocument"] = field(
|
|
417
|
+
default_factory=dict
|
|
418
|
+
)
|
|
419
|
+
schema_documents: Mapping[WeaverSchemaId, "SchemaSes"] = field(default_factory=dict)
|
|
420
|
+
support_files: tuple[str, ...] = ()
|
|
421
|
+
#: The bytes of the support files a build has to *carry*, by the same
|
|
422
|
+
#: repository-relative path. A ``lib/`` module is authored source that no
|
|
423
|
+
#: Weaver document declares, and the load layer deploys it — so its content
|
|
424
|
+
#: has to reach signature derivation and the bundle without either of them
|
|
425
|
+
#: reopening the repository. Files nothing deploys, such as ``alias.yml``,
|
|
426
|
+
#: are listed in :attr:`support_files` and not held here.
|
|
427
|
+
support_file_contents: Mapping[str, bytes] = field(default_factory=dict)
|
|
428
|
+
signature: str = ""
|
|
429
|
+
aliases: tuple[RepositoryAlias, ...] = ()
|
|
430
|
+
dependency_edges: tuple[ItemDependency, ...] = ()
|
|
431
|
+
dependency_graph: object | None = None
|
|
432
|
+
#: The item-level graph over :attr:`items`, and its topological layers.
|
|
433
|
+
#: The document graph orders work *inside* an item; this orders the items
|
|
434
|
+
#: themselves, and is the outer structure a build is planned against. It is
|
|
435
|
+
#: derived once, here, so no later stage reconstructs an ordering of its own.
|
|
436
|
+
item_graph: object | None = None
|
|
437
|
+
item_layers: tuple[tuple[WeaverItemId, ...], ...] = ()
|
|
438
|
+
generated_files: Mapping[str, bytes] = field(default_factory=dict)
|
|
439
|
+
|
|
440
|
+
def __post_init__(self) -> None:
|
|
441
|
+
object.__setattr__(self, "name", _logical_name(self.name, what="repository name"))
|
|
442
|
+
_reject_duplicates(tuple(item.identity for item in self.items), what="item")
|
|
443
|
+
|
|
444
|
+
def __getitem__(self, identity: str) -> WeaverItem:
|
|
445
|
+
for item in self.items:
|
|
446
|
+
if str(item.identity) == identity:
|
|
447
|
+
return item
|
|
448
|
+
raise DiscoveryError(f"{identity!r} is not an item in repository {self.name!r}")
|