diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py index 660af67c1..19ef04186 100644 --- a/cibuildwheel/platforms/ios.py +++ b/cibuildwheel/platforms/ios.py @@ -25,7 +25,7 @@ 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) diff --git a/cibuildwheel/util/cmd.py b/cibuildwheel/util/cmd.py index fcf725819..a528480e7 100644 --- a/cibuildwheel/util/cmd.py +++ b/cibuildwheel/util/cmd.py @@ -4,7 +4,7 @@ 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 diff --git a/test/test_ios.py b/test/test_ios.py index 3d1674585..b2d7e892a 100644 --- a/test/test_ios.py +++ b/test/test_ios.py @@ -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()