Skip to content

Commit ce7e030

Browse files
committed
Cross-compile armv7l wheels with zig instead of QEMU
armv7l was the only target built under QEMU emulation. Replace it with a native x86_64 job that cross-compiles with zig: target Python trees are copied out of the manylinux/musllinux armv7l images via docker cp (no emulation), and CPython's cross-build env vars produce correct abi3 and free-threaded wheel tags. FFmpeg is still fetched prebuilt for armv7l.
1 parent 1566a4b commit ce7e030

4 files changed

Lines changed: 198 additions & 9 deletions

File tree

.github/workflows/tests.yml

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ jobs:
3434
arch: x86_64
3535
- os: ubuntu-24.04-arm
3636
arch: aarch64
37-
- os: ubuntu-24.04
38-
arch: armv7l
3937
- os: ubuntu-24.04
4038
arch: x86_64
4139
- os: windows-latest
@@ -47,11 +45,6 @@ jobs:
4745
- uses: actions/setup-python@v6
4846
with:
4947
python-version: "3.14"
50-
- name: Set up QEMU
51-
if: matrix.arch == 'armv7l'
52-
uses: docker/setup-qemu-action@v3
53-
with:
54-
platforms: arm
5548
- name: Set Minimum MacOS Target
5649
if: runner.os == 'macOS'
5750
run: |
@@ -73,7 +66,6 @@ jobs:
7366
CIBW_BUILD: "cp311* cp314t*"
7467
CIBW_TEST_COMMAND: mv {project}/av {project}/av.disabled && python -m pytest {package}/tests && mv {project}/av.disabled {project}/av
7568
CIBW_TEST_REQUIRES: pytest numpy
76-
CIBW_TEST_SKIP: "*_armv7l"
7769
run: |
7870
pip install cibuildwheel delvewheel
7971
cibuildwheel --output-dir dist
@@ -84,9 +76,42 @@ jobs:
8476
name: dist-${{ matrix.os }}-${{ matrix.arch }}
8577
path: dist/
8678

79+
# armv7l (32-bit ARM) is cross-compiled with zig on a native x86_64 runner
80+
# instead of emulated with QEMU. cibuildwheel cannot cross-compile, so this
81+
# target uses its own driver (scripts/build-armv7l-cross.sh).
82+
package-wheel-armv7l:
83+
runs-on: ubuntu-24.04
84+
steps:
85+
- uses: actions/checkout@v6
86+
- name: Set up host Python 3.11
87+
uses: actions/setup-python@v6
88+
with:
89+
python-version: "3.11"
90+
- name: Set up host Python 3.14t
91+
uses: actions/setup-python@v6
92+
with:
93+
python-version: "3.14t"
94+
- name: Install build tooling
95+
run: |
96+
for py in python3.11 python3.14t; do
97+
$py -m pip install -U "cython>=3.1.0,<4" "setuptools>=77.0" wheel
98+
done
99+
python3.11 -m pip install -U ziglang auditwheel
100+
- name: Cross-compile armv7l wheels with zig
101+
env:
102+
HOST_PY311: python3.11
103+
HOST_PY314T: python3.14t
104+
TOOLS_PY: python3.11
105+
run: bash scripts/build-armv7l-cross.sh
106+
- name: Upload wheels
107+
uses: actions/upload-artifact@v6
108+
with:
109+
name: dist-armv7l
110+
path: dist/
111+
87112
publish:
88113
runs-on: ubuntu-latest
89-
needs: [package-source, package-wheel]
114+
needs: [package-source, package-wheel, package-wheel-armv7l]
90115
steps:
91116
- uses: actions/checkout@v6
92117
- uses: actions/download-artifact@v7

