Skip to content

Commit 8e8a9d7

Browse files
committed
Simplified wheelbuilder with delocate/auditwheel for win/mac/lin
1 parent f9b2f31 commit 8e8a9d7

24 files changed

+863
-2474
lines changed

.github/workflows/build-act-wheels.yml

-577
This file was deleted.

.github/workflows/build-linux-aarch64-wheels.yml

+5-916
Large diffs are not rendered by default.

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ language runtime. The main focus is on user-observable behavior of the engine.
1313
* This means all Python methods of these types are available on the corresponding foreign objects, which behave as close as possible as if they were Python objects.
1414
* See [the documentation](https://github.com/oracle/graalpython/blob/master/docs/user/Interoperability.md#interacting-with-foreign-objects-from-python-scripts) for more information.
1515
* Remove support for running with Sulong managed both in embeddings as well as through the `graalpy-managed` launcher.
16+
* Rewrite wheelbuilder to be easier to use and contribute to. This version is now the same we run internally to build publishable wheels for some platforms we support, so the community can build the same wheels on their own hardware easily if desired.
1617

1718
## Version 24.1.0
1819
* GraalPy is now considered stable for pure Python workloads. While many workloads involving native extension modules work, we continue to consider them experimental. You can use the command-line option `--python.WarnExperimentalFeatures` to enable warnings for such modules at runtime. In Java embeddings the warnings are enabled by default and you can suppress them by setting the context option 'python.WarnExperimentalFeatures' to 'false'.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ required-version = '23'
88
# Disables the black formatter in this repository
99
# If individual suites want to enable the formatter, they can create a
1010
# pyproject.toml with their own configuration in their suite folder
11-
force-exclude = '.*'
11+
force-exclude = '.*graalpy.*|.*scripts/[^w].*py'

scripts/wheelbuilder/README.md

+11-33
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Scripts to build wheels for GraalPy.
22

33
[GraalPy](https://github.com/oracle/graalpython) is compatible with many Python libraries, including those that extend the Python runtime with native code.
4-
However, implemented in Java and thus binary incompatible with existing extensions, users of native Python extension libraries such as NumPy, SciPy, or PyTorch have to build their own binaries when installing these libraries.
4+
However, implemented in Java and thus binary incompatible with existing extensions, users of native Python extension libraries such as NumPy, SciPy, or PyTorch have to build their own binaries when installing these libraries if neither the project nor the GraalPy team provides prebuilt wheels.
55
For many libraries, this means installing additional build dependencies and sitting through long and resource-intensive compilation processes.
66

7-
This project is meant to be a place for the community to collect build recipes for as many popular packages as possible that can then be built once with GitHub Actions for each major release of GraalPy.
7+
This project is meant to be a place for the community to collect build recipes for as many popular packages as possible that can then be built individually or in CI/CD systems like GitHub Actions.
88

99
## Quickstart
1010

@@ -16,42 +16,20 @@ This project is meant to be a place for the community to collect build recipes f
1616

1717
4. Click on "Run workflow".
1818
You can enter a package name or build all packages.
19-
See [the spec list](../../../../blob/master/scripts/wheelbuilder/generate_workflow.py) for which packages are available.
19+
See [the platform subfolders](../../../../blob/master/scripts/wheelbuilder/) for which packages have buildscripts.
2020

2121
![](guide02.png)
2222

2323
## How to contribute
2424

25-
There should be only one relevant file, `generate_workflow.py`.
26-
In it we collect `BuildSpec` objects that define how to build a particular package.
27-
Many packages do not need special definitions, just a name and maybe which platforms to build it for.
28-
System package dependencies can be specified by platform where needed.
29-
Dependencies between specs are not strictly necessary, but can reduce the overall build times and thus resource usage of GitHub Action runners.
30-
31-
Changes to `generate_workflow.py` are reflected in the build specs by running the file.
32-
It creates GitHub Action workflow files, one for each platform, and a giant one with all jobs.
25+
We collect simple build scripts per platform and package in the `linux`, `darwin`, and `win32` subdirectories.
26+
The format is simply the package name followed by `.sh` for macOS and Linux or `.bat` for Windows.
27+
An additional component can be added in between the name and the extension.
28+
This file is then only run if the process environment contains a variable matching the middle component.
29+
That can be useful to put things like package installations specific to GitHub Actions while keeping the main build script generic for other platforms.
3330

3431
## How to run this
3532

36-
Many packages use a lot of resources to build, and even those that do not quickly add up.
37-
We have chosen GitHub Action workflows as the cross-platform specification for how to build packages.
38-
39-
### Running actions locally with nektos/act
40-
41-
[Act](https://github.com/nektos/act) allows running GitHub actions locally.
42-
We can use that to just build packages on a local machine:
43-
44-
```
45-
./act --artifact-server-path /tmp/artifacts \
46-
-W .github/workflows/build-linux-amd64-wheels.yml \
47-
-P self-hosted=-self-hosted \
48-
-P macOS=-self-hosted -P Linux=-self-hosted \
49-
-P X64=-self-hosted -P ARM64=-self-hosted \
50-
--input name=psutil
51-
```
52-
53-
You can vary the `--input name=` argument to select which package to build or to build all.
54-
55-
On Linux you will need Docker or Podman.
56-
If you're using Podman, make sure you are running the system service (e.g.
57-
`podman system service -t 0` to run it in a shell), have symlinked or aliased `docker` to `podman`, and prepared you env with `export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock` to allow `act` to pick up where podman is listening.
33+
Just run the `build_wheels.py` script.
34+
It expects a URL to download the GraalPy release from.
35+
You can set the environment variable `PACKAGES_TO_BUILD` to a comma-separated list of package build scripts you want to consider.

scripts/wheelbuilder/build_wheels.py

+312
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
# Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.
2+
# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
3+
#
4+
# The Universal Permissive License (UPL), Version 1.0
5+
#
6+
# Subject to the condition set forth below, permission is hereby granted to any
7+
# person obtaining a copy of this software, associated documentation and/or
8+
# data (collectively the "Software"), free of charge and under any and all
9+
# copyright rights in the Software, and any and all patent rights owned or
10+
# freely licensable by each licensor hereunder covering either (i) the
11+
# unmodified Software as contributed to or provided by such licensor, or (ii)
12+
# the Larger Works (as defined below), to deal in both
13+
#
14+
# (a) the Software, and
15+
#
16+
# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
17+
# one is included with the Software each a "Larger Work" to which the Software
18+
# is contributed by such licensors),
19+
#
20+
# without restriction, including without limitation the rights to copy, create
21+
# derivative works of, display, perform, and distribute the Software and make,
22+
# use, sell, offer for sale, import, export, have made, and have sold the
23+
# Software and the Larger Work(s), and to sublicense the foregoing rights on
24+
# either these or other terms.
25+
#
26+
# This license is subject to the following condition:
27+
#
28+
# The above copyright notice and either this complete permission notice or at a
29+
# minimum a reference to the UPL must be included in all copies or substantial
30+
# portions of the Software.
31+
#
32+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38+
# SOFTWARE.
39+
40+
__doc__ = """
41+
A very simple script to build wheels for which we have build scripts.
42+
The steps are:
43+
44+
1. Download GraalPy
45+
2. Make a venv
46+
3. Install some default packages like wheel or auditwheel
47+
4. Go over the build scripts and run them in the venv
48+
"""
49+
50+
import hashlib
51+
import importlib
52+
import os
53+
import re
54+
import shutil
55+
import subprocess
56+
import sys
57+
import tarfile
58+
import zipfile
59+
60+
from argparse import ArgumentParser
61+
from glob import glob
62+
from os.path import abspath, basename, dirname, exists, isabs, join, splitext
63+
from tempfile import TemporaryDirectory
64+
from urllib.request import urlretrieve
65+
66+
67+
def ensure_installed(name):
68+
try:
69+
return importlib.import_module(name)
70+
except ImportError:
71+
subprocess.check_call([sys.executable, "-m", "pip", "install", name])
72+
return importlib.import_module(name)
73+
74+
75+
def download(url, out):
76+
print("Downloading", url, flush=True)
77+
urlretrieve(url, out)
78+
79+
80+
def extract(archive):
81+
print("Extracting", archive, flush=True)
82+
if splitext(archive)[1] == ".zip":
83+
with zipfile.ZipFile(archive) as f:
84+
f.extractall()
85+
else:
86+
with tarfile.open(archive) as f:
87+
f.extractall()
88+
89+
90+
def create_venv():
91+
if sys.platform == "win32":
92+
exe = ".exe"
93+
pip = "graalpy/Scripts/pip.exe"
94+
else:
95+
exe = ""
96+
pip = "graalpy/bin/pip"
97+
binary = next(iter(glob(f"graalpy-*/bin/graalpy{exe}")))
98+
print("Creating venv with", binary, flush=True)
99+
subprocess.check_call([binary, "-m", "venv", "graalpy"])
100+
print("Installing wheel with", pip, flush=True)
101+
subprocess.check_call([pip, "install", "wheel"])
102+
return pip
103+
104+
105+
def build_wheels(pip):
106+
packages_selected = [s for s in os.environ.get("PACKAGES_TO_BUILD", "").split(",") if s]
107+
packages_to_build = set()
108+
with open(join(dirname(__file__), "packages.txt")) as f:
109+
for line in f.readlines():
110+
name, version = line.split("==")
111+
if not packages_selected or name in packages_selected or line in packages_selected:
112+
packages_to_build.add(line)
113+
scriptdir = abspath(join(dirname(__file__), sys.platform))
114+
if sys.platform == "win32":
115+
script_ext = "bat"
116+
else:
117+
script_ext = "sh"
118+
if exists(scriptdir):
119+
available_scripts = {s.lower(): s for s in os.listdir(scriptdir)}
120+
else:
121+
available_scripts = {}
122+
for spec in packages_to_build:
123+
name, version = spec.split("==")
124+
whl_count = len(glob("*.whl"))
125+
script = f"{name}.{version}.{script_ext}".lower()
126+
if script not in available_scripts:
127+
script = f"{name}.{script_ext}".lower()
128+
if script in available_scripts:
129+
script = join(scriptdir, available_scripts[script])
130+
env = os.environ.copy()
131+
env["PATH"] = abspath(dirname(pip)) + os.pathsep + env["PATH"]
132+
env["VIRTUAL_ENV"] = abspath(dirname(dirname(pip)))
133+
print("Building", name, version, "with", script, flush=True)
134+
subprocess.check_call([script, version], shell=True, env=env)
135+
if not len(glob("*.whl")) > whl_count:
136+
print("Building wheel for", name, version, "after", script, "did not", flush=True)
137+
subprocess.check_call([pip, "wheel", spec])
138+
else:
139+
print("Building", name, version, flush=True)
140+
subprocess.check_call([pip, "wheel", spec])
141+
142+
143+
_warned_dlls = []
144+
145+
146+
def repair_wheels_windows(output_dir, wheels):
147+
import pefile
148+
from machomachomangler.pe import redll
149+
150+
def resolve_dll_src(dll):
151+
# search for dependencies in system directories
152+
dll_search_paths = [
153+
os.environ["WINDIR"],
154+
join(os.environ["WINDIR"], "System32"),
155+
*os.environ["PATH"].split(";"),
156+
]
157+
ignored_dlls = [
158+
# These DLLs are just provided by Windows.
159+
# This list is probably incomplete.
160+
r"advapi32\.dll",
161+
r"advapires32\.dll",
162+
r"atl.*\.dll",
163+
r"comctl32\.dll",
164+
r"comdlg32\.dll",
165+
r"crtdll\.dll",
166+
r"gdi32\.dll",
167+
r"hal.*\.dll",
168+
r"imm32\.dll",
169+
r"iphlpapi\.dll",
170+
r"kernel32\.dll",
171+
r"kernelbase\.dll",
172+
r"msvbvm60\.dll",
173+
r"msvcirt\.dll",
174+
r"msvcrt?.*\.dll",
175+
r"netapi32\.dll",
176+
r"ntdll\.dll",
177+
r"ole32\.dll",
178+
r"pdh\.dll",
179+
r"powrprof\.dll",
180+
r"psapi\.dll",
181+
r"rpcrt4\.dll",
182+
r"sechost\.dll",
183+
r"shell32\.dll",
184+
r"shlwapi\.dll",
185+
r"shscrap\.dll",
186+
r"ucrtbase\.dll",
187+
r"user32\.dll",
188+
r"version\.dll",
189+
r"winmm\.dll",
190+
r"ws2_32\.dll",
191+
# These match DLLs that provide API sets.
192+
# See https://learn.microsoft.com/en-us/windows/win32/apiindex/windows-apisets
193+
r"api-ms-win-.*\.dll",
194+
r"ext-ms-win-.*\.dll",
195+
# These match DLLs that we provide in GraalPy
196+
r"python.*\.dll",
197+
# These are the DLLs typically linked when building with MSVC. See
198+
# https://learn.microsoft.com/en-us/cpp/windows/determining-which-dlls-to-redistribute
199+
# When these are included, the user should install the latest
200+
# redist package from
201+
# https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist
202+
# However, https://aka.ms/vs/17/redist.txt lists the libraries
203+
# which can be included in application distributions.
204+
r"concrt.*\.dll",
205+
r"mfc.*\.dll",
206+
r"msvcp.*\.dll",
207+
r"vcamp.*\.dll",
208+
r"vccorlib.*\.dll",
209+
r"vcomp.*\.dll",
210+
r"vcruntime.*\.dll",
211+
]
212+
if not dll:
213+
return
214+
if any(re.match(pat, basename(dll), re.IGNORECASE) for pat in ignored_dlls):
215+
if dll not in _warned_dlls:
216+
print("Not including", dll, flush=True)
217+
_warned_dlls.append(dll)
218+
return
219+
if isabs(dll):
220+
return dll
221+
for search_path in dll_search_paths:
222+
if exists(src := join(search_path, dll)):
223+
return src
224+
225+
def resolve_dll_target(dll, dependent, checksum):
226+
return join(dirname(dependent), f"{checksum}.{basename(dll)}")
227+
228+
def filehash(files):
229+
sha1 = hashlib.sha1()
230+
for file in files:
231+
with open(file, mode="rb") as f:
232+
sha1.update(f.read())
233+
return sha1.hexdigest()[:8]
234+
235+
for whl in wheels:
236+
with TemporaryDirectory() as name:
237+
with zipfile.ZipFile(whl) as f:
238+
f.extractall(name)
239+
240+
# find all pyd files and recursively copy dependencies
241+
dlls = glob(f"{name}/**/*.pyd", recursive=True)
242+
checksum = filehash(dlls)
243+
dependents_to_dependencies = {}
244+
while dlls:
245+
dll = dlls.pop()
246+
with pefile.PE(dll) as pe:
247+
pe_info = pe.dump_dict()
248+
for syms in pe_info.get("Imported symbols", []):
249+
for sym in syms:
250+
if dep_src := resolve_dll_src(sym.get("DLL", b"").decode("utf-8")):
251+
if not exists(dep_tgt := resolve_dll_target(dep_src, dll, checksum)):
252+
print("Including", dep_src, "as", dep_tgt, flush=True)
253+
shutil.copy(dep_src, dep_tgt)
254+
dlls.append(dep_tgt)
255+
dependents_to_dependencies.setdefault(dll, []).append(dep_src)
256+
257+
for dll, dependencies in dependents_to_dependencies.items():
258+
mapping = {}
259+
for dep_src in dependencies:
260+
mapping[basename(dep_src).encode("utf-8")] = basename(
261+
resolve_dll_target(dep_src, dll, checksum)
262+
).encode("utf-8")
263+
with open(dll, mode="rb") as f:
264+
data = f.read()
265+
print(
266+
"Rewriting\n\t",
267+
"\n\t".join([k.decode("utf-8") for k in mapping.keys()]),
268+
"\n\t->\n\t",
269+
"\n\t".join([v.decode("utf-8") for v in mapping.values()]),
270+
"\nin",
271+
dll,
272+
)
273+
data = redll(data, mapping)
274+
with open(dll, mode="wb") as f:
275+
f.write(data)
276+
277+
os.makedirs(output_dir, exist_ok=True)
278+
if exists(whl_tgt := join(output_dir, whl)):
279+
os.unlink(whl_tgt)
280+
shutil.make_archive(whl_tgt, "zip", name)
281+
os.rename(f"{whl_tgt}.zip", whl_tgt)
282+
283+
284+
def repair_wheels():
285+
if sys.platform == "win32":
286+
ensure_installed("machomachomangler")
287+
ensure_installed("pefile")
288+
repair_wheels_windows("wheelhouse", glob("*.whl"))
289+
elif sys.platform == "linux":
290+
ensure_installed("auditwheel")
291+
subprocess.check_call(
292+
[join(dirname(sys.executable), "auditwheel"), "repair", "-w", "wheelhouse", *glob("*.whl")]
293+
)
294+
elif sys.platform == "darwin":
295+
ensure_installed("delocate")
296+
subprocess.check_call(
297+
[join(dirname(sys.executable), "delocate-wheel"), "-v", "-w", "wheelhouse", *glob("*.whl")]
298+
)
299+
300+
301+
if __name__ == "__main__":
302+
parser = ArgumentParser()
303+
parser.add_argument("graalpy_url")
304+
args = parser.parse_args()
305+
ext = splitext(args.graalpy_url)[1]
306+
outpath = f"graalpy{ext}"
307+
308+
download(args.graalpy_url, outpath)
309+
extract(outpath)
310+
pip = create_venv()
311+
build_wheels(pip)
312+
repair_wheels()

0 commit comments

Comments
 (0)