weaverstack 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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
+ ]