Skip to content
File

Blob: src/pyodide/create_vendor_zip.py

python177 lines
1#!/usr/bin/env python3
2"""
3Script to vendor a Python package for vendored_py_wd_test.
4
5This script creates a pyproject.toml with the specified package as a dependency,
6runs `uv run pywrangler sync` to download and prepare the package, and then
7creates a zip file of the resulting python_modules directory.
8"""
9 
10import argparse
11import os
12import shutil
13import sys
14import zipfile
15from pathlib import Path
16from typing import Literal
17 
18from tool_utils import hexdigest, run
19 
20PYPROJECT_TEMPLATE = """[project]
21name = "vendor-test"
22version = "0.1.0"
23requires-python = ">=3.12"
24dependencies = {dependencies}
25
26[dependency-groups]
27dev = ["workers-py>=1.6"]
28"""
29 
30WRANGLER_TOML = """
31name = "hello-python-bindings"
32main = "src/entry.py"
33compatibility_flags = {compat_flags}
34compatibility_date = "2025-08-14"
35"""
36 
37type PyVer = Literal["3.12", "3.13"]
38 
39 
40def create_pyproject_toml(package_names: list[str], target_dir: Path) -> Path:
41 """Create a pyproject.toml file with the specified package as a dependency."""
42 pyproject_content = PYPROJECT_TEMPLATE.format(dependencies=repr(package_names))
43 pyproject_path = target_dir / "pyproject.toml"
44 pyproject_path.write_text(pyproject_content)
45 return pyproject_path
46 
47 
48def create_wrangler_toml(target_dir: Path, python: PyVer):
49 compat_flags = ["python_workers"]
50 match python:
51 case "3.13":
52 compat_flags.append("python_workers_20250116")
53 
54 (target_dir / "wrangler.toml").write_text(
55 WRANGLER_TOML.format(compat_flags=repr(compat_flags))
56 )
57 
58 
59def run_pywrangler_sync(work_dir: Path) -> Path:
60 """Run `uv run pywrangler sync` in the specified directory."""
61 env = os.environ.copy()
62 env["_PYODIDE_EXTRA_MOUNTS"] = str(work_dir)
63 # TODO: Make pywrangler understand how to use Python 3.13 correctly and
64 # remove these extra commands
65 run(["uv", "run", "pywrangler", "sync"], cwd=work_dir, env=env)
66 python_modules_dir = work_dir / "python_modules"
67 if not python_modules_dir.exists():
68 print(f"Error: python_modules directory not found at {python_modules_dir}")
69 sys.exit(1)
70 return python_modules_dir
71 
72 
73def create_zip_archive(source_dir: Path, output_dir: Path) -> bool:
74 """Create a zip archive of the python_modules directory.
75
76 Return value indicates whether the archive includes any binary modules (.so
77 files).
78 """
79 tmp_path = output_dir / "tmp.zip"
80 native = False
81 
82 with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zipf:
83 for file_path in source_dir.rglob("*"):
84 if file_path.is_file():
85 # Store relative path from python_modules directory
86 arcname = file_path.relative_to(source_dir)
87 zipf.write(file_path, arcname)
88 if file_path.suffix == ".so":
89 native = True
90 
91 return native
92 
93 
94def vendor_package(package_names: list[str], python: PyVer) -> tuple[Path, bool]:
95 """Main function to vendor a Python package."""
96 tmp_dir = Path("/tmp")
97 vendor_name = "-".join(package_names)
98 work_dir = tmp_dir / f"vendor-{vendor_name}"
99 
100 # Clean up any existing work directory
101 if work_dir.exists():
102 shutil.rmtree(work_dir)
103 work_dir.mkdir(parents=True)
104 
105 try:
106 # Create pyproject.toml
107 print(f"Creating pyproject.toml in {work_dir}")
108 create_pyproject_toml(package_names, work_dir)
109 create_wrangler_toml(work_dir, python)
110 
111 # Run pywrangler sync
112 print("Running uv run pywrangler sync...")
113 python_modules_dir = run_pywrangler_sync(work_dir)
114 
115 # Create zip archive
116 print("Creating zip archive...")
117 native = create_zip_archive(python_modules_dir, tmp_dir)
118 py = f"-{python}" if native else ""
119 zip_name = f"{vendor_name}{py}-vendored-for-ew-testing.zip"
120 zip_path = tmp_dir / zip_name
121 shutil.move(tmp_dir / "tmp.zip", zip_path)
122 except Exception as e:
123 print(f"Error vendoring packages {package_names!r}: {e}")
124 sys.exit(1)
125 else:
126 print(f"Successfully created: {zip_path}")
127 print(
128 "Upload this zip file to the ew-snapshot-tests R2 bucket: "
129 + "https://dash.cloudflare.com/e415f1017791ced9d5f3eb0df2b31c9e/r2/default/buckets/ew-snapshot-tests"
130 )
131 return zip_path, native
132 finally:
133 # Clean up work directory
134 if work_dir.exists():
135 shutil.rmtree(work_dir)
136 
137 
138def main() -> int:
139 """Main entry point."""
140 parser = argparse.ArgumentParser(
141 description="Create a zip file of a vendored Python package's source files for vendored_py_wd_test."
142 )
143 parser.add_argument(
144 "package_name", help="Name of the Python package to vendor", nargs="*"
145 )
146 parser.add_argument("-p", "--python", help="Name of the Python version to use")
147 
148 args = parser.parse_args()
149 if args.python is None:
150 args.python = "3.12"
151 
152 if not args.package_name:
153 print("Error: Package name is required")
154 return 1
155 
156 try:
157 zip_path, native = vendor_package(args.package_name, args.python)
158 print("Update python_metadata.bzl with:\n")
159 abi = args.python if native else None
160 i1 = " " * 12
161 i2 = " " * 16
162 print(i1 + "{")
163 print(i2 + f'"name": "{"-".join(args.package_name)}",')
164 print(i2 + f'"abi": {abi!r},')
165 print(i2 + f'"sha256": "{hexdigest(zip_path)}",')
166 print(i1 + "},")
167 print()
168 except KeyboardInterrupt:
169 print("\nOperation cancelled by user")
170 return 1
171 else:
172 return 0
173 
174 
175if __name__ == "__main__":
176 sys.exit(main())