Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Usage
<sup>² [Uses cross-compilation](https://cibuildwheel.pypa.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform.</sup><br>
<sup>³ Requires a macOS runner; runs tests on the simulator for the runner's architecture. </sup><br>
<sup>⁴ 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).</sup><br>
<sup>⁵ The `macos-15` and `macos-latest` images are [incompatible with cibuildwheel at this time](platforms/#ios-system-requirements)</sup><br> when building iOS wheels.
<sup>⁵ 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.</sup><br>

<!--intro-end-->

Expand Down
9 changes: 5 additions & 4 deletions cibuildwheel/platforms/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]):
Copy link
Contributor

@henryiii henryiii Oct 15, 2025

Choose a reason for hiding this comment

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

Python also supports merged short args, like python3 -Om calendar. Maybe a leading python should be stripped regardless of what follows? Also, what about python3?

Also, I think this would read nicely with pattern matching:

    in_test_args = shlex.split(test_command)
    match in_test_args:
        case ["python" | "python3", *test_args]:
            pass
        case ["pytest", *_]:
            # We transform some commands into the `python -m` form, but this is deprecated.
            msg = (
                f"Test command {test_command!r} is not supported on Android. "
                "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."
            )
            log.warning(msg)
            test_args = ["-m", *in_test_args]
        case _:
            msg = (
                f"Test command {test_command!r} is not supported on Android. "
                f"Command must begin with 'python' and contain '-m' or '-c'."
            )
            raise errors.FatalError(msg)

Copy link
Member Author

@mhsmith mhsmith Oct 16, 2025

Choose a reason for hiding this comment

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

Python also supports merged short args, like python3 -Om calendar.

That's a good point, but the arguments are processed by the cpython test script, which, if it doesn't find a literal -m or -c, will insert -m test at the start of the command. This would be very confusing for cibuildwheel users.

The script could be improved to detect merged short args, but then this PR would be blocked for 2 months until the next Python release.

This PR also fixes a regression in cibuildwheel 3.2.1, which updated the Android section of build-platforms.toml to Python 3.14.0 (which has the new version of the test script), but didn't include the cibuildwheel side of the changes. This has broken Android testing on Python 3.14.

To make sure any similar problems are caught in the future, I’ve updated test_expected_wheels so it not only builds on all Python versions, but also tests on them.

Also, what about python3?

I've updated the code to accept that.

del test_args[0]
elif test_args[0] in ["pytest"]:
# We transform some commands into the `python -m` form, but this is deprecated.
msg = (
Expand All @@ -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)

Expand All @@ -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,
)
Expand Down
4 changes: 2 additions & 2 deletions cibuildwheel/resources/build-platforms.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
10 changes: 5 additions & 5 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:<br/>
`CIBW_TEST_COMMAND_MACOS` | `CIBW_TEST_COMMAND_WINDOWS` | `CIBW_TEST_COMMAND_LINUX` | `CIBW_TEST_COMMAND_ANDROID` | `CIBW_TEST_COMMAND_IOS` | `CIBW_TEST_COMMAND_PYODIDE`
Expand Down
92 changes: 67 additions & 25 deletions test/test_android.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,33 @@
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):
# All copies of the testbed app run on the same emulator with the same
# 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

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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",
}


Expand All @@ -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 "),
],
Expand All @@ -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
(
Expand All @@ -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())
Expand All @@ -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
Expand Down
Loading