scripts/auditwheel-cross.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Run ``auditwheel`` for a foreign architecture/libc from an x86_64 host.
2+
3+
auditwheel builds its ``--plat`` choices from the host's detected architecture
4+
and libc, so on an x86_64 runner it rejects e.g. ``manylinux_2_31_armv7l``. The
5+
repair logic itself re-derives the architecture and libc from the wheel and works
6+
cross-arch, so we only need to override the host detection used to build the CLI
7+
choice list.
8+
9+
Usage::
10+
11+
python auditwheel-cross.py <arch> <glibc|musl> <auditwheel args...>
12+
"""
13+
14+
import sys
15+
16+
from auditwheel.architecture import Architecture
17+
from auditwheel.libc import Libc
18+
19+
_arch = Architecture(sys.argv[1])
20+
_libc = {"glibc": Libc.GLIBC, "musl": Libc.MUSL}[sys.argv[2]]
21+
del sys.argv[1:3]
22+
23+
Architecture.detect = staticmethod(lambda *, bits=None: _arch)
24+
Libc.detect = staticmethod(lambda: _libc)
25+
26+
from auditwheel.main import main # noqa: E402 (import after patching detection)
27+
28+
sys.exit(main())

scripts/build-armv7l-cross.sh

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#! /usr/bin/env bash
2+
#
3+
# Cross-compile PyAV's armv7l (32-bit ARM) wheels with zig on an x86_64 host,
4+
# without QEMU. Only PyAV's Cython->C extensions are compiled; FFmpeg is fetched
5+
# prebuilt for armv7l. zig is the cross compiler, and CPython's cross-build env
6+
# vars (_PYTHON_HOST_PLATFORM / _PYTHON_SYSCONFIGDATA_NAME) make sysconfig report
7+
# armv7l without running any armv7l code. The target Python trees + system libs
8+
# are copied out of the manylinux armv7l image with `docker cp` (no execution).
9+
#
10+
# Builds two manylinux (glibc) wheels: cp311-abi3 (covers 3.11-3.13) and cp314t.
11+
# musllinux armv7l is skipped: the musl FFmpeg needs system libs (libdrm,
12+
# libxcb*, libbz2) the musllinux image doesn't ship for auditwheel to bundle.
13+
14+
set -euo pipefail
15+
16+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17+
root="$(cd "$here/.." && pwd)"
18+
cd "$root"
19+
20+
# Host interpreters that drive each build (must match the wheel's Python: 3.11
21+
# emits cp311-abi3, 3.14t emits cp314t). zig/auditwheel run from TOOLS_PY.
22+
HOST_PY311="${HOST_PY311:-python3.11}"
23+
HOST_PY314T="${HOST_PY314T:-python3.14t}"
24+
TOOLS_PY="${TOOLS_PY:-python3}"
25+
26+
DIST_DIR="${DIST_DIR:-$root/dist}"
27+
WORK_DIR="${WORK_DIR:-$root/build/armv7l-cross}"
28+
IMAGE="quay.io/pypa/manylinux_2_31_armv7l"
29+
TRIPLE="arm-linux-gnueabihf.2.31" # pin glibc to the manylinux_2_31 policy
30+
PLAT="manylinux_2_31_armv7l"
31+
FFMPEG_CONFIG="scripts/ffmpeg-latest.json"
32+
33+
PY_DIR="$WORK_DIR/py"
34+
BIN_DIR="$WORK_DIR/bin"
35+
mkdir -p "$DIST_DIR" "$BIN_DIR" "$PY_DIR"
36+
37+
# --- zig compiler wrappers ------------------------------------------------
38+
# setuptools invokes $CC / $LDSHARED as plain commands. Resolve the zig binary
39+
# once and call it directly (not "python -m ziglang"): the build sets PYTHONPATH
40+
# to the target stdlib, which would crash a per-compile Python. The wrappers also
41+
# drop a few gcc-only flags clang (zig) rejects.
42+
ZIG_BIN="$("$TOOLS_PY" -c 'import os, ziglang; print(os.path.join(os.path.dirname(ziglang.__file__), "zig"))')"
43+
44+
write_cc() {
45+
cat >"$1" <<EOF
46+
#! /usr/bin/env bash
47+
args=()
48+
for a in "\$@"; do
49+
case "\$a" in
50+
-fno-semantic-interposition|-fstack-clash-protection|-fcf-protection*|-mno-cet|-march=native|-mtune=native|--param=*|-fipa-pta) ;;
51+
*) args+=("\$a") ;;
52+
esac
53+
done
54+
exec "$ZIG_BIN" $2 -target "$TRIPLE" "\${args[@]}"
55+
EOF
56+
chmod +x "$1"
57+
}
58+
write_cc "$BIN_DIR/zig-cc" cc
59+
write_cc "$BIN_DIR/zig-cxx" c++
60+
printf '#! /usr/bin/env bash\nexec "%s" ar "$@"\n' "$ZIG_BIN" >"$BIN_DIR/zig-ar"
61+
chmod +x "$BIN_DIR/zig-ar"
62+
63+
# --- copy the target Python trees + system libs out of the image ----------
64+
extract_image() {
65+
if [[ -d "$PY_DIR/_internal" ]]; then
66+
return # already extracted
67+
fi
68+
echo "Pulling $IMAGE"
69+
docker pull --platform linux/arm/v7 "$IMAGE"
70+
local cid
71+
cid="$(docker create --platform linux/arm/v7 "$IMAGE")"
72+
# /opt/python/<tag> are symlinks into /opt/_internal/<tree>; copy both. Also
73+
# grab the armhf system libs (libxcb, ...) FFmpeg needs so auditwheel can
74+
# bundle them. docker cp reads the filesystem; it runs no armv7l code.
75+
docker cp "$cid:/opt/python" "$PY_DIR"
76+
docker cp "$cid:/opt/_internal" "$PY_DIR"
77+
docker cp "$cid:/usr/lib/arm-linux-gnueabihf" "$PY_DIR/syslib"
78+
docker rm -f "$cid" >/dev/null
79+
}
80+
81+
# --- build + repair one wheel ---------------------------------------------
82+
# args: <host_py> <python-tag-glob>
83+
build_one() {
84+
local host_py="$1" py_glob="$2"
85+
86+
# Resolve /opt/python/<tag> symlink to its real tree under /opt/_internal.
87+
local link
88+
link="$(find "$PY_DIR/python" -maxdepth 1 -name "$py_glob" | head -1)"
89+
[[ -n "$link" ]] || { echo "no python matching '$py_glob':" >&2; ls "$PY_DIR/python" >&2; exit 1; }
90+
local pytree="$PY_DIR/_internal/$(basename "$(readlink "$link")")"
91+
92+
local inc=( "$pytree"/include/python3.* )
93+
local scd=( "$pytree"/lib/python3.*/_sysconfigdata_*.py )
94+
echo "=== building for $(basename "$pytree") (host $host_py) ==="
95+
96+
# FFmpeg's pkg-config files bake in prefix=/tmp/vendor, so extract there.
97+
rm -rf /tmp/vendor
98+
PYAV_VENDOR_PLATFORM=manylinux-armv7l "$TOOLS_PY" scripts/fetch-vendor.py \
99+
--config-file "$FFMPEG_CONFIG" /tmp/vendor
100+
101+
local raw="$WORK_DIR/raw/$(basename "$pytree")"
102+
rm -rf "$raw"; mkdir -p "$raw"
103+
104+
# The _PYTHON_* vars make sysconfig report armv7l; zig does the compiling.
105+
env \
106+
CC="$BIN_DIR/zig-cc" CXX="$BIN_DIR/zig-cxx" AR="$BIN_DIR/zig-ar" \
107+
LDSHARED="$BIN_DIR/zig-cc -shared" \
108+
_PYTHON_HOST_PLATFORM=linux-armv7l \
109+
_PYTHON_SYSCONFIGDATA_NAME="$(basename "${scd[0]}" .py)" \
110+
PYTHONPATH="$(dirname "${scd[0]}")" \
111+
CFLAGS="-I${inc[0]} -Wno-error=incompatible-pointer-types" \
112+
PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig \
113+
LD_LIBRARY_PATH=/tmp/vendor/lib \
114+
"$host_py" -m pip wheel . --no-build-isolation --no-deps -w "$raw"
115+
116+
# Bundle FFmpeg + its armhf system deps and stamp the platform tag. The shim
117+
# lets auditwheel accept the armv7l --plat from an x86_64 host; --ldpaths
118+
# points it at the target's libs instead of the host search path.
119+
"$TOOLS_PY" scripts/auditwheel-cross.py armv7l glibc repair \
120+
--ldpaths "/tmp/vendor/lib:$PY_DIR/syslib" \
121+
--plat "$PLAT" -w "$DIST_DIR" "$raw"/*.whl
122+
}
123+
124+
extract_image
125+
build_one "$HOST_PY311" "cp311-cp311"
126+
build_one "$HOST_PY314T" "cp314*-cp314t"
127+
128+
echo
129+
echo "Built armv7l wheels:"
130+
ls -1 "$DIST_DIR"/*armv7l*.whl

scripts/fetch-vendor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99

1010
def get_platform():
11+
# Allow forcing the target platform so we can fetch e.g. an armv7l build
12+
# while running on an x86_64 host (cross-compilation).
13+
forced = os.environ.get("PYAV_VENDOR_PLATFORM")
14+
if forced:
15+
return forced
16+
1117
system = platform.system()
1218
machine = platform.machine().lower()
1319
is_arm64 = machine in {"arm64", "aarch64"}

0 commit comments

Comments
 (0)