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
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
+ ]