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,660 @@
|
|
|
1
|
+
"""Read and reconcile catalogue claims before bundle generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from ..declaration.metadata import ObjectId
|
|
11
|
+
from ..declaration.model import (
|
|
12
|
+
FILE_SHAPE,
|
|
13
|
+
PROCEDURE_SHAPE,
|
|
14
|
+
WeaverDocumentId,
|
|
15
|
+
WeaverItemId,
|
|
16
|
+
)
|
|
17
|
+
from ..errors import BuildError
|
|
18
|
+
from ..spark.tokens import object_token
|
|
19
|
+
from .claims import CatalogueClaim, catalogue_schema, claim_rules_for_object_type
|
|
20
|
+
from .reader import _is_absent, read_installation, read_table
|
|
21
|
+
from .render import InstallationScope
|
|
22
|
+
from .tables import (
|
|
23
|
+
BUILD_EPOCH,
|
|
24
|
+
CATALOGUE_TABLES,
|
|
25
|
+
INSTALLATION,
|
|
26
|
+
OBJECT_TYPES,
|
|
27
|
+
REGISTRY,
|
|
28
|
+
SCOPE_ITEM_NAME,
|
|
29
|
+
SCOPE_ITEM_TYPE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
#: What a Folder's stored schema carries, so a table and a folder of the same
|
|
33
|
+
#: name stay apart. Only the object shape uses it — see :func:`catalogue_schema`.
|
|
34
|
+
_FILES_PREFIX = "Files/"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, init=False)
|
|
38
|
+
class Catalogue:
|
|
39
|
+
"""The catalogue the build reads and reasons about.
|
|
40
|
+
|
|
41
|
+
One class, whatever produced it. In production it is read from the Weaver
|
|
42
|
+
Lakehouse over Spark; a test may build one directly from Registry rows, or
|
|
43
|
+
from a repository — the state a successful build of that repository would
|
|
44
|
+
have left. That is not a fake: it is the same class the build consumes, begun
|
|
45
|
+
further along, exactly as installing a frozen bundle begins further along
|
|
46
|
+
than building one from a repository.
|
|
47
|
+
|
|
48
|
+
Everything the build's own logic needs is here and nothing else. Incremental
|
|
49
|
+
selection, alias staleness and claim collection all work from ``registered``
|
|
50
|
+
and ``rows``, so they are pure Python and can be proven without standing up a
|
|
51
|
+
Lakehouse to seed a signature.
|
|
52
|
+
|
|
53
|
+
``rows`` is the catalogue's own row data, by item and table. ``registered`` is
|
|
54
|
+
the certified documents derived from the Registry rows — identity, type,
|
|
55
|
+
signature and publication epoch, and no audit columns, because none of the
|
|
56
|
+
build's decisions depend on who wrote a row or when it was touched.
|
|
57
|
+
|
|
58
|
+
``present_tables`` records which catalogue tables physically exist. One line
|
|
59
|
+
of the reconciler needs it and the rule it encodes is not optional: a claim
|
|
60
|
+
can only be raised against a table that is actually there, or reconciliation
|
|
61
|
+
would emit deletes against tables that are not.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
rows: Mapping[WeaverItemId, Mapping[str, tuple[Mapping[str, object], ...]]]
|
|
65
|
+
registered: Mapping[WeaverDocumentId, "RegisteredDocument"]
|
|
66
|
+
present_tables: frozenset[str]
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
rows: Mapping[WeaverItemId, Mapping[str, tuple[Mapping[str, object], ...]]],
|
|
71
|
+
*,
|
|
72
|
+
registered: Mapping[WeaverDocumentId, "RegisteredDocument"] | None = None,
|
|
73
|
+
present_tables: frozenset[str] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
frozen_rows = MappingProxyType(dict(rows))
|
|
76
|
+
object.__setattr__(self, "rows", frozen_rows)
|
|
77
|
+
|
|
78
|
+
object.__setattr__(
|
|
79
|
+
self,
|
|
80
|
+
"registered",
|
|
81
|
+
_registered_documents(frozen_rows)
|
|
82
|
+
if registered is None
|
|
83
|
+
else MappingProxyType(dict(registered)),
|
|
84
|
+
)
|
|
85
|
+
# Defaulting to "every table this catalogue carries rows for" keeps a
|
|
86
|
+
# hand-built catalogue honest without making every caller state it.
|
|
87
|
+
object.__setattr__(
|
|
88
|
+
self,
|
|
89
|
+
"present_tables",
|
|
90
|
+
frozenset(
|
|
91
|
+
present_tables
|
|
92
|
+
if present_tables is not None
|
|
93
|
+
else {
|
|
94
|
+
table
|
|
95
|
+
for tables in frozen_rows.values()
|
|
96
|
+
for table in tables
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def to_mapping(self) -> dict[str, object]:
|
|
102
|
+
"""A versioned JSON-safe representation for a remote state boundary."""
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"format_version": 1,
|
|
106
|
+
"items": [
|
|
107
|
+
{
|
|
108
|
+
"item": str(item),
|
|
109
|
+
"tables": {
|
|
110
|
+
table: [
|
|
111
|
+
{key: _encode_json_value(value) for key, value in row.items()}
|
|
112
|
+
for row in rows
|
|
113
|
+
]
|
|
114
|
+
for table, rows in sorted(tables.items())
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
for item, tables in sorted(self.rows.items(), key=lambda pair: str(pair[0]))
|
|
118
|
+
],
|
|
119
|
+
"present_tables": sorted(self.present_tables),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_mapping(cls, mapping) -> "Catalogue":
|
|
124
|
+
"""Reconstruct catalogue state without querying a target locally."""
|
|
125
|
+
|
|
126
|
+
version = mapping.get("format_version")
|
|
127
|
+
if version != 1:
|
|
128
|
+
raise BuildError(
|
|
129
|
+
f"unsupported catalogue format_version {version!r}; expected 1"
|
|
130
|
+
)
|
|
131
|
+
rows = {
|
|
132
|
+
WeaverItemId.parse(entry["item"]): MappingProxyType(
|
|
133
|
+
{
|
|
134
|
+
table: tuple(
|
|
135
|
+
{
|
|
136
|
+
key: _decode_json_value(value)
|
|
137
|
+
for key, value in row.items()
|
|
138
|
+
}
|
|
139
|
+
for row in table_rows
|
|
140
|
+
)
|
|
141
|
+
for table, table_rows in entry.get("tables", {}).items()
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
for entry in mapping.get("items", ())
|
|
145
|
+
}
|
|
146
|
+
return cls(
|
|
147
|
+
rows=MappingProxyType(rows),
|
|
148
|
+
present_tables=frozenset(mapping.get("present_tables", ())),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# --- constructors ---------------------------------------------------------
|
|
152
|
+
#
|
|
153
|
+
# Symmetrical on purpose. One reads what is persisted, the other derives what
|
|
154
|
+
# the source says should be; both produce this class, which is what lets the
|
|
155
|
+
# two be compared at all.
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_weaver_lakehouse(cls, catalogue: Any, items) -> "Catalogue":
|
|
159
|
+
"""The persisted catalogue, read over Spark from the Weaver Lakehouse."""
|
|
160
|
+
|
|
161
|
+
return read_catalogue_state(catalogue, items)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_repository(cls, repository) -> "Catalogue":
|
|
165
|
+
"""Everything the source declares — the whole logical catalogue.
|
|
166
|
+
|
|
167
|
+
*All* of it, deliberately. Not the subset some build is ready to certify:
|
|
168
|
+
making selection an input would mean the caller had to know what was
|
|
169
|
+
certifiable before the desired state could be described, and the desired
|
|
170
|
+
state would then be a statement about a build rather than about the
|
|
171
|
+
repository. Selection and materialisation *transform* this later — see
|
|
172
|
+
:meth:`retaining` and :meth:`for_targets`.
|
|
173
|
+
|
|
174
|
+
It carries no binding: no target name, no Weaver version, no Installation
|
|
175
|
+
row, no publication epoch, and no Registry certification for an alias
|
|
176
|
+
destination, because an alias is a view in a Warehouse and a table in a
|
|
177
|
+
Lakehouse and this does not know which.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
from ..etl import item_load_artefacts
|
|
181
|
+
from .projection import project_item_catalogue
|
|
182
|
+
|
|
183
|
+
rows = {}
|
|
184
|
+
for model in repository.items:
|
|
185
|
+
item = model.identity
|
|
186
|
+
declared = {
|
|
187
|
+
identity
|
|
188
|
+
for identity in repository.source_documents
|
|
189
|
+
if identity.item == item
|
|
190
|
+
}
|
|
191
|
+
# A load artefact is declared by the source exactly as a document is
|
|
192
|
+
# — derived from it, but a target in its own right — so it belongs in
|
|
193
|
+
# what the repository says should exist.
|
|
194
|
+
declared.update(
|
|
195
|
+
artefact.identity
|
|
196
|
+
for artefact in item_load_artefacts(repository, item=item)
|
|
197
|
+
)
|
|
198
|
+
projection = project_item_catalogue(
|
|
199
|
+
repository, item=item, retained=declared
|
|
200
|
+
)
|
|
201
|
+
rows[item] = MappingProxyType(dict(projection.rows))
|
|
202
|
+
return cls(rows=MappingProxyType(rows))
|
|
203
|
+
|
|
204
|
+
# --- transformations ------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def diff(self, desired: "Catalogue") -> "CatalogueChanges":
|
|
207
|
+
"""How this catalogue would move toward the one ``desired`` describes.
|
|
208
|
+
|
|
209
|
+
Read it as: *persisted* `.diff(` *derived from source* `)`. The result
|
|
210
|
+
reports from both sides and renders statements from ``desired`` alone —
|
|
211
|
+
see :class:`CatalogueChanges` for why that asymmetry is deliberate.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
return CatalogueChanges(current=self, desired=desired)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class CatalogueChanges:
|
|
219
|
+
"""How a persisted catalogue would move toward the one a repository describes.
|
|
220
|
+
|
|
221
|
+
Carries both sides, and uses them for different things — which is the whole
|
|
222
|
+
of the design and the part worth not getting wrong.
|
|
223
|
+
|
|
224
|
+
``current`` informs *reporting*: how many rows are new, changed, unchanged
|
|
225
|
+
and removed, so a reviewer can see what a bundle will do before it runs.
|
|
226
|
+
|
|
227
|
+
``desired`` alone drives the *statements*. The delete keeps exactly the keys
|
|
228
|
+
the desired catalogue claims and the merge is idempotent, so the pair is
|
|
229
|
+
correct against any prior state — including one the reader never saw. Deriving
|
|
230
|
+
the delete from the row-level difference instead would look equivalent and
|
|
231
|
+
would not be: a partial or scoped-wrong read returns fewer rows in
|
|
232
|
+
``current``, so the diff would emit fewer deletes and obsolete claims would
|
|
233
|
+
survive indefinitely, with nothing to notice. As it stands a bad read costs a
|
|
234
|
+
misleading *report* and cannot corrupt the catalogue.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
current: "Catalogue"
|
|
238
|
+
desired: "Catalogue"
|
|
239
|
+
|
|
240
|
+
def per_table(self):
|
|
241
|
+
"""``{item: (TableChanges, ...)}`` — reporting only, never statements."""
|
|
242
|
+
|
|
243
|
+
from .reconcile import compare
|
|
244
|
+
from .tables import DICTIONARY_TABLES, INSTALLATION, REGISTRY
|
|
245
|
+
|
|
246
|
+
tables = (*DICTIONARY_TABLES, INSTALLATION, REGISTRY)
|
|
247
|
+
report = {}
|
|
248
|
+
for item, wanted in self.desired.rows.items():
|
|
249
|
+
found = self.current.rows.get(item, {})
|
|
250
|
+
report[item] = tuple(
|
|
251
|
+
compare(table, wanted.get(table.name, ()), found.get(table.name, ()))
|
|
252
|
+
for table in tables
|
|
253
|
+
)
|
|
254
|
+
return report
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def is_noop(self) -> bool:
|
|
258
|
+
return all(
|
|
259
|
+
change.is_noop
|
|
260
|
+
for changes in self.per_table().values()
|
|
261
|
+
for change in changes
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def render_dml(self, *, installation=None):
|
|
265
|
+
"""``{item: CatalogueReconciliation}`` making the catalogue match ``desired``.
|
|
266
|
+
|
|
267
|
+
A structured result rather than flat statements, and deliberately: the
|
|
268
|
+
caller needs the dictionaries, the Installation row and the Registry
|
|
269
|
+
separated, because Registry is written last in its own barrier so a row
|
|
270
|
+
certifying an object cannot outrun the work it attests to. Handing back
|
|
271
|
+
one list would leave the caller to recover that ordering by inspecting
|
|
272
|
+
the SQL, which is a guess dressed as a grouping.
|
|
273
|
+
|
|
274
|
+
``installation`` supplies the binding facts per item — which target, which
|
|
275
|
+
Weaver — because a repository-derived catalogue does not know them and
|
|
276
|
+
must not invent them.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
from .projection import CatalogueProjection
|
|
280
|
+
from .reconcile import reconcile
|
|
281
|
+
|
|
282
|
+
installation = dict(installation or {})
|
|
283
|
+
rendered = {}
|
|
284
|
+
for item, wanted in self.desired.rows.items():
|
|
285
|
+
rows = dict(wanted)
|
|
286
|
+
binding = installation.get(item)
|
|
287
|
+
if binding is not None:
|
|
288
|
+
rows[INSTALLATION.name] = tuple(binding)
|
|
289
|
+
projection = CatalogueProjection(
|
|
290
|
+
scope=InstallationScope(item.item_type, item.item_name),
|
|
291
|
+
rows=rows,
|
|
292
|
+
)
|
|
293
|
+
rendered[item] = reconcile(projection)
|
|
294
|
+
return rendered
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def retaining(catalogue: Catalogue, repository, identities) -> Catalogue:
|
|
298
|
+
"""Narrow a desired catalogue to what a build actually certified.
|
|
299
|
+
|
|
300
|
+
The step that keeps a Registry row meaning *this succeeded*. Publishing the
|
|
301
|
+
whole logical catalogue would claim every declared object as installed,
|
|
302
|
+
including the ones a build omitted or failed to materialise — which is what
|
|
303
|
+
the planner's uncertified set exists to prevent.
|
|
304
|
+
|
|
305
|
+
A function rather than a method, and ``repository`` passed rather than
|
|
306
|
+
remembered, because a catalogue is rows: making it carry the repository it
|
|
307
|
+
came from would give a *persisted* one two fields that mean nothing and two
|
|
308
|
+
methods that refuse. The dependency is real, so it is visible.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
from .projection import project_item_catalogue
|
|
312
|
+
|
|
313
|
+
wanted = set(identities)
|
|
314
|
+
rows = {}
|
|
315
|
+
for item in catalogue.rows:
|
|
316
|
+
kept = {identity for identity in wanted if identity.item == item}
|
|
317
|
+
if not kept:
|
|
318
|
+
# An item this build retains nothing of is out of scope, not empty.
|
|
319
|
+
# Keeping it would publish a scope that deletes everything the item
|
|
320
|
+
# has, and would demand a binding for an item that has none.
|
|
321
|
+
continue
|
|
322
|
+
projection = project_item_catalogue(repository, item=item, retained=kept)
|
|
323
|
+
rows[item] = MappingProxyType(dict(projection.rows))
|
|
324
|
+
return Catalogue(rows=MappingProxyType(rows))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def for_targets(
|
|
328
|
+
catalogue: Catalogue,
|
|
329
|
+
repository,
|
|
330
|
+
identities,
|
|
331
|
+
target_kinds: Mapping[WeaverItemId, str],
|
|
332
|
+
) -> Catalogue:
|
|
333
|
+
"""Bind to targets: certify alias destinations, and scope to what is bound.
|
|
334
|
+
|
|
335
|
+
``target_kinds`` names the items being published *and* what each is bound to,
|
|
336
|
+
and those are one decision rather than two. An item not named is not
|
|
337
|
+
published — so an alias can never be certified against a guessed kind,
|
|
338
|
+
because there is no path that reaches the certification without stating the
|
|
339
|
+
binding. A default would have written a Warehouse alias into the Registry as
|
|
340
|
+
a table, quietly, in the authoritative record.
|
|
341
|
+
|
|
342
|
+
``identities`` is what the build certified, and it is passed rather than read
|
|
343
|
+
off the rows because the two differ on purpose: an alias whose source item is
|
|
344
|
+
unbound still has its *declaration* published — the name does point there —
|
|
345
|
+
while a Registry row would claim work that never happened.
|
|
346
|
+
|
|
347
|
+
An item named here but retaining nothing is still published, and must be: its
|
|
348
|
+
scope's rows are all obsolete and the publication is what says so.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
from .projection import project_alias_registry
|
|
352
|
+
|
|
353
|
+
certified = set(identities)
|
|
354
|
+
rows = {}
|
|
355
|
+
for item, kind in target_kinds.items():
|
|
356
|
+
tables = dict(catalogue.rows.get(item, {}))
|
|
357
|
+
certifiable = {
|
|
358
|
+
alias.destination
|
|
359
|
+
for alias in repository.aliases
|
|
360
|
+
if alias.destination.item == item and alias.destination in certified
|
|
361
|
+
}
|
|
362
|
+
if certifiable:
|
|
363
|
+
tables[REGISTRY.name] = tuple(
|
|
364
|
+
tables.get(REGISTRY.name, ())
|
|
365
|
+
) + project_alias_registry(
|
|
366
|
+
repository, item=item, retained=certifiable, target_kind=kind
|
|
367
|
+
)
|
|
368
|
+
rows[item] = MappingProxyType(tables)
|
|
369
|
+
return Catalogue(rows=MappingProxyType(rows))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass(frozen=True)
|
|
373
|
+
class Reconciliation:
|
|
374
|
+
"""What reconciling a catalogue against prepared inventories produced.
|
|
375
|
+
|
|
376
|
+
Two things, and they are genuinely two: the catalogue with disproved claims
|
|
377
|
+
removed, and the claims that were removed — which the build turns into delete
|
|
378
|
+
DML. Carrying the second on the catalogue itself made it look like catalogue
|
|
379
|
+
state, when it is a *finding about* the catalogue, and left claim collection
|
|
380
|
+
reading one of its two claim sources off an object and computing the other.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
catalogue: Catalogue
|
|
384
|
+
#: Claims disproved by the inventory, to be deleted before physical work.
|
|
385
|
+
stale_claims: tuple[CatalogueClaim, ...]
|
|
386
|
+
#: The disproved objects, as readable labels. Nothing in the build consumes
|
|
387
|
+
#: this — it exists so a reconciliation decision can be seen and asserted
|
|
388
|
+
#: rather than inferred from the DML it eventually produces.
|
|
389
|
+
stale_objects: tuple[str, ...] = ()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class RegisteredDocument:
|
|
394
|
+
"""One validated Registry row, parsed once at the catalogue boundary."""
|
|
395
|
+
|
|
396
|
+
identity: WeaverDocumentId
|
|
397
|
+
object_type: str
|
|
398
|
+
signature: str
|
|
399
|
+
#: When the build that last certified this object published it. ``None`` for
|
|
400
|
+
#: a row written before epochs existed, which orders as older than any epoch.
|
|
401
|
+
build_epoch: object = None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _encode_json_value(value):
|
|
405
|
+
if isinstance(value, datetime):
|
|
406
|
+
return {"$weaver_type": "datetime", "value": value.isoformat()}
|
|
407
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
408
|
+
return value
|
|
409
|
+
raise BuildError(
|
|
410
|
+
f"catalogue state contains a non-JSON value: {type(value).__name__}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _decode_json_value(value):
|
|
415
|
+
if isinstance(value, dict) and value.get("$weaver_type") == "datetime":
|
|
416
|
+
return datetime.fromisoformat(value["value"])
|
|
417
|
+
return value
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _registered_documents(
|
|
421
|
+
rows: Mapping[WeaverItemId, Mapping[str, tuple[Mapping[str, object], ...]]]
|
|
422
|
+
) -> Mapping[WeaverDocumentId, RegisteredDocument]:
|
|
423
|
+
registered: dict[WeaverDocumentId, RegisteredDocument] = {}
|
|
424
|
+
for item, tables in rows.items():
|
|
425
|
+
for row in tables.get(REGISTRY.name, ()):
|
|
426
|
+
# The type is read first because it is what says how the other two
|
|
427
|
+
# columns are shaped: ``_/Load/lib`` and ``dates.py`` are a schema
|
|
428
|
+
# and an object only once the row has said it describes a file.
|
|
429
|
+
object_type = str(row.get("object_type") or "")
|
|
430
|
+
if object_type not in OBJECT_TYPES:
|
|
431
|
+
expected = ", ".join(OBJECT_TYPES)
|
|
432
|
+
raise BuildError(
|
|
433
|
+
f"Registry row for {item}/{row.get('schema_name')}."
|
|
434
|
+
f"{row.get('object_name')} has unsupported object_type "
|
|
435
|
+
f"{object_type!r}; expected one of {expected}"
|
|
436
|
+
)
|
|
437
|
+
identity = _row_identity(item, row, object_type)
|
|
438
|
+
signature = str(row.get("signature") or "")
|
|
439
|
+
if not signature:
|
|
440
|
+
raise BuildError(f"Registry row for {identity} has no signature")
|
|
441
|
+
document = RegisteredDocument(
|
|
442
|
+
identity, object_type, signature, row.get(BUILD_EPOCH)
|
|
443
|
+
)
|
|
444
|
+
prior = registered.get(identity)
|
|
445
|
+
if prior is not None and prior != document:
|
|
446
|
+
raise BuildError(f"Registry contains conflicting rows for {identity}")
|
|
447
|
+
registered[identity] = document
|
|
448
|
+
return MappingProxyType(registered)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def read_catalogue_state(catalogue: Any, items) -> Catalogue:
|
|
452
|
+
"""Read the catalogue over Spark — the production way to populate one.
|
|
453
|
+
|
|
454
|
+
The one place a catalogue meets a session. Everything downstream of it is
|
|
455
|
+
pure, so this is the boundary whose *fidelity* is worth a Spark test: does a
|
|
456
|
+
real catalogue read back into the same class a fixture builds directly.
|
|
457
|
+
|
|
458
|
+
The shape check is deliberately strict and must stay so. A physically
|
|
459
|
+
incomplete catalogue is rejected here rather than tolerated, because tests
|
|
460
|
+
wanting a Registry-only catalogue can construct one directly — weakening this
|
|
461
|
+
to accommodate them would trade a real production guarantee for a fixture's
|
|
462
|
+
convenience.
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
present: set[str] = set()
|
|
466
|
+
missing: set[str] = set()
|
|
467
|
+
incompatible: list[str] = []
|
|
468
|
+
for table in CATALOGUE_TABLES:
|
|
469
|
+
name = catalogue.expand(object_token("_", table.name))
|
|
470
|
+
try:
|
|
471
|
+
columns = catalogue.spark.table(name).columns
|
|
472
|
+
except Exception as exc:
|
|
473
|
+
if _is_absent(exc):
|
|
474
|
+
missing.add(table.name)
|
|
475
|
+
continue
|
|
476
|
+
raise
|
|
477
|
+
present.add(table.name)
|
|
478
|
+
folded = {column.casefold() for column in columns}
|
|
479
|
+
# Published columns are required too, and deliberately: the merge writes
|
|
480
|
+
# one on every insert, so a catalogue without it can be *read* but not
|
|
481
|
+
# *written*. Exempting it here would let planning succeed and push the
|
|
482
|
+
# failure into the install, where it surfaces as an engine complaint
|
|
483
|
+
# about an unknown column rather than as a statement about the
|
|
484
|
+
# catalogue's shape. The reader's null tolerance answers a different
|
|
485
|
+
# question — a column that exists but predates some rows — and both hold
|
|
486
|
+
# at once: require the column, tolerate the value.
|
|
487
|
+
required = {
|
|
488
|
+
name.casefold()
|
|
489
|
+
for name in table.column_names + table.published_column_names
|
|
490
|
+
}
|
|
491
|
+
absent_columns = sorted(required - folded)
|
|
492
|
+
if absent_columns:
|
|
493
|
+
incompatible.append(f"{table.name}.{absent_columns[0]}")
|
|
494
|
+
if incompatible:
|
|
495
|
+
raise BuildError(
|
|
496
|
+
"catalogue schema is incompatible; missing required column(s): "
|
|
497
|
+
+ ", ".join(incompatible)
|
|
498
|
+
)
|
|
499
|
+
rows = {
|
|
500
|
+
item: MappingProxyType(
|
|
501
|
+
read_installation(
|
|
502
|
+
catalogue,
|
|
503
|
+
scope=InstallationScope(item.item_type, item.item_name),
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
for item in items
|
|
507
|
+
}
|
|
508
|
+
return Catalogue(
|
|
509
|
+
rows=MappingProxyType(rows),
|
|
510
|
+
present_tables=frozenset(present),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def read_installed_catalogue(catalogue: Any) -> Catalogue:
|
|
515
|
+
"""Read the whole installed catalogue, without being told what is in it.
|
|
516
|
+
|
|
517
|
+
The sibling of :func:`read_catalogue_state`, and the difference is the one
|
|
518
|
+
that matters to an operation which runs *after* a build. A build knows its
|
|
519
|
+
items — it is holding the repository that declares them — and reads each
|
|
520
|
+
installation's scope. Load orchestration knows only physical targets, and
|
|
521
|
+
has to discover which logical items are installed and where they are bound
|
|
522
|
+
before it can decide anything at all. So this reads unscoped and groups the
|
|
523
|
+
rows by the installation scope they carry.
|
|
524
|
+
|
|
525
|
+
The shape check is deliberately weaker than the build's: a table that does
|
|
526
|
+
not exist reads as no rows rather than as a fault, because an estate with no
|
|
527
|
+
aliases has never had an Alias table written and that is an ordinary state
|
|
528
|
+
for something that only reads. Nothing here writes, so nothing here needs
|
|
529
|
+
the guarantee that the catalogue can *be* written.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
rows: dict[WeaverItemId, dict[str, list[Mapping[str, object]]]] = {}
|
|
533
|
+
present: set[str] = set()
|
|
534
|
+
for table in CATALOGUE_TABLES:
|
|
535
|
+
table_rows = read_table(catalogue, table)
|
|
536
|
+
if table_rows:
|
|
537
|
+
present.add(table.name)
|
|
538
|
+
for row in table_rows:
|
|
539
|
+
item_type = str(row.get(SCOPE_ITEM_TYPE) or "")
|
|
540
|
+
item_name = str(row.get(SCOPE_ITEM_NAME) or "")
|
|
541
|
+
if not item_type or not item_name:
|
|
542
|
+
raise BuildError(
|
|
543
|
+
f"{table.qualified} holds a row with no installation scope; "
|
|
544
|
+
"every catalogue row names the logical item it belongs to"
|
|
545
|
+
)
|
|
546
|
+
item = WeaverItemId(item_type, item_name)
|
|
547
|
+
rows.setdefault(item, {}).setdefault(table.name, []).append(row)
|
|
548
|
+
return Catalogue(
|
|
549
|
+
rows=MappingProxyType(
|
|
550
|
+
{
|
|
551
|
+
item: MappingProxyType(
|
|
552
|
+
{name: tuple(table_rows) for name, table_rows in tables.items()}
|
|
553
|
+
)
|
|
554
|
+
for item, tables in rows.items()
|
|
555
|
+
}
|
|
556
|
+
),
|
|
557
|
+
present_tables=frozenset(present),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def reconcile_catalogue_state(
|
|
562
|
+
state: Catalogue, *, inventories: Mapping[WeaverItemId, Any]
|
|
563
|
+
) -> Reconciliation:
|
|
564
|
+
"""Discard catalogue claims the prepared inventories physically disprove.
|
|
565
|
+
|
|
566
|
+
Pure: a catalogue in, an inventory in, a catalogue and its stale claims out.
|
|
567
|
+
Both inputs can be built directly, so what a registered object does when the
|
|
568
|
+
thing it claims is *not there* needs no Lakehouse to demonstrate.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
registered = state.registered
|
|
572
|
+
reconciled = {}
|
|
573
|
+
stale_claims: list[CatalogueClaim] = []
|
|
574
|
+
stale_labels: list[str] = []
|
|
575
|
+
for item, tables in state.rows.items():
|
|
576
|
+
inventory = inventories.get(item)
|
|
577
|
+
stale: dict[WeaverDocumentId, RegisteredDocument] = {}
|
|
578
|
+
if inventory is not None:
|
|
579
|
+
for identity, document in registered.items():
|
|
580
|
+
if identity.item != item:
|
|
581
|
+
continue
|
|
582
|
+
if not inventory.has_object(
|
|
583
|
+
catalogue_schema(identity),
|
|
584
|
+
identity.object_id.object,
|
|
585
|
+
document.object_type,
|
|
586
|
+
):
|
|
587
|
+
stale[identity] = document
|
|
588
|
+
filtered = {}
|
|
589
|
+
for table in CATALOGUE_TABLES:
|
|
590
|
+
rows = tables.get(table.name, ())
|
|
591
|
+
rules = {
|
|
592
|
+
rule
|
|
593
|
+
for document in stale.values()
|
|
594
|
+
for rule in claim_rules_for_object_type(document.object_type)
|
|
595
|
+
if rule.table == table
|
|
596
|
+
}
|
|
597
|
+
if not rules:
|
|
598
|
+
filtered[table.name] = tuple(rows)
|
|
599
|
+
continue
|
|
600
|
+
filtered[table.name] = tuple(
|
|
601
|
+
row
|
|
602
|
+
for row in rows
|
|
603
|
+
if not any(
|
|
604
|
+
rule.owns(row, identity)
|
|
605
|
+
for identity, document in stale.items()
|
|
606
|
+
for rule in claim_rules_for_object_type(document.object_type)
|
|
607
|
+
if rule.table == table
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
if table.name in state.present_tables:
|
|
611
|
+
stale_claims.extend(
|
|
612
|
+
CatalogueClaim(identity, rule)
|
|
613
|
+
for identity, document in stale.items()
|
|
614
|
+
for rule in claim_rules_for_object_type(document.object_type)
|
|
615
|
+
if rule.table == table
|
|
616
|
+
)
|
|
617
|
+
reconciled[item] = MappingProxyType(filtered)
|
|
618
|
+
stale_labels.extend(str(identity) for identity in stale)
|
|
619
|
+
retained = {
|
|
620
|
+
identity: document
|
|
621
|
+
for identity, document in registered.items()
|
|
622
|
+
if identity not in {claim.identity for claim in stale_claims}
|
|
623
|
+
}
|
|
624
|
+
return Reconciliation(
|
|
625
|
+
catalogue=Catalogue(
|
|
626
|
+
rows=MappingProxyType(reconciled),
|
|
627
|
+
registered=retained,
|
|
628
|
+
present_tables=state.present_tables,
|
|
629
|
+
),
|
|
630
|
+
stale_claims=tuple(dict.fromkeys(stale_claims)),
|
|
631
|
+
stale_objects=tuple(sorted(stale_labels)),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _row_identity(
|
|
636
|
+
item: WeaverItemId, row: Mapping[str, object], object_type: str
|
|
637
|
+
) -> WeaverDocumentId:
|
|
638
|
+
"""One Registry row's identity, built from its own columns.
|
|
639
|
+
|
|
640
|
+
The row already *is* the identity: item, schema, object and what kind of
|
|
641
|
+
object it is are four stored fields. Composing them into a line and handing
|
|
642
|
+
it to a parser was a round trip through a grammar built for table-style
|
|
643
|
+
``Schema.Object`` names, and it could not express the two the load layer
|
|
644
|
+
installs — a path and a filename, or a procedure named for what it loads.
|
|
645
|
+
Constructing directly means the stored name is the real name, in both
|
|
646
|
+
directions.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
schema = str(row.get("schema_name") or "")
|
|
650
|
+
name = str(row.get("object_name") or "")
|
|
651
|
+
if object_type == "file":
|
|
652
|
+
return WeaverDocumentId(item, ObjectId(schema, name), shape=FILE_SHAPE)
|
|
653
|
+
if object_type == "stored_procedure":
|
|
654
|
+
return WeaverDocumentId(item, ObjectId(schema, name), shape=PROCEDURE_SHAPE)
|
|
655
|
+
is_files = schema.startswith(_FILES_PREFIX)
|
|
656
|
+
return WeaverDocumentId(
|
|
657
|
+
item,
|
|
658
|
+
ObjectId(schema[len(_FILES_PREFIX) :] if is_files else schema, name),
|
|
659
|
+
is_files=is_files,
|
|
660
|
+
)
|