feat: support multiple commands on iOS (#2432)

* feat: support multiple commands on iOS

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

* Move split_command into a util module

* (unrelated) fix test docstring

* Implement short-circuit behaviour on test-command

---------

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
Co-authored-by: Joe Rickerby <joerick@mac.com>
This commit is contained in:
Henry Schreiner
2025-05-30 09:53:20 -04:00
committed by GitHub
co-authored by Joe Rickerby
parent f8168e2a89
commit ea08120e25
3 changed files with 102 additions and 55 deletions
+48 -52
View File
@@ -25,7 +25,7 @@ from ..logger import log
from ..options import Options
from ..selector import BuildSelector
from ..util import resources
from ..util.cmd import call, shell
from ..util.cmd import call, shell, split_command
from ..util.file import (
CIBW_CACHE_PATH,
copy_test_sources,
@@ -618,62 +618,58 @@ def build(options: Options, tmp_path: Path) -> None:
)
raise errors.FatalError(msg)
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:]
test_command_list = shlex.split(build_options.test_command)
try:
call(
"python",
testbed_path,
"run",
*(["--verbose"] if build_options.build_verbosity > 0 else []),
"--",
*test_command_parts,
env=test_env,
)
failed = False
for test_command_parts in split_command(test_command_list):
match test_command_parts:
case ["python", "-m", *rest]:
final_command = rest
case ["pytest", *rest]:
# pytest works exactly the same as a module, so we
# can just run it as a module.
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.
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}
""")
log.warning(msg)
final_command = ["pytest", *rest]
case _:
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)
call(
"python",
testbed_path,
"run",
*(["--verbose"] if build_options.build_verbosity > 0 else []),
"--",
*final_command,
env=test_env,
)
except subprocess.CalledProcessError:
failed = True
log.step_end(success=not failed)
if failed:
# catches the first test command failure in the loop,
# implementing short-circuiting
log.step_end(success=False)
log.error(f"Test suite failed on {config.identifier}")
sys.exit(1)
log.step_end()
# We're all done here; move it to output (overwrite existing)
if compatible_wheel is None:
output_wheel = build_options.output_dir.joinpath(built_wheel.name)
+16 -1
View File
@@ -4,7 +4,7 @@ import shutil
import subprocess
import sys
import typing
from collections.abc import Mapping
from collections.abc import Iterator, Mapping
from typing import Final, Literal
from ..errors import FatalError
@@ -81,3 +81,18 @@ def shell(
command = " ".join(commands)
print(f"+ {command}")
subprocess.run(command, env=env, cwd=cwd, shell=True, check=True)
def split_command(lst: list[str]) -> Iterator[list[str]]:
"""
Split a shell-style command, as returned by shlex.split, into a sequence
of commands, separated by '&&'.
"""
items = list[str]()
for item in lst:
if item == "&&":
yield items
items = []
else:
items.append(item)
yield items
+38 -2
View File
@@ -86,7 +86,7 @@ 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": "python -m unittest discover tests test_platform.py",
"CIBW_TEST_COMMAND": "python -m this && python -m unittest discover tests test_platform.py",
"CIBW_BUILD_VERBOSITY": "1",
**build_config,
},
@@ -102,6 +102,9 @@ def test_ios_platforms(tmp_path, build_config, monkeypatch, capfd):
captured = capfd.readouterr()
assert "'does-exist' will be included in the cross-build environment" in captured.out
# Make sure the first command ran
assert "Zen of Python" in captured.out
@pytest.mark.serial
def test_no_test_sources(tmp_path, capfd):
@@ -134,7 +137,10 @@ def test_no_test_sources(tmp_path, capfd):
def test_ios_testing_with_placeholder(tmp_path, capfd):
"""Build will run tests with the {project} placeholder."""
"""
Tests with the {project} placeholder are not supported on iOS, because the test command
is run in the simulator.
"""
skip_if_ios_testing_not_supported()
project_dir = tmp_path / "project"
@@ -159,6 +165,36 @@ def test_ios_testing_with_placeholder(tmp_path, capfd):
assert "iOS tests cannot use placeholders" in captured.out + captured.err
@pytest.mark.serial
def test_ios_test_command_short_circuit(tmp_path, capfd):
skip_if_ios_testing_not_supported()
project_dir = tmp_path / "project"
basic_project = test_projects.new_c_project()
basic_project.files.update(basic_project_files)
basic_project.generate(project_dir)
with pytest.raises(subprocess.CalledProcessError):
# `python -m not_a_module` will fail, so `python -m this` should not be run.
utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_PLATFORM": "ios",
"CIBW_BUILD": "cp313-*",
"CIBW_XBUILD_TOOLS": "",
"CIBW_TEST_SOURCES": "tests",
"CIBW_TEST_COMMAND": "python -m not_a_module && python -m this",
"CIBW_BUILD_VERBOSITY": "1",
},
)
captured = capfd.readouterr()
assert "No module named not_a_module" in captured.out + captured.err
# assert that `python -m this` was not run
assert "Zen of Python" not in captured.out + captured.err
def test_missing_xbuild_tool(tmp_path, capfd):
"""Build will fail if xbuild-tools references a non-existent tool."""
skip_if_ios_testing_not_supported()