Skip to content

Add support for building ARM64 iOS wheels on CI #181

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 106 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ jobs:
- { spec: cp314-manylinux_aarch64, arch: aarch64 }
- { spec: cp314t-manylinux_aarch64, arch: aarch64 }

# aarch64 musllinux
# aarch64 musllinux
- { spec: cp39-musllinux_aarch64, arch: aarch64, omit: ${{ env.skip_ci_redundant_jobs }} }
- { spec: cp310-musllinux_aarch64, arch: aarch64, omit: ${{ env.skip_ci_redundant_jobs }} }
- { spec: cp311-musllinux_aarch64, arch: aarch64, omit: ${{ env.skip_ci_redundant_jobs }} }
Expand Down Expand Up @@ -379,8 +379,111 @@ jobs:
if-no-files-found: error
if: ${{ env.skip_artifact_upload != 'true' }}

make_ios_matrix:
runs-on: ubuntu-24.04
outputs:
matrix_json: ${{ steps.make_matrix.outputs.matrix_json }}
steps:
- uses: actions/checkout@v4
- name: make a matrix
id: make_matrix
uses: ./.github/actions/dynamatrix
with:
matrix_yaml: |
include:
# arm64 iOS device
- { spec: cp313-ios_arm64_iphoneos, platform: 'iphoneos' }
- { spec: cp314-ios_arm64_iphoneos, platform: 'iphoneos' }

# arm64 iOS simulator
- { spec: cp313-ios_arm64_iphonesimulator, platform: 'iphonesimulator' }
- { spec: cp314-ios_arm64_iphonesimulator, platform: 'iphonesimulator' }

ios:
needs: [python_sdist, make_ios_matrix]
runs-on: macos-14
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.make_ios_matrix.outputs.matrix_json) }}

steps:
- name: fetch sdist artifact
id: fetch_sdist
uses: actions/download-artifact@v4
with:
name: ${{ needs.python_sdist.outputs.sdist_artifact_name }}

- name: install python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: build wheel prereqs
run: |
set -eux
python3 -m pip install --user --upgrade cibuildwheel>=3.1.0
brew uninstall --ignore-dependencies libffi 2>&1 || true

- name: download libffi for iOS
env:
CFFI_IOS_LIBFFI_VERSION: '3.4.7-2'
run: |
set -eux

# Download prebuilt libffi from beeware/cpython-apple-source-deps
platform="${{ matrix.platform }}"
version="${CFFI_IOS_LIBFFI_VERSION}"
url="https://github.com/beeware/cpython-apple-source-deps/releases/download/libFFI-${version}/libffi-${version}-${platform}.arm64.tar.gz"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it looks a little weird to download binaries from beeware, cibuildwheel internally actually does the same for the "iOS support" python interpreter used to run python -m build with.


echo "Downloading libffi for iOS (${platform})..."
curl -L -o libffi-ios.tar.gz "${url}"

# Extract libffi
mkdir -p libffi-ios
tar zxf libffi-ios.tar.gz -C libffi-ios

# Set up paths for cibuildwheel
echo "LIBFFI_IOS_DIR=$(pwd)/libffi-ios" >> "$GITHUB_ENV"
Comment on lines +421 to +446

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would make sense to plug this into the cibuildwheel config so that it's usable outside the CI and isn't coupled with it.


- name: build/test wheels
id: build
env:
CIBW_BUILD: ${{ matrix.spec }}
CIBW_ENABLE: cpython-prerelease
CIBW_PLATFORM: ios
SDKROOT: ${{ matrix.platform }}
CIBW_TEST_REQUIRES: pytest setuptools
CIBW_TEST_SOURCES: cffi
# Running tests from `testing/` will not work since they try to compile C code on device
CIBW_TEST_COMMAND: python -m pytest -sv cffi/src/c/
# Environment variables for the build
CIBW_ENVIRONMENT: >
CFLAGS="-I${LIBFFI_IOS_DIR}/include"
LDFLAGS="-L${LIBFFI_IOS_DIR}/lib"
PYTHONUNBUFFERED=1
# Pass through our custom env vars
CIBW_ENVIRONMENT_PASS_IOS: LIBFFI_IOS_DIR PYTHONUNBUFFERED
run: |
set -eux

mkdir cffi

tar zxf ${{ steps.fetch_sdist.outputs.download-path }}/cffi*.tar.gz --strip-components=1 -C cffi

python3 -m cibuildwheel --output-dir dist cffi

echo "artifact_name=$(ls ./dist/)" >> "$GITHUB_OUTPUT"

