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
weaver/store.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""File transport — the operations Weaver performs on locations.
|
|
2
|
+
|
|
3
|
+
This is transport, never policy. It knows how to list, read, write, delete and
|
|
4
|
+
move; it has no opinion about what *should* be copied or deleted. Push,
|
|
5
|
+
deployment and Folder reconciliation each carry their own rules and sit on top
|
|
6
|
+
of these primitives:
|
|
7
|
+
|
|
8
|
+
- push owns its destination subtree, so a file missing from source is deleted;
|
|
9
|
+
- Folder reconciliation deletes only within its ``File key`` scope, and under
|
|
10
|
+
``Incremental`` deletes nothing — governed by load policy, not by transport.
|
|
11
|
+
|
|
12
|
+
Collapsing those into one ``sync(delete_missing=...)`` would put a data-correctness
|
|
13
|
+
decision behind a transport flag, so they stay separate.
|
|
14
|
+
|
|
15
|
+
**Listing carries metadata.** :class:`Entry` reports size, modification time and
|
|
16
|
+
etag, because every incremental strategy needs them. A listing of bare names
|
|
17
|
+
would foreclose all of them.
|
|
18
|
+
|
|
19
|
+
There is deliberately no move operation. Staging promotion, destination
|
|
20
|
+
replacement and atomic publication are all real needs, but their contract comes
|
|
21
|
+
from the load algorithm, not from a guess made here — and the mechanisms differ
|
|
22
|
+
enough (a local rename, a OneLake copy, an in-session ``notebookutils.fs.mv``)
|
|
23
|
+
that a single reassuring name would hide materially different cost. Load
|
|
24
|
+
introduces whatever it actually needs, named for what it does.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import shutil
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Protocol, runtime_checkable
|
|
34
|
+
|
|
35
|
+
from .errors import CommandError, WeaverError
|
|
36
|
+
from .locations import Location
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StoreError(WeaverError):
|
|
40
|
+
"""Raised when a store operation fails."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Entry:
|
|
45
|
+
"""One listed item, with enough metadata to diff without reading."""
|
|
46
|
+
|
|
47
|
+
location: Location
|
|
48
|
+
is_directory: bool
|
|
49
|
+
size: int | None = None
|
|
50
|
+
modified: datetime | None = None
|
|
51
|
+
etag: str | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
return self.location.name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@runtime_checkable
|
|
59
|
+
class Store(Protocol):
|
|
60
|
+
"""File transport within one workspace.
|
|
61
|
+
|
|
62
|
+
A within-workspace store operates beneath a local root or through Fabric's
|
|
63
|
+
session-native utilities. A cross-boundary caller may also implement this
|
|
64
|
+
protocol (the desktop's OneLake DFS client) and inject it explicitly, but
|
|
65
|
+
moving files from a laptop into Fabric remains CLI orchestration rather than
|
|
66
|
+
a workspace default.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def exists(self, location: Location) -> bool: ...
|
|
70
|
+
|
|
71
|
+
def is_directory(self, location: Location) -> bool: ...
|
|
72
|
+
|
|
73
|
+
def list(self, location: Location, *, recursive: bool = False) -> list[Entry]: ...
|
|
74
|
+
|
|
75
|
+
def read(self, location: Location) -> bytes: ...
|
|
76
|
+
|
|
77
|
+
def write(self, location: Location, data: bytes) -> None: ...
|
|
78
|
+
|
|
79
|
+
def delete(self, location: Location, *, recursive: bool = False) -> None: ...
|
|
80
|
+
|
|
81
|
+
def make_directory(self, location: Location) -> None: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LocalStore:
|
|
85
|
+
"""Filesystem implementation.
|
|
86
|
+
|
|
87
|
+
Not sandboxed to a workspace root, because push reads from arbitrary source
|
|
88
|
+
directories. Containment comes from name validation in
|
|
89
|
+
:mod:`weaver.targets`, which rejects separators and traversal.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def _local(self, location: Location) -> Path:
|
|
93
|
+
if not isinstance(location, Location):
|
|
94
|
+
raise CommandError(
|
|
95
|
+
f"store operations take a Location, got {type(location).__name__}"
|
|
96
|
+
)
|
|
97
|
+
if location.is_url:
|
|
98
|
+
raise CommandError(f"LocalStore cannot address the URL location {location.value!r}")
|
|
99
|
+
return location.path
|
|
100
|
+
|
|
101
|
+
def exists(self, location: Location) -> bool:
|
|
102
|
+
return self._local(location).exists()
|
|
103
|
+
|
|
104
|
+
def is_directory(self, location: Location) -> bool:
|
|
105
|
+
return self._local(location).is_dir()
|
|
106
|
+
|
|
107
|
+
def list(self, location: Location, *, recursive: bool = False) -> list[Entry]:
|
|
108
|
+
root = self._local(location)
|
|
109
|
+
if not root.exists():
|
|
110
|
+
raise StoreError(f"cannot list a location that does not exist: {location.value}")
|
|
111
|
+
if not root.is_dir():
|
|
112
|
+
raise StoreError(f"cannot list a file: {location.value}")
|
|
113
|
+
paths = sorted(root.rglob("*") if recursive else root.glob("*"))
|
|
114
|
+
return [self._entry(path, location, root) for path in paths]
|
|
115
|
+
|
|
116
|
+
def _entry(self, path: Path, root_location: Location, root: Path) -> Entry:
|
|
117
|
+
relative = path.relative_to(root).as_posix()
|
|
118
|
+
info = path.stat()
|
|
119
|
+
is_directory = path.is_dir()
|
|
120
|
+
return Entry(
|
|
121
|
+
location=root_location.join(*relative.split("/")),
|
|
122
|
+
is_directory=is_directory,
|
|
123
|
+
size=None if is_directory else info.st_size,
|
|
124
|
+
modified=datetime.fromtimestamp(info.st_mtime, tz=timezone.utc),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def read(self, location: Location) -> bytes:
|
|
128
|
+
path = self._local(location)
|
|
129
|
+
try:
|
|
130
|
+
return path.read_bytes()
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
raise StoreError(f"cannot read {location.value}: {exc}") from exc
|
|
133
|
+
|
|
134
|
+
def write(self, location: Location, data: bytes) -> None:
|
|
135
|
+
path = self._local(location)
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
try:
|
|
138
|
+
path.write_bytes(data)
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
raise StoreError(f"cannot write {location.value}: {exc}") from exc
|
|
141
|
+
|
|
142
|
+
def delete(self, location: Location, *, recursive: bool = False) -> None:
|
|
143
|
+
path = self._local(location)
|
|
144
|
+
if path.is_symlink():
|
|
145
|
+
# A link is removed, never followed: the alias it stands for is
|
|
146
|
+
# disposable, the object it points at belongs to another item.
|
|
147
|
+
path.unlink()
|
|
148
|
+
return
|
|
149
|
+
if not path.exists():
|
|
150
|
+
return
|
|
151
|
+
if path.is_dir():
|
|
152
|
+
if not recursive:
|
|
153
|
+
raise StoreError(
|
|
154
|
+
f"{location.value} is a directory — pass recursive=True to delete it"
|
|
155
|
+
)
|
|
156
|
+
shutil.rmtree(path)
|
|
157
|
+
else:
|
|
158
|
+
path.unlink()
|
|
159
|
+
|
|
160
|
+
def make_directory(self, location: Location) -> None:
|
|
161
|
+
self._local(location).mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
def copy_to_local(self, source: Location, destination: Path) -> None:
|
|
164
|
+
"""Copy one local file or tree to an exact driver-local path."""
|
|
165
|
+
|
|
166
|
+
source_path = self._local(source)
|
|
167
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
if source_path.is_dir():
|
|
169
|
+
shutil.copytree(source_path, destination)
|
|
170
|
+
else:
|
|
171
|
+
shutil.copy2(source_path, destination)
|
|
172
|
+
|
|
173
|
+
def copy_from_local(self, source: Path, destination: Location) -> None:
|
|
174
|
+
"""Copy one driver-local file or tree to an exact local location."""
|
|
175
|
+
|
|
176
|
+
destination_path = self._local(destination)
|
|
177
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
if source.is_dir():
|
|
179
|
+
shutil.copytree(source, destination_path)
|
|
180
|
+
else:
|
|
181
|
+
shutil.copy2(source, destination_path)
|
|
182
|
+
|
|
183
|
+
def link(self, source: Location, destination: Location) -> None:
|
|
184
|
+
"""Make ``destination`` refer to ``source`` without copying it.
|
|
185
|
+
|
|
186
|
+
The emulator's counterpart of a OneLake shortcut, and the reason it is a
|
|
187
|
+
link rather than a copy: a shortcut has no bytes of its own, so a copy
|
|
188
|
+
would drift the moment the source was rebuilt and would make the emulator
|
|
189
|
+
stop reproducing what Fabric does.
|
|
190
|
+
|
|
191
|
+
Not part of the :class:`Store` protocol. A shortcut in Fabric is made
|
|
192
|
+
through the workspace API, not through file transport, so an environment
|
|
193
|
+
either offers this or offers that — see
|
|
194
|
+
:mod:`weaver.build_bundle.executors.alias`.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
source_path = self._local(source)
|
|
198
|
+
if not source_path.exists():
|
|
199
|
+
raise StoreError(f"cannot link to something that does not exist: {source.value}")
|
|
200
|
+
destination_path = self._local(destination)
|
|
201
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
if destination_path.is_symlink():
|
|
203
|
+
destination_path.unlink()
|
|
204
|
+
try:
|
|
205
|
+
destination_path.symlink_to(source_path, target_is_directory=source_path.is_dir())
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
raise StoreError(
|
|
208
|
+
f"cannot link {destination.value} to {source.value}: {exc}"
|
|
209
|
+
) from exc
|
weaver/targets.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Physical identities — the third level of the four-level model.
|
|
2
|
+
|
|
3
|
+
Weaver names things the way SQL does::
|
|
4
|
+
|
|
5
|
+
Server . Database . Schema . Object
|
|
6
|
+
|
|
7
|
+
4 3 2 1
|
|
8
|
+
|
|
9
|
+
+-------+-------------------+-------------------------------+
|
|
10
|
+
| Level | Fabric | Local |
|
|
11
|
+
+=======+===================+===============================+
|
|
12
|
+
| 4 | workspace | root directory |
|
|
13
|
+
| 3 | Lakehouse, | subdirectory |
|
|
14
|
+
| | Warehouse, | |
|
|
15
|
+
| | Environment | |
|
|
16
|
+
| 2 | schema | schema directory |
|
|
17
|
+
| 1 | table, view, | table or folder |
|
|
18
|
+
| | folder, procedure | |
|
|
19
|
+
+-------+-------------------+-------------------------------+
|
|
20
|
+
|
|
21
|
+
Level 4 is the only level written down in Workspace configuration. Level 3
|
|
22
|
+
needs no alias because an item is *uniquely
|
|
23
|
+
identifiable within its workspace* — so it is referred to by its real name, never by
|
|
24
|
+
an alias. That is uniqueness, not invariance: promoting ``Dev_Lakehouse`` to
|
|
25
|
+
``Prod_Lakehouse`` inside one workspace is ordinary, so level-3 names are always
|
|
26
|
+
supplied explicitly at the call site and never inferred.
|
|
27
|
+
|
|
28
|
+
Levels 2 and 1 come from the object's own metadata (``Schema.Object``) and do
|
|
29
|
+
not appear here.
|
|
30
|
+
|
|
31
|
+
This module is pure identity. Nothing here resolves an item to a path, an ID or
|
|
32
|
+
an endpoint — that is the local or Fabric resolver's job.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
from .errors import IdentityError
|
|
40
|
+
|
|
41
|
+
#: The Lakehouse area holding folder materialisations. Written explicitly in a
|
|
42
|
+
#: folder target because it is what the user sees in the Fabric UI. The Delta
|
|
43
|
+
#: area (``Tables``) is implicit for the same reason: a Delta target names a
|
|
44
|
+
#: Lakehouse, and the area follows from the object kind.
|
|
45
|
+
FILES_AREA = "Files"
|
|
46
|
+
|
|
47
|
+
_ILLEGAL_IN_NAME = ("/", "\\", ":", "*", "?", '"', "<", ">", "|")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_name(value: object, *, what: str) -> str:
|
|
51
|
+
"""Validate one level-3 or path name and return it stripped."""
|
|
52
|
+
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
|
|
55
|
+
name = value.strip()
|
|
56
|
+
if not name:
|
|
57
|
+
raise IdentityError(f"{what} must not be empty")
|
|
58
|
+
for character in _ILLEGAL_IN_NAME:
|
|
59
|
+
if character in name:
|
|
60
|
+
raise IdentityError(f"{what} must not contain {character!r}: {value!r}")
|
|
61
|
+
if set(name) == {"."}:
|
|
62
|
+
raise IdentityError(f"{what} must not be {name!r}")
|
|
63
|
+
return name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _split(text: object, *, what: str) -> list[str]:
|
|
67
|
+
if not isinstance(text, str):
|
|
68
|
+
raise IdentityError(f"{what} must be a string, got {type(text).__name__}")
|
|
69
|
+
if not text.strip():
|
|
70
|
+
raise IdentityError(f"{what} must not be empty")
|
|
71
|
+
return [segment for segment in text.strip().strip("/").split("/")]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ItemRef:
|
|
76
|
+
"""A uniquely-named item within a workspace — level three.
|
|
77
|
+
|
|
78
|
+
A Lakehouse, a Warehouse or a Fabric Environment. Which of those it must be
|
|
79
|
+
is decided by the slot it is used in, never by the name itself: the same
|
|
80
|
+
string passed as a ``delta_target`` names a Lakehouse and passed as a
|
|
81
|
+
``sql_target`` names a Warehouse.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
object.__setattr__(self, "name", validate_name(self.name, what="item name"))
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def parse(cls, text: str) -> "ItemRef":
|
|
91
|
+
segments = _split(text, what="item name")
|
|
92
|
+
if len(segments) != 1:
|
|
93
|
+
raise IdentityError(f"item name must be a single name, got {text!r}")
|
|
94
|
+
return cls(name=segments[0])
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
return self.name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class FolderTarget:
|
|
102
|
+
"""A Lakehouse Files area — ``Sales/Files``, and nothing further.
|
|
103
|
+
|
|
104
|
+
A folder object's physical location is derived from its identity alone:
|
|
105
|
+
``Files/<Schema>/<Object>``. A binding-level subpath used to be accepted here,
|
|
106
|
+
and it made that derivation untrue — the same object landed in different
|
|
107
|
+
places depending on how its item was bound, so authored code could not compose
|
|
108
|
+
its own path and neither could anything else without carrying the binding
|
|
109
|
+
around. One deterministic location per object is worth more than a
|
|
110
|
+
configurable root.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
lakehouse: ItemRef
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def parse(cls, text: str) -> "FolderTarget":
|
|
117
|
+
segments = _split(text, what="folder target")
|
|
118
|
+
if len(segments) != 2:
|
|
119
|
+
raise IdentityError(
|
|
120
|
+
f"folder target must be '<Lakehouse>/{FILES_AREA}', got {text!r}"
|
|
121
|
+
+ (
|
|
122
|
+
" — a folder object lands at Files/<Schema>/<Object>, so there is "
|
|
123
|
+
"nothing to configure beneath the area"
|
|
124
|
+
if len(segments) > 2
|
|
125
|
+
else ""
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
if segments[1] != FILES_AREA:
|
|
129
|
+
raise IdentityError(
|
|
130
|
+
f"folder target must name the {FILES_AREA!r} area after the Lakehouse, "
|
|
131
|
+
f"got {segments[1]!r} in {text!r}"
|
|
132
|
+
)
|
|
133
|
+
return cls(lakehouse=ItemRef(segments[0]))
|
|
134
|
+
|
|
135
|
+
def __str__(self) -> str:
|
|
136
|
+
return f"{self.lakehouse.name}/{FILES_AREA}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class DeltaTarget:
|
|
141
|
+
"""A Lakehouse holding Delta tables.
|
|
142
|
+
|
|
143
|
+
Named bare — ``Sales``. The ``Tables`` area is implicit because the object
|
|
144
|
+
kind already determines it.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
lakehouse: ItemRef
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def parse(cls, text: str) -> "DeltaTarget":
|
|
151
|
+
segments = _split(text, what="delta target")
|
|
152
|
+
if len(segments) != 1:
|
|
153
|
+
raise IdentityError(
|
|
154
|
+
"delta target must name a Lakehouse only — the 'Tables' area is implicit, "
|
|
155
|
+
f"got {text!r}"
|
|
156
|
+
)
|
|
157
|
+
return cls(lakehouse=ItemRef(segments[0]))
|
|
158
|
+
|
|
159
|
+
def __str__(self) -> str:
|
|
160
|
+
return self.lakehouse.name
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class WarehouseTarget:
|
|
165
|
+
"""A Warehouse holding SQL tables, views and generated load procedures."""
|
|
166
|
+
|
|
167
|
+
warehouse: ItemRef
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def parse(cls, text: str) -> "WarehouseTarget":
|
|
171
|
+
segments = _split(text, what="warehouse target")
|
|
172
|
+
if len(segments) != 1:
|
|
173
|
+
raise IdentityError(f"warehouse target must name a Warehouse only, got {text!r}")
|
|
174
|
+
return cls(warehouse=ItemRef(segments[0]))
|
|
175
|
+
|
|
176
|
+
def __str__(self) -> str:
|
|
177
|
+
return self.warehouse.name
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- the one typed physical grammar the public operations share ---------------
|
|
181
|
+
#
|
|
182
|
+
# ``Lakehouse/Name`` and ``Warehouse/Name`` are what a caller writes at every
|
|
183
|
+
# boundary that names a whole physical item: a build binding's left-hand side, a
|
|
184
|
+
# wipe target, an unbind target, a load target. One parser, deliberately — four
|
|
185
|
+
# spellings of one grammar is four places for it to drift, and the drift would
|
|
186
|
+
# show up as one operation accepting a target another refuses.
|
|
187
|
+
#
|
|
188
|
+
# It returns the *existing* typed targets rather than a fifth wrapper, so what a
|
|
189
|
+
# caller gets back is what the resolvers and executors already take.
|
|
190
|
+
|
|
191
|
+
LAKEHOUSE_KIND = "Lakehouse"
|
|
192
|
+
WAREHOUSE_KIND = "Warehouse"
|
|
193
|
+
|
|
194
|
+
#: How the grammar spells each kind, in the order the error message lists them.
|
|
195
|
+
PHYSICAL_KINDS = (LAKEHOUSE_KIND, WAREHOUSE_KIND)
|
|
196
|
+
|
|
197
|
+
_PHYSICAL_TYPES = {LAKEHOUSE_KIND: DeltaTarget, WAREHOUSE_KIND: WarehouseTarget}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_physical_target(
|
|
201
|
+
text: object, *, what: str = "target", error: type[Exception] = IdentityError
|
|
202
|
+
):
|
|
203
|
+
"""``Lakehouse/Name`` or ``Warehouse/Name``, as the typed physical target.
|
|
204
|
+
|
|
205
|
+
``what`` names the caller's own noun so the message reads in that operation's
|
|
206
|
+
vocabulary — "a wipe target must …", "a load target must …". ``error`` is the
|
|
207
|
+
class the caller's boundary raises, because *which* error a malformed request
|
|
208
|
+
produces belongs to the operation and not to the grammar.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
if not isinstance(text, str):
|
|
212
|
+
raise error(f"{what}s must be strings, got {type(text).__name__}")
|
|
213
|
+
parts = text.strip().strip("/").split("/")
|
|
214
|
+
if len(parts) != 2 or not all(part.strip() for part in parts):
|
|
215
|
+
raise error(
|
|
216
|
+
f"a {what} must name a whole physical item as "
|
|
217
|
+
+ " or ".join(f"{kind}/Name" for kind in PHYSICAL_KINDS)
|
|
218
|
+
+ f", got {text!r}"
|
|
219
|
+
)
|
|
220
|
+
kind, name = parts[0].strip(), parts[1].strip()
|
|
221
|
+
if kind not in _PHYSICAL_TYPES:
|
|
222
|
+
raise error(
|
|
223
|
+
f"a {what} must start with "
|
|
224
|
+
+ " or ".join(PHYSICAL_KINDS)
|
|
225
|
+
+ f", got {kind!r}"
|
|
226
|
+
)
|
|
227
|
+
return _PHYSICAL_TYPES[kind](ItemRef.parse(name))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def physical_kind(target) -> str:
|
|
231
|
+
"""``Lakehouse`` or ``Warehouse`` for one typed physical target."""
|
|
232
|
+
|
|
233
|
+
if isinstance(target, DeltaTarget):
|
|
234
|
+
return LAKEHOUSE_KIND
|
|
235
|
+
if isinstance(target, WarehouseTarget):
|
|
236
|
+
return WAREHOUSE_KIND
|
|
237
|
+
raise IdentityError(
|
|
238
|
+
f"{type(target).__name__} is not a typed physical target"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def physical_item(target) -> ItemRef:
|
|
243
|
+
"""The item one typed physical target names."""
|
|
244
|
+
|
|
245
|
+
if isinstance(target, DeltaTarget):
|
|
246
|
+
return target.lakehouse
|
|
247
|
+
if isinstance(target, WarehouseTarget):
|
|
248
|
+
return target.warehouse
|
|
249
|
+
raise IdentityError(
|
|
250
|
+
f"{type(target).__name__} is not a typed physical target"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def physical_target_text(target) -> str:
|
|
255
|
+
"""One typed physical target, spelled back in the grammar it was parsed from."""
|
|
256
|
+
|
|
257
|
+
return f"{physical_kind(target)}/{physical_item(target).name}"
|
weaver/task_logging.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Immutable file evidence for one top-level Weaver task.
|
|
2
|
+
|
|
3
|
+
Generic across Weaver's five top-level tasks — ``wipe``, ``mirror``, ``build``,
|
|
4
|
+
``load`` and ``test`` — because a log that knew what a load was would need a
|
|
5
|
+
second one the first time anything else wanted evidence, and the two would drift.
|
|
6
|
+
Nothing here understands a DAG: a task writes a plan, some steps and a
|
|
7
|
+
completion, and what those contain is the task's business.
|
|
8
|
+
|
|
9
|
+
.. code-block:: text
|
|
10
|
+
|
|
11
|
+
Files/_/Log/
|
|
12
|
+
└── task_date=2026-08-03/
|
|
13
|
+
└── 20260803T091522.123456Z_load_<task-uuid>/
|
|
14
|
+
├── plan.json
|
|
15
|
+
├── 20260803T091523.012345Z_load_<step-uuid>.json
|
|
16
|
+
├── 20260803T091530.441092Z_refresh_<step-uuid>.json
|
|
17
|
+
└── 20260803T091540.102334Z_complete_<task-uuid>.json
|
|
18
|
+
|
|
19
|
+
**The folder is a declared Weaver document, not a path this module knows.**
|
|
20
|
+
``_.Log`` is an ordinary ``Folder`` in the built-in control-plane item, so it is
|
|
21
|
+
projected into the catalogue, inventoried, installed, converged on and protected
|
|
22
|
+
from prune by the machinery that already exists — and this module asks a resolver
|
|
23
|
+
where that folder is rather than composing ``Files/_/Log`` for itself. A special
|
|
24
|
+
path known only to the logger would need its own creation rule, its own prune
|
|
25
|
+
exemption and its own removal rule, each of which is a rule nothing else has.
|
|
26
|
+
|
|
27
|
+
**Nothing is ever rewritten.** A step's file is written when the step finishes
|
|
28
|
+
and is never touched again, which is what makes the log usable after an
|
|
29
|
+
interruption: the plan says what was intended, the step files say what completed,
|
|
30
|
+
and the *absence* of a completion file is how you know the task did not finish.
|
|
31
|
+
A log the runner updated in place could not say that — a crashed task and a
|
|
32
|
+
finished one would look the same.
|
|
33
|
+
|
|
34
|
+
**A dry run writes nothing at all.** Validation is not execution, and a folder of
|
|
35
|
+
plausible-looking steps that never ran is worse than no folder: it is evidence of
|
|
36
|
+
work nobody did. The dry run's complete result is returned in memory instead.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import uuid
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
from typing import Any, Callable, Mapping
|
|
46
|
+
|
|
47
|
+
from .catalogue.builtin import LOG_FOLDER, LOG_FOLDER_ID
|
|
48
|
+
from .catalogue.tables import CATALOGUE_SCHEMA
|
|
49
|
+
from .errors import CommandError
|
|
50
|
+
from .locations import Location
|
|
51
|
+
from .store import Store
|
|
52
|
+
from .targets import FolderTarget, ItemRef
|
|
53
|
+
|
|
54
|
+
#: Weaver's top-level tasks. A task type is part of a folder name, so a reader
|
|
55
|
+
#: can see what ran without opening anything.
|
|
56
|
+
TASK_TYPES = ("wipe", "mirror", "build", "load", "test")
|
|
57
|
+
|
|
58
|
+
#: The date partition's key. ``task_date`` is the UTC date the task *started*; a
|
|
59
|
+
#: task that crosses midnight stays in the partition it began in, because a run
|
|
60
|
+
#: is one thing and splitting it across two partitions would make it two.
|
|
61
|
+
DATE_PARTITION = "task_date"
|
|
62
|
+
|
|
63
|
+
PLAN_FILE = "plan.json"
|
|
64
|
+
COMPLETE_STEP = "complete"
|
|
65
|
+
|
|
66
|
+
_TIMESTAMP = "%Y%m%dT%H%M%S.%f"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def log_folder(resolver: Any, weaver_lakehouse: ItemRef | str) -> Location:
|
|
70
|
+
"""Where the declared ``_.Log`` folder materialises, per the resolver.
|
|
71
|
+
|
|
72
|
+
Derived from the folder's *identity* through the same resolution every other
|
|
73
|
+
Folder object goes through, so the logger writes where the build installed
|
|
74
|
+
rather than where the logger guessed.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
item = ItemRef(weaver_lakehouse) if isinstance(weaver_lakehouse, str) else weaver_lakehouse
|
|
78
|
+
return resolver.folder_object(FolderTarget(item), CATALOGUE_SCHEMA, LOG_FOLDER)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class TaskLog:
|
|
83
|
+
"""One task's evidence folder, open for writing.
|
|
84
|
+
|
|
85
|
+
Holds no mutable run state beyond the files it has written — which is the
|
|
86
|
+
point. Every method appends; nothing amends.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
task_id: str
|
|
90
|
+
task_type: str
|
|
91
|
+
started: datetime
|
|
92
|
+
root: Location
|
|
93
|
+
store: Store
|
|
94
|
+
clock: Callable[[], datetime] = field(default=lambda: datetime.now(timezone.utc))
|
|
95
|
+
#: Every file written, in the order it was written. A record for the caller,
|
|
96
|
+
#: never read back to decide anything.
|
|
97
|
+
written: list[Location] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def partition(self) -> str:
|
|
101
|
+
return f"{DATE_PARTITION}={self.started.date().isoformat()}"
|
|
102
|
+
|
|
103
|
+
def write_plan(self, plan: Mapping[str, Any]) -> Location:
|
|
104
|
+
"""Write the complete intended task, once, before execution begins."""
|
|
105
|
+
|
|
106
|
+
return self._write(PLAN_FILE, {**plan, **self._identity()})
|
|
107
|
+
|
|
108
|
+
def write_step(self, step_type: str, result: Mapping[str, Any]) -> Location:
|
|
109
|
+
"""Write one executed step's immutable result.
|
|
110
|
+
|
|
111
|
+
``step_type`` is the broad kind, and it is in the *filename* so the folder
|
|
112
|
+
reads as a sequence without opening anything. The exact identity — which
|
|
113
|
+
object, in which target, through which primitive — is in the JSON, where
|
|
114
|
+
it can be as precise as it needs to be.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name = (
|
|
118
|
+
f"{self._stamp()}_{_slug(step_type)}_{uuid.uuid4().hex}.json"
|
|
119
|
+
)
|
|
120
|
+
return self._write(name, {**result, **self._identity(), "step_type": step_type})
|
|
121
|
+
|
|
122
|
+
def write_completion(self, summary: Mapping[str, Any]) -> Location:
|
|
123
|
+
"""Write the one file whose presence means the task finished normally."""
|
|
124
|
+
|
|
125
|
+
name = f"{self._stamp()}_{COMPLETE_STEP}_{self.task_id}.json"
|
|
126
|
+
return self._write(
|
|
127
|
+
name,
|
|
128
|
+
{
|
|
129
|
+
**summary,
|
|
130
|
+
**self._identity(),
|
|
131
|
+
"started_at": _iso(self.started),
|
|
132
|
+
"ended_at": _iso(self.clock()),
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# --- writing --------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _identity(self) -> dict[str, str]:
|
|
139
|
+
return {"task_id": self.task_id, "task_type": self.task_type}
|
|
140
|
+
|
|
141
|
+
def _stamp(self) -> str:
|
|
142
|
+
return _stamp(self.clock())
|
|
143
|
+
|
|
144
|
+
def _write(self, name: str, payload: Mapping[str, Any]) -> Location:
|
|
145
|
+
location = self.root / name
|
|
146
|
+
self.store.make_directory(self.root)
|
|
147
|
+
self.store.write(
|
|
148
|
+
location,
|
|
149
|
+
json.dumps(payload, indent=2, sort_keys=True, default=str).encode("utf-8"),
|
|
150
|
+
)
|
|
151
|
+
self.written.append(location)
|
|
152
|
+
return location
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def open_task_log(
|
|
156
|
+
*,
|
|
157
|
+
task_type: str,
|
|
158
|
+
folder: Location,
|
|
159
|
+
store: Store,
|
|
160
|
+
task_id: str | None = None,
|
|
161
|
+
clock: Callable[[], datetime] | None = None,
|
|
162
|
+
) -> TaskLog:
|
|
163
|
+
"""Create one task's folder beneath the declared log folder.
|
|
164
|
+
|
|
165
|
+
``folder`` is where ``_.Log`` materialises — see :func:`log_folder`. Taking
|
|
166
|
+
the location rather than resolving it means the logger can be tested against
|
|
167
|
+
its folder abstraction, with no control plane anywhere near it.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
if task_type not in TASK_TYPES:
|
|
171
|
+
raise CommandError(
|
|
172
|
+
f"{task_type!r} is not a Weaver task type; expected one of "
|
|
173
|
+
+ ", ".join(TASK_TYPES)
|
|
174
|
+
)
|
|
175
|
+
clock = clock or (lambda: datetime.now(timezone.utc))
|
|
176
|
+
started = clock()
|
|
177
|
+
task_id = task_id or uuid.uuid4().hex
|
|
178
|
+
partition = f"{DATE_PARTITION}={started.date().isoformat()}"
|
|
179
|
+
name = f"{_stamp(started)}_{_slug(task_type)}_{task_id}"
|
|
180
|
+
root = folder / partition / name
|
|
181
|
+
store.make_directory(root)
|
|
182
|
+
return TaskLog(
|
|
183
|
+
task_id=task_id,
|
|
184
|
+
task_type=task_type,
|
|
185
|
+
started=started,
|
|
186
|
+
root=root,
|
|
187
|
+
store=store,
|
|
188
|
+
clock=clock,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _stamp(moment: datetime) -> str:
|
|
193
|
+
"""``20260803T091522.123456Z`` — sortable, and unambiguous about its zone."""
|
|
194
|
+
|
|
195
|
+
return moment.astimezone(timezone.utc).strftime(_TIMESTAMP) + "Z"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _iso(moment: datetime) -> str:
|
|
199
|
+
return moment.astimezone(timezone.utc).isoformat()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _slug(value: str) -> str:
|
|
203
|
+
return str(value).replace("/", "-").replace(" ", "-")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = [
|
|
207
|
+
"COMPLETE_STEP",
|
|
208
|
+
"DATE_PARTITION",
|
|
209
|
+
"LOG_FOLDER_ID",
|
|
210
|
+
"PLAN_FILE",
|
|
211
|
+
"TASK_TYPES",
|
|
212
|
+
"TaskLog",
|
|
213
|
+
"log_folder",
|
|
214
|
+
"open_task_log",
|
|
215
|
+
]
|