Skip to content

Commit 362020b

Browse files
Update Android test-command handling (#2590)
* Update Android test-command handling * Update to Python 3.13.8 * Update documentation and tests * Deal with `sysconfig.get_config_var("exec_prefix")` changing in Python 3.14, and add cross venv tests * Allow test commands starting with `python3` * test-command cleanups * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8165422 commit 362020b

File tree

7 files changed

+130
-62
lines changed

7 files changed

+130
-62
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Usage
7070
<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>
7171
<sup>³ Requires a macOS runner; runs tests on the simulator for the runner's architecture. </sup><br>
7272
<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>
73-
<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.
73+
<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>
7474

7575
<!--intro-end-->
7676

cibuildwheel/platforms/android.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -617,8 +617,11 @@ def test_wheel(state: BuildState, wheel: Path) -> None:
617617

618618
# Parse test-command.
619619
test_args = shlex.split(test_command)
620-
if test_args[:2] in [["python", "-c"], ["python", "-m"]]:
621-
test_args[:3] = [test_args[1], test_args[2], "--"]
620+
if test_args[0] in ["python", "python3"] and any(arg in test_args for arg in ["-c", "-m"]):
621+
# Forward the args to the CPython testbed script. We require '-c' or '-m'
622+
# to be in the command, because without those flags, the testbed script
623+
# will prepend '-m test', which will run Python's own test suite.
624+
del test_args[0]
622625
elif test_args[0] in ["pytest"]:
623626
# We transform some commands into the `python -m` form, but this is deprecated.
624627
msg = (
@@ -627,11 +630,11 @@ def test_wheel(state: BuildState, wheel: Path) -> None:
627630
"If this works, all you need to do is add that to your test command."
628631
)
629632
log.warning(msg)
630-
test_args[:1] = ["-m", test_args[0], "--"]
633+
test_args.insert(0, "-m")
631634
else:
632635
msg = (
633636
f"Test command {test_command!r} is not supported on Android. "
634-
f"Supported commands are 'python -m' and 'python -c'."
637+
f"Command must begin with 'python' or 'python3', and contain '-m' or '-c'."
635638
)
636639
raise errors.FatalError(msg)
637640

@@ -646,6 +649,7 @@ def test_wheel(state: BuildState, wheel: Path) -> None:
646649
"--cwd",
647650
cwd_dir,
648651
*(["-v"] if state.options.build_verbosity > 0 else []),
652+
"--",
649653
*test_args,
650654
env=state.build_env,
651655
)

cibuildwheel/resources/_cross_venv.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,20 @@ def cross_getandroidapilevel() -> int:
6868

6969
# sysconfig ###############################################################
7070
#
71-
# We don't change the actual sys.base_prefix and base_exec_prefix, because that
72-
# could have unpredictable effects. Instead, we change the internal variables
73-
# used to generate sysconfig.get_path("include").
74-
exec_prefix = sysconfig.get_config_var("exec_prefix")
75-
sysconfig._BASE_PREFIX = sysconfig._BASE_EXEC_PREFIX = exec_prefix # type: ignore[attr-defined]
76-
77-
# Reload the sysconfigdata file, generating its name from sys.abiflags,
71+
# Load the sysconfigdata file, generating its name from sys.abiflags,
7872
# sys.platform, and sys.implementation._multiarch.
7973
sysconfig._init_config_vars() # type: ignore[attr-defined]
8074

75+
# We don't change the actual sys.base_prefix and base_exec_prefix, because that
76+
# could have unpredictable effects. Instead, we change the sysconfig variables
77+
# used by sysconfig.get_paths().
78+
vars = sysconfig.get_config_vars()
79+
try:
80+
host_prefix = vars["host_prefix"] # This variable was added in Python 3.14.
81+
except KeyError:
82+
host_prefix = vars["exec_prefix"]
83+
vars["installed_base"] = vars["installed_platbase"] = host_prefix
84+
8185
# sysconfig.get_platform, which determines the wheel tag, is implemented in terms of
8286
# sys.platform, sysconfig.get_config_var("ANDROID_API_LEVEL") (see localized_vars in
8387
# android.py), and os.uname.

