weaverstack 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Installing the Weaver runtime into a Fabric Environment.
|
|
2
|
+
|
|
3
|
+
This is the authoritative deployment path: a wheel built from the current
|
|
4
|
+
checkout is uploaded to a named Fabric Environment as a custom library, the
|
|
5
|
+
external packages Weaver needs are staged from ``environment.yml``, and the
|
|
6
|
+
Environment is published. Afterwards a notebook, a Livy session or a Fabric
|
|
7
|
+
pytest run attached to that Environment can simply ``import weaver`` — no source
|
|
8
|
+
copied into a Lakehouse, no ``sys.path`` surgery.
|
|
9
|
+
|
|
10
|
+
The command runs from a developer's checkout, not from installed Weaver: it
|
|
11
|
+
builds the wheel and reads the Environment definition from the working tree.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from ..errors import CommandError
|
|
24
|
+
from .client import FabricClient, FabricError
|
|
25
|
+
from .resources import (
|
|
26
|
+
ENVIRONMENT,
|
|
27
|
+
Item,
|
|
28
|
+
ItemNotFoundError,
|
|
29
|
+
Workspace,
|
|
30
|
+
find_item,
|
|
31
|
+
find_workspace,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#: The wheel filenames this deployment owns. Stale copies matching it are
|
|
35
|
+
#: replaced; anything else in the Environment is left untouched.
|
|
36
|
+
DISTRIBUTION = "weaverstack"
|
|
37
|
+
WHEEL_PREFIX = f"{DISTRIBUTION}-"
|
|
38
|
+
WHEEL_SUFFIX = ".whl"
|
|
39
|
+
|
|
40
|
+
#: Where the Environment definition lives, relative to the project root.
|
|
41
|
+
ENVIRONMENT_DEFINITION = Path("deployment/fabric/environment.yml")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def project_root() -> Path:
|
|
45
|
+
"""The checkout root — the nearest ancestor holding ``pyproject.toml``.
|
|
46
|
+
|
|
47
|
+
The install command is a desktop developer tool, so it always runs from a
|
|
48
|
+
source tree. Locating the root from the package file keeps it working
|
|
49
|
+
whatever directory the command is invoked from.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
here = Path(__file__).resolve()
|
|
53
|
+
for parent in here.parents:
|
|
54
|
+
if (parent / "pyproject.toml").is_file():
|
|
55
|
+
return parent
|
|
56
|
+
raise CommandError(
|
|
57
|
+
"cannot find the project root (no pyproject.toml above "
|
|
58
|
+
f"{here}); run weaver install from a Weaver checkout"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalise(name: str) -> str:
|
|
63
|
+
"""A PEP 503 distribution name, stripped of any version specifier."""
|
|
64
|
+
|
|
65
|
+
bare = re.split(r"[<>=!~;\[\s]", name.strip(), maxsplit=1)[0]
|
|
66
|
+
return re.sub(r"[-_.]+", "-", bare).lower()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def runtime_dependencies(root: Path | None = None) -> list[str]:
|
|
70
|
+
"""The packages installed Weaver needs, from ``[project].dependencies``."""
|
|
71
|
+
|
|
72
|
+
import tomllib
|
|
73
|
+
|
|
74
|
+
root = root or project_root()
|
|
75
|
+
data = tomllib.loads((root / "pyproject.toml").read_text("utf-8"))
|
|
76
|
+
return list(data.get("project", {}).get("dependencies", []))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def environment_dependencies(root: Path | None = None) -> list[str]:
|
|
80
|
+
"""The pip packages named in ``environment.yml``."""
|
|
81
|
+
|
|
82
|
+
import yaml
|
|
83
|
+
|
|
84
|
+
root = root or project_root()
|
|
85
|
+
text = (root / ENVIRONMENT_DEFINITION).read_text("utf-8")
|
|
86
|
+
document = yaml.safe_load(text) or {}
|
|
87
|
+
pip: list[str] = []
|
|
88
|
+
for entry in document.get("dependencies", []):
|
|
89
|
+
if isinstance(entry, dict) and "pip" in entry:
|
|
90
|
+
pip.extend(entry["pip"] or [])
|
|
91
|
+
return pip
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def missing_from_environment(root: Path | None = None) -> list[str]:
|
|
95
|
+
"""Runtime dependencies that ``environment.yml`` fails to install.
|
|
96
|
+
|
|
97
|
+
The check that keeps the two definitions from drifting: a package added to
|
|
98
|
+
``pyproject.toml`` but not to the Environment would be absent in Fabric.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
root = root or project_root()
|
|
102
|
+
staged = {_normalise(name) for name in environment_dependencies(root)}
|
|
103
|
+
return [
|
|
104
|
+
dependency
|
|
105
|
+
for dependency in runtime_dependencies(root)
|
|
106
|
+
if _normalise(dependency) not in staged
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- building the wheel ------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_weaver_wheel(filename: str) -> bool:
|
|
114
|
+
"""Whether a filename is a Weaver distribution wheel, and only that.
|
|
115
|
+
|
|
116
|
+
The guard on every delete: an Environment may hold other custom libraries,
|
|
117
|
+
and this deployment owns exactly the ``weaverstack-*.whl`` it uploads.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
return filename.startswith(WHEEL_PREFIX) and filename.endswith(WHEEL_SUFFIX)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_wheel(root: Path | None = None, *, output_dir: Path | None = None) -> Path:
|
|
124
|
+
"""Build a wheel from the checkout and return its exact path.
|
|
125
|
+
|
|
126
|
+
The version is git-derived (see pyproject), so a changed tree produces a
|
|
127
|
+
differently-named wheel without anyone editing a version string.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
root = root or project_root()
|
|
131
|
+
output_dir = output_dir or (root / "dist")
|
|
132
|
+
before = set(output_dir.glob(f"{WHEEL_PREFIX}*{WHEEL_SUFFIX}"))
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
[sys.executable, "-m", "build", "--wheel", "--outdir", str(output_dir), str(root)],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
)
|
|
138
|
+
if result.returncode != 0:
|
|
139
|
+
raise CommandError(
|
|
140
|
+
"wheel build failed — is the [cli] extra installed?\n"
|
|
141
|
+
+ (result.stderr.strip() or result.stdout.strip())[-1000:]
|
|
142
|
+
)
|
|
143
|
+
built = sorted(
|
|
144
|
+
set(output_dir.glob(f"{WHEEL_PREFIX}*{WHEEL_SUFFIX}")) - before,
|
|
145
|
+
key=lambda p: p.stat().st_mtime,
|
|
146
|
+
)
|
|
147
|
+
if built:
|
|
148
|
+
return built[-1]
|
|
149
|
+
# A rebuild of an unchanged, already-built version produces no new file.
|
|
150
|
+
existing = sorted(
|
|
151
|
+
output_dir.glob(f"{WHEEL_PREFIX}*{WHEEL_SUFFIX}"), key=lambda p: p.stat().st_mtime
|
|
152
|
+
)
|
|
153
|
+
if not existing:
|
|
154
|
+
raise CommandError(f"wheel build produced no {WHEEL_PREFIX}*{WHEEL_SUFFIX} in {output_dir}")
|
|
155
|
+
return existing[-1]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- the Fabric Environment --------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def find_or_create_environment(
|
|
162
|
+
workspace: Workspace, name: str, *, client: FabricClient
|
|
163
|
+
) -> tuple[Item, bool]:
|
|
164
|
+
"""The named Environment, created if it does not yet exist.
|
|
165
|
+
|
|
166
|
+
Idempotent: a second call with the same workspace and name returns the same
|
|
167
|
+
item rather than a suffixed duplicate. Returns ``(item, created)``.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
return find_item(workspace, name, item_type=ENVIRONMENT, client=client), False
|
|
172
|
+
except ItemNotFoundError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
response = client.request(
|
|
176
|
+
"POST",
|
|
177
|
+
f"workspaces/{workspace.id}/environments",
|
|
178
|
+
payload={"displayName": name, "description": "Weaver runtime"},
|
|
179
|
+
expected=(200, 201, 202),
|
|
180
|
+
)
|
|
181
|
+
if response.status_code == 202:
|
|
182
|
+
item = _await_environment(workspace, name, client=client)
|
|
183
|
+
else:
|
|
184
|
+
body = response.json()
|
|
185
|
+
item = Item(id=body["id"], name=name, type=ENVIRONMENT, workspace_id=workspace.id)
|
|
186
|
+
return item, True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _await_environment(
|
|
190
|
+
workspace: Workspace, name: str, *, client: FabricClient, timeout: float = 120.0
|
|
191
|
+
) -> Item:
|
|
192
|
+
deadline = time.time() + timeout
|
|
193
|
+
while time.time() < deadline:
|
|
194
|
+
try:
|
|
195
|
+
return find_item(workspace, name, item_type=ENVIRONMENT, client=client)
|
|
196
|
+
except ItemNotFoundError:
|
|
197
|
+
time.sleep(3.0)
|
|
198
|
+
raise FabricError(f"Environment {name!r} did not appear within {int(timeout)}s")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _staging_base(env: Item) -> str:
|
|
202
|
+
return f"workspaces/{env.workspace_id}/environments/{env.id}/staging"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read_staging(env: Item, *, client: FabricClient) -> dict:
|
|
206
|
+
"""What the Environment currently has staged: custom wheels and the env yml.
|
|
207
|
+
|
|
208
|
+
A **freshly created** Environment answers 404 here — ``This environment does
|
|
209
|
+
not have any staged libraries`` — which is the same "nothing yet" that
|
|
210
|
+
:func:`read_published` already reads from a 404, and has to be read the same
|
|
211
|
+
way. Treating it as fatal made the very first ``weaver install`` into a new
|
|
212
|
+
Environment fail, so the one path that had never been exercised was the one
|
|
213
|
+
that could not work.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
return client.get_json(f"{_staging_base(env)}/libraries")
|
|
218
|
+
except FabricError as exc:
|
|
219
|
+
if exc.status_code == 404:
|
|
220
|
+
return {}
|
|
221
|
+
raise
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def read_published(env: Item, *, client: FabricClient) -> dict:
|
|
225
|
+
"""What the Environment has *published* — the live image's libraries.
|
|
226
|
+
|
|
227
|
+
The diff that decides whether a republish is needed compares against this,
|
|
228
|
+
not against staging: staging can hold half-finished changes from an
|
|
229
|
+
interrupted run, whereas the published revision is what a session actually
|
|
230
|
+
imports. A never-published Environment answers 404, read as "nothing".
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
return client.get_json(
|
|
235
|
+
f"workspaces/{env.workspace_id}/environments/{env.id}/libraries"
|
|
236
|
+
)
|
|
237
|
+
except FabricError as exc:
|
|
238
|
+
if exc.status_code == 404:
|
|
239
|
+
return {}
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def library_wheels(libraries: dict) -> list[str]:
|
|
244
|
+
custom = (libraries.get("customLibraries") or {}).get("wheelFiles") or []
|
|
245
|
+
return list(custom)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
#: Backwards-compatible alias — reads wheels out of a staging or published body.
|
|
249
|
+
staged_wheels = library_wheels
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def publish_state(env: Item, *, client: FabricClient) -> str:
|
|
253
|
+
"""The Environment's last publish outcome, e.g. ``Success`` or ``Running``."""
|
|
254
|
+
|
|
255
|
+
info = client.get_json(f"workspaces/{env.workspace_id}/environments/{env.id}")
|
|
256
|
+
details = (info.get("properties") or {}).get("publishDetails") or {}
|
|
257
|
+
return details.get("state", "")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def upload_wheel(env: Item, wheel: Path, *, client: FabricClient) -> None:
|
|
261
|
+
"""Upload one wheel's exact bytes to Environment staging."""
|
|
262
|
+
|
|
263
|
+
import requests
|
|
264
|
+
|
|
265
|
+
url = f"{client.api_base_url}/{_staging_base(env)}/libraries"
|
|
266
|
+
response = requests.post(
|
|
267
|
+
url,
|
|
268
|
+
headers={"Authorization": f"Bearer {client.token}"},
|
|
269
|
+
files={"file": (wheel.name, wheel.read_bytes(), "application/octet-stream")},
|
|
270
|
+
timeout=client.timeout,
|
|
271
|
+
)
|
|
272
|
+
if response.status_code not in (200, 201):
|
|
273
|
+
raise FabricError(
|
|
274
|
+
f"uploading {wheel.name} returned {response.status_code}: "
|
|
275
|
+
f"{response.text.strip()[:400] or 'no body'}",
|
|
276
|
+
status_code=response.status_code,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def upload_environment_yml(env: Item, definition: Path, *, client: FabricClient) -> None:
|
|
281
|
+
"""Stage the external-dependency definition as the Environment's yml."""
|
|
282
|
+
|
|
283
|
+
import requests
|
|
284
|
+
|
|
285
|
+
url = f"{client.api_base_url}/{_staging_base(env)}/libraries"
|
|
286
|
+
response = requests.post(
|
|
287
|
+
url,
|
|
288
|
+
headers={"Authorization": f"Bearer {client.token}"},
|
|
289
|
+
files={"file": ("environment.yml", definition.read_bytes(), "application/octet-stream")},
|
|
290
|
+
timeout=client.timeout,
|
|
291
|
+
)
|
|
292
|
+
if response.status_code not in (200, 201):
|
|
293
|
+
raise FabricError(
|
|
294
|
+
f"uploading environment.yml returned {response.status_code}: "
|
|
295
|
+
f"{response.text.strip()[:400] or 'no body'}",
|
|
296
|
+
status_code=response.status_code,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def delete_stale_wheels(env: Item, keep: str, staged: list[str], *, client: FabricClient) -> list[str]:
|
|
301
|
+
"""Remove staged Weaver wheels other than ``keep``. Returns what was removed.
|
|
302
|
+
|
|
303
|
+
Only ``weaverstack-*.whl`` is ever deleted, so an unrelated custom library
|
|
304
|
+
an operator added to the Environment is never touched.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
removed = []
|
|
308
|
+
for filename in staged:
|
|
309
|
+
if filename == keep or not is_weaver_wheel(filename):
|
|
310
|
+
continue
|
|
311
|
+
client.request(
|
|
312
|
+
"DELETE",
|
|
313
|
+
f"{_staging_base(env)}/libraries?libraryToDelete={filename}",
|
|
314
|
+
expected=(200, 202, 204),
|
|
315
|
+
)
|
|
316
|
+
removed.append(filename)
|
|
317
|
+
return removed
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
#: Publish is complete at one of these states; anything else is still running.
|
|
321
|
+
_TERMINAL_PUBLISH = frozenset({"success", "succeeded", "failed", "cancelled"})
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def publish_and_wait(
|
|
325
|
+
env: Item,
|
|
326
|
+
*,
|
|
327
|
+
client: FabricClient,
|
|
328
|
+
timeout: float = 1800.0,
|
|
329
|
+
poll_interval: float = 15.0,
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Publish the staged Environment and poll until it settles.
|
|
332
|
+
|
|
333
|
+
Returns the terminal state. Publication is where Fabric resolves the pip
|
|
334
|
+
dependencies into the image, so it is the slow step and the one that decides
|
|
335
|
+
whether ``import weaver`` will work.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
client.request("POST", f"{_staging_base(env)}/publish", expected=(200, 202))
|
|
339
|
+
deadline = time.time() + timeout
|
|
340
|
+
while time.time() < deadline:
|
|
341
|
+
state = publish_state(env, client=client)
|
|
342
|
+
if state.lower() in _TERMINAL_PUBLISH:
|
|
343
|
+
return state
|
|
344
|
+
time.sleep(poll_interval)
|
|
345
|
+
raise FabricError(f"publish did not finish within {int(timeout)}s (last state polled)")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --- the orchestrated install ------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass
|
|
352
|
+
class InstallResult:
|
|
353
|
+
"""What one ``weaver install`` did — serialisable for ``--json``."""
|
|
354
|
+
|
|
355
|
+
workspace_name: str
|
|
356
|
+
workspace_id: str
|
|
357
|
+
environment_name: str
|
|
358
|
+
environment_id: str
|
|
359
|
+
package_name: str
|
|
360
|
+
package_version: str
|
|
361
|
+
wheel_filename: str
|
|
362
|
+
created_environment: bool
|
|
363
|
+
dependencies_changed: bool
|
|
364
|
+
wheel_changed: bool
|
|
365
|
+
published: bool
|
|
366
|
+
publish_status: str
|
|
367
|
+
timings: dict = field(default_factory=dict)
|
|
368
|
+
|
|
369
|
+
def as_dict(self) -> dict:
|
|
370
|
+
data = self.__dict__.copy()
|
|
371
|
+
return data
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _version_from_wheel(filename: str) -> str:
|
|
375
|
+
# weaverstack-<version>-py3-none-any.whl
|
|
376
|
+
stem = filename[len(WHEEL_PREFIX):-len(WHEEL_SUFFIX)]
|
|
377
|
+
return stem.split("-py3-")[0].split("-py2.py3-")[0]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def install(
|
|
381
|
+
workspace_name: str,
|
|
382
|
+
environment_name: str,
|
|
383
|
+
*,
|
|
384
|
+
publish: bool = True,
|
|
385
|
+
client: FabricClient | None = None,
|
|
386
|
+
root: Path | None = None,
|
|
387
|
+
) -> InstallResult:
|
|
388
|
+
"""Build the wheel, stage what changed, and publish only if needed.
|
|
389
|
+
|
|
390
|
+
The one supported installation path. The wanted wheel and dependencies are
|
|
391
|
+
diffed against the Environment's *published* revision: an ordinary code
|
|
392
|
+
change replaces only the wheel, an unchanged dependency set is left alone,
|
|
393
|
+
and a rerun that changes nothing (same source — the version is stable) does
|
|
394
|
+
not republish at all.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
root = root or project_root()
|
|
398
|
+
client = client or FabricClient()
|
|
399
|
+
timings: dict[str, float] = {}
|
|
400
|
+
|
|
401
|
+
t = time.perf_counter()
|
|
402
|
+
wheel = build_wheel(root)
|
|
403
|
+
timings["build"] = time.perf_counter() - t
|
|
404
|
+
version = _version_from_wheel(wheel.name)
|
|
405
|
+
|
|
406
|
+
workspace = find_workspace(workspace_name, client=client)
|
|
407
|
+
env, created = find_or_create_environment(workspace, environment_name, client=client)
|
|
408
|
+
|
|
409
|
+
definition_path = root / ENVIRONMENT_DEFINITION
|
|
410
|
+
wanted_yml = definition_path.read_text("utf-8")
|
|
411
|
+
|
|
412
|
+
# Diff against what is *published* (what a session imports), not staging.
|
|
413
|
+
published_libs = read_published(env, client=client)
|
|
414
|
+
deps_changed = wanted_yml.strip() != (published_libs.get("environmentYml") or "").strip()
|
|
415
|
+
wheel_changed = wheel.name not in library_wheels(published_libs)
|
|
416
|
+
|
|
417
|
+
# Stage only the differences, and only if they are not already staged (an
|
|
418
|
+
# interrupted earlier run may have staged them).
|
|
419
|
+
staging = read_staging(env, client=client)
|
|
420
|
+
staged = library_wheels(staging)
|
|
421
|
+
t = time.perf_counter()
|
|
422
|
+
if deps_changed and wanted_yml.strip() != (staging.get("environmentYml") or "").strip():
|
|
423
|
+
upload_environment_yml(env, definition_path, client=client)
|
|
424
|
+
if wheel_changed and wheel.name not in staged:
|
|
425
|
+
upload_wheel(env, wheel, client=client)
|
|
426
|
+
delete_stale_wheels(env, wheel.name, staged, client=client)
|
|
427
|
+
timings["upload"] = time.perf_counter() - t
|
|
428
|
+
|
|
429
|
+
state = publish_state(env, client=client)
|
|
430
|
+
already_published = state.lower() in {"success", "succeeded"}
|
|
431
|
+
something_changed = created or deps_changed or wheel_changed
|
|
432
|
+
published_now = False
|
|
433
|
+
if not publish:
|
|
434
|
+
publish_status = "Skipped"
|
|
435
|
+
elif not something_changed and already_published:
|
|
436
|
+
publish_status = "AlreadyInstalled"
|
|
437
|
+
published_now = True
|
|
438
|
+
else:
|
|
439
|
+
t = time.perf_counter()
|
|
440
|
+
publish_status = publish_and_wait(env, client=client)
|
|
441
|
+
timings["publish"] = time.perf_counter() - t
|
|
442
|
+
published_now = publish_status.lower() in {"success", "succeeded"}
|
|
443
|
+
if not published_now:
|
|
444
|
+
raise FabricError(f"Environment publish finished as {publish_status!r}, not Success")
|
|
445
|
+
|
|
446
|
+
return InstallResult(
|
|
447
|
+
workspace_name=workspace.name,
|
|
448
|
+
workspace_id=workspace.id,
|
|
449
|
+
environment_name=env.name,
|
|
450
|
+
environment_id=env.id,
|
|
451
|
+
package_name=DISTRIBUTION,
|
|
452
|
+
package_version=version,
|
|
453
|
+
wheel_filename=wheel.name,
|
|
454
|
+
created_environment=created,
|
|
455
|
+
dependencies_changed=deps_changed,
|
|
456
|
+
wheel_changed=wheel_changed,
|
|
457
|
+
published=published_now,
|
|
458
|
+
publish_status=publish_status,
|
|
459
|
+
timings={k: round(v, 2) for k, v in timings.items()},
|
|
460
|
+
)
|