weaverstack 0.1.1__py3-none-any.whl

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