cibuildwheel/resources/build-platforms.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,8 @@ python_configurations = [
231231

232232
[android]
233233
python_configurations = [
234-
{ 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" },
235-
{ 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" },
234+
{ 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" },
235+
{ 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" },
236236
{ 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" },
237237
{ 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" },
238238
]

docs/options.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,7 @@ The available Pyodide versions are determined by the version of `pyodide-build`
13181318
### `test-command` {: #test-command env-var toml}
13191319
> The command to test each built wheel
13201320
1321-
Shell command to run tests after the build. The wheel will be installed
1321+
Command to run tests after the build. The wheel will be installed
13221322
automatically and available for import from the tests. If this variable is not
13231323
set, your wheel will not be installed after building.
13241324

@@ -1346,11 +1346,12 @@ tree. To access your test code, you have a couple of options:
13461346

13471347
On all platforms other than Android and iOS, the command is run in a shell, so you can write things like `cmd1 && cmd2`.
13481348

1349-
On Android and iOS, the command is parsed by `shlex.split`, and is required to
1350-
be in one of the following forms:
1349+
On Android and iOS, the command is parsed by `shlex.split`, and must be a Python
1350+
command:
13511351

1352-
* `python -c command ...` (Android only)
1353-
* `python -m module-name ...`
1352+
* On Android, the command must must begin with `python` or `python3`, and contain `-m`
1353+
or `-c`.
1354+
* On iOS, the command must begin with `python -m`.
13541355

13551356
Platform-specific environment variables are also available:<br/>
13561357
`CIBW_TEST_COMMAND_MACOS` | `CIBW_TEST_COMMAND_WINDOWS` | `CIBW_TEST_COMMAND_LINUX` | `CIBW_TEST_COMMAND_ANDROID` | `CIBW_TEST_COMMAND_IOS` | `CIBW_TEST_COMMAND_PYODIDE`

test/_cross_venv_test_android.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import sys
2+
import sysconfig
3+
from pathlib import Path
4+
5+
assert sys.platform == "android"
6+
assert sysconfig.get_platform().startswith("android-")
7+
8+
android_prefix = Path(f"{sys.prefix}/../python/prefix").resolve()
9+
assert android_prefix.is_dir()
10+
11+
vars = sysconfig.get_config_vars()
12+
assert vars["INCLUDEDIR"] == f"{android_prefix}/include"
13+
assert vars["LDVERSION"] == f"{sys.version_info[0]}.{sys.version_info[1]}{sys.abiflags}"
14+
assert vars["INCLUDEPY"] == f"{vars['INCLUDEDIR']}/python{vars['LDVERSION']}"
15+
assert vars["LIBDIR"] == f"{android_prefix}/lib"
16+
assert vars["Py_ENABLE_SHARED"] == 1
17+
18+
paths = sysconfig.get_paths()
19+
assert paths["include"] == vars["INCLUDEPY"]
20+
assert paths["platinclude"] == vars["INCLUDEPY"]

test/test_android.py

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -32,30 +32,33 @@
3232
allow_module_level=True,
3333
)
3434

35-
# Detect CI services which have the Android SDK pre-installed.
36-
ci_supports_build = (
37-
("CIRRUS_CI" in os.environ and platform.system() == "Darwin")
38-
or "GITHUB_ACTIONS" in os.environ
39-
or "TF_BUILD" in os.environ # Azure Pipelines
40-
)
35+
# Azure Pipelines does not set the CI variable.
36+
ci = any(key in os.environ for key in ["CI", "TF_BUILD"])
4137

4238
if "ANDROID_HOME" not in os.environ:
4339
msg = "ANDROID_HOME environment variable is not set"
44-
if ci_supports_build:
40+
41+
# Fail if we're on a CI service which is supposed to have the Android SDK
42+
# pre-installed; otherwise skip the module.
43+
if (
44+
("CIRRUS_CI" in os.environ and platform.system() == "Darwin")
45+
or "GITHUB_ACTIONS" in os.environ
46+
or "TF_BUILD" in os.environ
47+
):
4548
pytest.fail(msg)
4649
else:
4750
pytest.skip(msg, allow_module_level=True)
4851

4952
# Many CI services don't support running the Android emulator: see platforms.md.
50-
ci_supports_emulator = "GITHUB_ACTIONS" in os.environ and platform.system() == "Linux"
53+
supports_emulator = (not ci) or ("GITHUB_ACTIONS" in os.environ and platform.system() == "Linux")
5154

5255

5356
def needs_emulator(test):
5457
# All copies of the testbed app run on the same emulator with the same
5558
# application ID, so these tests must be run serially.
5659
test = pytest.mark.serial(test)
5760

58-
if ci_supports_build and not ci_supports_emulator:
61+
if not supports_emulator:
5962
test = pytest.mark.skip("This CI platform doesn't support the emulator")(test)
6063
return test
6164

@@ -92,12 +95,24 @@ def test_android_home(tmp_path, capfd):
9295
assert "ANDROID_HOME environment variable is not set" in capfd.readouterr().err
9396

9497

95-
# the first build can fail to setup - mark as flaky, and serial to make sure it runs first
98+
# android-env.sh may need to install the NDK, and it isn't safe to do that multiple
99+
# times in parallel. So make sure there's at least one test which gets as far as doing
100+
# a build, which is marked as serial so it will run before the parallel tests, but isn't
101+
# marked as needs_emulator so it will run on all CI platforms.
96102
@pytest.mark.serial
97-
@pytest.mark.flaky(reruns=2)
98-
def test_expected_wheels(tmp_path):
99-
new_c_project().generate(tmp_path)
100-
wheels = cibuildwheel_run(tmp_path, add_env={"CIBW_PLATFORM": "android"})
103+
def test_expected_wheels(tmp_path, spam_env):
104+
# Since this test covers all Python versions, check the cross venv.
105+
test_module = "_cross_venv_test_android"
106+
project = new_c_project(setup_py_add=f"import {test_module}")
107+
project.files[f"{test_module}.py"] = (Path(__file__).parent / f"{test_module}.py").read_text()
108+
project.generate(tmp_path)
109+
110+
# Build wheels for all Python versions on the current architecture.
111+
del spam_env["CIBW_BUILD"]
112+
if not supports_emulator:
113+
del spam_env["CIBW_TEST_COMMAND"]
114+
115+
wheels = cibuildwheel_run(tmp_path, add_env=spam_env)
101116
assert wheels == expected_wheels(
102117
"spam", "0.1.0", platform="android", machine_arch=native_arch.android_abi
103118
)
@@ -222,20 +237,29 @@ def test_spam():
222237
print("Spam test passed")
223238
"""
224239
)
240+
project.files["test_empty.py"] = dedent(
241+
"""\
242+
def test_empty():
243+
pass
244+
"""
245+
)
246+
225247
project.generate(tmp_path)
226248

227249
return {
228250
**cp313_env,
229-
"CIBW_TEST_SOURCES": "test_spam.py",
251+
"CIBW_TEST_SOURCES": "test_spam.py test_empty.py",
230252
"CIBW_TEST_REQUIRES": "pytest==8.3.5",
253+
"CIBW_TEST_COMMAND": "python -m pytest",
231254
}
232255

233256

234257
@needs_emulator
235258
@pytest.mark.parametrize(
236259
("command", "expected_output"),
237260
[
238-
("python -c 'import test_spam; test_spam.test_spam()'", "Spam test passed"),
261+
("python3 -c 'import test_spam; test_spam.test_spam()'", "Spam test passed"),
262+
("python -m pytest", "=== 2 passed in "),
239263
("python -m pytest test_spam.py", "=== 1 passed in "),
240264
("pytest test_spam.py", "=== 1 passed in "),
241265
],
@@ -252,27 +276,25 @@ def test_test_command_good(command, expected_output, tmp_path, spam_env, capfd):
252276
) in stderr
253277

254278

279+
BAD_FORMAT_ERROR = (
280+
"Test command '{}' is not supported on Android. "
281+
"Command must begin with 'python' or 'python3', and contain '-m' or '-c'."
282+
)
283+
BAD_PLACEHOLDER_ERROR = (
284+
"Test command '{}' with a '{{project}}' or '{{package}}' placeholder "
285+
"is not supported on Android"
286+
)
287+
288+
255289
@needs_emulator
256290
@pytest.mark.parametrize(
257291
("command", "expected_output"),
258292
[
259-
# Build-time failure: unrecognized command
260-
(
261-
"./test_spam.py",
262-
"Test command './test_spam.py' is not supported on Android. "
263-
"Supported commands are 'python -m' and 'python -c'.",
264-
),
265-
# Build-time failure: unrecognized placeholder
266-
(
267-
"pytest {project}",
268-
"Test command 'pytest {project}' with a '{project}' or '{package}' "
269-
"placeholder is not supported on Android",
270-
),
271-
(
272-
"pytest {package}",
273-
"Test command 'pytest {package}' with a '{project}' or '{package}' "
274-
"placeholder is not supported on Android",
275-
),
293+
# Build-time failure
294+
("./test_spam.py", BAD_FORMAT_ERROR.format("./test_spam.py")),
295+
("python test_spam.py", BAD_FORMAT_ERROR.format("python test_spam.py")),
296+
("pytest {project}", BAD_PLACEHOLDER_ERROR.format("pytest {project}")),
297+
("pytest {package}", BAD_PLACEHOLDER_ERROR.format("pytest {package}")),
276298
# Runtime failure
277299
("pytest test_ham.py", "not found: test_ham.py"),
278300
],
@@ -283,6 +305,29 @@ def test_test_command_bad(command, expected_output, tmp_path, spam_env, capfd):
283305
assert expected_output in capfd.readouterr().err
284306

285307

308+
@needs_emulator
309+
@pytest.mark.parametrize(
310+
("options", "expected"),
311+
[
312+
("", 0),
313+
("-E", 1),
314+
],
315+
)
316+
def test_test_command_python_options(options, expected, tmp_path, capfd):
317+
project = new_c_project()
318+
project.generate(tmp_path)
319+
320+
command = 'import sys; print(f"{sys.flags.ignore_environment=}")'
321+
cibuildwheel_run(
322+
tmp_path,
323+
add_env={
324+
**cp313_env,
325+
"CIBW_TEST_COMMAND": f"python {options} -c '{command}'",
326+
},
327+
)
328+
assert f"sys.flags.ignore_environment={expected}" in capfd.readouterr().out
329+
330+
286331
@needs_emulator
287332
def test_package_subdir(tmp_path, spam_env, capfd):
288333
spam_paths = list(tmp_path.iterdir())
@@ -291,17 +336,11 @@ def test_package_subdir(tmp_path, spam_env, capfd):
291336
for path in spam_paths:
292337
path.rename(package_dir / path.name)
293338

294-
test_filename = "package/" + spam_env["CIBW_TEST_SOURCES"]
295-
cibuildwheel_run(
296-
tmp_path,
297-
package_dir,
298-
add_env={
299-
**spam_env,
300-
"CIBW_TEST_SOURCES": test_filename,
301-
"CIBW_TEST_COMMAND": f"python -m pytest {test_filename}",
302-
},
339+
spam_env["CIBW_TEST_SOURCES"] = " ".join(
340+
f"package/{path}" for path in spam_env["CIBW_TEST_SOURCES"].split()
303341
)
304-
assert "=== 1 passed in " in capfd.readouterr().out
342+
cibuildwheel_run(tmp_path, package_dir, add_env=spam_env)
343+
assert "=== 2 passed in " in capfd.readouterr().out
305344

306345

307346
@needs_emulator

0 commit comments

Comments
 (0)