- name: upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ steps.build.outputs.artifact_name }}
path: dist/*.whl
if-no-files-found: error
if: ${{ env.skip_artifact_upload != 'true' }}

merge_artifacts:
needs: [python_sdist, linux, macos, windows]
needs: [python_sdist, linux, macos, windows, ios]
runs-on: ubuntu-24.04
steps:
- name: merge all artifacts
Expand Down Expand Up @@ -456,7 +559,7 @@ jobs:

check:
if: always()
needs: [python_sdist, linux, macos, windows, clang_TSAN, pytest-run-parallel, merge_artifacts]
needs: [python_sdist, linux, macos, windows, ios, clang_TSAN, pytest-run-parallel, merge_artifacts]
runs-on: ubuntu-24.04
steps:
- name: Verify all previous jobs succeeded (provides a single check to sample for gating purposes)
Expand Down
31 changes: 31 additions & 0 deletions src/c/test_c.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
except ImportError:
pass

is_ios = sys.platform == 'ios'


def _setup_path():
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
Expand Down Expand Up @@ -1230,6 +1233,11 @@ def test_cannot_pass_struct_with_array_of_length_0():
BFunc2 = new_function_type((BInt,), BStruct, False)
pytest.raises(NotImplementedError, cast(BFunc2, 123), 123)

@pytest.mark.xfail(
is_ios,
reason="For an unknown reason f(1, cast(BInt, 42)) returns 36792864",
raises=AssertionError,
)
def test_call_function_9():
BInt = new_primitive_type("int")
BFunc9 = new_function_type((BInt,), BInt, True) # vararg
Expand Down Expand Up @@ -1362,6 +1370,7 @@ def test_write_variable():
pytest.raises(ValueError, ll.write_variable, BVoidP, "stderr", stderr)


@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback():
BInt = new_primitive_type("int")
def make_callback():
Expand All @@ -1378,6 +1387,7 @@ def cb(n):
assert str(e.value) == "'int(*)(int)' expects 1 arguments, got 0"


@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
@pytest.mark.thread_unsafe("mocks sys.unraiseablehook")
def test_callback_exception():
def check_value(x):
Expand Down Expand Up @@ -1435,6 +1445,7 @@ def oops(*args):
assert ff(bigvalue) == -42


@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_return_type():
for rettype in ["signed char", "short", "int", "long", "long long",
"unsigned char", "unsigned short", "unsigned int",
Expand All @@ -1455,6 +1466,7 @@ def cb(n):
assert f(max - 1) == max
assert f(max) == 42

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_a_lot_of_callbacks():
BIGNUM = 10000
if 'PY_DOT_PY' in globals(): BIGNUM = 100 # tests on py.py
Expand All @@ -1470,6 +1482,7 @@ def cb(n):
for i, f in enumerate(flist):
assert f(-142) == -142 + i

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_receiving_tiny_struct():
BSChar = new_primitive_type("signed char")
BInt = new_primitive_type("int")
Expand All @@ -1485,6 +1498,7 @@ def cb(s):
n = f(p[0])
assert n == -42

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_tiny_struct():
BSChar = new_primitive_type("signed char")
BInt = new_primitive_type("int")
Expand All @@ -1502,6 +1516,7 @@ def cb(n):
assert s.a == -10
assert s.b == -30

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_receiving_struct():
BSChar = new_primitive_type("signed char")
BInt = new_primitive_type("int")
Expand All @@ -1518,6 +1533,7 @@ def cb(s):
n = f(p[0])
assert n == 42

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_struct():
BSChar = new_primitive_type("signed char")
BInt = new_primitive_type("int")
Expand All @@ -1537,6 +1553,7 @@ def cb(n):
assert s.a == -10
assert s.b == 1E-42

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_receiving_big_struct():
BInt = new_primitive_type("int")
BStruct = new_struct_type("struct foo")
Expand All @@ -1561,6 +1578,7 @@ def cb(s):
n = f(p[0])
assert n == 42

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_big_struct():
BInt = new_primitive_type("int")
BStruct = new_struct_type("struct foo")
Expand All @@ -1586,6 +1604,7 @@ def cb():
for i, name in enumerate("abcdefghij"):
assert getattr(s, name) == 13 - i

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_void():
BVoid = new_void_type()
BFunc = new_function_type((), BVoid, False)
Expand Down Expand Up @@ -1694,6 +1713,7 @@ def test_enum_overflow():
pytest.raises(OverflowError, new_enum_type,
"foo", ("AA",), (testcase,), BPrimitive)

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_enum():
BInt = new_primitive_type("int")
BEnum = new_enum_type("foo", ('def', 'c', 'ab'), (0, 1, -20), BInt)
Expand All @@ -1710,6 +1730,7 @@ def cb(n):
assert f(20) == 20
assert f(21) == 21

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_enum_unsigned():
BInt = new_primitive_type("int")
BUInt = new_primitive_type("unsigned int")
Expand All @@ -1727,6 +1748,7 @@ def cb(n):
assert f(20) == 20
assert f(21) == 21

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_char():
BInt = new_primitive_type("int")
BChar = new_primitive_type("char")
Expand All @@ -1741,6 +1763,7 @@ def _hacked_pypy_uni4():
pyuni4 = {1: True, 2: False}[len(u+'\U00012345')]
return 'PY_DOT_PY' in globals() and not pyuni4

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_callback_returning_wchar_t():
BInt = new_primitive_type("int")
BWChar = new_primitive_type("wchar_t")
Expand Down Expand Up @@ -2310,6 +2333,8 @@ def _test_wchar_variant(typename):
assert str(q) == repr(q)
pytest.raises(RuntimeError, string, q)
#
if is_ios:
return # cannot allocate executable memory for the callback() below
def cb(p):
assert repr(p).startswith("<cdata '%s *' 0x" % typename)
return len(string(p))
Expand Down Expand Up @@ -2546,6 +2571,7 @@ def test_errno():
f(); f()
assert get_errno() == 95

@pytest.mark.skipif(is_ios, reason="Cannot allocate executable memory on iOS")
def test_errno_callback():
if globals().get('PY_DOT_PY'):
pytest.skip("cannot run this test on py.py (e.g. fails on Windows)")
Expand Down Expand Up @@ -2996,6 +3022,11 @@ def test_string_assignment_to_byte_array():
except ImportError:
pass # win32

@pytest.mark.skipif(
is_ios,
reason="For an unknown reason fscanf() doesn't read anything on 3.14"
" and crashes on 3.13 (that's why it's not an xfail)",
)
def test_FILE():
if sys.platform == "win32":
pytest.skip("testing FILE not implemented")
Expand Down
Loading