diff --git a/README.md b/README.md
index 5266ee697..5c4eb6219 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ Usage
² [Uses cross-compilation](https://cibuildwheel.pypa.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform.
³ Requires a macOS runner; runs tests on the simulator for the runner's architecture.
⁴ Building for Android requires the runner to be Linux x86_64, macOS ARM64 or macOS x86_64. Testing has [additional requirements](https://cibuildwheel.pypa.io/en/stable/platforms/#android).
-⁵ The `macos-15` and `macos-latest` images are [incompatible with cibuildwheel at this time](platforms/#ios-system-requirements)
when building iOS wheels.
+⁵ The `macos-15` and `macos-latest` images are [incompatible with cibuildwheel at this time](https://cibuildwheel.pypa.io/en/stable/platforms/#ios-system-requirements) when building iOS wheels.
diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py
index cb57b907a..952e24c5d 100644
--- a/cibuildwheel/platforms/android.py
+++ b/cibuildwheel/platforms/android.py
@@ -617,8 +617,8 @@ def test_wheel(state: BuildState, wheel: Path) -> None:
# Parse test-command.
test_args = shlex.split(test_command)
- if test_args[:2] in [["python", "-c"], ["python", "-m"]]:
- test_args[:3] = [test_args[1], test_args[2], "--"]
+ if test_args[0] == "python" and any(arg in test_args for arg in ["-c", "-m"]):
+ del test_args[0]
elif test_args[0] in ["pytest"]:
# We transform some commands into the `python -m` form, but this is deprecated.
msg = (
@@ -627,11 +627,11 @@ def test_wheel(state: BuildState, wheel: Path) -> None:
"If this works, all you need to do is add that to your test command."
)
log.warning(msg)
- test_args[:1] = ["-m", test_args[0], "--"]
+ test_args[:1] = ["-m", test_args[0]]
else:
msg = (
f"Test command {test_command!r} is not supported on Android. "
- f"Supported commands are 'python -m' and 'python -c'."
+ f"Command must begin with 'python' and contain '-m' or '-c'."
)
raise errors.FatalError(msg)
@@ -646,6 +646,7 @@ def test_wheel(state: BuildState, wheel: Path) -> None:
"--cwd",
cwd_dir,
*(["-v"] if state.options.build_verbosity > 0 else []),
+ "--",
*test_args,
env=state.build_env,
)
diff --git a/cibuildwheel/resources/build-platforms.toml b/cibuildwheel/resources/build-platforms.toml
index f773b8bac..5005e57b1 100644
--- a/cibuildwheel/resources/build-platforms.toml
+++ b/cibuildwheel/resources/build-platforms.toml
@@ -231,8 +231,8 @@ python_configurations = [
[android]
python_configurations = [
- { identifier = "cp313-android_arm64_v8a", version = "3.13", url = "https://repo.maven.apache.org/maven2/com/chaquo/python/python/3.13.7/python-3.13.7-aarch64-linux-android.tar.gz" },
- { identifier = "cp313-android_x86_64", version = "3.13", url = "https://repo.maven.apache.org/maven2/com/chaquo/python/python/3.13.7/python-3.13.7-x86_64-linux-android.tar.gz" },
+ { identifier = "cp313-android_arm64_v8a", version = "3.13", url = "https://repo.maven.apache.org/maven2/com/chaquo/python/python/3.13.8/python-3.13.8-aarch64-linux-android.tar.gz" },
+ { identifier = "cp313-android_x86_64", version = "3.13", url = "https://repo.maven.apache.org/maven2/com/chaquo/python/python/3.13.8/python-3.13.8-x86_64-linux-android.tar.gz" },
{ identifier = "cp314-android_arm64_v8a", version = "3.14", url = "https://www.python.org/ftp/python/3.14.0/python-3.14.0-aarch64-linux-android.tar.gz" },
{ identifier = "cp314-android_x86_64", version = "3.14", url = "https://www.python.org/ftp/python/3.14.0/python-3.14.0-x86_64-linux-android.tar.gz" },
]
diff --git a/docs/options.md b/docs/options.md
index 9f02fffec..17fe43205 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -1317,7 +1317,7 @@ The available Pyodide versions are determined by the version of `pyodide-build`
### `test-command` {: #test-command env-var toml}
> The command to test each built wheel
-Shell command to run tests after the build. The wheel will be installed
+Command to run tests after the build. The wheel will be installed
automatically and available for import from the tests. If this variable is not
set, your wheel will not be installed after building.
@@ -1345,11 +1345,11 @@ tree. To access your test code, you have a couple of options:
On all platforms other than Android and iOS, the command is run in a shell, so you can write things like `cmd1 && cmd2`.
-On Android and iOS, the command is parsed by `shlex.split`, and is required to
-be in one of the following forms:
+On Android and iOS, the command is parsed by `shlex.split`, and must be a Python
+command:
-* `python -c command ...` (Android only)
-* `python -m module-name ...`
+* On Android, the command must must begin with `python` and contain `-m` or `-c`.
+* On iOS, the command must begin with `python -m`.
Platform-specific environment variables are also available:
`CIBW_TEST_COMMAND_MACOS` | `CIBW_TEST_COMMAND_WINDOWS` | `CIBW_TEST_COMMAND_LINUX` | `CIBW_TEST_COMMAND_ANDROID` | `CIBW_TEST_COMMAND_IOS` | `CIBW_TEST_COMMAND_PYODIDE`
diff --git a/test/test_android.py b/test/test_android.py
index a391674e8..4539310d7 100644
--- a/test/test_android.py
+++ b/test/test_android.py
@@ -32,22 +32,25 @@
allow_module_level=True,
)
-# Detect CI services which have the Android SDK pre-installed.
-ci_supports_build = (
- ("CIRRUS_CI" in os.environ and platform.system() == "Darwin")
- or "GITHUB_ACTIONS" in os.environ
- or "TF_BUILD" in os.environ # Azure Pipelines
-)
+# Azure Pipelines does not set the CI variable.
+ci = any(key in os.environ for key in ["CI", "TF_BUILD"])
if "ANDROID_HOME" not in os.environ:
msg = "ANDROID_HOME environment variable is not set"
- if ci_supports_build:
+
+ # Fail if we're on a CI service which is supposed to have the Android SDK
+ # pre-installed; otherwise skip the module.
+ if (
+ ("CIRRUS_CI" in os.environ and platform.system() == "Darwin")
+ or "GITHUB_ACTIONS" in os.environ
+ or "TF_BUILD" in os.environ
+ ):
pytest.fail(msg)
else:
pytest.skip(msg, allow_module_level=True)
# Many CI services don't support running the Android emulator: see platforms.md.
-ci_supports_emulator = "GITHUB_ACTIONS" in os.environ and platform.system() == "Linux"
+supports_emulator = (not ci) or ("GITHUB_ACTIONS" in os.environ and platform.system() == "Linux")
def needs_emulator(test):
@@ -55,7 +58,7 @@ def needs_emulator(test):
# application ID, so these tests must be run serially.
test = pytest.mark.serial(test)
- if ci_supports_build and not ci_supports_emulator:
+ if not supports_emulator:
test = pytest.mark.skip("This CI platform doesn't support the emulator")(test)
return test
@@ -92,12 +95,20 @@ def test_android_home(tmp_path, capfd):
assert "ANDROID_HOME environment variable is not set" in capfd.readouterr().err
-# the first build can fail to setup - mark as flaky, and serial to make sure it runs first
+# android-env.sh may need to install the NDK, and it isn't safe to do that multiple
+# times in parallel. So make sure there's at least one test which gets as far as doing
+# a build, which is marked as serial so it will run before the parallel tests, but isn't
+# marked as needs_emulator so it will run on all CI platforms.
@pytest.mark.serial
-@pytest.mark.flaky(reruns=2)
-def test_expected_wheels(tmp_path):
+def test_expected_wheels(tmp_path, spam_env):
new_c_project().generate(tmp_path)
- wheels = cibuildwheel_run(tmp_path, add_env={"CIBW_PLATFORM": "android"})
+
+ # Build wheels for all Python versions on the current architecture.
+ del spam_env["CIBW_BUILD"]
+ if not supports_emulator:
+ del spam_env["CIBW_TEST_COMMAND"]
+
+ wheels = cibuildwheel_run(tmp_path, add_env=spam_env)
assert wheels == expected_wheels(
"spam", "0.1.0", platform="android", machine_arch=native_arch.android_abi
)
@@ -222,12 +233,20 @@ def test_spam():
print("Spam test passed")
"""
)
+ project.files["test_empty.py"] = dedent(
+ """\
+ def test_empty():
+ pass
+ """
+ )
+
project.generate(tmp_path)
return {
**cp313_env,
- "CIBW_TEST_SOURCES": "test_spam.py",
+ "CIBW_TEST_SOURCES": "test_spam.py test_empty.py",
"CIBW_TEST_REQUIRES": "pytest==8.3.5",
+ "CIBW_TEST_COMMAND": "python -m pytest",
}
@@ -236,6 +255,7 @@ def test_spam():
("command", "expected_output"),
[
("python -c 'import test_spam; test_spam.test_spam()'", "Spam test passed"),
+ ("python -m pytest", "=== 2 passed in "),
("python -m pytest test_spam.py", "=== 1 passed in "),
("pytest test_spam.py", "=== 1 passed in "),
],
@@ -260,7 +280,12 @@ def test_test_command_good(command, expected_output, tmp_path, spam_env, capfd):
(
"./test_spam.py",
"Test command './test_spam.py' is not supported on Android. "
- "Supported commands are 'python -m' and 'python -c'.",
+ "Command must begin with 'python' and contain '-m' or '-c'.",
+ ),
+ (
+ "python test_spam.py",
+ "Test command 'python test_spam.py' is not supported on Android. "
+ "Command must begin with 'python' and contain '-m' or '-c'.",
),
# Build-time failure: unrecognized placeholder
(
@@ -283,6 +308,29 @@ def test_test_command_bad(command, expected_output, tmp_path, spam_env, capfd):
assert expected_output in capfd.readouterr().err
+@needs_emulator
+@pytest.mark.parametrize(
+ ("options", "expected"),
+ [
+ ("", 0),
+ ("-E", 1),
+ ],
+)
+def test_test_command_python_options(options, expected, tmp_path, capfd):
+ project = new_c_project()
+ project.generate(tmp_path)
+
+ command = 'import sys; print(f"{sys.flags.ignore_environment=}")'
+ cibuildwheel_run(
+ tmp_path,
+ add_env={
+ **cp313_env,
+ "CIBW_TEST_COMMAND": f"python {options} -c '{command}'",
+ },
+ )
+ assert f"sys.flags.ignore_environment={expected}" in capfd.readouterr().out
+
+
@needs_emulator
def test_package_subdir(tmp_path, spam_env, capfd):
spam_paths = list(tmp_path.iterdir())
@@ -291,17 +339,11 @@ def test_package_subdir(tmp_path, spam_env, capfd):
for path in spam_paths:
path.rename(package_dir / path.name)
- test_filename = "package/" + spam_env["CIBW_TEST_SOURCES"]
- cibuildwheel_run(
- tmp_path,
- package_dir,
- add_env={
- **spam_env,
- "CIBW_TEST_SOURCES": test_filename,
- "CIBW_TEST_COMMAND": f"python -m pytest {test_filename}",
- },
+ spam_env["CIBW_TEST_SOURCES"] = " ".join(
+ f"package/{path}" for path in spam_env["CIBW_TEST_SOURCES"].split()
)
- assert "=== 1 passed in " in capfd.readouterr().out
+ cibuildwheel_run(tmp_path, package_dir, add_env=spam_env)
+ assert "=== 2 passed in " in capfd.readouterr().out
@needs_emulator