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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- 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
|