diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index d04ebf581..5720074dd 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -20,6 +20,7 @@ get_build_verbosity_extra_flags, prepare_command, read_python_configs, + split_config_settings, unwrap, ) @@ -212,8 +213,10 @@ def build_in_container( container.call(["mkdir", "-p", built_wheel_dir]) verbosity_flags = get_build_verbosity_extra_flags(build_options.build_verbosity) + extra_flags = split_config_settings(build_options.config_settings) if build_options.build_frontend == "pip": + extra_flags += verbosity_flags container.call( [ "python", @@ -223,12 +226,13 @@ def build_in_container( container_package_dir, f"--wheel-dir={built_wheel_dir}", "--no-deps", - *verbosity_flags, + *extra_flags, ], env=env, ) elif build_options.build_frontend == "build": - config_setting = " ".join(verbosity_flags) + verbosity_setting = " ".join(verbosity_flags) + extra_flags += (f"--config-setting={verbosity_setting}",) container.call( [ "python", @@ -237,7 +241,7 @@ def build_in_container( container_package_dir, "--wheel", f"--outdir={built_wheel_dir}", - f"--config-setting={config_setting}", + *extra_flags, ], env=env, ) diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index 008206744..f15a04f87 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -34,6 +34,7 @@ prepare_command, read_python_configs, shell, + split_config_settings, unwrap, virtualenv, ) @@ -345,8 +346,10 @@ def build(options: Options, tmp_path: Path) -> None: built_wheel_dir.mkdir() verbosity_flags = get_build_verbosity_extra_flags(build_options.build_verbosity) + extra_flags = split_config_settings(build_options.config_settings) if build_options.build_frontend == "pip": + extra_flags += verbosity_flags # Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org # see https://github.com/pypa/cibuildwheel/pull/369 call( @@ -357,11 +360,12 @@ def build(options: Options, tmp_path: Path) -> None: build_options.package_dir.resolve(), f"--wheel-dir={built_wheel_dir}", "--no-deps", - *verbosity_flags, + *extra_flags, env=env, ) elif build_options.build_frontend == "build": - config_setting = " ".join(verbosity_flags) + verbosity_setting = " ".join(verbosity_flags) + extra_flags += (f"--config-setting={verbosity_setting}",) build_env = env.copy() if build_options.dependency_constraints: constraint_path = ( @@ -378,7 +382,7 @@ def build(options: Options, tmp_path: Path) -> None: build_options.package_dir, "--wheel", f"--outdir={built_wheel_dir}", - f"--config-setting={config_setting}", + *extra_flags, env=build_env, ) else: diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index c8d050fa9..241f0e0e3 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -3,13 +3,14 @@ import difflib import functools import os +import shlex import sys import traceback from configparser import ConfigParser from contextlib import contextmanager from dataclasses import asdict, dataclass from pathlib import Path -from typing import Any, Dict, Generator, List, Mapping, Union, cast +from typing import Any, Dict, Generator, Iterator, List, Mapping, Union, cast if sys.version_info >= (3, 11): import tomllib @@ -77,6 +78,7 @@ class BuildOptions: test_extras: str build_verbosity: int build_frontend: BuildFrontend + config_settings: str @property def package_dir(self) -> Path: @@ -293,8 +295,9 @@ def get( accept platform versions of the environment variable. If this is an array it will be merged with "sep" before returning. If it is a table, it will be formatted with "table['item']" using {k} and {v} and merged - with "table['sep']". Empty variables will not override if ignore_empty - is True. + with "table['sep']". If sep is also given, it will be used for arrays + inside the table (must match table['sep']). Empty variables will not + override if ignore_empty is True. """ if name not in self.default_options and name not in self.default_platform_options: @@ -324,7 +327,9 @@ def get( if isinstance(result, dict): if table is None: raise ConfigOptionError(f"{name!r} does not accept a table") - return table["sep"].join(table["item"].format(k=k, v=v) for k, v in result.items()) + return table["sep"].join( + item for k, v in result.items() for item in _inner_fmt(k, v, table["item"]) + ) if isinstance(result, list): if sep is None: @@ -337,6 +342,16 @@ def get( return result +def _inner_fmt(k: str, v: Any, table_item: str) -> Iterator[str]: + if isinstance(v, list): + for inner_v in v: + qv = shlex.quote(inner_v) + yield table_item.format(k=k, v=qv) + else: + qv = shlex.quote(v) + yield table_item.format(k=k, v=qv) + + class Options: def __init__(self, platform: PlatformName, command_line_arguments: CommandLineArguments): self.platform = platform @@ -427,11 +442,14 @@ def build_options(self, identifier: str | None) -> BuildOptions: build_frontend_str = self.reader.get("build-frontend", env_plat=False) environment_config = self.reader.get( - "environment", table={"item": '{k}="{v}"', "sep": " "} + "environment", table={"item": "{k}={v}", "sep": " "} ) environment_pass = self.reader.get("environment-pass", sep=" ").split() before_build = self.reader.get("before-build", sep=" && ") repair_command = self.reader.get("repair-wheel-command", sep=" && ") + config_settings = self.reader.get( + "config-settings", table={"item": "{k}={v}", "sep": " "} + ) dependency_versions = self.reader.get("dependency-versions") test_command = self.reader.get("test-command", sep=" && ") @@ -537,6 +555,7 @@ def build_options(self, identifier: str | None) -> BuildOptions: manylinux_images=manylinux_images or None, musllinux_images=musllinux_images or None, build_frontend=build_frontend, + config_settings=config_settings, ) def check_for_invalid_configuration(self, identifiers: list[str]) -> None: diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml index 5e0bdacfd..2b5708c7a 100644 --- a/cibuildwheel/resources/defaults.toml +++ b/cibuildwheel/resources/defaults.toml @@ -5,6 +5,7 @@ test-skip = "" archs = ["auto"] build-frontend = "pip" +config-settings = {} dependency-versions = "pinned" environment = {} environment-pass = [] diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 2cf161877..4b3d4fb9f 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -60,6 +60,7 @@ "strtobool", "cached_property", "chdir", + "split_config_settings", ] resources_dir: Final = Path(__file__).parent / "resources" @@ -205,6 +206,11 @@ def get_build_verbosity_extra_flags(level: int) -> list[str]: return [] +def split_config_settings(config_settings: str) -> list[str]: + config_settings_list = shlex.split(config_settings) + return [f"--config-setting={setting}" for setting in config_settings_list] + + def read_python_configs(config: PlatformName) -> list[dict[str, str]]: input_file = resources_dir / "build-platforms.toml" with input_file.open("rb") as f: diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index b6e223ec5..06bdc8b86 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -32,6 +32,7 @@ prepare_command, read_python_configs, shell, + split_config_settings, virtualenv, ) @@ -302,8 +303,10 @@ def build(options: Options, tmp_path: Path) -> None: built_wheel_dir.mkdir() verbosity_flags = get_build_verbosity_extra_flags(build_options.build_verbosity) + extra_flags = split_config_settings(build_options.config_settings) if build_options.build_frontend == "pip": + extra_flags += verbosity_flags # Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org # see https://github.com/pypa/cibuildwheel/pull/369 call( @@ -314,11 +317,12 @@ def build(options: Options, tmp_path: Path) -> None: options.globals.package_dir.resolve(), f"--wheel-dir={built_wheel_dir}", "--no-deps", - *get_build_verbosity_extra_flags(build_options.build_verbosity), + *extra_flags, env=env, ) elif build_options.build_frontend == "build": - config_setting = " ".join(verbosity_flags) + verbosity_setting = " ".join(verbosity_flags) + extra_flags += (f"--config-setting={verbosity_setting}",) build_env = env.copy() if build_options.dependency_constraints: constraints_path = ( @@ -345,7 +349,7 @@ def build(options: Options, tmp_path: Path) -> None: build_options.package_dir, "--wheel", f"--outdir={built_wheel_dir}", - f"--config-setting={config_setting}", + *extra_flags, env=build_env, ) else: diff --git a/docs/options.md b/docs/options.md index ced7febf9..cc0930797 100644 --- a/docs/options.md +++ b/docs/options.md @@ -529,6 +529,36 @@ Choose which build backend to use. Can either be "pip", which will run build-frontend = "pip" ``` +### `CIBW_CONFIG_SETTINGS` {: #config-settings} +> Specify config-settings for the build backend. + +Specify config settings for the build backend. Each space separated +item will be passed via `--config-setting`. In TOML, you can specify +a table of items, including arrays. + +!!! tip + Currently, "build" supports arrays for options, but "pip" only supports + single values. + +Platform-specific environment variables also available:
+`CIBW_BEFORE_ALL_MACOS` | `CIBW_BEFORE_ALL_WINDOWS` | `CIBW_BEFORE_ALL_LINUX` + + +#### Examples + +!!! tab examples "Environment variables" + + ```yaml + CIBW_CONFIG_SETTINGS: "--build-option=--use-mypyc" + ``` + +!!! tab examples "pyproject.toml" + + ```toml + [tool.cibuildwheel.config-settings] + --build-option = "--use-mypyc" + ``` + ### `CIBW_ENVIRONMENT` {: #environment} > Set environment variables needed during the build diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index 813fd5c56..2ca1b25d5 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -14,7 +14,7 @@ from cibuildwheel.__main__ import main from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.options import BuildOptions, _get_pinned_container_images -from cibuildwheel.util import BuildSelector, resources_dir +from cibuildwheel.util import BuildSelector, resources_dir, split_config_settings # CIBW_PLATFORM is tested in main_platform_test.py @@ -263,6 +263,27 @@ def test_build_verbosity( assert build_options.build_verbosity == expected_verbosity +@pytest.mark.parametrize("platform_specific", [False, True]) +def test_config_settings(platform_specific, platform, intercepted_build_args, monkeypatch): + config_settings = 'setting=value setting=value2 other="something else"' + if platform_specific: + monkeypatch.setenv("CIBW_CONFIG_SETTINGS_" + platform.upper(), config_settings) + monkeypatch.setenv("CIBW_CONFIG_SETTIGNS", "a=b") + else: + monkeypatch.setenv("CIBW_CONFIG_SETTINGS", config_settings) + + main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.config_settings == config_settings + + assert split_config_settings(config_settings) == [ + "--config-setting=setting=value", + "--config-setting=setting=value2", + "--config-setting=other=something else", + ] + + @pytest.mark.parametrize( "selector", [ diff --git a/unit_test/options_test.py b/unit_test/options_test.py index a609bb492..f0fb5f63e 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -1,6 +1,7 @@ from __future__ import annotations import platform as platform_module +import textwrap import pytest @@ -58,7 +59,7 @@ def test_options_1(tmp_path, monkeypatch): default_build_options = options.build_options(identifier=None) - assert default_build_options.environment == parse_environment('FOO="BAR"') + assert default_build_options.environment == parse_environment("FOO=BAR") all_pinned_container_images = _get_pinned_container_images() pinned_x86_64_container_image = all_pinned_container_images["x86_64"] @@ -116,3 +117,32 @@ def test_passthrough_evil(tmp_path, monkeypatch, env_var_value): monkeypatch.setenv("ENV_VAR", env_var_value) parsed_environment = options.build_options(identifier=None).environment assert parsed_environment.as_dictionary(prev_environment={}) == {"ENV_VAR": env_var_value} + + +@pytest.mark.parametrize( + "env_var_value", + [ + "normal value", + '"value wrapped in quotes"', + 'an unclosed double-quote: "', + "string\nwith\ncarriage\nreturns\n", + "a trailing backslash \\", + ], +) +def test_toml_environment_evil(tmp_path, monkeypatch, env_var_value): + args = get_default_command_line_arguments() + args.package_dir = tmp_path + + with tmp_path.joinpath("pyproject.toml").open("w") as f: + f.write( + textwrap.dedent( + f"""\ + [tool.cibuildwheel.environment] + EXAMPLE='''{env_var_value}''' + """ + ) + ) + + options = Options(platform="linux", command_line_arguments=args) + parsed_environment = options.build_options(identifier=None).environment + assert parsed_environment.as_dictionary(prev_environment={}) == {"EXAMPLE": env_var_value} diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index a0a1705d5..5c01a5fe4 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -331,3 +331,38 @@ def test_overrides_not_a_list(tmp_path, platform): with pytest.raises(ConfigOptionError): OptionsReader(config_file_path=pyproject_toml, platform=platform) + + +def test_config_settings(tmp_path): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel.config-settings] +example = "one" +other = ["two", "three"] +""" + ) + + options_reader = OptionsReader(config_file_path=pyproject_toml, platform="linux") + assert ( + options_reader.get("config-settings", table={"item": '{k}="{v}"', "sep": " "}) + == 'example="one" other="two" other="three"' + ) + + +def test_pip_config_settings(tmp_path): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel.config-settings] +--build-option="--use-mypyc" +""" + ) + + options_reader = OptionsReader(config_file_path=pyproject_toml, platform="linux") + assert ( + options_reader.get( + "config-settings", table={"item": "--config-settings='{k}=\"{v}\"'", "sep": " "} + ) + == "--config-settings='--build-option=\"--use-mypyc\"'" + )