diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py
index 3473ef740..9e06ecd07 100644
--- a/cibuildwheel/platforms/ios.py
+++ b/cibuildwheel/platforms/ios.py
@@ -32,7 +32,7 @@
download,
move_file,
)
-from ..util.helpers import prepare_command
+from ..util.helpers import prepare_command, unwrap_preserving_paragraphs
from ..util.packaging import (
combine_constraints,
find_compatible_wheel,
@@ -593,6 +593,43 @@ def build(options: Options, tmp_path: Path) -> None:
)
log.step("Running test suite...")
+
+ test_command_parts = shlex.split(build_options.test_command)
+ if test_command_parts[0:2] != ["python", "-m"]:
+ first_part = test_command_parts[0]
+ if first_part == "pytest":
+ # pytest works exactly the same as a module, so we
+ # can just run it as a module.
+ log.warning(
+ unwrap_preserving_paragraphs(f"""
+ iOS tests configured with a test command which doesn't start
+ with 'python -m'. iOS tests must execute python modules - other
+ entrypoints are not supported.
+
+ cibuildwheel will try to execute it as if it started with
+ 'python -m'. If this works, all you need to do is add that to
+ your test command.
+
+ Test command: {build_options.test_command!r}
+ """)
+ )
+ else:
+ msg = unwrap_preserving_paragraphs(
+ f"""
+ iOS tests configured with a test command which doesn't start
+ with 'python -m'. iOS tests must execute python modules - other
+ entrypoints are not supported.
+
+ Test command: {build_options.test_command!r}
+ """
+ )
+ raise errors.FatalError(msg)
+ else:
+ # the testbed run command actually doesn't want the
+ # python -m prefix - it's implicit, so we remove it
+ # here.
+ test_command_parts = test_command_parts[2:]
+
try:
call(
"python",
@@ -600,7 +637,7 @@ def build(options: Options, tmp_path: Path) -> None:
"run",
*(["--verbose"] if build_options.build_verbosity > 0 else []),
"--",
- *(shlex.split(build_options.test_command)),
+ *test_command_parts,
env=build_env,
)
failed = False
diff --git a/cibuildwheel/util/helpers.py b/cibuildwheel/util/helpers.py
index dbef452c0..363fd3345 100644
--- a/cibuildwheel/util/helpers.py
+++ b/cibuildwheel/util/helpers.py
@@ -76,6 +76,21 @@ def unwrap(text: str) -> str:
return re.sub(r"\s+", " ", text)
+def unwrap_preserving_paragraphs(text: str) -> str:
+ """
+ Unwraps multi-line text to a single line, but preserves paragraphs
+ """
+ # remove initial line indent
+ text = textwrap.dedent(text)
+ # remove leading/trailing whitespace
+ text = text.strip()
+
+ paragraphs = text.split("\n\n")
+ # remove consecutive whitespace
+ paragraphs = [re.sub(r"\s+", " ", paragraph) for paragraph in paragraphs]
+ return "\n\n".join(paragraphs)
+
+
def parse_key_value_string(
key_value_string: str,
positional_arg_names: Sequence[str] | None = None,
diff --git a/docs/options.md b/docs/options.md
index 2b8113970..d24db227b 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -1253,7 +1253,7 @@ run your test suite.
On all platforms other than iOS, the command is run in a shell, so you can write things like `cmd1 && cmd2`.
-On iOS, the value of the `CIBW_TEST_COMMAND` setting is interpreted as the arguments to pass to `python -m` - that is, a Python module name, followed by arguments that will be assigned to `sys.argv`. Shell commands cannot be used.
+On iOS, the value of the `CIBW_TEST_COMMAND` setting must follow the format `python -m MODULE [ARGS...]` - where MODULE is a Python module name, followed by arguments that will be assigned to `sys.argv`. Other commands cannot be used.
Platform-specific environment variables are also available:
`CIBW_TEST_COMMAND_MACOS` | `CIBW_TEST_COMMAND_WINDOWS` | `CIBW_TEST_COMMAND_LINUX` | `CIBW_TEST_COMMAND_IOS` | `CIBW_TEST_COMMAND_PYODIDE`
@@ -1273,6 +1273,10 @@ Platform-specific environment variables are also available:
CIBW_TEST_COMMAND: >
pytest ./tests &&
python ./test.py
+
+ # run tests on ios
+ CIBW_TEST_SOURCES_IOS: tests
+ CIBW_TEST_COMMAND_IOS: python -m pytest ./tests
```
!!! tab examples "pyproject.toml"
@@ -1290,6 +1294,11 @@ Platform-specific environment variables are also available:
"pytest ./tests",
"python ./test.py",
]
+
+ # run tests on ios
+ [tool.cibuildwheel.ios]
+ test-sources = ["tests"]
+ test-command = "python -m pytest ./tests"
```
In configuration files, you can use an array, and the items will be joined with `&&`.
diff --git a/test/test_ios.py b/test/test_ios.py
index 7c5080da5..39d54ae91 100644
--- a/test/test_ios.py
+++ b/test/test_ios.py
@@ -4,6 +4,7 @@
import platform
import shutil
import subprocess
+import textwrap
import pytest
@@ -73,28 +74,17 @@ def test_ios_platforms(tmp_path, build_config, monkeypatch, capfd):
"CIBW_BUILD": "cp313-*",
"CIBW_XBUILD_TOOLS": "does-exist",
"CIBW_TEST_SOURCES": "tests",
- "CIBW_TEST_COMMAND": "unittest discover tests test_platform.py",
+ "CIBW_TEST_COMMAND": "python -m unittest discover tests test_platform.py",
"CIBW_BUILD_VERBOSITY": "1",
**build_config,
},
)
# The expected wheels were produced.
- ios_version = os.getenv("IPHONEOS_DEPLOYMENT_TARGET", "13.0").replace(".", "_")
- platform_machine = platform.machine()
-
- 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
+ expected_wheels = utils.expected_wheels(
+ "spam", "0.1.0", platform="ios", python_abi_tags=["cp313-cp313"]
+ )
+ assert set(actual_wheels) == set(expected_wheels)
# The user was notified that the cross-build tool was found.
captured = capfd.readouterr()
@@ -119,7 +109,7 @@ def test_no_test_sources(tmp_path, capfd):
add_env={
"CIBW_PLATFORM": "ios",
"CIBW_BUILD": "cp313-*",
- "CIBW_TEST_COMMAND": "tests",
+ "CIBW_TEST_COMMAND": "python -m tests",
},
)
@@ -146,7 +136,7 @@ def test_missing_xbuild_tool(tmp_path, capfd):
add_env={
"CIBW_PLATFORM": "ios",
"CIBW_BUILD": "cp313-*",
- "CIBW_TEST_COMMAND": "tests",
+ "CIBW_TEST_COMMAND": "python -m tests",
"CIBW_XBUILD_TOOLS": "does-not-exist",
},
)
@@ -180,21 +170,13 @@ def test_no_xbuild_tool_definition(tmp_path, capfd):
)
# The expected wheels were produced.
- ios_version = os.getenv("IPHONEOS_DEPLOYMENT_TARGET", "13.0").replace(".", "_")
- platform_machine = platform.machine()
-
- 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
+ expected_wheels = utils.expected_wheels(
+ "spam",
+ "0.1.0",
+ platform="ios",
+ python_abi_tags=["cp313-cp313"],
+ )
+ assert set(actual_wheels) == set(expected_wheels)
# The user was notified that there was no cross-build tool definition.
captured = capfd.readouterr()
@@ -225,23 +207,79 @@ def test_empty_xbuild_tool_definition(tmp_path, capfd):
},
)
- # The expected wheels were produced.
- ios_version = os.getenv("IPHONEOS_DEPLOYMENT_TARGET", "13.0").replace(".", "_")
- platform_machine = platform.machine()
-
- 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
+ expected_wheels = utils.expected_wheels(
+ "spam", "0.1.0", platform="ios", python_abi_tags=["cp313-cp313"]
+ )
+ assert set(actual_wheels) == set(expected_wheels)
# The warnings about cross-build notifications were silenced.
captured = capfd.readouterr()
assert "Your project configuration does not define any cross-build tools." not in captured.err
+
+
+@pytest.mark.serial
+def test_ios_test_command_without_python_dash_m(tmp_path, capfd):
+ """pytest should be able to run without python -m, but it should warn."""
+ if utils.get_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"
+
+ project = test_projects.new_c_project()
+ project.files["tests/__init__.py"] = ""
+ project.files["tests/test_spam.py"] = textwrap.dedent("""
+ import spam
+ def test_spam():
+ assert spam.filter("spam") == 0
+ assert spam.filter("ham") != 0
+ """)
+ project.generate(project_dir)
+
+ actual_wheels = utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ "CIBW_PLATFORM": "ios",
+ "CIBW_BUILD": "cp313-*",
+ "CIBW_TEST_COMMAND": "pytest ./tests",
+ "CIBW_TEST_SOURCES": "tests",
+ "CIBW_TEST_REQUIRES": "pytest",
+ "CIBW_XBUILD_TOOLS": "",
+ },
+ )
+
+ expected_wheels = utils.expected_wheels(
+ "spam", "0.1.0", platform="ios", python_abi_tags=["cp313-cp313"]
+ )
+ assert set(actual_wheels) == set(expected_wheels)
+
+ out, err = capfd.readouterr()
+
+ assert "iOS tests configured with a test command which doesn't start with 'python -m'" in err
+
+
+def test_ios_test_command_invalid(tmp_path, capfd):
+ """Test command should raise an error if it's clearly invalid."""
+ if utils.get_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 = test_projects.new_c_project()
+ basic_project.files["./my_test_script.sh"] = "echo hello"
+ basic_project.generate(project_dir)
+
+ with pytest.raises(subprocess.CalledProcessError):
+ utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ "CIBW_PLATFORM": "ios",
+ "CIBW_TEST_COMMAND": "./my_test_script.sh",
+ "CIBW_TEST_SOURCES": "./my_test_script.sh",
+ "CIBW_XBUILD_TOOLS": "",
+ },
+ )
+ out, err = capfd.readouterr()
+ assert "iOS tests configured with a test command which doesn't start with 'python -m'" in err
diff --git a/test/utils.py b/test/utils.py
index dfc66aabd..028e88863 100644
--- a/test/utils.py
+++ b/test/utils.py
@@ -167,8 +167,10 @@ def expected_wheels(
package_version: str,
manylinux_versions: list[str] | None = None,
musllinux_versions: list[str] | None = None,
- macosx_deployment_target: str = "10.9",
+ macosx_deployment_target: str | None = None,
+ iphoneos_deployment_target: str | None = None,
machine_arch: str | None = None,
+ platform: str | None = None,
python_abi_tags: list[str] | None = None,
include_universal2: bool = False,
single_python: bool = False,
@@ -177,14 +179,22 @@ def expected_wheels(
"""
Returns the expected wheels from a run of cibuildwheel.
"""
+ platform = platform or get_platform()
+
if machine_arch is None:
machine_arch = pm.machine()
- if get_platform() == "linux":
+ if platform == "linux":
machine_arch = arch_name_for_linux(machine_arch)
+ if macosx_deployment_target is None:
+ macosx_deployment_target = os.environ.get("MACOSX_DEPLOYMENT_TARGET", "10.9")
+
+ if iphoneos_deployment_target is None:
+ iphoneos_deployment_target = os.environ.get("IPHONEOS_DEPLOYMENT_TARGET", "13.0")
+
architectures = [machine_arch]
if not single_arch:
- if get_platform() == "linux":
+ if platform == "linux":
if machine_arch == "x86_64":
architectures.append("i686")
elif (
@@ -193,22 +203,24 @@ def expected_wheels(
and _AARCH64_CAN_RUN_ARMV7
):
architectures.append("armv7l")
- elif get_platform() == "windows" and machine_arch == "AMD64":
+ elif platform == "windows" and machine_arch == "AMD64":
architectures.append("x86")
return [
wheel
for architecture in architectures
for wheel in _expected_wheels(
- package_name,
- package_version,
- architecture,
- manylinux_versions,
- musllinux_versions,
- macosx_deployment_target,
- python_abi_tags,
- include_universal2,
- single_python,
+ package_name=package_name,
+ package_version=package_version,
+ machine_arch=architecture,
+ manylinux_versions=manylinux_versions,
+ musllinux_versions=musllinux_versions,
+ macosx_deployment_target=macosx_deployment_target,
+ iphoneos_deployment_target=iphoneos_deployment_target,
+ platform=platform,
+ python_abi_tags=python_abi_tags,
+ include_universal2=include_universal2,
+ single_python=single_python,
)
]
@@ -220,6 +232,8 @@ def _expected_wheels(
manylinux_versions: list[str] | None,
musllinux_versions: list[str] | None,
macosx_deployment_target: str,
+ iphoneos_deployment_target: str,
+ platform: str,
python_abi_tags: list[str] | None,
include_universal2: bool,
single_python: bool,
@@ -227,8 +241,6 @@ def _expected_wheels(
"""
Returns a list of expected wheels from a run of cibuildwheel.
"""
- platform = get_platform()
-
# per PEP 425 (https://www.python.org/dev/peps/pep-0425/), wheel files shall have name of the form
# {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl
# {python tag} and {abi tag} are closely related to the python interpreter used to build the wheel
@@ -249,7 +261,9 @@ def _expected_wheels(
if platform == "pyodide" and python_abi_tags is None:
python_abi_tags = ["cp312-cp312"]
- if python_abi_tags is None:
+ elif platform == "ios" and python_abi_tags is None:
+ python_abi_tags = ["cp313-cp313"]
+ elif python_abi_tags is None:
python_abi_tags = [
"cp38-cp38",
"cp39-cp39",
@@ -353,6 +367,20 @@ def _expected_wheels(
if include_universal2:
platform_tags.append(f"macosx_{min_macosx.replace('.', '_')}_universal2")
+ elif platform == "ios":
+ if machine_arch == "x86_64":
+ platform_tags = [
+ f"ios_{iphoneos_deployment_target.replace('.', '_')}_x86_64_iphonesimulator"
+ ]
+ elif machine_arch == "arm64":
+ platform_tags = [
+ f"ios_{iphoneos_deployment_target.replace('.', '_')}_arm64_iphoneos",
+ f"ios_{iphoneos_deployment_target.replace('.', '_')}_arm64_iphonesimulator",
+ ]
+ else:
+ msg = f"Unsupported architecture {machine_arch!r} for iOS"
+ raise Exception(msg)
+
elif platform == "pyodide":
platform_tags = ["pyodide_2024_0_wasm32"]