feat: add support for building iOS wheels. (#2286)

* Add support for building iOS wheels.

* Replace use of system() in test binary module.

* Restored the 'minimal' approach of the minimal examples.

* Split out platform details into standalone pages, and expand iOS platform details.

* More doc corrections.

* Bump support package to include fix for python/cpython#130292

* Ensure iOS tests are all run on the same xdist worker.

* More iOS documentation tweaks.

* Factor out common xcode version test utility.

* Simplify iOS to a single platform with an expanded interpretation of arch.

* I guess I should update the iOS tests as well...

* Additional safety for missing iOS test output.

* Remove DYLD_LIBRARY_PATH from the iOS environment.

* Make test-sources mandatory for iOS builds.

* Updates and clarifications to documentation.

* Clarify what a slice is.

* Normalize use of underscores in platform name.

* Modify auto target to be matching CPU only.

* Use consistent ordering of platforms in examples.

* Use consistent naming in iOS archiectures.

* Placate the linter.

* Miscellaneous cleanups picked up by @joerick's review.

* Correct the list of expected wheels.

* Correct which 'native' we're actually checking.

* Correct the docs links so they're all relative.

* Correct the identification of free threaded builds.

* Use target instead of host to describe the platform we're building for.

* Rework iOS test to remove issue with log completeness.

* Convert errors to FatalError

Co-authored-by: Matthieu Darbois <mayeut@users.noreply.github.com>
Co-authored-by: Joe Rickerby <joerick@mac.com>

* Removed a repeated check for a valid python.

* Update bin/update_pythons.py to update iOS support packages.

* Document that iOS CI is available on other platforms.

* Restore a comment needed for some platforms.

* Small cleanups identified in code review

Co-authored-by: Joe Rickerby <joerick@mac.com>

* Simplify logic to appease linter.

* Modify dependency constraint handling to use new API.

* Cosmetic change to trigger a CI rebuild.

---------

Co-authored-by: Matthieu Darbois <mayeut@users.noreply.github.com>
Co-authored-by: Joe Rickerby <joerick@mac.com>
This commit is contained in:
Russell Keith-Magee
2025-03-11 16:48:35 -04:00
committed by GitHub
co-authored by Matthieu Darbois Joe Rickerby
parent eefd48e266
commit 26e12290b5
31 changed files with 1189 additions and 240 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ project_with_a_test.files["test/spam_test.py"] = r"""
import spam
def test_spam():
assert spam.system('python -c "exit(0)"') == 0
assert spam.system('python -c "exit(1)"') != 0
assert spam.filter("spam") == 0
assert spam.filter("ham") != 0
"""
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import os
import platform
import subprocess
import pytest
from . import test_projects, utils
basic_project = test_projects.new_c_project()
basic_project.files["tests/test_platform.py"] = f"""
import platform
from unittest import TestCase
class TestPlatform(TestCase):
def test_platform(self):
self.assertEqual(platform.machine(), "{platform.machine()}")
"""
# iOS tests shouldn't be run in parallel, because they're dependent on starting
# a simulator. It's *possible* to start multiple simulators, but not advisable
# to start as many simulators as there are CPUs on the test machine.
@pytest.mark.xdist_group(name="ios")
@pytest.mark.parametrize(
"build_config",
[
# Default to the pip build frontend
{"CIBW_PLATFORM": "ios"},
# Also check the build frontend
{"CIBW_PLATFORM": "ios", "CIBW_BUILD_FRONTEND": "build"},
],
)
def test_ios_platforms(tmp_path, build_config):
if utils.platform != "macos":
pytest.skip("this test can only run on macOS")
if utils.get_xcode_version() < (13, 0):
pytest.skip("this test only works with Xcode 13.0 or greater")
project_dir = tmp_path / "project"
basic_project.generate(project_dir)
actual_wheels = utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_BUILD": "cp313-*",
"CIBW_TEST_SOURCES": "tests",
"CIBW_TEST_COMMAND": "unittest discover tests test_platform.py",
**build_config,
},
)
ios_version = os.getenv("IPHONEOS_DEPLOYMENT_TARGET", "13.0").replace(".", "_")
platform_machine = platform.machine()
# Tests are only executed on simulator. The test suite passes if it's
# running on the same architecture as the current platform.
if platform_machine == "x86_64":
expected_wheels = {
f"spam-0.1.0-cp313-cp313-ios_{ios_version}_x86_64_iphonesimulator.whl",
}
elif platform_machine == "arm64":
expected_wheels = {
f"spam-0.1.0-cp313-cp313-ios_{ios_version}_arm64_iphoneos.whl",
f"spam-0.1.0-cp313-cp313-ios_{ios_version}_arm64_iphonesimulator.whl",
}
assert set(actual_wheels) == expected_wheels
@pytest.mark.xdist_group(name="ios")
def test_no_test_sources(tmp_path, capfd):
if utils.platform != "macos":
pytest.skip("this test can only run on macOS")
if utils.get_xcode_version() < (13, 0):
pytest.skip("this test only works with Xcode 13.0 or greater")
project_dir = tmp_path / "project"
basic_project.generate(project_dir)
with pytest.raises(subprocess.CalledProcessError):
utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_PLATFORM": "ios",
"CIBW_BUILD": "cp313-*",
"CIBW_TEST_COMMAND": "tests",
},
)
captured = capfd.readouterr()
assert "Testing on iOS requires a definition of test-sources." in captured.err
+4 -19
View File
@@ -1,5 +1,4 @@
import platform
import subprocess
import pytest
@@ -20,24 +19,10 @@ ALL_MACOS_WHEELS = {
DEPLOYMENT_TARGET_TOO_LOW_WARNING = "Bumping MACOSX_DEPLOYMENT_TARGET"
def get_xcode_version() -> tuple[int, int]:
output = subprocess.run(
["xcodebuild", "-version"],
text=True,
check=True,
stdout=subprocess.PIPE,
).stdout
lines = output.splitlines()
_, version_str = lines[0].split()
version_parts = version_str.split(".")
return (int(version_parts[0]), int(version_parts[1]))
def test_cross_compiled_build(tmp_path):
if utils.platform != "macos":
pytest.skip("this test is only relevant to macos")
if get_xcode_version() < (12, 2):
if utils.get_xcode_version() < (12, 2):
pytest.skip("this test only works with Xcode 12.2 or greater")
project_dir = tmp_path / "project"
@@ -71,7 +56,7 @@ def test_cross_compiled_build(tmp_path):
def test_cross_compiled_test(tmp_path, capfd, build_universal2, test_config):
if utils.platform != "macos":
pytest.skip("this test is only relevant to macos")
if get_xcode_version() < (12, 2):
if utils.get_xcode_version() < (12, 2):
pytest.skip("this test only works with Xcode 12.2 or greater")
project_dir = tmp_path / "project"
@@ -153,7 +138,7 @@ def test_deployment_target_warning_is_firing(tmp_path, capfd):
def test_universal2_testing_on_x86_64(tmp_path, capfd, skip_arm64_test):
if utils.platform != "macos":
pytest.skip("this test is only relevant to macos")
if get_xcode_version() < (12, 2):
if utils.get_xcode_version() < (12, 2):
pytest.skip("this test only works with Xcode 12.2 or greater")
if platform.machine() != "x86_64":
pytest.skip("this test only works on x86_64")
@@ -223,7 +208,7 @@ def test_universal2_testing_on_arm64(build_frontend_env, tmp_path, capfd):
def test_cp38_arm64_testing(tmp_path, capfd, request):
if utils.platform != "macos":
pytest.skip("this test is only relevant to macos")
if get_xcode_version() < (12, 2):
if utils.get_xcode_version() < (12, 2):
pytest.skip("this test only works with Xcode 12.2 or greater")
if platform.machine() != "arm64":
pytest.skip("this test only works on arm64")
+6 -5
View File
@@ -8,15 +8,16 @@ SPAM_C_TEMPLATE = r"""
{{ spam_c_top_level_add }}
static PyObject *
spam_system(PyObject *self, PyObject *args)
spam_filter(PyObject *self, PyObject *args)
{
const char *command;
const char *content;
int sts;
if (!PyArg_ParseTuple(args, "s", &command))
if (!PyArg_ParseTuple(args, "s", &content))
return NULL;
sts = system(command);
// Spam should not be allowed through the filter.
sts = strcmp(content, "spam");
{{ spam_c_function_add | indent(4) }}
@@ -25,7 +26,7 @@ spam_system(PyObject *self, PyObject *args)
/* Module initialization */
static PyMethodDef module_methods[] = {
{"system", (PyCFunction)spam_system, METH_VARARGS,
{"filter", (PyCFunction)spam_filter, METH_VARARGS,
"Execute a shell command."},
{NULL} /* Sentinel */
};
+3 -3
View File
@@ -44,9 +44,9 @@ def path_contains(parent, child):
class TestSpam(TestCase):
def test_system(self):
self.assertEqual(0, spam.system('python -c "exit(0)"'))
self.assertNotEqual(0, spam.system('python -c "exit(1)"'))
def test_filter(self):
self.assertEqual(0, spam.filter("spam"))
self.assertNotEqual(0, spam.filter("ham"))
def test_virtualenv(self):
# sys.prefix is different from sys.base_prefix when running a virtualenv
+15
View File
@@ -335,6 +335,21 @@ def get_macos_version() -> tuple[int, int]:
return tuple(map(int, version_str.split(".")[:2])) # type: ignore[return-value]
def get_xcode_version() -> tuple[int, int]:
"""Calls `xcodebuild -version` to retrieve the Xcode version as a 2-tuple."""
output = subprocess.run(
["xcodebuild", "-version"],
text=True,
check=True,
stdout=subprocess.PIPE,
).stdout
lines = output.splitlines()
_, version_str = lines[0].split()
version_parts = version_str.split(".")
return (int(version_parts[0]), int(version_parts[1]))
def skip_if_pyodide(reason: str) -> Any:
return pytest.mark.skipif(platform == "pyodide", reason=reason)