From bb4ee9989643711ed53140496dcd1bddb276e570 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 2 Jun 2026 07:57:09 -0400 Subject: [PATCH] fix: config settings expansion issue (#2886) --- cibuildwheel/frontend.py | 12 +++++++++++- cibuildwheel/platforms/android.py | 7 ++++++- unit_test/options_test.py | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cibuildwheel/frontend.py b/cibuildwheel/frontend.py index 46357034..14a8ede8 100644 --- a/cibuildwheel/frontend.py +++ b/cibuildwheel/frontend.py @@ -62,7 +62,17 @@ def _split_config_settings(config_settings: str) -> list[str]: def prepare_config_settings(config_settings: str, *, project: PathOrStr, package: PathOrStr) -> str: - return prepare_command(config_settings, project=project, package=package) + # Substitute the {project}/{package} placeholders on each already-split + # token rather than on the raw string. A substituted path may contain + # spaces or backslashes (e.g. a Windows `{package}` path), and the result + # is later re-parsed with shlex.split (in _split_config_settings / + # parse_config_settings) — substituting on the whole string would let + # those characters be reinterpreted, splitting one setting into several or + # eating backslashes. shlex.join re-quotes each token so the round-trip is + # lossless. + settings = shlex.split(config_settings) + prepared = [prepare_command(setting, project=project, package=package) for setting in settings] + return shlex.join(prepared) # Based on build.__main__.main. diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py index fc8fe01d..3a1cc1b2 100644 --- a/cibuildwheel/platforms/android.py +++ b/cibuildwheel/platforms/android.py @@ -276,7 +276,12 @@ def setup_env( call(*pip, "install", *pb.build_system_requires, env=build_env) requires_for_build = pb.get_requires_for_build( - "wheel", parse_config_settings(build_options.config_settings) + "wheel", + parse_config_settings( + prepare_config_settings( + build_options.config_settings, project=".", package=build_options.package_dir + ) + ), ) if requires_for_build: call(*pip, "install", *requires_for_build, env=build_env) diff --git a/unit_test/options_test.py b/unit_test/options_test.py index 77fb29ae..6b586038 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -13,7 +13,9 @@ from cibuildwheel import errors from cibuildwheel.bashlex_eval import local_environment_executor from cibuildwheel.frontend import ( BuildFrontendConfig, + _split_config_settings, get_build_frontend_extra_flags, + parse_config_settings, prepare_config_settings, ) from cibuildwheel.logger import Logger @@ -608,6 +610,29 @@ def test_prepare_config_settings(config_settings: str, expected: str) -> None: ) +@pytest.mark.parametrize( + ("project", "package"), + [ + (R"C:\Users\John Doe\project", R"C:\Users\John Doe\project\pkg"), + ("/home/me/my project", "/home/me/my project/pkg"), + ], +) +def test_prepare_config_settings_special_chars(project: str, package: str) -> None: + # Paths with spaces or backslashes must survive the round-trip through the + # downstream shlex.split intact (regression test for the {package}/{project} + # substitution being mangled by re-parsing). + config_settings = "setup-args=--cross-file={package}/cross.ini other-setting={project}" + prepared = prepare_config_settings(config_settings, project=project, package=package) + assert _split_config_settings(prepared) == [ + f"-Csetup-args=--cross-file={package}/cross.ini", + f"-Cother-setting={project}", + ] + assert parse_config_settings(prepared) == { + "setup-args": f"--cross-file={package}/cross.ini", + "other-setting": project, + } + + @pytest.mark.parametrize( ("definition", "expected_args"), [