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,402 @@
|
|
|
1
|
+
"""Loading a Python-defined folder — the mechanics behind ``Folder.load()``.
|
|
2
|
+
|
|
3
|
+
The folder counterpart of :mod:`weaver.runtime.table_load`, and the same
|
|
4
|
+
division holds: ``read()`` writes into the staging directory Weaver issued and
|
|
5
|
+
returns ``(staging_folder, files_to_delete)``; everything that reaches the
|
|
6
|
+
destination happens here.
|
|
7
|
+
|
|
8
|
+
Ported from ``weaver_runtime.dbrep.runtime.folders``. Almost all of it is
|
|
9
|
+
validation, and that is the point rather than an accident — a folder load is the
|
|
10
|
+
one primitive that can delete a file nobody declared, so what it is *entitled*
|
|
11
|
+
to touch has to be established before it touches anything.
|
|
12
|
+
|
|
13
|
+
**The file key is the whole safety argument.** It says which files inside the
|
|
14
|
+
destination are Weaver's, and it is matched segment by segment with ``**``
|
|
15
|
+
support, not as a flat string. That distinction is load-bearing: with a key of
|
|
16
|
+
``*.csv``, ``a.csv`` is managed and ``archive/old.csv`` is not, so a replacement
|
|
17
|
+
removes the first and leaves the second. Matching the key against a whole path
|
|
18
|
+
instead would quietly claim — and then delete — every nested file beneath the
|
|
19
|
+
folder.
|
|
20
|
+
|
|
21
|
+
**Staging is Weaver's to issue and the object's to fill.** It is emptied and
|
|
22
|
+
recreated before ``read()``, must be a sibling of the destination named
|
|
23
|
+
``<destination>_Staging``, and is consumed exactly once. An object that returns
|
|
24
|
+
some other directory, or the same folder twice, is refused rather than trusted.
|
|
25
|
+
|
|
26
|
+
**Everything is checked before anything is copied.** Staged files must match the
|
|
27
|
+
file key, deletes must be exact relative paths inside the folder, and nothing
|
|
28
|
+
may be both staged and deleted. The first violation raises, with the target
|
|
29
|
+
untouched.
|
|
30
|
+
|
|
31
|
+
Two departures from the reference, both narrow. Weaver has no ``load_mode``, so
|
|
32
|
+
the append/replace distinction is simply the ``Incremental`` policy. And
|
|
33
|
+
``fault_tolerant`` is Weaver's addition: it governs recognised row-level
|
|
34
|
+
rejections, of which a folder has exactly one — a staged file the key does not
|
|
35
|
+
claim.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import filecmp
|
|
41
|
+
import fnmatch
|
|
42
|
+
import os
|
|
43
|
+
import shutil
|
|
44
|
+
import uuid
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from ..errors import LoadError
|
|
48
|
+
from .load_contract import FolderLoadContract
|
|
49
|
+
from .load_result import LoadResult
|
|
50
|
+
|
|
51
|
+
#: Files Weaver owns inside a managed folder. Object code may never stage or
|
|
52
|
+
#: delete one: they describe the folder itself, so a load that replaced one
|
|
53
|
+
#: would rewrite its own bookkeeping.
|
|
54
|
+
RESERVED_NAMES = frozenset({"_weaver.json"})
|
|
55
|
+
|
|
56
|
+
#: Characters that make a delete entry a pattern rather than a path. A delete is
|
|
57
|
+
#: an exact statement about one file; a glob would let an object remove files it
|
|
58
|
+
#: never named and could not have known were there.
|
|
59
|
+
_GLOB_CHARS = set("*?[]")
|
|
60
|
+
|
|
61
|
+
_TMP_PREFIX = "._weaver_tmp_"
|
|
62
|
+
|
|
63
|
+
STAGING_SUFFIX = "_Staging"
|
|
64
|
+
|
|
65
|
+
INTOLERANT_MESSAGE = (
|
|
66
|
+
"staged files were rejected and fault_tolerant = 0, so the folder was not "
|
|
67
|
+
"modified"
|
|
68
|
+
)
|
|
69
|
+
TOLERATED_MESSAGE = "staged files were rejected and excluded from the load"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_folder(
|
|
73
|
+
*,
|
|
74
|
+
contract: FolderLoadContract,
|
|
75
|
+
destination: str,
|
|
76
|
+
staging: str,
|
|
77
|
+
deletes=(),
|
|
78
|
+
fault_tolerant: bool = False,
|
|
79
|
+
) -> LoadResult:
|
|
80
|
+
"""Reconcile one folder's staged files into its destination.
|
|
81
|
+
|
|
82
|
+
Validation runs to completion before the first copy, so a folder that is
|
|
83
|
+
going to be refused is refused with its destination exactly as it was.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
destination_path, staging_path = _validate_paths(destination, staging)
|
|
87
|
+
staged, rejected = _classify(staging_path, contract)
|
|
88
|
+
deletes = _validate_deletes(
|
|
89
|
+
deletes, staged, destination_path, contract=contract
|
|
90
|
+
)
|
|
91
|
+
rows_read = len(staged) + len(rejected)
|
|
92
|
+
|
|
93
|
+
if rejected and not fault_tolerant:
|
|
94
|
+
raise LoadError(
|
|
95
|
+
f"{contract.qualified}: {INTOLERANT_MESSAGE}",
|
|
96
|
+
result=LoadResult.failure(
|
|
97
|
+
INTOLERANT_MESSAGE, rows_read=rows_read, rows_rejected=len(rejected)
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
inserted, updated = _publish(staged, staging_path, destination_path)
|
|
102
|
+
deleted = _reconcile_deletes(
|
|
103
|
+
destination_path, deletes, staged, contract=contract
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
result = LoadResult(
|
|
107
|
+
succeeded=True,
|
|
108
|
+
rows_read=rows_read,
|
|
109
|
+
rows_inserted=inserted,
|
|
110
|
+
rows_updated=updated,
|
|
111
|
+
rows_deleted=deleted,
|
|
112
|
+
rows_rejected=len(rejected),
|
|
113
|
+
)
|
|
114
|
+
if rejected:
|
|
115
|
+
return result.rejected(f"{len(rejected)} {TOLERATED_MESSAGE}")
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def new_staging_folder(destination: str, staging: str) -> str:
|
|
120
|
+
"""Reset and create the object-local staging directory, and return it.
|
|
121
|
+
|
|
122
|
+
Reset rather than reused: a run must begin from nothing it did not itself
|
|
123
|
+
produce, or the previous run's files are published again and a replacement
|
|
124
|
+
concludes that nothing was retired.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
_destination_path, staging_path = _validate_paths(destination, staging)
|
|
128
|
+
if staging_path.exists():
|
|
129
|
+
shutil.rmtree(staging_path)
|
|
130
|
+
staging_path.mkdir(parents=True, exist_ok=False)
|
|
131
|
+
return str(staging_path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- validation ----------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_paths(destination: str, staging: str) -> tuple[Path, Path]:
|
|
138
|
+
"""The exact destination/staging relationship, refusing anything else.
|
|
139
|
+
|
|
140
|
+
Staging must be the sibling Weaver names, because that is the only directory
|
|
141
|
+
the object was given and the only one a load will read from. An object that
|
|
142
|
+
returned somewhere else would have Weaver publish a tree nothing validated.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
destination_path = Path(destination)
|
|
146
|
+
staging_path = Path(staging)
|
|
147
|
+
if destination_path == staging_path:
|
|
148
|
+
raise LoadError("a folder's staging path must not be its destination")
|
|
149
|
+
if staging_path.parent != destination_path.parent:
|
|
150
|
+
raise LoadError(
|
|
151
|
+
"a folder's staging directory must be a sibling of its destination, "
|
|
152
|
+
f"and {staging_path} is not beside {destination_path}"
|
|
153
|
+
)
|
|
154
|
+
expected = f"{destination_path.name}{STAGING_SUFFIX}"
|
|
155
|
+
if staging_path.name != expected:
|
|
156
|
+
raise LoadError(
|
|
157
|
+
f"a folder's staging directory must be named {expected!r}, not "
|
|
158
|
+
f"{staging_path.name!r} — return self.staging_folder()"
|
|
159
|
+
)
|
|
160
|
+
return destination_path, staging_path
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _classify(staging_path: Path, contract) -> tuple[list[str], list[str]]:
|
|
164
|
+
"""Split the staged tree into what may be published and what may not.
|
|
165
|
+
|
|
166
|
+
A file the key does not claim is a rejection rather than something to skip
|
|
167
|
+
quietly: the author staged it deliberately, and publishing a folder while
|
|
168
|
+
silently dropping part of it is the kind of success nobody wants.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
if not staging_path.is_dir():
|
|
172
|
+
raise LoadError(
|
|
173
|
+
f"a folder's staging directory does not exist: {staging_path} — "
|
|
174
|
+
"write files into self.staging_folder() and return it"
|
|
175
|
+
)
|
|
176
|
+
staged, rejected = [], []
|
|
177
|
+
for relative in _relative_files(staging_path):
|
|
178
|
+
if Path(relative).name in RESERVED_NAMES:
|
|
179
|
+
raise LoadError(
|
|
180
|
+
f"{contract.qualified}: {relative!r} is a Weaver file and cannot "
|
|
181
|
+
"be staged"
|
|
182
|
+
)
|
|
183
|
+
if matches_file_key(relative, contract.file_keys):
|
|
184
|
+
staged.append(relative)
|
|
185
|
+
else:
|
|
186
|
+
rejected.append(relative)
|
|
187
|
+
return staged, rejected
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _validate_deletes(deletes, staged, destination: Path, *, contract) -> tuple[str, ...]:
|
|
191
|
+
"""Every delete entry, checked to be an exact file this folder may remove."""
|
|
192
|
+
|
|
193
|
+
if isinstance(deletes, (str, bytes)):
|
|
194
|
+
raise LoadError(
|
|
195
|
+
f"{contract.qualified}: read() must return a sequence of relative "
|
|
196
|
+
"file names to delete, not a single string"
|
|
197
|
+
)
|
|
198
|
+
entries = list(deletes or ())
|
|
199
|
+
if entries and contract.replaces_wholesale:
|
|
200
|
+
raise LoadError(
|
|
201
|
+
f"{contract.qualified}: a non-incremental folder cannot name explicit "
|
|
202
|
+
"deletes — it is replaced whole, so absence from staging is what "
|
|
203
|
+
"retires a file"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
staged_set = set(staged)
|
|
207
|
+
normalised: list[str] = []
|
|
208
|
+
for raw in entries:
|
|
209
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
210
|
+
raise LoadError(
|
|
211
|
+
f"{contract.qualified}: a delete entry must be a non-empty "
|
|
212
|
+
f"relative path, got {raw!r}"
|
|
213
|
+
)
|
|
214
|
+
if raw.endswith("/") or "\\" in raw:
|
|
215
|
+
raise LoadError(
|
|
216
|
+
f"{contract.qualified}: a delete must name an exact file, not a "
|
|
217
|
+
f"directory: {raw!r}"
|
|
218
|
+
)
|
|
219
|
+
if any(char in _GLOB_CHARS for char in raw):
|
|
220
|
+
raise LoadError(
|
|
221
|
+
f"{contract.qualified}: a delete must name an exact file, not a "
|
|
222
|
+
f"pattern: {raw!r}"
|
|
223
|
+
)
|
|
224
|
+
path = Path(raw)
|
|
225
|
+
if path.is_absolute() or raw.startswith("/"):
|
|
226
|
+
raise LoadError(
|
|
227
|
+
f"{contract.qualified}: a delete must be relative to the folder, "
|
|
228
|
+
f"not absolute: {raw!r}"
|
|
229
|
+
)
|
|
230
|
+
if ".." in path.parts:
|
|
231
|
+
raise LoadError(
|
|
232
|
+
f"{contract.qualified}: a delete must not traverse out of the "
|
|
233
|
+
f"folder with '..': {raw!r}"
|
|
234
|
+
)
|
|
235
|
+
if path.name in RESERVED_NAMES:
|
|
236
|
+
raise LoadError(
|
|
237
|
+
f"{contract.qualified}: {raw!r} is a Weaver file and cannot be "
|
|
238
|
+
"deleted"
|
|
239
|
+
)
|
|
240
|
+
relative = path.as_posix()
|
|
241
|
+
if not matches_file_key(relative, contract.file_keys):
|
|
242
|
+
raise LoadError(
|
|
243
|
+
f"{contract.qualified}: {relative!r} does not match the File key, "
|
|
244
|
+
"so it is not this folder's to delete"
|
|
245
|
+
)
|
|
246
|
+
if relative in staged_set:
|
|
247
|
+
raise LoadError(
|
|
248
|
+
f"{contract.qualified}: {relative!r} is both staged and deleted"
|
|
249
|
+
)
|
|
250
|
+
if (destination / path).is_dir():
|
|
251
|
+
raise LoadError(
|
|
252
|
+
f"{contract.qualified}: a delete must name a file, and "
|
|
253
|
+
f"{relative!r} is a directory"
|
|
254
|
+
)
|
|
255
|
+
normalised.append(relative)
|
|
256
|
+
return tuple(normalised)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --- reconciliation --------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _publish(staged, staging_path: Path, destination: Path) -> tuple[int, int]:
|
|
263
|
+
"""Copy the staged files into place, distinguishing arrival from replacement.
|
|
264
|
+
|
|
265
|
+
A file whose bytes already match is neither inserted nor updated and is not
|
|
266
|
+
rewritten, so a folder restaged with identical content reports no change
|
|
267
|
+
rather than a full rewrite.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
inserted = updated = 0
|
|
272
|
+
for relative in staged:
|
|
273
|
+
source = staging_path / relative
|
|
274
|
+
target = destination / relative
|
|
275
|
+
if not target.exists():
|
|
276
|
+
inserted += 1
|
|
277
|
+
elif not _identical(source, target):
|
|
278
|
+
updated += 1
|
|
279
|
+
else:
|
|
280
|
+
continue
|
|
281
|
+
_safe_replace(source, target)
|
|
282
|
+
return inserted, updated
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _reconcile_deletes(destination: Path, deletes, staged, *, contract) -> int:
|
|
286
|
+
"""Explicit deletes, plus — when the folder is replaced — what it stopped staging.
|
|
287
|
+
|
|
288
|
+
Automatic removal inventories only *managed* files, so anything the file key
|
|
289
|
+
does not claim survives a replacement it was never part of.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
targets = set(deletes)
|
|
293
|
+
if contract.replaces_wholesale:
|
|
294
|
+
managed = set(managed_relative_files(destination, contract.file_keys))
|
|
295
|
+
targets.update(managed - set(staged))
|
|
296
|
+
|
|
297
|
+
deleted = 0
|
|
298
|
+
for relative in sorted(targets):
|
|
299
|
+
if Path(relative).name in RESERVED_NAMES:
|
|
300
|
+
continue
|
|
301
|
+
target = destination / relative
|
|
302
|
+
if target.is_file():
|
|
303
|
+
target.unlink()
|
|
304
|
+
deleted += 1
|
|
305
|
+
return deleted
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --- the file key ----------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def matches_file_key(relative: str, patterns) -> bool:
|
|
312
|
+
"""Whether the declared file key claims this path, segment by segment.
|
|
313
|
+
|
|
314
|
+
Segment-wise, not a flat string match, and the difference decides what a
|
|
315
|
+
replacement may delete. ``*`` stops at a directory boundary, so ``*.csv``
|
|
316
|
+
claims ``a.csv`` and not ``archive/old.csv``; ``**`` spans any number of
|
|
317
|
+
segments, which is what makes ``**/*`` mean "everything beneath here".
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
if not patterns:
|
|
321
|
+
return True
|
|
322
|
+
parts = tuple(Path(relative).as_posix().split("/"))
|
|
323
|
+
return any(_match_parts(parts, tuple(p.split("/"))) for p in patterns)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _match_parts(path_parts, pattern_parts) -> bool:
|
|
327
|
+
if not pattern_parts:
|
|
328
|
+
return not path_parts
|
|
329
|
+
head, *tail = pattern_parts
|
|
330
|
+
remaining = tuple(tail)
|
|
331
|
+
if head == "**":
|
|
332
|
+
return _match_parts(path_parts, remaining) or (
|
|
333
|
+
bool(path_parts) and _match_parts(path_parts[1:], pattern_parts)
|
|
334
|
+
)
|
|
335
|
+
return (
|
|
336
|
+
bool(path_parts)
|
|
337
|
+
and fnmatch.fnmatchcase(path_parts[0], head)
|
|
338
|
+
and _match_parts(path_parts[1:], remaining)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def managed_relative_files(root: Path, patterns) -> list[str]:
|
|
343
|
+
"""The files beneath ``root`` the file key claims, as sorted POSIX paths."""
|
|
344
|
+
|
|
345
|
+
root = Path(root)
|
|
346
|
+
if not root.is_dir():
|
|
347
|
+
return []
|
|
348
|
+
return sorted(
|
|
349
|
+
relative
|
|
350
|
+
for relative in _relative_files(root)
|
|
351
|
+
if Path(relative).name not in RESERVED_NAMES
|
|
352
|
+
and matches_file_key(relative, patterns)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _relative_files(root: Path) -> list[str]:
|
|
357
|
+
"""Every leaf file beneath ``root``. Directories are not CRUD units."""
|
|
358
|
+
|
|
359
|
+
files: list[str] = []
|
|
360
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
361
|
+
for name in filenames:
|
|
362
|
+
full = Path(dirpath) / name
|
|
363
|
+
files.append(full.relative_to(root).as_posix())
|
|
364
|
+
return sorted(files)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _identical(source: Path, target: Path) -> bool:
|
|
368
|
+
try:
|
|
369
|
+
if source.stat().st_size != target.stat().st_size:
|
|
370
|
+
return False
|
|
371
|
+
except OSError:
|
|
372
|
+
return False
|
|
373
|
+
return filecmp.cmp(source, target, shallow=False)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _safe_replace(source: Path, target: Path) -> None:
|
|
377
|
+
"""Copy into place through a temporary sibling and one atomic rename.
|
|
378
|
+
|
|
379
|
+
Copying straight over the destination leaves a half-written file there if
|
|
380
|
+
anything fails mid-copy — and a folder load's whole job is that what lands
|
|
381
|
+
is what was staged.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
tmp = target.parent / f"{_TMP_PREFIX}{uuid.uuid4().hex}"
|
|
386
|
+
try:
|
|
387
|
+
shutil.copyfile(source, tmp)
|
|
388
|
+
os.replace(tmp, target)
|
|
389
|
+
finally:
|
|
390
|
+
if tmp.exists():
|
|
391
|
+
tmp.unlink()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
__all__ = [
|
|
395
|
+
"INTOLERANT_MESSAGE",
|
|
396
|
+
"RESERVED_NAMES",
|
|
397
|
+
"TOLERATED_MESSAGE",
|
|
398
|
+
"load_folder",
|
|
399
|
+
"managed_relative_files",
|
|
400
|
+
"matches_file_key",
|
|
401
|
+
"new_staging_folder",
|
|
402
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""What one object's load needs to know, and where a running module reads it.
|
|
2
|
+
|
|
3
|
+
A load contract is the *whole* input to loading one object: the key it matches
|
|
4
|
+
on, the columns whose change means an update, whether absent rows are deleted.
|
|
5
|
+
It is deliberately small. Everything a repository knows and a load does not need
|
|
6
|
+
— dependencies, lineage, revision notes, aliases, the other documents in the
|
|
7
|
+
item — is absent, so a primitive cannot come to rely on state only an
|
|
8
|
+
orchestrator could supply.
|
|
9
|
+
|
|
10
|
+
Two ways in, one model out:
|
|
11
|
+
|
|
12
|
+
.. code-block:: text
|
|
13
|
+
|
|
14
|
+
a parsed SesDocument -> LoadContract (generation, on the desktop)
|
|
15
|
+
an installed module's
|
|
16
|
+
docstring -> LoadContract (runtime, inside the session)
|
|
17
|
+
|
|
18
|
+
The second is why this module exists. A deployed ``Sales__Customer.py`` is a
|
|
19
|
+
complete executable artefact: it carries its own contract in its docstring, and
|
|
20
|
+
``Sales__Customer(spark).load()`` reads it from there. No repository is opened,
|
|
21
|
+
no catalogue is queried and no build bundle is consulted — which is what lets a
|
|
22
|
+
developer edit a module in a notebook and see the change on the next reload,
|
|
23
|
+
and what stops the load primitives quietly acquiring an orchestrator.
|
|
24
|
+
|
|
25
|
+
**The runtime parser is not the repository parser.** It reuses the same metadata
|
|
26
|
+
model, because two spellings of one contract would be a defect waiting to
|
|
27
|
+
happen, but it validates only what loading one object requires. Filename and
|
|
28
|
+
class agreement, dependency resolution and repository-wide constraints are the
|
|
29
|
+
repository's business and were settled before the module was ever installed.
|
|
30
|
+
Importing a module edited by hand after deployment is therefore at the
|
|
31
|
+
operator's risk, exactly as running an altered stored procedure is.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import inspect
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
from ..declaration.metadata import (
|
|
40
|
+
DEFAULT_DELETE_THRESHOLD,
|
|
41
|
+
DEFAULT_STABILITY_ROWS,
|
|
42
|
+
DEFAULT_UPDATE_THRESHOLD,
|
|
43
|
+
AUDIT_DELETE,
|
|
44
|
+
AUDIT_INSERT,
|
|
45
|
+
AUDIT_UPDATE,
|
|
46
|
+
FOLDER,
|
|
47
|
+
PYTHON,
|
|
48
|
+
TABLE,
|
|
49
|
+
ObjectId,
|
|
50
|
+
SesDocument,
|
|
51
|
+
audit_column_name,
|
|
52
|
+
parse_document,
|
|
53
|
+
)
|
|
54
|
+
from ..errors import LoadError
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class LoadContract:
|
|
59
|
+
"""Everything needed to execute one table's load, and nothing else.
|
|
60
|
+
|
|
61
|
+
``primary_key`` empty means full replacement: with no way to match a source
|
|
62
|
+
row to a target row there is no such thing as an update, so the target's
|
|
63
|
+
contents are replaced by the source's. Every other field is meaningful only
|
|
64
|
+
when there is a key, which is why the parser refuses ``Incremental`` and
|
|
65
|
+
``Comparison columns`` without one rather than letting them sit unused.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
object_id: ObjectId
|
|
69
|
+
primary_key: tuple[str, ...] = ()
|
|
70
|
+
comparison_columns: tuple[str, ...] = ()
|
|
71
|
+
identity_column: str | None = None
|
|
72
|
+
incremental: bool = False
|
|
73
|
+
delete_threshold: int = DEFAULT_DELETE_THRESHOLD
|
|
74
|
+
update_threshold: int = DEFAULT_UPDATE_THRESHOLD
|
|
75
|
+
stability_rows: int = DEFAULT_STABILITY_ROWS
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def qualified(self) -> str:
|
|
79
|
+
return self.object_id.qualified
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def replaces_wholesale(self) -> bool:
|
|
83
|
+
"""No key, so the load replaces the target's contents entirely."""
|
|
84
|
+
|
|
85
|
+
return not self.primary_key
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def deletes_absent_rows(self) -> bool:
|
|
89
|
+
"""Whether a target row the source stopped producing is removed.
|
|
90
|
+
|
|
91
|
+
Only a keyed, non-incremental load deletes. An incremental load is a
|
|
92
|
+
statement that the source shows a *window* rather than the whole truth,
|
|
93
|
+
so absence from it says nothing about whether a row should still exist.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
return bool(self.primary_key) and not self.incremental
|
|
97
|
+
|
|
98
|
+
def breaches(self, *, target_rows: int, deleting: int, updating: int) -> str | None:
|
|
99
|
+
"""Why this load looks wrong, or ``None`` if it does not.
|
|
100
|
+
|
|
101
|
+
The guard against a load that is *technically* correct and obviously
|
|
102
|
+
wrong: a source that broke overnight and returned a tenth of its rows
|
|
103
|
+
produces a change Weaver would otherwise carry out faithfully.
|
|
104
|
+
|
|
105
|
+
Both percentages are of the target as it stands *before* the load, which
|
|
106
|
+
is the number an operator means by "5% of the table". Neither applies
|
|
107
|
+
below the row threshold, because on a small table one row is a large
|
|
108
|
+
percentage and tripping on that would teach everyone to disable the
|
|
109
|
+
guard.
|
|
110
|
+
|
|
111
|
+
An unkeyed load is exempt: with no key there is nothing to match, so
|
|
112
|
+
replacing every row is what the declaration asked for rather than a
|
|
113
|
+
symptom of anything.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
# An empty target has no proportion to be a percentage of, and a first
|
|
117
|
+
# load into one is the case the guard must never stand in the way of.
|
|
118
|
+
if (
|
|
119
|
+
self.replaces_wholesale
|
|
120
|
+
or target_rows == 0
|
|
121
|
+
or target_rows < self.stability_rows
|
|
122
|
+
):
|
|
123
|
+
return None
|
|
124
|
+
for count, limit, what in (
|
|
125
|
+
(deleting, self.delete_threshold, "delete"),
|
|
126
|
+
(updating, self.update_threshold, "update"),
|
|
127
|
+
):
|
|
128
|
+
percentage = count * 100 / target_rows
|
|
129
|
+
if percentage > limit:
|
|
130
|
+
return (
|
|
131
|
+
f"{what} of {count} rows is {percentage:.1f}% of {target_rows}, "
|
|
132
|
+
f"over the {limit}% threshold"
|
|
133
|
+
)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_document(cls, document: SesDocument) -> "LoadContract":
|
|
138
|
+
"""The contract a parsed Weaver document describes.
|
|
139
|
+
|
|
140
|
+
One derivation, used by generation and by the runtime alike, so the
|
|
141
|
+
procedure Weaver generates for a Warehouse table and the Python load of
|
|
142
|
+
a Delta table cannot come to disagree about what the same header meant.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
if document.kind != TABLE:
|
|
146
|
+
raise LoadError(
|
|
147
|
+
f"{document.qualified}: a {document.kind} has no table load "
|
|
148
|
+
"contract"
|
|
149
|
+
)
|
|
150
|
+
return cls(
|
|
151
|
+
object_id=document.object_id,
|
|
152
|
+
primary_key=document.primary_key,
|
|
153
|
+
comparison_columns=document.comparison_columns,
|
|
154
|
+
identity_column=document.identity,
|
|
155
|
+
incremental=document.is_incremental,
|
|
156
|
+
delete_threshold=document.delete_threshold,
|
|
157
|
+
update_threshold=document.update_threshold,
|
|
158
|
+
stability_rows=document.stability_rows,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class FolderLoadContract:
|
|
164
|
+
"""What a folder load needs: what it manages, and whether it accumulates.
|
|
165
|
+
|
|
166
|
+
A folder has no rows, so none of the row machinery applies. What it has is a
|
|
167
|
+
file key naming the scope of what Weaver manages inside it — which is what
|
|
168
|
+
makes replacement safe, because it says which files a replacement is
|
|
169
|
+
entitled to remove.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
object_id: ObjectId
|
|
173
|
+
file_keys: tuple[str, ...] = ()
|
|
174
|
+
incremental: bool = False
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def qualified(self) -> str:
|
|
178
|
+
return self.object_id.qualified
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def replaces_wholesale(self) -> bool:
|
|
182
|
+
"""A non-incremental folder is replaced; an incremental one accumulates."""
|
|
183
|
+
|
|
184
|
+
return not self.incremental
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_document(cls, document: SesDocument) -> "FolderLoadContract":
|
|
188
|
+
if document.kind != FOLDER:
|
|
189
|
+
raise LoadError(
|
|
190
|
+
f"{document.qualified}: a {document.kind} has no folder load "
|
|
191
|
+
"contract"
|
|
192
|
+
)
|
|
193
|
+
return cls(
|
|
194
|
+
object_id=document.object_id,
|
|
195
|
+
file_keys=document.file_keys,
|
|
196
|
+
incremental=document.is_incremental,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def document_for_module(module) -> SesDocument:
|
|
201
|
+
"""Parse an installed Python module's own docstring into a document.
|
|
202
|
+
|
|
203
|
+
The docstring *is* the metadata block — the repository reader extracts the
|
|
204
|
+
same text with :func:`ast.get_docstring`, and ``cleandoc`` reproduces the
|
|
205
|
+
dedenting it does, so the runtime and the repository read one document from
|
|
206
|
+
one source of truth.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
doc = getattr(module, "__doc__", None)
|
|
210
|
+
name = getattr(module, "__name__", "<module>")
|
|
211
|
+
if doc is None or not doc.strip():
|
|
212
|
+
raise LoadError(
|
|
213
|
+
f"{name} carries no Weaver metadata: a deployed object module must "
|
|
214
|
+
"begin with its docstring metadata block, which is the contract its "
|
|
215
|
+
"load runs from"
|
|
216
|
+
)
|
|
217
|
+
return parse_document(inspect.cleandoc(doc), language=PYTHON)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
#: Why a row was refused. One spelling for all four primitives, so a reject
|
|
221
|
+
#: table written by a Warehouse procedure and one written by a Python load can
|
|
222
|
+
#: be read by the same query. Taken from the reference implementation rather
|
|
223
|
+
#: than reinvented — these strings are already in use against real data.
|
|
224
|
+
REASON_BLANK_PK = "blank_primary_key"
|
|
225
|
+
REASON_DUPLICATE_PK = "duplicate_primary_key"
|
|
226
|
+
|
|
227
|
+
#: The column a reject table carries the reason in.
|
|
228
|
+
REJECTION_REASON = "_reject_reason"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def delta_audit_columns() -> tuple[str, str, str]:
|
|
232
|
+
"""The insert, update and delete audit column names, spelled for Delta."""
|
|
233
|
+
|
|
234
|
+
return tuple(
|
|
235
|
+
audit_column_name(logical, PYTHON)
|
|
236
|
+
for logical in (AUDIT_INSERT, AUDIT_UPDATE, AUDIT_DELETE)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = [
|
|
241
|
+
"FolderLoadContract",
|
|
242
|
+
"LoadContract",
|
|
243
|
+
"delta_audit_columns",
|
|
244
|
+
"document_for_module",
|
|
245
|
+
]
|