Skip to content
File

Blob: src/pyodide/make_snapshots.py

python300 lines
1import argparse
2import json
3import re
4import shutil
5import subprocess
6import sys
7from copy import deepcopy
8from functools import cache
9from os import environ
10from pathlib import Path
11from tempfile import TemporaryDirectory
12from textwrap import dedent, indent
13 
14from tool_utils import hexdigest, run, timing
15 
16 
17def cquery(rule):
18 res = subprocess.run(
19 [
20 "bazel",
21 "cquery",
22 rule,
23 "--output=files",
24 ],
25 capture_output=True,
26 text=True,
27 check=False,
28 )
29 if res.returncode:
30 print(res.stdout)
31 print(res.stderr)
32 sys.exit(res.returncode)
33 return res.stdout.strip()
34 
35 
36@cache
37def _bundle_version_info():
38 with Path(cquery("@workerd//src/pyodide:bundle_version_info")).open() as f:
39 return json.load(f)
40 
41 
42def bundle_version_info():
43 return deepcopy(_bundle_version_info())
44 
45 
46TEMPLATE = """
47using Workerd = import "/workerd/workerd.capnp";
48
49const config :Workerd.Config = (
50 services = [
51 (name = "main", worker = .mainWorker),
52 ],
53);
54
55const mainWorker :Workerd.Worker = (
56 modules = [
57 (name = "worker.py", pythonModule = embed "./worker.py"),
58 {requirements}
59 ],
60 compatibilityDate = "2025-08-05",
61 compatibilityFlags = ["python_no_global_handlers", {compat_flags}],
62 # Learn more about compatibility dates at:
63 # https://developers.cloudflare.com/workers/platform/compatibility-dates/
64);
65"""
66 
67 
68def make_config(
69 flags: list[str],
70 reqs: list[str],
71) -> str:
72 requirements = ""
73 for name in reqs:
74 requirements += f'(name="{name}", pythonRequirement=""),'
75 
76 compat_flags = ""
77 for flag in flags:
78 compat_flags += f'"{flag}", '
79 return TEMPLATE.format(requirements=requirements, compat_flags=compat_flags)
80 
81 
82def make_worker(imports: list[str]) -> str:
83 contents = ""
84 for i in imports:
85 contents += f"import {i}\n"
86 contents += dedent("""\
87 from workers import WorkerEntrypoint
88 class Default(WorkerEntrypoint):
89 def test(self):
90 pass
91 """)
92 return contents
93 
94 
95def make_snapshot( # noqa: PLR0913
96 d: Path,
97 outdir: Path,
98 outprefix: str,
99 compat_flags: list[str],
100 requirements: list[str],
101 imports: list[str],
102) -> str:
103 config_path = d / "config.capnp"
104 config_path.write_text(make_config(compat_flags, requirements))
105 worker_path = d / "worker.py"
106 worker_path.write_text(make_worker(imports))
107 if imports:
108 snapshot_flag = "--python-save-snapshot"
109 else:
110 snapshot_flag = "--python-save-baseline-snapshot"
111 
112 if "WORKERD_BINARY" in environ:
113 workerd = [environ["WORKERD_BINARY"]]
114 else:
115 workerd = [
116 "bazel",
117 "run",
118 "@workerd//src/workerd/server:workerd",
119 "--",
120 ]
121 run(
122 [
123 *workerd,
124 "test",
125 config_path,
126 snapshot_flag,
127 "--pyodide-bundle-disk-cache-dir",
128 d,
129 "--pyodide-package-disk-cache-dir",
130 d,
131 "--experimental",
132 ],
133 )
134 snapshot_path = d / "snapshot.bin"
135 digest = hexdigest(snapshot_path)
136 digest9 = digest[:9]
137 outname = f"{outprefix}-{digest9}.bin"
138 outfile = outdir / outname
139 shutil.copyfile(snapshot_path, outfile)
140 snapshot_path.unlink()
141 return [outname, digest]
142 
143 
144def make_baseline_snapshot(
145 cache: Path, outdir: Path, compat_flags: list[str]
146) -> list[tuple[str, str]]:
147 name, digest = make_snapshot(cache, outdir, "baseline", compat_flags, [], [])
148 return [
149 ("baseline_snapshot", name),
150 ("baseline_snapshot_hash", digest),
151 ]
152 
153 
154def make_numpy_snapshot(
155 cache: Path, outdir: Path, compat_flags: list[str]
156) -> list[tuple[str, str]]:
157 name, digest = make_snapshot(
158 cache, outdir, "package_snapshot_numpy", compat_flags, ["numpy"], ["numpy"]
159 )
160 return [
161 ("numpy_snapshot", name),
162 ("numpy_snapshot_hash", digest),
163 ]
164 
165 
166def make_fastapi_snapshot(
167 cache: Path, outdir: Path, compat_flags: list[str]
168) -> list[tuple[str, str]]:
169 name, digest = make_snapshot(
170 cache,
171 outdir,
172 "package_snapshot_fastapi",
173 compat_flags,
174 ["fastapi"],
175 ["fastapi", "pydantic"],
176 )
177 return [
178 ("fastapi_snapshot", name),
179 ("fastapi_snapshot_hash", digest),
180 ]
181 
182 
183def make_snapshots(
184 cache: Path, outdir: Path, update_released: bool
185) -> tuple[str, tuple[str, str]]:
186 res = []
187 for ver, info in bundle_version_info().items():
188 if ver.startswith("dev"):
189 continue
190 if not update_released and info.get("released", False):
191 continue
192 compat_flags = list({"python_workers", info["enable_flag_name"]})
193 
194 ver_info = []
195 with timing(f"version {ver} snapshots"):
196 with timing("baseline snapshot"):
197 ver_info += make_baseline_snapshot(cache, outdir, compat_flags)
198 with timing("numpy snapshot"):
199 ver_info += make_numpy_snapshot(cache, outdir, compat_flags)
200 with timing("fastapi snapshot"):
201 ver_info += make_fastapi_snapshot(cache, outdir, compat_flags)
202 res.append((ver, ver_info))
203 return res
204 
205 
206def update_python_metadata_bzl(res: tuple[str, tuple[str, str]]):
207 """Update python_metadata.bzl file with new snapshot values."""
208 metadata_path = (
209 Path(__file__).parent.parent.parent / "build" / "python_metadata.bzl"
210 )
211 content = metadata_path.read_text()
212 
213 for ver, kvs in res:
214 # Find the version block and update snapshot values
215 version_pattern = rf'(\s+{{\s*\n\s*"name":\s*"{re.escape(ver)}",.*?)}}'
216 
217 def replace_version_block(match, *, kvs=kvs):
218 block = match.group(1)
219 # Update each key-value pair
220 for key, val in kvs:
221 key_pattern = rf'("{re.escape(key)}":\s*)"[^"]*"'
222 block = re.sub(key_pattern, rf'\1"{val}"', block)
223 return block + "}"
224 
225 content = re.sub(
226 version_pattern, replace_version_block, content, flags=re.DOTALL
227 )
228 
229 metadata_path.write_text(content)
230 
231 
232def upload_snapshots(outdir: Path):
233 from boto3 import client
234 
235 s3 = client(
236 "s3",
237 endpoint_url=f"https://{environ['R2_ACCOUNT_ID']}.r2.cloudflarestorage.com",
238 aws_access_key_id=environ["R2_ACCESS_KEY_ID"],
239 aws_secret_access_key=environ["R2_SECRET_ACCESS_KEY"],
240 region_name="auto",
241 )
242 
243 for file in outdir.glob("*.bin"):
244 if file.name.startswith("baseline-"):
245 key = "baseline-snapshot/" + hexdigest(file)
246 else:
247 key = "test-snapshot/" + file.name
248 s3.upload_file(str(file), "pyodide-capnp-bin", key)
249 
250 
251def main() -> int:
252 parser = argparse.ArgumentParser(
253 description="Upload Pyodide bundles and update metadata"
254 )
255 parser.add_argument(
256 "--update-released",
257 action="store_true",
258 help="Update already released versions?",
259 )
260 args = parser.parse_args()
261 
262 subprocess.run(
263 [
264 "bazel",
265 "build",
266 "@workerd//src/pyodide:bundle_version_info",
267 ],
268 check=True,
269 )
270 
271 # Create generated-snapshots directory
272 outdir = Path(__file__).parent / "generated-snapshots"
273 if outdir.exists() and outdir.is_dir() and any(outdir.iterdir()):
274 print(f"Error: Directory {outdir} exists and is not empty", file=sys.stderr)
275 return 1
276 outdir.mkdir(parents=True, exist_ok=True)
277 
278 with TemporaryDirectory() as package_cache:
279 cache = Path(package_cache)
280 res = make_snapshots(cache, outdir, args.update_released)
281 
282 update_python_metadata_bzl(res)
283 
284 upload_snapshots(outdir)
285 print()
286 print(
287 "Upload these files to the ew-snapshot-tests R2 bucket: "
288 + "https://dash.cloudflare.com/e415f1017791ced9d5f3eb0df2b31c9e/r2/default/buckets/ew-snapshot-tests"
289 )
290 print("Updated python_metadata.bzl with:")
291 for ver, kvs in res:
292 print("Version", ver)
293 for key, val in kvs:
294 print(indent(f'"{key}": "{val}",', " " * 8))
295 return 0
296 
297 
298if __name__ == "__main__":
299 sys.exit(main())