Skip to content

Commit 1d3cfdf

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 1d3cfdf

3 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/build-armv7l-cross.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#! /usr/bin/env bash
2+
#
3+
# Only PyAV's own Cython->C extensions are compiled here; FFmpeg is downloaded
4+
# prebuilt for armv7l (see scripts/fetch-vendor.py). The pieces that make this
5+
# work fully emulation-free are:
6+
#
7+
# * zig as the cross C compiler ("zig cc -target arm-linux-gnueabihf").
8+
# * CPython's native cross-build env vars (_PYTHON_HOST_PLATFORM and
9+
# _PYTHON_SYSCONFIGDATA_NAME) so sysconfig reports armv7l values without
10+
# ever running an armv7l binary. The target Python trees are copied out of
11+
# the official manylinux/musllinux armv7l images with `docker cp`, which
12+
# reads the image filesystem and does not execute any armv7l code.
13+
#
14+
# Produces the four wheels the old QEMU job built:
15+
# cp311-abi3 (Limited API, covers 3.11-3.13) x {manylinux, musllinux}
16+
# cp314t (free-threaded 3.14) x {manylinux, musllinux}
17+
18+
set -euo pipefail
19+
20+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21+
root="$(cd "$here/.." && pwd)"
22+
cd "$root"
23+
24+
# --- configuration (override via env) ------------------------------------
25+
DIST_DIR="${DIST_DIR:-$root/dist}"
26+
WORK_DIR="${WORK_DIR:-$root/build/armv7l-cross}"
27+
28+
# Official images that ship matching target Python builds + sysconfigdata.
29+
MANYLINUX_IMAGE="${MANYLINUX_IMAGE:-quay.io/pypa/manylinux_2_31_armv7l}"
30+
MUSLLINUX_IMAGE="${MUSLLINUX_IMAGE:-quay.io/pypa/musllinux_1_2_armv7l}"
31+
32+
# Pin the glibc the extension may require to match the manylinux policy.
33+
GLIBC_VER="${GLIBC_VER:-2.31}"
34+
35+
# Host interpreters that drive each build. They must match the wheel's Python
36+
# version: 3.11 emits cp311-abi3, 3.14t emits cp314t. zig/auditwheel run from
37+
# TOOLS_PY (any 3.x with `ziglang` and `auditwheel` installed).
38+
HOST_PY311="${HOST_PY311:-python3.11}"
39+
HOST_PY314T="${HOST_PY314T:-python3.14t}"
40+
TOOLS_PY="${TOOLS_PY:-python3}"
41+
42+
FFMPEG_CONFIG="${FFMPEG_CONFIG:-scripts/ffmpeg-latest.json}"
43+
44+
BIN_DIR="$WORK_DIR/bin"
45+
mkdir -p "$DIST_DIR" "$BIN_DIR" "$WORK_DIR/py" "$WORK_DIR/vendor" "$WORK_DIR/raw"
46+
47+
# --- zig compiler wrappers ------------------------------------------------
48+
# setuptools invokes $CC / $LDSHARED as plain commands, so wrap "zig cc" in a
49+
# script. The wrapper also drops a few gcc-only flags the target sysconfigdata
50+
# may carry that clang (zig) rejects. The target triple comes from $ZIG_TARGET
51+
# so one wrapper serves every build.
52+
write_wrapper() {
53+
local path="$1" subcmd="$2"
54+
cat >"$path" <<EOF
55+
#! /usr/bin/env bash
56+
args=()
57+
for a in "\$@"; do
58+
case "\$a" in
59+
-fno-semantic-interposition|-fstack-clash-protection|-fcf-protection*|-mno-cet|-march=native|-mtune=native|--param=*|-fipa-pta) ;;
60+
*) args+=("\$a") ;;
61+
esac
62+
done
63+
exec "${TOOLS_PY}" -m ziglang ${subcmd} -target "\${ZIG_TARGET}" "\${args[@]}"
64+
EOF
65+
chmod +x "$path"
66+
}
67+
write_wrapper "$BIN_DIR/zig-cc" "cc"
68+
write_wrapper "$BIN_DIR/zig-cxx" "c++"
69+
cat >"$BIN_DIR/zig-ar" <<EOF
70+
#! /usr/bin/env bash
71+
exec "${TOOLS_PY}" -m ziglang ar "\$@"
72+
EOF
73+
chmod +x "$BIN_DIR/zig-ar"
74+
75+
# --- copy target Python trees out of the armv7l images (no execution) -----
76+
extract_pythons() {
77+
local libc="$1" image="$2"
78+
local dest="$WORK_DIR/py/$libc"
79+
if [[ -d "$dest/cp311-cp311" ]]; then
80+
echo "Reusing cached target pythons for $libc"
81+
return
82+
fi
83+
echo "Pulling $image and copying /opt/python ($libc)"
84+
docker pull --platform linux/arm/v7 "$image"
85+
local cid
86+
cid="$(docker create --platform linux/arm/v7 "$image")"
87+
mkdir -p "$dest"
88+
docker cp "$cid:/opt/python/." "$dest/"
89+
docker rm -f "$cid" >/dev/null
90+
}
91+
92+
# --- build one wheel ------------------------------------------------------
93+
# args: <libc> <host_py> <py_glob> <triple> <plat_tag> <vendor_platform>
94+
build_one() {
95+
local libc="$1" host_py="$2" py_glob="$3" triple="$4" plat="$5" vendor_plat="$6"
96+
97+
local pytree
98+
pytree="$(ls -d "$WORK_DIR/py/$libc/"$py_glob 2>/dev/null | head -1 || true)"
99+
if [[ -z "$pytree" || ! -d "$pytree" ]]; then
100+
echo "ERROR: no target python matching '$py_glob' for $libc" >&2
101+
echo " (check that the image still ships this build)" >&2
102+
exit 1
103+
fi
104+
105+
local incdirs=( "$pytree"/include/python3.* )
106+
local incdir="${incdirs[0]}"
107+
local scds=( "$pytree"/lib/python3.*/_sysconfigdata_*.py )
108+
local scd_file="${scds[0]}"
109+
local scd_dir scd_name
110+
scd_dir="$(dirname "$scd_file")"
111+
scd_name="$(basename "$scd_file" .py)"
112+
113+
echo "=== $plat / $(basename "$pytree") ==="
114+
echo " host python : $host_py"
115+
echo " zig target : $triple"
116+
echo " headers : $incdir"
117+
echo " sysconfig : $scd_name"
118+
119+
# Fetch the matching prebuilt FFmpeg for armv7l.
120+
local vendor="$WORK_DIR/vendor/$vendor_plat"
121+
PYAV_VENDOR_PLATFORM="$vendor_plat" "$TOOLS_PY" scripts/fetch-vendor.py \
122+
--config-file "$FFMPEG_CONFIG" "$vendor"
123+
124+
local raw="$WORK_DIR/raw/$libc-$(basename "$pytree")"
125+
rm -rf "$raw"; mkdir -p "$raw"
126+
127+
env \
128+
ZIG_TARGET="$triple" \
129+
CC="$BIN_DIR/zig-cc" \
130+
CXX="$BIN_DIR/zig-cxx" \
131+
AR="$BIN_DIR/zig-ar" \
132+
LDSHARED="$BIN_DIR/zig-cc -shared" \
133+
_PYTHON_HOST_PLATFORM="linux-armv7l" \
134+
_PYTHON_SYSCONFIGDATA_NAME="$scd_name" \
135+
PYTHONPATH="$scd_dir" \
136+
CFLAGS="-I$incdir -Wno-error=incompatible-pointer-types" \
137+
PKG_CONFIG_PATH="$vendor/lib/pkgconfig" \
138+
LD_LIBRARY_PATH="$vendor/lib" \
139+
"$host_py" -m pip wheel . --no-build-isolation --no-deps -w "$raw"
140+
141+
# Bundle the FFmpeg shared libs and stamp the platform tag. auditwheel and
142+
# patchelf are arch-agnostic, so this works on the x86_64 host.
143+
LD_LIBRARY_PATH="$vendor/lib" "$TOOLS_PY" -m auditwheel repair \
144+
--plat "$plat" -w "$DIST_DIR" "$raw"/*.whl
145+
}
146+
147+
# --- run ------------------------------------------------------------------
148+
extract_pythons glibc "$MANYLINUX_IMAGE"
149+
extract_pythons musl "$MUSLLINUX_IMAGE"
150+
151+
build_one glibc "$HOST_PY311" "cp311-cp311" "arm-linux-gnueabihf.$GLIBC_VER" "manylinux_2_31_armv7l" "manylinux-armv7l"
152+
build_one musl "$HOST_PY311" "cp311-cp311" "arm-linux-musleabihf" "musllinux_1_2_armv7l" "musllinux-armv7l"
153+
build_one glibc "$HOST_PY314T" "cp314*-cp314t" "arm-linux-gnueabihf.$GLIBC_VER" "manylinux_2_31_armv7l" "manylinux-armv7l"
154+
build_one musl "$HOST_PY314T" "cp314*-cp314t" "arm-linux-musleabihf" "musllinux_1_2_armv7l" "musllinux-armv7l"
155+
156+
echo
157+
echo "Built armv7l wheels:"
158+
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)