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,437 @@
|
|
|
1
|
+
"""Projecting one logical item's catalogue rows from a validated declaration.
|
|
2
|
+
|
|
3
|
+
This is the boundary the whole design turns on. On one side is a declaration
|
|
4
|
+
that has already been read, validated and closed, and a build that has already
|
|
5
|
+
decided which items it is installing. On the other side are rows. Nothing here
|
|
6
|
+
re-reads a source file, imports an object module, or asks a physical table what
|
|
7
|
+
shape it is — every value comes from the validated declaration or from the
|
|
8
|
+
declaration's own resolved graph.
|
|
9
|
+
|
|
10
|
+
**Only bound items are projected.** Objects owned by unbound items are *out of
|
|
11
|
+
scope*, not deleted: a build has no opinion about an item it was not asked to
|
|
12
|
+
install, and projecting them would invite a comparison that removed them.
|
|
13
|
+
|
|
14
|
+
**Every row is stamped with the same item scope.** The scope is passed in once
|
|
15
|
+
and applied to every row, rather than each projector deriving it — a projector
|
|
16
|
+
that derived it differently would silently write into the wrong installation,
|
|
17
|
+
which the renderer then refuses.
|
|
18
|
+
|
|
19
|
+
**An alias is not a dependency.** A dependency row records the reference exactly
|
|
20
|
+
as the author wrote it, and :data:`~weaver.catalogue.tables.ALIAS` records what
|
|
21
|
+
the consuming item's alias points at. Joining Dependency, Alias and Registry is
|
|
22
|
+
what yields the estate's whole graph; keeping them apart is what stops one item
|
|
23
|
+
appearing to depend directly on another's physical object.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Iterable, Mapping
|
|
30
|
+
|
|
31
|
+
from ..declaration.metadata import FOLDER, TABLE, VIEW, ObjectId, Reference
|
|
32
|
+
from ..declaration.model import WeaverDocumentId, WeaverItemId, WeaverRepository
|
|
33
|
+
from ..declaration.references import declared_column_notes, resolve_text
|
|
34
|
+
from ..etl import PROCEDURE_TYPE, item_load_artefacts, load_artefacts_by_identity
|
|
35
|
+
from .claims import catalogue_schema
|
|
36
|
+
from .render import InstallationScope, Row, column_set
|
|
37
|
+
from .tables import (
|
|
38
|
+
ALIAS,
|
|
39
|
+
CATALOGUE_TABLES,
|
|
40
|
+
COLUMN_DICTIONARY,
|
|
41
|
+
DEPENDENCY,
|
|
42
|
+
FOLDER_DICTIONARY,
|
|
43
|
+
FOREIGN_KEY_DICTIONARY,
|
|
44
|
+
INDEX_DICTIONARY,
|
|
45
|
+
INSTALLATION,
|
|
46
|
+
KEY_PRIMARY,
|
|
47
|
+
KEY_UNIQUE,
|
|
48
|
+
REGISTRY,
|
|
49
|
+
ROLE_DATA,
|
|
50
|
+
ROLE_LOAD,
|
|
51
|
+
SCHEMA_DICTIONARY,
|
|
52
|
+
TABLE_DICTIONARY,
|
|
53
|
+
CatalogueTable,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Catalogue projection consumes the stable persisted target-kind vocabulary; it
|
|
57
|
+
# does not depend on build-package binding classes. Keeping these values here
|
|
58
|
+
# also prevents importing the build package while catalogue reconciliation is
|
|
59
|
+
# still initialising.
|
|
60
|
+
LAKEHOUSE_TARGET = "lakehouse"
|
|
61
|
+
WAREHOUSE_TARGET = "warehouse"
|
|
62
|
+
|
|
63
|
+
#: How an Weaver document kind names itself in the catalogue. Deliberately a translation
|
|
64
|
+
#: rather than a reuse: Weaver document kinds are title case and the catalogue's vocabulary
|
|
65
|
+
#: is lower case, and pinning the mapping here means a new Weaver document kind must be given
|
|
66
|
+
#: a catalogue meaning rather than leaking one.
|
|
67
|
+
OBJECT_TYPE_FOR_KIND = {FOLDER: "folder", TABLE: "table", VIEW: "view"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class CatalogueProjection:
|
|
72
|
+
"""Every catalogue row one build invocation wants, for one installation."""
|
|
73
|
+
|
|
74
|
+
scope: InstallationScope
|
|
75
|
+
rows: Mapping[str, tuple[Row, ...]]
|
|
76
|
+
|
|
77
|
+
def for_table(self, table: CatalogueTable) -> tuple[Row, ...]:
|
|
78
|
+
return self.rows.get(table.name, ())
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def total(self) -> int:
|
|
82
|
+
return sum(len(rows) for rows in self.rows.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def project_item_catalogue(
|
|
86
|
+
repository: WeaverRepository,
|
|
87
|
+
*,
|
|
88
|
+
item: WeaverItemId,
|
|
89
|
+
retained: Iterable[WeaverDocumentId],
|
|
90
|
+
) -> CatalogueProjection:
|
|
91
|
+
"""One item's catalogue rows, from the declaration and nothing else.
|
|
92
|
+
|
|
93
|
+
Every value here is a function of *source*: what the item declares, what it
|
|
94
|
+
aliases, what its documents describe. Nothing about a build, a binding or a
|
|
95
|
+
target reaches it. That is what makes the projection something a developer
|
|
96
|
+
keeps correct by adding a declaration, rather than a fixture someone has to
|
|
97
|
+
remember to update alongside one.
|
|
98
|
+
|
|
99
|
+
Not an *installation* projection, despite what this was once called. It says
|
|
100
|
+
what the repository declares; whether any of it has been installed, where,
|
|
101
|
+
and by which Weaver are separate facts composed at publication.
|
|
102
|
+
|
|
103
|
+
**No Registry row is written for an alias destination.** The Alias row —
|
|
104
|
+
this name points at that object — is a declaration and belongs here. The
|
|
105
|
+
Registry row is a certification that a physical object exists at that name
|
|
106
|
+
*and what it is*, and an alias is a view in a Warehouse and a table in a
|
|
107
|
+
Lakehouse. That cannot be answered without a binding, so it is not answered
|
|
108
|
+
here; see :func:`project_alias_registry`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
scope = InstallationScope(item.item_type, item.item_name)
|
|
112
|
+
retained = tuple(sorted(set(retained), key=str))
|
|
113
|
+
if any(identity.item != item for identity in retained):
|
|
114
|
+
raise ValueError(f"item projection {item} received a document owned elsewhere")
|
|
115
|
+
|
|
116
|
+
# ``retained`` carries every kind of registered object. Splitting them here
|
|
117
|
+
# rather than at the call site keeps the caller from having to know which is
|
|
118
|
+
# which — the repository already does.
|
|
119
|
+
alias_by_destination = {
|
|
120
|
+
alias.destination: alias
|
|
121
|
+
for alias in repository.aliases
|
|
122
|
+
if alias.destination.item == item
|
|
123
|
+
}
|
|
124
|
+
retained_aliases = tuple(
|
|
125
|
+
alias_by_destination[identity]
|
|
126
|
+
for identity in retained
|
|
127
|
+
if identity in alias_by_destination
|
|
128
|
+
)
|
|
129
|
+
loads = load_artefacts_by_identity(item_load_artefacts(repository, item=item))
|
|
130
|
+
retained_loads = tuple(
|
|
131
|
+
loads[identity] for identity in retained if identity in loads
|
|
132
|
+
)
|
|
133
|
+
retained = tuple(
|
|
134
|
+
identity
|
|
135
|
+
for identity in retained
|
|
136
|
+
if identity not in alias_by_destination and identity not in loads
|
|
137
|
+
)
|
|
138
|
+
documents = [repository.source_documents[identity] for identity in retained]
|
|
139
|
+
all_documents = tuple(repository.source_documents.values())
|
|
140
|
+
rows: dict[str, list[dict]] = {table.name: [] for table in CATALOGUE_TABLES}
|
|
141
|
+
|
|
142
|
+
for identity, source in zip(retained, documents):
|
|
143
|
+
common = _identity(scope, identity)
|
|
144
|
+
signature = source.effective_signature
|
|
145
|
+
rows[REGISTRY.name].append(
|
|
146
|
+
{
|
|
147
|
+
**common,
|
|
148
|
+
"object_type": OBJECT_TYPE_FOR_KIND[source.kind],
|
|
149
|
+
"object_role": ROLE_DATA,
|
|
150
|
+
"signature": signature,
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
described = _described(
|
|
154
|
+
source,
|
|
155
|
+
all_documents,
|
|
156
|
+
repository,
|
|
157
|
+
)
|
|
158
|
+
if source.kind == FOLDER:
|
|
159
|
+
rows[FOLDER_DICTIONARY.name].append(
|
|
160
|
+
{
|
|
161
|
+
**common,
|
|
162
|
+
**described,
|
|
163
|
+
"file_key": column_set(source.document.file_keys),
|
|
164
|
+
**_behaviour(source),
|
|
165
|
+
"signature": signature,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
rows[TABLE_DICTIONARY.name].append(
|
|
170
|
+
{
|
|
171
|
+
**common,
|
|
172
|
+
"object_type": OBJECT_TYPE_FOR_KIND[source.kind],
|
|
173
|
+
**described,
|
|
174
|
+
"primary_key": column_set(source.document.primary_key),
|
|
175
|
+
"not_null_columns": column_set(source.document.declared_not_null),
|
|
176
|
+
"identity_column": source.document.identity,
|
|
177
|
+
"comparison_columns": column_set(source.document.comparison_columns),
|
|
178
|
+
**_behaviour(source),
|
|
179
|
+
"signature": signature,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
for column_name, note in declared_column_notes(source):
|
|
183
|
+
resolved = resolve_text(
|
|
184
|
+
note,
|
|
185
|
+
owner=source,
|
|
186
|
+
documents=all_documents,
|
|
187
|
+
aliases=repository.aliases,
|
|
188
|
+
)
|
|
189
|
+
rows[COLUMN_DICTIONARY.name].append(
|
|
190
|
+
{
|
|
191
|
+
**common,
|
|
192
|
+
"column_name": column_name,
|
|
193
|
+
"description": resolved.literal,
|
|
194
|
+
"description_reference": resolved.reference,
|
|
195
|
+
"is_identity": column_name == source.document.identity,
|
|
196
|
+
"signature": signature,
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
if source.document.primary_key:
|
|
200
|
+
rows[INDEX_DICTIONARY.name].append(
|
|
201
|
+
{
|
|
202
|
+
**common,
|
|
203
|
+
"index_type": KEY_PRIMARY,
|
|
204
|
+
"column_set": column_set(source.document.primary_key),
|
|
205
|
+
"signature": signature,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
for unique in source.document.unique_keys:
|
|
209
|
+
rows[INDEX_DICTIONARY.name].append(
|
|
210
|
+
{
|
|
211
|
+
**common,
|
|
212
|
+
"index_type": KEY_UNIQUE,
|
|
213
|
+
"column_set": column_set(unique),
|
|
214
|
+
"signature": signature,
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
rows[FOREIGN_KEY_DICTIONARY.name].extend(
|
|
218
|
+
_foreign_keys(source, identity, common, signature)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# A load artefact claims the Registry and nothing else. It has no columns to
|
|
222
|
+
# describe, no keys to record and no dependencies to keep — it is a deployed
|
|
223
|
+
# module or a generated statement, and what the catalogue knows about it is
|
|
224
|
+
# that Weaver installed it and at what signature.
|
|
225
|
+
for artefact in retained_loads:
|
|
226
|
+
rows[REGISTRY.name].append(
|
|
227
|
+
{
|
|
228
|
+
**_identity(scope, artefact.identity),
|
|
229
|
+
"object_type": artefact.object_type,
|
|
230
|
+
"object_role": ROLE_LOAD,
|
|
231
|
+
"signature": artefact.signature,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
retained_set = set(retained)
|
|
236
|
+
for edge in repository.dependency_edges:
|
|
237
|
+
if edge.consumer not in retained_set:
|
|
238
|
+
continue
|
|
239
|
+
source = repository.source_documents[edge.consumer]
|
|
240
|
+
rows[DEPENDENCY.name].append(
|
|
241
|
+
{
|
|
242
|
+
**_identity(scope, edge.consumer),
|
|
243
|
+
"dependency_name": edge.reference,
|
|
244
|
+
"is_within_item": edge.is_within_item,
|
|
245
|
+
"signature": source.effective_signature,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
for alias in repository.aliases:
|
|
250
|
+
if alias.destination.item != item:
|
|
251
|
+
continue
|
|
252
|
+
rows[ALIAS.name].append(
|
|
253
|
+
{
|
|
254
|
+
**_scope(scope),
|
|
255
|
+
"destination_schema_name": _catalogue_schema(alias.destination),
|
|
256
|
+
"destination_object_name": alias.destination.object_id.object,
|
|
257
|
+
"source_item_type": alias.source.item.item_type,
|
|
258
|
+
"source_item_name": alias.source.item.item_name,
|
|
259
|
+
"source_schema_name": _catalogue_schema(alias.source),
|
|
260
|
+
"source_object_name": alias.source.object_id.object,
|
|
261
|
+
"signature": alias.signature,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
used_schemas = sorted(
|
|
266
|
+
{
|
|
267
|
+
(_catalogue_schema(identity), identity.object_id.schema)
|
|
268
|
+
for identity in retained
|
|
269
|
+
}
|
|
270
|
+
| {
|
|
271
|
+
(_catalogue_schema(alias.destination), alias.destination.object_id.schema)
|
|
272
|
+
for alias in retained_aliases
|
|
273
|
+
}
|
|
274
|
+
# A generated load procedure puts a schema into use that no document
|
|
275
|
+
# declares an object in, so it would otherwise be a schema the
|
|
276
|
+
# installation uses and does not describe. A deployed file contributes
|
|
277
|
+
# nothing here: its schema half is a path, and the namespace it sits in
|
|
278
|
+
# is described by the folder document that owns the tree.
|
|
279
|
+
| {
|
|
280
|
+
(artefact.identity.object_id.schema, artefact.identity.object_id.schema)
|
|
281
|
+
for artefact in retained_loads
|
|
282
|
+
if artefact.object_type == PROCEDURE_TYPE
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
item_model = next(model for model in repository.items if model.identity == item)
|
|
286
|
+
schemas = {identity.schema: identity for identity in item_model.schemas}
|
|
287
|
+
for catalogue_name, declared_name in used_schemas:
|
|
288
|
+
schema = repository.schema_documents[schemas[declared_name]]
|
|
289
|
+
rows[SCHEMA_DICTIONARY.name].append(
|
|
290
|
+
{
|
|
291
|
+
**_scope(scope),
|
|
292
|
+
"schema_name": catalogue_name,
|
|
293
|
+
"description": schema.description,
|
|
294
|
+
"description_reference": None,
|
|
295
|
+
"signature": schema.source_hash,
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return CatalogueProjection(
|
|
300
|
+
scope=scope,
|
|
301
|
+
rows={name: tuple(values) for name, values in rows.items()},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def project_alias_registry(
|
|
306
|
+
repository: WeaverRepository,
|
|
307
|
+
*,
|
|
308
|
+
item: WeaverItemId,
|
|
309
|
+
retained: Iterable[WeaverDocumentId],
|
|
310
|
+
target_kind: str,
|
|
311
|
+
) -> tuple[Row, ...]:
|
|
312
|
+
"""Registry rows certifying this item's alias destinations, given a binding.
|
|
313
|
+
|
|
314
|
+
Separate from :func:`project_item_catalogue` because it is the one part of an
|
|
315
|
+
item's catalogue that cannot be derived from source: an alias is registered
|
|
316
|
+
as the thing it physically *is*, and that depends on what it was bound to.
|
|
317
|
+
|
|
318
|
+
Requiring the kind rather than defaulting it is deliberate. A default would
|
|
319
|
+
write a *wrong* Registry row quietly — a Warehouse alias recorded as a table —
|
|
320
|
+
and a wrong certification is the one failure the catalogue must never produce
|
|
321
|
+
on its own.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
scope = InstallationScope(item.item_type, item.item_name)
|
|
325
|
+
wanted = set(retained)
|
|
326
|
+
return tuple(
|
|
327
|
+
{
|
|
328
|
+
**_identity(scope, alias.destination),
|
|
329
|
+
"object_type": _alias_object_type(alias.destination, target_kind),
|
|
330
|
+
"object_role": ROLE_DATA,
|
|
331
|
+
"signature": alias.signature,
|
|
332
|
+
}
|
|
333
|
+
for alias in sorted(
|
|
334
|
+
(
|
|
335
|
+
alias
|
|
336
|
+
for alias in repository.aliases
|
|
337
|
+
if alias.destination.item == item and alias.destination in wanted
|
|
338
|
+
),
|
|
339
|
+
key=lambda alias: str(alias.destination),
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _alias_object_type(destination: WeaverDocumentId, target_kind: str) -> str:
|
|
345
|
+
"""What an alias destination physically *is*, in the catalogue's vocabulary.
|
|
346
|
+
|
|
347
|
+
Not a type of its own. An alias is registered as the thing it actually is —
|
|
348
|
+
a folder under ``Files``, a view in a Warehouse, a table in a Lakehouse —
|
|
349
|
+
because to every reader of the catalogue that is what it is, and because the
|
|
350
|
+
operations that matter (does it exist, how is it addressed, how is it
|
|
351
|
+
dropped) are the ordinary ones for that type. That a Lakehouse table alias
|
|
352
|
+
happens to be implemented as a OneLake shortcut is execution detail, the way
|
|
353
|
+
a managed table's storage layout is.
|
|
354
|
+
|
|
355
|
+
Its *alias-ness* is not lost: :data:`~weaver.catalogue.tables.ALIAS` records
|
|
356
|
+
it, and that is the only place that does.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
if destination.is_files:
|
|
360
|
+
return OBJECT_TYPE_FOR_KIND[FOLDER]
|
|
361
|
+
if target_kind == WAREHOUSE_TARGET:
|
|
362
|
+
return OBJECT_TYPE_FOR_KIND[VIEW]
|
|
363
|
+
return OBJECT_TYPE_FOR_KIND[TABLE]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _scope(scope: InstallationScope) -> dict[str, str]:
|
|
367
|
+
return dict(scope.values)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _catalogue_schema(identity: WeaverDocumentId) -> str:
|
|
371
|
+
return catalogue_schema(identity)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _identity(scope: InstallationScope, identity: WeaverDocumentId) -> dict:
|
|
375
|
+
return {
|
|
376
|
+
**_scope(scope),
|
|
377
|
+
"schema_name": _catalogue_schema(identity),
|
|
378
|
+
"object_name": identity.object_id.object,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _described(source, all_documents, repository) -> dict:
|
|
383
|
+
description = resolve_text(
|
|
384
|
+
source.document.description,
|
|
385
|
+
owner=source,
|
|
386
|
+
documents=all_documents,
|
|
387
|
+
aliases=repository.aliases,
|
|
388
|
+
)
|
|
389
|
+
lineage = resolve_text(
|
|
390
|
+
source.document.lineage,
|
|
391
|
+
owner=source,
|
|
392
|
+
documents=all_documents,
|
|
393
|
+
aliases=repository.aliases,
|
|
394
|
+
)
|
|
395
|
+
return {
|
|
396
|
+
"description": description.literal,
|
|
397
|
+
"description_reference": description.reference,
|
|
398
|
+
"lineage": lineage.literal,
|
|
399
|
+
"lineage_reference": lineage.reference,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _behaviour(source) -> dict:
|
|
404
|
+
return {
|
|
405
|
+
"is_incremental": source.document.is_incremental,
|
|
406
|
+
"is_static": source.document.static,
|
|
407
|
+
"prohibit_rebuild": source.document.prohibit_rebuild,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _foreign_keys(source, identity, common, signature) -> list[dict]:
|
|
412
|
+
rows = []
|
|
413
|
+
for key in source.document.foreign_keys:
|
|
414
|
+
reference = key.logical_reference or Reference(
|
|
415
|
+
schema=key.reference.schema,
|
|
416
|
+
object=key.reference.object,
|
|
417
|
+
)
|
|
418
|
+
parent_item = (
|
|
419
|
+
WeaverItemId(reference.item_type, reference.item_name)
|
|
420
|
+
if reference.is_item_qualified
|
|
421
|
+
else identity.item
|
|
422
|
+
)
|
|
423
|
+
rows.append(
|
|
424
|
+
{
|
|
425
|
+
**common,
|
|
426
|
+
"column_set": column_set(key.columns),
|
|
427
|
+
"reference_item_type": parent_item.item_type,
|
|
428
|
+
"reference_item_name": parent_item.item_name,
|
|
429
|
+
"reference_schema_name": (
|
|
430
|
+
f"Files/{reference.schema}" if reference.is_files else reference.schema
|
|
431
|
+
),
|
|
432
|
+
"reference_object_name": reference.object,
|
|
433
|
+
"reference_column_set": column_set(key.reference_columns),
|
|
434
|
+
"signature": signature,
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
return rows
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Reading the catalogue through its fixed expected schema.
|
|
2
|
+
|
|
3
|
+
A build compares what the catalogue holds with what the repository now declares,
|
|
4
|
+
so it has to read tables that may not be there yet and may be an older shape than
|
|
5
|
+
this Weaver knows about. Both are ordinary states, not faults:
|
|
6
|
+
|
|
7
|
+
**Bootstrap.** The very first build has no catalogue at all — the tables are
|
|
8
|
+
created by the build that then writes to them. A missing table reads as no rows.
|
|
9
|
+
|
|
10
|
+
**Upgrade.** A Weaver that adds a column runs against a catalogue built by an
|
|
11
|
+
older one. A missing column reads as a typed null, so newer code compares
|
|
12
|
+
successfully against an older shape and the next build repairs it. An unexpected
|
|
13
|
+
extra column is ignored, which is the other half: an older Weaver must not choke
|
|
14
|
+
on a catalogue a newer one extended.
|
|
15
|
+
|
|
16
|
+
What must **not** happen is a genuine failure being read as an empty catalogue. A
|
|
17
|
+
permission error, a corrupt Delta log, an unavailable store or a broken session
|
|
18
|
+
that returned "no rows" would make the next build's comparison conclude that
|
|
19
|
+
everything is new — and, once drop policy lands, that everything the catalogue no
|
|
20
|
+
longer mentions may be removed. So the absence check is deliberately narrow: only
|
|
21
|
+
Spark's own ``TABLE_OR_VIEW_NOT_FOUND`` is absence. Everything else propagates.
|
|
22
|
+
|
|
23
|
+
That asymmetry is the whole design of this module. Tolerance is cheap when it is
|
|
24
|
+
specific and dangerous when it is a bare ``except``.
|
|
25
|
+
|
|
26
|
+
**A read names the Lakehouse it reads from.** The catalogue lives in the Weaver
|
|
27
|
+
Lakehouse; a build's other work is aimed at a destination Lakehouse; one session
|
|
28
|
+
serves both. So a read takes a :class:`~weaver.spark.catalogue.SparkCatalogue`
|
|
29
|
+
rather than a bare session — asking "the catalogue" of whatever the session is
|
|
30
|
+
attached to would answer for the wrong Lakehouse, and answer *plausibly*, which
|
|
31
|
+
is the failure mode this whole area exists to remove.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from .render import InstallationScope, Row, identifier, qualified_name
|
|
39
|
+
from .tables import CatalogueTable
|
|
40
|
+
|
|
41
|
+
#: Spark's error class for a table or view that is not registered. A missing
|
|
42
|
+
#: *schema* reports the same class, which is what we want — an installation whose
|
|
43
|
+
#: schema `_` has never been created is as absent as one whose table has not.
|
|
44
|
+
_ABSENT = frozenset({"TABLE_OR_VIEW_NOT_FOUND"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_absent(exception: Exception) -> bool:
|
|
48
|
+
"""Whether this exception means "not created yet" rather than "went wrong".
|
|
49
|
+
|
|
50
|
+
Keyed on Spark's error class rather than on message text, so a reworded
|
|
51
|
+
message cannot silently turn an infrastructure failure into an empty read. The
|
|
52
|
+
message is consulted only when no class is available, which is the case for a
|
|
53
|
+
session or connector that raises a plain error.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
error_class = getattr(exception, "getErrorClass", None)
|
|
57
|
+
if callable(error_class):
|
|
58
|
+
try:
|
|
59
|
+
found = error_class()
|
|
60
|
+
except Exception: # pragma: no cover - defensive; a broken accessor is not absence
|
|
61
|
+
found = None
|
|
62
|
+
if found:
|
|
63
|
+
return found in _ABSENT
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_table(
|
|
68
|
+
catalogue: Any,
|
|
69
|
+
table: CatalogueTable,
|
|
70
|
+
*,
|
|
71
|
+
scope: InstallationScope | None = None,
|
|
72
|
+
) -> tuple[Row, ...]:
|
|
73
|
+
"""Every row of one catalogue table, projected through its expected schema.
|
|
74
|
+
|
|
75
|
+
``catalogue`` is a :class:`~weaver.spark.catalogue.SparkCatalogue` bound to
|
|
76
|
+
the Weaver Lakehouse — the read has to say where the catalogue is, because
|
|
77
|
+
the session is not necessarily pointed at it.
|
|
78
|
+
|
|
79
|
+
``scope`` narrows the read to one installation, which is what a build wants:
|
|
80
|
+
it compares and writes within one ``(repository, target_type)`` and has no
|
|
81
|
+
business seeing another's rows.
|
|
82
|
+
|
|
83
|
+
Returns plain dictionaries of ``str``/``bool``/``None`` — the same shape the
|
|
84
|
+
projection produces, so the two can be compared directly.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if catalogue is None:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"reading {table.qualified} needs a Spark catalogue bound to the Weaver "
|
|
90
|
+
"Lakehouse — the catalogue lives there, and a session alone does not "
|
|
91
|
+
"say which Lakehouse that is"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
name = catalogue.expand(qualified_name(table))
|
|
95
|
+
try:
|
|
96
|
+
existing = catalogue.spark.table(name).columns
|
|
97
|
+
except Exception as exception:
|
|
98
|
+
if _is_absent(exception):
|
|
99
|
+
return ()
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
# Case-folded, because the local metastore lowercases column names where
|
|
103
|
+
# Fabric preserves them, and a column's presence must not depend on that.
|
|
104
|
+
present = {column.lower(): column for column in existing}
|
|
105
|
+
|
|
106
|
+
projected = ", ".join(
|
|
107
|
+
_projected_column(column, present.get(column.name.lower()))
|
|
108
|
+
for column in table.columns
|
|
109
|
+
)
|
|
110
|
+
where = ""
|
|
111
|
+
if scope is not None:
|
|
112
|
+
where = f" WHERE {scope.predicate}"
|
|
113
|
+
|
|
114
|
+
rows = catalogue.spark.sql(f"SELECT {projected} FROM {name}{where}").collect()
|
|
115
|
+
return tuple(row.asDict() for row in rows)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _projected_column(column, actual: str | None) -> str:
|
|
119
|
+
"""One column of the expected schema, as a select expression.
|
|
120
|
+
|
|
121
|
+
Rendered as SQL rather than built with ``pyspark.sql.functions`` so this module
|
|
122
|
+
names no Spark API — the core stays importable without PySpark, and a session
|
|
123
|
+
is only ever duck-typed. It also keeps the projection inspectable as text.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
if actual is None:
|
|
127
|
+
# Older shape: give this Weaver the column it expects, as a typed null.
|
|
128
|
+
return f"CAST(NULL AS {column.type.upper()}) AS {identifier(column.name)}"
|
|
129
|
+
# Cast even when present: an older catalogue may have stored a boolean as a
|
|
130
|
+
# string, and a comparison against a projected boolean would then differ for a
|
|
131
|
+
# row that has not actually changed.
|
|
132
|
+
return (
|
|
133
|
+
f"CAST({identifier(actual)} AS {column.type.upper()}) "
|
|
134
|
+
f"AS {identifier(column.name)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_installation(
|
|
139
|
+
catalogue: Any, *, scope: InstallationScope, tables=None
|
|
140
|
+
) -> dict[str, tuple[Row, ...]]:
|
|
141
|
+
"""Every catalogue table, read for one installation.
|
|
142
|
+
|
|
143
|
+
Keyed by table name, so a caller compares table by table against the
|
|
144
|
+
projection without repeating the scope — which it could otherwise forget.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
from .tables import CATALOGUE_TABLES
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
table.name: read_table(catalogue, table, scope=scope)
|
|
151
|
+
for table in (tables if tables is not None else CATALOGUE_TABLES)
|
|
152
|
+
}
|