toolframe-core 0.6.2__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 (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
@@ -0,0 +1,728 @@
1
+ """Capture sources and resolve content-addressed, non-editable worker environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import configparser
7
+ import email
8
+ import fnmatch
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import signal
15
+ import sys
16
+ import tempfile
17
+ import tomllib
18
+ import urllib.parse
19
+ import zipfile
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+ import tomlkit
24
+ from packaging.requirements import Requirement
25
+ from packaging.tags import sys_tags
26
+ from packaging.utils import canonicalize_name, parse_wheel_filename
27
+ from toolframe_sdk import (
28
+ PROTOCOL_VERSION,
29
+ PluginDefinition,
30
+ PluginError,
31
+ digest,
32
+ validate,
33
+ )
34
+ from toolframe_sdk import __version__ as SDK_VERSION
35
+ from toolframe_sdk.tracing import tracing_requested
36
+
37
+ from .config import atomic_write
38
+ from .process import WorkerProcess, child_environment
39
+
40
+ IGNORED = {
41
+ ".git",
42
+ ".local",
43
+ ".worktrees",
44
+ ".venv",
45
+ ".toolframe",
46
+ "data",
47
+ "dist",
48
+ "build",
49
+ "target",
50
+ "node_modules",
51
+ "__pycache__",
52
+ ".pytest_cache",
53
+ ".ruff_cache",
54
+ ".mypy_cache",
55
+ ".env",
56
+ }
57
+
58
+
59
+ async def command(*arguments, cwd=None, timeout=300):
60
+ """No shell, bounded diagnostics, and owned child process group on cancellation."""
61
+ process = await asyncio.create_subprocess_exec(
62
+ *map(str, arguments),
63
+ cwd=cwd,
64
+ env=child_environment(),
65
+ stdout=asyncio.subprocess.PIPE,
66
+ stderr=asyncio.subprocess.STDOUT,
67
+ start_new_session=True,
68
+ )
69
+ tail = bytearray()
70
+
71
+ async def consume():
72
+ while chunk := await process.stdout.read(8192):
73
+ tail.extend(chunk)
74
+ del tail[:-32768]
75
+ return await process.wait()
76
+
77
+ try:
78
+ async with asyncio.timeout(timeout):
79
+ result = await consume()
80
+ except BaseException:
81
+ if process.returncode is None:
82
+ try:
83
+ os.killpg(process.pid, signal.SIGTERM)
84
+ except ProcessLookupError:
85
+ pass
86
+ try:
87
+ await asyncio.wait_for(process.wait(), 3)
88
+ except TimeoutError:
89
+ os.killpg(process.pid, signal.SIGKILL)
90
+ await process.wait()
91
+ raise
92
+ output = tail.decode(errors="replace")
93
+ if result:
94
+ raise PluginError(
95
+ "package_command_failed",
96
+ output,
97
+ {"executable": str(arguments[0]), "exit_code": result},
98
+ )
99
+ return output
100
+
101
+
102
+ def source_files(root):
103
+ patterns = []
104
+ ignore = root / ".toolframeignore"
105
+ if ignore.is_file():
106
+ patterns = [
107
+ line.strip()
108
+ for line in ignore.read_text().splitlines()
109
+ if line.strip() and not line.startswith("#")
110
+ ]
111
+ files = {}
112
+ for directory, names, filenames in os.walk(root, followlinks=False):
113
+ names[:] = sorted(name for name in names if name not in IGNORED)
114
+ for name in names + sorted(filenames):
115
+ path = Path(directory) / name
116
+ relative = path.relative_to(root).as_posix()
117
+ if (
118
+ name in IGNORED
119
+ or name.endswith((".pyc", ".pyo"))
120
+ or any(fnmatch.fnmatch(relative, p) for p in patterns)
121
+ ):
122
+ if name in names:
123
+ names.remove(name)
124
+ continue
125
+ if path.is_symlink():
126
+ raise PluginError(
127
+ "source_symlink",
128
+ f"Source symlinks must be materialized: {relative}",
129
+ )
130
+ if path.is_file():
131
+ data = path.read_bytes()
132
+ files[relative] = (
133
+ hashlib.sha256(data).hexdigest(),
134
+ bool(path.stat().st_mode & 0o111),
135
+ )
136
+ return files
137
+
138
+
139
+ def snapshot(root, destination):
140
+ root = root.resolve()
141
+ if not (root / "pyproject.toml").is_file():
142
+ raise PluginError(
143
+ "source_invalid", "Local/Git plugin must contain pyproject.toml"
144
+ )
145
+ before = source_files(root)
146
+ identifier = digest(before)
147
+ target = destination / identifier
148
+ with tempfile.TemporaryDirectory(dir=destination, prefix="capture-") as temporary:
149
+ copied = Path(temporary)
150
+ for name, (_, executable) in before.items():
151
+ path = copied / name
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ path.write_bytes((root / name).read_bytes())
154
+ path.chmod(0o755 if executable else 0o644)
155
+ if before != source_files(copied) or before != source_files(root):
156
+ raise PluginError(
157
+ "source_changed", "Source changed during capture; validate again"
158
+ )
159
+ if not target.exists():
160
+ os.rename(copied, target)
161
+ return target, identifier
162
+
163
+
164
+ def wheel_metadata(path):
165
+ with zipfile.ZipFile(path) as wheel:
166
+ metadata = [
167
+ name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")
168
+ ]
169
+ if len(metadata) != 1:
170
+ raise PluginError("wheel_invalid", "Wheel must contain one distribution")
171
+ message = email.message_from_bytes(wheel.read(metadata[0]))
172
+ entryfile = metadata[0].removesuffix("METADATA") + "entry_points.txt"
173
+ entries = configparser.ConfigParser(interpolation=None)
174
+ entries.optionxform = str
175
+ if entryfile not in wheel.namelist():
176
+ raise PluginError(
177
+ "entrypoint_missing", "Wheel has no toolframe.plugins entrypoint"
178
+ )
179
+ entries.read_string(wheel.read(entryfile).decode())
180
+ group = (
181
+ dict(entries["toolframe.plugins"])
182
+ if entries.has_section("toolframe.plugins")
183
+ else {}
184
+ )
185
+ if len(group) != 1:
186
+ raise PluginError(
187
+ "entrypoint_invalid",
188
+ "One distribution must expose exactly one toolframe.plugins entrypoint",
189
+ )
190
+ identifier, entrypoint = next(iter(group.items()))
191
+ return {
192
+ "distribution": canonicalize_name(message["Name"]),
193
+ "version": message["Version"],
194
+ "plugin_id": identifier,
195
+ "entrypoint": entrypoint,
196
+ "extras": message.get_all("Provides-Extra", []),
197
+ }
198
+
199
+
200
+ def pinned_index_source(source):
201
+ """Only an exact index version can be reloaded without resolving an update."""
202
+ if not source.startswith("pypi:"):
203
+ return False
204
+ requirement = Requirement(source.removeprefix("pypi:"))
205
+ versions = list(requirement.specifier)
206
+ return (
207
+ not requirement.url
208
+ and len(versions) == 1
209
+ and versions[0].operator == "=="
210
+ and "*" not in versions[0].version
211
+ )
212
+
213
+
214
+ class Packages:
215
+ def __init__(self, home, settings):
216
+ self.home, self.settings = Path(home).resolve(), settings
217
+ self.tracing = tracing_requested()
218
+ self.root = self.home / ".toolframe"
219
+ for name in ("sources", "artifacts", "environments", "tmp"):
220
+ (self.root / name).mkdir(parents=True, exist_ok=True)
221
+ configured_uv = os.environ.get("TOOLFRAME_UV")
222
+ self.uv = shutil.which(configured_uv if configured_uv is not None else "uv")
223
+ if not self.uv:
224
+ raise PluginError(
225
+ "uv_missing",
226
+ f"Configured uv executable is unavailable: {configured_uv}"
227
+ if configured_uv is not None
228
+ else "The launcher or PATH must provide uv",
229
+ )
230
+ self.uv = str(Path(self.uv).resolve())
231
+
232
+ def flags(self):
233
+ result = ["--index-url", self.settings.index_url]
234
+ for link in [*self.settings.find_links, *self.bundled_links()]:
235
+ result += [
236
+ "--find-links",
237
+ str((self.home / link).resolve()) if "://" not in link else link,
238
+ ]
239
+ if self.settings.offline:
240
+ result += ["--offline"]
241
+ return result
242
+
243
+ def bundled_links(self):
244
+ """Use release wheels without persisting machine-local paths in TOML.
245
+
246
+ An explicit local wheel with the same filename replaces its bundled copy.
247
+ uv does not promise precedence between duplicate --find-links candidates.
248
+ """
249
+ directory = os.environ.get("TOOLFRAME_COMPANION_WHEELHOUSE") or os.environ.get(
250
+ "TOOLFRAME_BUNDLED_WHEELHOUSE"
251
+ )
252
+ if not directory:
253
+ return []
254
+ overrides = set()
255
+ for link in self.settings.find_links:
256
+ parsed = urllib.parse.urlsplit(link)
257
+ if parsed.scheme == "file" and parsed.netloc in ("", "localhost"):
258
+ path = Path(urllib.parse.unquote(parsed.path))
259
+ elif "://" not in link:
260
+ path = self.home / link
261
+ else:
262
+ continue
263
+ overrides.update(wheel.name for wheel in path.glob("*.whl"))
264
+ return [
265
+ str(wheel.parent)
266
+ for wheel in sorted(Path(directory).glob("*/*.whl"))
267
+ if wheel.name not in overrides
268
+ ]
269
+
270
+ def artifact(self, path):
271
+ data = path.read_bytes()
272
+ identifier = hashlib.sha256(data).hexdigest()
273
+ target = self.root / "artifacts" / identifier / path.name
274
+ target.parent.mkdir(parents=True, exist_ok=True)
275
+ if not target.exists():
276
+ target.write_bytes(data)
277
+ return target, identifier
278
+
279
+ async def download(self, url, target, expected=None):
280
+ parts = urllib.parse.urlsplit(url)
281
+ if parts.username or parts.password or parts.scheme != "https":
282
+ raise PluginError(
283
+ "source_url_invalid",
284
+ "Remote artifacts require HTTPS without embedded credentials",
285
+ )
286
+ if self.settings.offline:
287
+ raise PluginError(
288
+ "offline_artifact_missing",
289
+ "Artifact is not captured locally; offline mode cannot download it",
290
+ )
291
+ sha = hashlib.sha256()
292
+ size = 0
293
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: # noqa: SIM117
294
+ async with client.stream("GET", url) as response:
295
+ response.raise_for_status()
296
+ with target.open("wb") as handle:
297
+ async for chunk in response.aiter_bytes():
298
+ size += len(chunk)
299
+ if size > 512 * 1024 * 1024:
300
+ raise PluginError(
301
+ "artifact_too_large", "Artifact exceeds 512 MiB"
302
+ )
303
+ sha.update(chunk)
304
+ handle.write(chunk)
305
+ if expected and sha.hexdigest() != expected:
306
+ raise PluginError(
307
+ "artifact_hash_mismatch",
308
+ "Downloaded artifact differs from its pinned SHA256",
309
+ )
310
+
311
+ async def fetch_locked(self, item, target):
312
+ expected = item.get("hashes", {}).get("sha256")
313
+ url = item["url"]
314
+ if url.startswith("file://"):
315
+ shutil.copyfile(
316
+ Path(urllib.parse.unquote(urllib.parse.urlsplit(url).path)), target
317
+ )
318
+ else:
319
+ await self.download(url, target, expected)
320
+ actual = hashlib.sha256(target.read_bytes()).hexdigest()
321
+ if expected and actual != expected:
322
+ raise PluginError(
323
+ "artifact_hash_mismatch", "Resolved artifact changed before capture"
324
+ )
325
+ return actual
326
+
327
+ async def _build(self, source, destination):
328
+ await command(
329
+ self.uv,
330
+ "--no-config",
331
+ "build",
332
+ "--no-sources",
333
+ "--wheel",
334
+ "--python",
335
+ sys.executable,
336
+ "--out-dir",
337
+ destination,
338
+ *self.flags(),
339
+ source,
340
+ )
341
+ wheels = list(destination.glob("*.whl"))
342
+ if len(wheels) != 1:
343
+ raise PluginError("build_invalid", "Build must produce exactly one wheel")
344
+ return wheels[0]
345
+
346
+ async def capture(self, source):
347
+ cached = self.root / "source-resolutions" / f"{digest(source)}.json"
348
+ if (
349
+ self.settings.offline
350
+ and cached.exists()
351
+ and source.startswith(("pypi:", "git+", "https://"))
352
+ ):
353
+ value = json.loads(cached.read_text())
354
+ if (
355
+ hashlib.sha256((self.home / value["artifact"]).read_bytes()).hexdigest()
356
+ != value["artifact_hash"]
357
+ ):
358
+ raise PluginError(
359
+ "artifact_hash_mismatch", "Cached source artifact was modified"
360
+ )
361
+ return value
362
+ with tempfile.TemporaryDirectory(dir=self.root / "tmp") as temporary:
363
+ temporary = Path(temporary)
364
+ provenance = {"request": source}
365
+ if source.startswith("git+"):
366
+ if self.settings.offline:
367
+ raise PluginError(
368
+ "offline_source_missing",
369
+ "Use the existing locked candidate offline; Git capture needs a fetch",
370
+ )
371
+ url, _, fragment = source[4:].partition("#")
372
+ # A ref follows the last @ after the URL authority (SSH URLs are also supported).
373
+ authority_end = (
374
+ url.find("/", url.find("://") + 3)
375
+ if "://" in url
376
+ else url.find(":")
377
+ )
378
+ ref = "HEAD"
379
+ if url.rfind("@") > authority_end:
380
+ url, ref = url.rsplit("@", 1)
381
+ checkout = temporary / "checkout"
382
+ await command("git", "clone", "--no-checkout", "--", url, checkout)
383
+ try:
384
+ commit = (
385
+ await command(
386
+ "git",
387
+ "rev-parse",
388
+ "--verify",
389
+ f"{ref}^{{commit}}",
390
+ cwd=checkout,
391
+ )
392
+ ).strip()
393
+ except PluginError:
394
+ commit = (
395
+ await command(
396
+ "git",
397
+ "rev-parse",
398
+ "--verify",
399
+ f"origin/{ref}^{{commit}}",
400
+ cwd=checkout,
401
+ )
402
+ ).strip()
403
+ await command("git", "checkout", "--detach", commit, cwd=checkout)
404
+ subdirectory = urllib.parse.parse_qs(fragment).get(
405
+ "subdirectory", [""]
406
+ )[0]
407
+ root = (checkout / subdirectory).resolve()
408
+ if not root.is_relative_to(checkout):
409
+ raise PluginError(
410
+ "source_invalid", "Git subdirectory escapes checkout"
411
+ )
412
+ captured, source_hash = await asyncio.to_thread(
413
+ snapshot, root, self.root / "sources"
414
+ )
415
+ provenance.update(kind="git", commit=commit, source_hash=source_hash)
416
+ wheel = await self._build(captured, temporary / "wheel")
417
+ elif source.startswith("https://"):
418
+ url, _, fragment = source.partition("#")
419
+ name = Path(urllib.parse.urlsplit(url).path).name
420
+ if not name.endswith(".whl"):
421
+ raise PluginError(
422
+ "source_invalid", "Direct HTTPS sources must be wheels"
423
+ )
424
+ wheel = temporary / name
425
+ expected = urllib.parse.parse_qs(fragment).get("sha256", [None])[0]
426
+ await self.download(url, wheel, expected)
427
+ provenance.update(kind="wheel")
428
+ elif source.startswith("pypi:"):
429
+ requirement = str(Requirement(source.removeprefix("pypi:")))
430
+ requirements = temporary / "requirements.in"
431
+ requirements.write_text(requirement + "\n")
432
+ lockfile = temporary / "pylock.toml"
433
+ await command(
434
+ self.uv,
435
+ "--no-config",
436
+ "pip",
437
+ "compile",
438
+ "--no-deps",
439
+ "--format",
440
+ "pylock.toml",
441
+ "--python",
442
+ sys.executable,
443
+ "--output-file",
444
+ lockfile,
445
+ *self.flags(),
446
+ requirements,
447
+ )
448
+ packages = tomllib.loads(lockfile.read_text())["packages"]
449
+ if len(packages) != 1:
450
+ raise PluginError(
451
+ "source_invalid", "PyPI source must resolve one distribution"
452
+ )
453
+ package = packages[0]
454
+ compatible = set(sys_tags())
455
+ chosen = None
456
+ for item in package.get("wheels", []):
457
+ name = Path(urllib.parse.urlsplit(item["url"]).path).name
458
+ if compatible & parse_wheel_filename(name)[3]:
459
+ chosen = item
460
+ break
461
+ if chosen is None:
462
+ chosen = package.get("sdist")
463
+ if not chosen:
464
+ raise PluginError(
465
+ "artifact_unavailable",
466
+ "Index package has no compatible wheel or source archive",
467
+ )
468
+ archive = (
469
+ temporary / Path(urllib.parse.urlsplit(chosen["url"]).path).name
470
+ )
471
+ provenance["sdist_hash"] = await self.fetch_locked(chosen, archive)
472
+ self.artifact(archive)
473
+ wheel = await self._build(archive, temporary / "wheel")
474
+ else:
475
+ wheel = (
476
+ temporary / Path(urllib.parse.urlsplit(chosen["url"]).path).name
477
+ )
478
+ await self.fetch_locked(chosen, wheel)
479
+ provenance.update(
480
+ kind="pypi",
481
+ distribution=package["name"],
482
+ version=package["version"],
483
+ )
484
+ else:
485
+ raw = source.removeprefix("path:").removeprefix("wheel:")
486
+ path = (self.home / raw).resolve()
487
+ if path.is_dir():
488
+ captured, source_hash = await asyncio.to_thread(
489
+ snapshot, path, self.root / "sources"
490
+ )
491
+ provenance.update(
492
+ kind="local", path=str(path), source_hash=source_hash
493
+ )
494
+ wheel = await self._build(captured, temporary / "wheel")
495
+ elif path.is_file() and path.suffix == ".whl":
496
+ wheel = temporary / path.name
497
+ shutil.copyfile(path, wheel)
498
+ provenance.update(kind="wheel", path=str(path))
499
+ else:
500
+ raise PluginError("source_missing", f"Unknown local source: {path}")
501
+ metadata = wheel_metadata(wheel)
502
+ stored, wheel_hash = self.artifact(wheel)
503
+ result = {
504
+ **metadata,
505
+ "artifact": str(stored.relative_to(self.home)),
506
+ "artifact_hash": wheel_hash,
507
+ "source": provenance,
508
+ }
509
+ atomic_write(cached, json.dumps(result))
510
+ return result
511
+
512
+ def sdk_wheel(self):
513
+ wheel = os.environ.get("TOOLFRAME_SDK_WHEEL")
514
+ if wheel and Path(wheel).is_file():
515
+ return Path(wheel).resolve()
516
+ for link in self.settings.find_links:
517
+ if "://" in link:
518
+ continue
519
+ matches = sorted(
520
+ (self.home / link).glob(f"toolframe_sdk-{SDK_VERSION}-*.whl")
521
+ )
522
+ if matches:
523
+ return matches[-1].resolve()
524
+ return None
525
+
526
+ def runtime_profile(self):
527
+ wheel = self.sdk_wheel()
528
+ return {
529
+ "tracing": self.tracing,
530
+ "sdk_version": SDK_VERSION,
531
+ "sdk_sha256": hashlib.sha256(wheel.read_bytes()).hexdigest()
532
+ if wheel
533
+ else None,
534
+ }
535
+
536
+ async def environment(self, artifacts, *, ui=False):
537
+ requirements = []
538
+ names = set()
539
+ for artifact in artifacts:
540
+ name = artifact["distribution"]
541
+ if name in names:
542
+ raise PluginError(
543
+ "duplicate_distribution",
544
+ f"Conflicting distributions in environment: {name}",
545
+ )
546
+ names.add(name)
547
+ extra = "ui" if ui else "backend"
548
+ suffix = f"[{extra}]" if extra in artifact.get("extras", []) else ""
549
+ path = self.home / artifact["artifact"]
550
+ if (
551
+ hashlib.sha256(path.read_bytes()).hexdigest()
552
+ != artifact["artifact_hash"]
553
+ ):
554
+ raise PluginError(
555
+ "artifact_hash_mismatch", "Captured wheel was modified"
556
+ )
557
+ requirements.append(f"{name}{suffix} @ {path.as_uri()}")
558
+ # A release supplies its SDK wheel; development may provide it through a wheelhouse.
559
+ if "toolframe-sdk" not in names:
560
+ wheel = self.sdk_wheel()
561
+ if wheel:
562
+ sdk, _ = self.artifact(wheel)
563
+ requirements.append(f"toolframe-sdk @ {sdk.as_uri()}")
564
+ if self.tracing:
565
+ requirements.append(f"toolframe-sdk[tracing]=={SDK_VERSION}")
566
+ profile = self.runtime_profile()
567
+ with tempfile.TemporaryDirectory(dir=self.root / "tmp") as temporary:
568
+ temporary = Path(temporary)
569
+ input_file = temporary / "requirements.in"
570
+ input_file.write_text("\n".join(requirements) + "\n")
571
+ lockfile = temporary / "pylock.toml"
572
+ await command(
573
+ self.uv,
574
+ "--no-config",
575
+ "pip",
576
+ "compile",
577
+ "--no-header",
578
+ "--format",
579
+ "pylock.toml",
580
+ "--python",
581
+ sys.executable,
582
+ "--output-file",
583
+ lockfile,
584
+ *self.flags(),
585
+ input_file,
586
+ )
587
+ document = tomlkit.parse(lockfile.read_text())
588
+ for package in document.get("packages", []):
589
+ for wheel in package.get("wheels", []):
590
+ if wheel.get("url", "").startswith("file://"):
591
+ path = Path(
592
+ urllib.parse.unquote(
593
+ urllib.parse.urlsplit(wheel["url"]).path
594
+ )
595
+ )
596
+ stored, checksum = self.artifact(path)
597
+ wheel["url"] = stored.as_uri()
598
+ wheel["hashes"] = {"sha256": checksum}
599
+ lock = tomlkit.dumps(document).replace(str(self.home), "${TOOLFRAME_HOME}")
600
+ key = digest(
601
+ {
602
+ "lock": lock,
603
+ "python": sys.version,
604
+ "platform": platform.platform(),
605
+ "ui": ui,
606
+ "runtime_profile": profile,
607
+ }
608
+ )
609
+ target = self.root / "environments" / key
610
+ if not (target / ".ready").exists():
611
+ if target.exists():
612
+ shutil.rmtree(target)
613
+ await command(
614
+ self.uv, "--no-config", "venv", "--python", sys.executable, target
615
+ )
616
+ expanded = target / "pylock.toml"
617
+ expanded.write_text(lock.replace("${TOOLFRAME_HOME}", str(self.home)))
618
+ await command(
619
+ self.uv,
620
+ "--no-config",
621
+ "pip",
622
+ "sync",
623
+ "--python",
624
+ target / "bin/python",
625
+ *self.flags(),
626
+ expanded,
627
+ )
628
+ atomic_write(target / ".ready", key)
629
+ return {
630
+ "id": key,
631
+ "python": str((target / "bin/python").relative_to(self.home)),
632
+ "lock": lock,
633
+ "runtime_profile": profile,
634
+ }
635
+
636
+ async def prepare(self, identifier, source, config, *, retained=None):
637
+ if retained is None:
638
+ artifact = await self.capture(source)
639
+ environment = None
640
+ else:
641
+ # Lifecycle operations can reuse an installed immutable wheel even
642
+ # after a release stops bundling it or its original source is gone.
643
+ # Still check integrity and rerun import/config preflight below.
644
+ if retained["source"]["request"] != source:
645
+ raise PluginError(
646
+ "source_mismatch", "Retained candidate source differs"
647
+ )
648
+ path = self.home / retained["artifact"]
649
+ if not path.is_file():
650
+ raise PluginError(
651
+ "artifact_missing", "Retained plugin wheel is missing"
652
+ )
653
+ if (
654
+ hashlib.sha256(path.read_bytes()).hexdigest()
655
+ != retained["artifact_hash"]
656
+ ):
657
+ raise PluginError(
658
+ "artifact_hash_mismatch", "Retained plugin wheel was modified"
659
+ )
660
+ artifact = {
661
+ **wheel_metadata(path),
662
+ "artifact": retained["artifact"],
663
+ "artifact_hash": retained["artifact_hash"],
664
+ "source": retained["source"],
665
+ }
666
+ environment = retained["environment"]
667
+ python = self.home / environment["python"]
668
+ if (
669
+ not python.is_file()
670
+ or not (python.parent.parent / ".ready").is_file()
671
+ or environment.get("runtime_profile") != self.runtime_profile()
672
+ ):
673
+ environment = None
674
+ if artifact["plugin_id"] != identifier:
675
+ raise PluginError(
676
+ "identity_mismatch",
677
+ "Entry point name differs from configured plugin ID",
678
+ )
679
+ if environment is None:
680
+ environment = await self.environment([artifact])
681
+
682
+ async def unavailable(method, params):
683
+ raise PluginError(
684
+ "preflight_only", "Preflight cannot access application services"
685
+ )
686
+
687
+ worker = await WorkerProcess.start(
688
+ self.home / environment["python"],
689
+ artifact["entrypoint"],
690
+ unavailable,
691
+ cwd=self.home,
692
+ )
693
+ try:
694
+ report = await worker.request("runtime.describe")
695
+ definition = PluginDefinition.model_validate(report["definition"])
696
+ if report["protocol"] != PROTOCOL_VERSION or definition.id != identifier:
697
+ raise PluginError(
698
+ "identity_mismatch",
699
+ "Worker definition/protocol does not match the installed entrypoint",
700
+ )
701
+ try:
702
+ validate(definition.config_schema, config)
703
+ except Exception as error:
704
+ raise PluginError(
705
+ "config_invalid",
706
+ "Configuration does not match the plugin schema",
707
+ {"path": list(getattr(error, "absolute_path", ()))},
708
+ ) from error
709
+ finally:
710
+ try:
711
+ await worker.close(activated=False)
712
+ except BaseException:
713
+ await (
714
+ worker.force_stop()
715
+ ) # isolated preflight has no application state or live work
716
+ raise
717
+ candidate = {
718
+ **artifact,
719
+ "environment": environment,
720
+ "definition": definition.model_dump(mode="json"),
721
+ "documents": report["documents"],
722
+ "config": config,
723
+ "validation": {"preflight": "passed", "runtime": "not_run"},
724
+ }
725
+ candidate["id"] = digest(
726
+ {key: value for key, value in candidate.items() if key != "validation"}
727
+ )
728
+ return candidate