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
+18 -22
View File
@@ -25,7 +25,7 @@ from ..logger import log
from ..options import Options from ..options import Options
from ..selector import BuildSelector from ..selector import BuildSelector
from ..util import resources from ..util import resources
from ..util.cmd import call, shell from ..util.cmd import call, shell, split_command
from ..util.file import ( from ..util.file import (
CIBW_CACHE_PATH, CIBW_CACHE_PATH,
copy_test_sources, copy_test_sources,
@@ -618,14 +618,16 @@ def build(options: Options, tmp_path: Path) -> None:
) )
raise errors.FatalError(msg) raise errors.FatalError(msg)
test_command_parts = shlex.split(build_options.test_command) test_command_list = shlex.split(build_options.test_command)
if test_command_parts[0:2] != ["python", "-m"]: try:
first_part = test_command_parts[0] for test_command_parts in split_command(test_command_list):
if first_part == "pytest": match test_command_parts:
case ["python", "-m", *rest]:
final_command = rest
case ["pytest", *rest]:
# pytest works exactly the same as a module, so we # pytest works exactly the same as a module, so we
# can just run it as a module. # can just run it as a module.
log.warning( msg = unwrap_preserving_paragraphs(f"""
unwrap_preserving_paragraphs(f"""
iOS tests configured with a test command which doesn't start iOS tests configured with a test command which doesn't start
with 'python -m'. iOS tests must execute python modules - other with 'python -m'. iOS tests must execute python modules - other
entrypoints are not supported. entrypoints are not supported.
@@ -636,8 +638,9 @@ def build(options: Options, tmp_path: Path) -> None:
Test command: {build_options.test_command!r} Test command: {build_options.test_command!r}
""") """)
) log.warning(msg)
else: final_command = ["pytest", *rest]
case _:
msg = unwrap_preserving_paragraphs( msg = unwrap_preserving_paragraphs(
f""" f"""
iOS tests configured with a test command which doesn't start iOS tests configured with a test command which doesn't start
@@ -648,32 +651,25 @@ def build(options: Options, tmp_path: Path) -> None:
""" """
) )
raise errors.FatalError(msg) 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:]
try:
call( call(
"python", "python",
testbed_path, testbed_path,
"run", "run",
*(["--verbose"] if build_options.build_verbosity > 0 else []), *(["--verbose"] if build_options.build_verbosity > 0 else []),
"--", "--",
*test_command_parts, *final_command,
env=test_env, env=test_env,
) )
failed = False
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
failed = True # catches the first test command failure in the loop,
# implementing short-circuiting
log.step_end(success=not failed) log.step_end(success=False)
if failed:
log.error(f"Test suite failed on {config.identifier}") log.error(f"Test suite failed on {config.identifier}")
sys.exit(1) sys.exit(1)
log.step_end()
# We're all done here; move it to output (overwrite existing) # We're all done here; move it to output (overwrite existing)
if compatible_wheel is None: if compatible_wheel is None:
output_wheel = build_options.output_dir.joinpath(built_wheel.name) output_wheel = build_options.output_dir.joinpath(built_wheel.name)
+16 -1
View File
@@ -4,7 +4,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import typing import typing
from collections.abc import Mapping from collections.abc import Iterator, Mapping
from typing import Final, Literal from typing import Final, Literal
from ..errors import FatalError from ..errors import FatalError
@@ -81,3 +81,18 @@ def shell(
command = " ".join(commands) command = " ".join(commands)
print(f"+ {command}") print(f"+ {command}")
subprocess.run(command, env=env, cwd=cwd, shell=True, check=True) 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_BUILD": "cp313-*",
"CIBW_XBUILD_TOOLS": "does-exist", "CIBW_XBUILD_TOOLS": "does-exist",
"CIBW_TEST_SOURCES": "tests", "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", "CIBW_BUILD_VERBOSITY": "1",
**build_config, **build_config,
}, },
@@ -102,6 +102,9 @@ def test_ios_platforms(tmp_path, build_config, monkeypatch, capfd):
captured = capfd.readouterr() captured = capfd.readouterr()
assert "'does-exist' will be included in the cross-build environment" in captured.out 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 @pytest.mark.serial
def test_no_test_sources(tmp_path, capfd): 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): 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() skip_if_ios_testing_not_supported()
project_dir = tmp_path / "project" 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 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): def test_missing_xbuild_tool(tmp_path, capfd):
"""Build will fail if xbuild-tools references a non-existent tool.""" """Build will fail if xbuild-tools references a non-existent tool."""
skip_if_ios_testing_not_supported() skip_if_ios_testing_not_supported()