From 077d334118cf04936a5887fc9f41cd850e07dda1 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 26 Jun 2026 13:20:01 +0100 Subject: [PATCH 1/5] Add the ability to set 'inherit' at all levels of the options cascade --- cibuildwheel/options.py | 156 ++++++++++++++++++++++++--------- cibuildwheel/util/helpers.py | 47 +++++++++- unit_test/options_toml_test.py | 6 +- unit_test/utils_test.py | 62 ++++++++++--- 4 files changed, 215 insertions(+), 56 deletions(-) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 8dd21a1a6..014d501cd 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -50,7 +50,13 @@ from cibuildwheel.selector import BuildSelector, EnableGroup, TestSelector, selector_matches from cibuildwheel.typing import PLATFORMS, PlatformName from cibuildwheel.util import resources -from cibuildwheel.util.helpers import format_safe, parse_key_value_string, strtobool, unwrap +from cibuildwheel.util.helpers import ( + format_safe, + parse_key_value_string, + parse_kw_string, + strtobool, + unwrap, +) from cibuildwheel.util.packaging import DependencyConstraints TYPE_CHECKING = False @@ -436,6 +442,28 @@ def _stringify_setting( return setting +def parse_inherit(config: str | dict[str, str] | None) -> dict[str, InheritRule]: + inherit_dict: dict[str, str] + + if config is None: + return {} + + if isinstance(config, str): + parsed = parse_kw_string(config, default_kw_value="append") + inherit_dict = {k: "".join(v).upper() for k, v in parsed.items()} + elif isinstance(config, dict): + inherit_dict = config + else: + msg = "'inherit' must be a string or a table" + raise OptionsReaderError(msg) + + if not all(v in {"none", "append", "prepend"} for v in inherit_dict.values()): + msg = "'inherit' must contain only {'none', 'append', 'prepend'} values" + raise OptionsReaderError(msg) + + return {k: InheritRule[v.upper()] for k, v in inherit_dict.items()} + + class OptionsReader: """ Gets options from the environment, config or defaults, optionally scoped @@ -484,39 +512,12 @@ def __init__( self._validate_platform_option(option_name) self.config_options = config_options + self.config_options_inherit = parse_inherit(config_options.get("inherit")) self.config_platform_options = config_platform_options + self.config_platform_options_inherit = parse_inherit(config_platform_options.get("inherit")) - self.overrides: list[Override] = [] self.current_identifier: str | None = None - config_overrides = self.config_options.get("overrides") - - if config_overrides is not None: - if not isinstance(config_overrides, list): - msg = "'tool.cibuildwheel.overrides' must be a list" - raise OptionsReaderError(msg) - - for config_override in config_overrides: - select = config_override.pop("select", None) - - if not select: - msg = "'select' must be set in an override" - raise OptionsReaderError(msg) - - if isinstance(select, list): - select = " ".join(select) - - inherit = config_override.pop("inherit", {}) - if not isinstance(inherit, dict) or not all( - i in {"none", "append", "prepend"} for i in inherit.values() - ): - msg = "'inherit' must be a dict containing only {'none', 'append', 'prepend'} values" - raise OptionsReaderError(msg) - - inherit_enum = {k: InheritRule[v.upper()] for k, v in inherit.items()} - - self.overrides.append(Override(select, config_override, inherit_enum)) - def _validate_global_option(self, name: str) -> None: """ Raises an error if an option with this name is not allowed in the @@ -562,6 +563,56 @@ def _load_file(self, filename: Path) -> tuple[dict[str, Any], dict[str, Any]]: return global_options, platform_options + @functools.cached_property + def overrides(self) -> list[Override]: + config_overrides = self.config_options.get("overrides") + overrides: list[Override] = [] + + if config_overrides is not None: + if not isinstance(config_overrides, list): + msg = "'tool.cibuildwheel.overrides' must be a list" + raise OptionsReaderError(msg) + + for config_override in config_overrides: + select = config_override.pop("select", None) + + if not select: + msg = "'select' must be set in an override" + raise OptionsReaderError(msg) + + if isinstance(select, list): + select = " ".join(select) + + inherit = config_override.pop("inherit", {}) + + overrides.append(Override(select, config_override, parse_inherit(inherit))) + + return overrides + + @functools.cached_property + def env_inherit(self) -> dict[str, InheritRule]: + env_inherit_str = self.env.get("CIBW_INHERIT", "") + try: + return parse_inherit(env_inherit_str) + except OptionsReaderError as e: + msg = f"Failed to parse CIBW_INHERIT environment variable. {e}" + raise errors.ConfigurationError(msg) from e + + @functools.cached_property + def env_platform_inherit(self) -> dict[str, InheritRule]: + env_inherit = self.env_inherit + + # find the rules which have -{platform} on the end of their key, + # remove the platform suffix from the key and return the resulting + # rule. + platform_suffix = f"-{self.platform}" + + return { + key.removesuffix(platform_suffix): value + for key, value in env_inherit.items() + if key.endswith(platform_suffix) + } + @property def active_config_overrides(self) -> list[Override]: if self.current_identifier is None: @@ -585,7 +636,7 @@ def get( env_plat: bool = True, option_format: OptionFormat | None = None, ignore_empty: bool = False, - env_rule: InheritRule = InheritRule.NONE, + default_env_rule: InheritRule = InheritRule.NONE, ) -> str: """ Get and return the value for the named option from environment, @@ -609,16 +660,37 @@ def get( # get the option from the default, then the config file, then finally the environment. # platform-specific options are preferred, if they're allowed. return _resolve_cascade( - (self.default_options.get(name), InheritRule.NONE), - (self.default_platform_options.get(name), InheritRule.NONE), - (self.config_options.get(name), InheritRule.NONE), - (self.config_platform_options.get(name), InheritRule.NONE), + ( + self.default_options.get(name), + InheritRule.NONE, + ), + ( + self.default_platform_options.get(name), + InheritRule.NONE, + ), + ( + self.config_options.get(name), + self.config_options_inherit.get(name, InheritRule.NONE), + ), + ( + self.config_platform_options.get(name), + self.config_platform_options_inherit.get(name, InheritRule.NONE), + ), *[ - (o.options.get(name), o.inherit.get(name, InheritRule.NONE)) + ( + o.options.get(name), + o.inherit.get(name, InheritRule.NONE), + ) for o in self.active_config_overrides ], - (self.env.get(envvar), env_rule), - (self.env.get(plat_envvar) if env_plat else None, env_rule), + ( + self.env.get(envvar), + self.env_inherit.get(name, default_env_rule), + ), + ( + self.env.get(plat_envvar) if env_plat else None, + self.env_platform_inherit.get(name, default_env_rule), + ), ignore_empty=ignore_empty, option_format=option_format, ) @@ -691,7 +763,10 @@ def globals(self) -> GlobalOptions: allow_empty = args.allow_empty or strtobool(self.env.get("CIBW_ALLOW_EMPTY", "0")) enable_groups = self.reader.get( - "enable", env_plat=False, option_format=ListFormat(sep=" "), env_rule=InheritRule.APPEND + "enable", + env_plat=False, + option_format=ListFormat(sep=" "), + default_env_rule=InheritRule.APPEND, ) try: enable = { @@ -797,12 +872,11 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions: if xbuild_tools == ["\u0000"]: xbuild_tools = None - xbuild_files = parse_key_value_string( + xbuild_files = parse_kw_string( self.reader.get( "xbuild-files", option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False), ), - kw_arg_names=["*"], ) test_sources = shlex.split( diff --git a/cibuildwheel/util/helpers.py b/cibuildwheel/util/helpers.py index d6ed17971..659df9356 100644 --- a/cibuildwheel/util/helpers.py +++ b/cibuildwheel/util/helpers.py @@ -111,7 +111,7 @@ def parse_key_value_string( if kw_arg_names is None: kw_arg_names = [] - all_field_names = None if ("*" in kw_arg_names) else [*positional_arg_names, *kw_arg_names] + all_field_names = [*positional_arg_names, *kw_arg_names] shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";") shlexer.commenters = "" @@ -128,7 +128,7 @@ def parse_key_value_string( # check to see if the option name is specified field_name, sep, first_value = field[0].partition(":") if sep: - if (all_field_names is not None) and (field_name not in all_field_names): + if field_name not in all_field_names: msg = f"Failed to parse {key_value_string!r}. Unknown field name {field_name!r}" raise ValueError(msg) @@ -147,6 +147,49 @@ def parse_key_value_string( return dict(result) +def parse_kw_string(kw_string: str, default_kw_value: str | None = None) -> dict[str, list[str]]: + """ + Parses a string like + + "before-build; before-test: append; after-test: prepend" + or + "package1: some/header.h some/library.a; package2: other/header.h" + + There are no restrictions on the keys than can be set. + + No positional arguments are allowed. Words without a colon attached are + interpreted as keys. Keys without a value will be assigned the + default_kw_value if provided, otherwise an empty list. + """ + shlexer = shlex.shlex(kw_string, posix=True, punctuation_chars=";") + shlexer.commenters = "" + shlexer.whitespace_split = True + parts = list(shlexer) + # parts now looks like + # ['before-build', ';', 'before-test:', 'append', ';', 'after-test:', 'prepend'] + + # split by semicolon + result: defaultdict[str, list[str]] = defaultdict(list) + fields = [list(group) for k, group in itertools.groupby(parts, lambda x: x == ";") if not k] + for field in fields: + # check to see if the option name is specified + field_name, sep, first_value = field[0].partition(":") + if sep: + # the colon was present, so the first value is the value after the colon + values = ([first_value] if first_value else []) + field[1:] + result[field_name] += values + else: + # no colon, so it's a key (or set of keys) without values + if default_kw_value is None: + msg = f"Failed to parse {kw_string!r}. No value specified for {field_name!r}. Expected ':' followed by a value." + raise ValueError(msg) + + for key in field: + result[key].append(default_kw_value) + + return dict(result) + + @dataclasses.dataclass(order=True) class FlexibleVersion: version_parts: tuple[int, ...] = dataclasses.field(init=False, repr=False) diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index d4008a2c4..dc14d3920 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -472,7 +472,11 @@ def test_overrides_not_a_list(tmp_path: Path, platform: str) -> None: ) with pytest.raises(OptionsReaderError): - OptionsReader(config_file_path=pyproject_toml, platform=cast("Any", platform), env={}) + print( + OptionsReader( + config_file_path=pyproject_toml, platform=cast("Any", platform), env={} + ).overrides + ) def test_config_settings(tmp_path: Path) -> None: diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 2322a0579..189f42627 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -12,6 +12,7 @@ FlexibleVersion, format_safe, parse_key_value_string, + parse_kw_string, prepare_command, unwrap, unwrap_preserving_paragraphs, @@ -218,20 +219,57 @@ def test_parse_key_value_string_unknown_name() -> None: with pytest.raises(ValueError, match=r"Failed to parse 'key: value'. Unknown field name 'key'"): parse_key_value_string("key: value") - # Unknown fields can be enabled by passing "*". - assert parse_key_value_string( - "key: value", - kw_arg_names=["*"], - ) == { - "key": ["value"], + +def test_parse_kw_string_basic() -> None: + assert parse_kw_string("before-test: append; after-test: prepend") == { + "before-test": ["append"], + "after-test": ["prepend"], } - assert parse_key_value_string( - "key1: value1a value1b; key2: value2", - kw_arg_names=["*"], - ) == { - "key1": ["value1a", "value1b"], - "key2": ["value2"], + +def test_parse_kw_string_multiple_values() -> None: + assert parse_kw_string("package1: some/header.h some/library.a; package2: other/header.h") == { + "package1": ["some/header.h", "some/library.a"], + "package2": ["other/header.h"], + } + + +def test_parse_kw_string_keys_without_values_default() -> None: + assert parse_kw_string("before-build; before-test: prepend", default_kw_value="append") == { + "before-build": ["append"], + "before-test": ["prepend"], + } + + +def test_parse_kw_string_keys_without_values_no_default() -> None: + with pytest.raises(ValueError, match="No value specified"): + parse_kw_string("before-build") + + +def test_parse_kw_string_empty() -> None: + assert parse_kw_string("") == {} + + +def test_parse_kw_string_duplicate_keys() -> None: + assert parse_kw_string("key: val1; key: val2") == { + "key": ["val1", "val2"], + } + + +def test_parse_kw_string_key_only_with_colon() -> None: + assert parse_kw_string("key:") == {"key": []} + + +def test_parse_kw_string_quoted_values() -> None: + assert parse_kw_string('key: "hello world"') == {"key": ["hello world"]} + + +def test_parse_kw_string_multiple_bare_keys_with_default() -> None: + """works, but should remain undocumented""" + assert parse_kw_string("a b c", default_kw_value="yes") == { + "a": ["yes"], + "b": ["yes"], + "c": ["yes"], } From e97e4562b1de2cc53824819c0452b096718ed0ad Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Tue, 4 Aug 2026 11:52:22 +0100 Subject: [PATCH 2/5] add tests --- cibuildwheel/options.py | 8 +- ...ns_toml_test.py => options_reader_test.py} | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) rename unit_test/{options_toml_test.py => options_reader_test.py} (88%) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 014d501cd..2345f18d2 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -450,7 +450,7 @@ def parse_inherit(config: str | dict[str, str] | None) -> dict[str, InheritRule] if isinstance(config, str): parsed = parse_kw_string(config, default_kw_value="append") - inherit_dict = {k: "".join(v).upper() for k, v in parsed.items()} + inherit_dict = {k: "".join(v) for k, v in parsed.items()} elif isinstance(config, dict): inherit_dict = config else: @@ -523,7 +523,7 @@ def _validate_global_option(self, name: str) -> None: Raises an error if an option with this name is not allowed in the [tool.cibuildwheel] section of a config file. """ - allowed_option_names = self.default_options.keys() | PLATFORMS | {"overrides"} + allowed_option_names = self.default_options.keys() | PLATFORMS | {"inherit", "overrides"} if name not in allowed_option_names: msg = f"Option {name!r} not supported in a config file." @@ -542,7 +542,9 @@ def _validate_platform_option(self, name: str) -> None: msg = f"{name!r} is not allowed in {disallowed_platform_options}" raise OptionsReaderError(msg) - allowed_option_names = self.default_options.keys() | self.default_platform_options.keys() + allowed_option_names = ( + self.default_options.keys() | self.default_platform_options.keys() | {"inherit"} + ) if name not in allowed_option_names: msg = f"Option {name!r} not supported in the {self.platform!r} section" diff --git a/unit_test/options_toml_test.py b/unit_test/options_reader_test.py similarity index 88% rename from unit_test/options_toml_test.py rename to unit_test/options_reader_test.py index dc14d3920..140b335ca 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_reader_test.py @@ -1,3 +1,5 @@ +"""Tests for reading and resolving configuration options.""" + from __future__ import annotations import shlex @@ -5,6 +7,7 @@ import pytest +from cibuildwheel import errors from cibuildwheel.options import ( EnvironmentFormat, InheritRule, @@ -553,6 +556,112 @@ def test_overrides_inherit(tmp_path: Path) -> None: ) +def test_global_inherit(tmp_path: Path) -> None: + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +inherit = {audit-command = "append"} +audit-command = "twine check {wheel}" +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform="linux", env={}) + + assert ( + options_reader.get("audit-command", option_format=ListFormat(" && ")) + == "abi3audit --strict --report {abi3_wheel} && twine check {wheel}" + ) + + +def test_platform_inherit(tmp_path: Path) -> None: + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +before-all = "global" + +[tool.cibuildwheel.linux] +inherit = {before-all = "prepend"} +before-all = "linux" +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform="linux", env={}) + + assert options_reader.get("before-all", option_format=ListFormat(" && ")) == "linux && global" + + +def test_environment_inherit(tmp_path: Path) -> None: + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +before-all = "config" +""" + ) + + options_reader = OptionsReader( + pyproject_toml, + platform="linux", + env={ + "CIBW_BEFORE_ALL": "env", + "CIBW_BEFORE_ALL_LINUX": "linux-env", + # A key without a value defaults to append. + "CIBW_INHERIT": "before-all; before-all-linux: prepend", + }, + ) + + assert ( + options_reader.get("before-all", option_format=ListFormat(" && ")) + == "linux-env && config && env" + ) + + +def test_environment_inherit_none_overrides_default_rule(tmp_path: Path) -> None: + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +enable = ["cpython-freethreading"] +""" + ) + options_reader = OptionsReader( + pyproject_toml, + platform="linux", + env={"CIBW_ENABLE": "pypy", "CIBW_INHERIT": "enable: none"}, + ) + + assert ( + options_reader.get( + "enable", option_format=ListFormat(" "), default_env_rule=InheritRule.APPEND + ) + == "pypy" + ) + + +def test_invalid_config_inherit_rule(tmp_path: Path) -> None: + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +inherit = {before-all = "invalid"} +""" + ) + + with pytest.raises(OptionsReaderError, match="must contain only"): + OptionsReader(pyproject_toml, platform="linux", env={}) + + +def test_invalid_environment_inherit_rule() -> None: + options_reader = OptionsReader(platform="linux", env={"CIBW_INHERIT": "before-all: invalid"}) + + with pytest.raises( + errors.ConfigurationError, match="Failed to parse CIBW_INHERIT environment variable" + ): + options_reader.get("before-all", option_format=ListFormat(" && ")) + + def test_audit_command_option(tmp_path: Path, platform: PlatformName) -> None: pyproject_toml: Path = tmp_path / "pyproject.toml" pyproject_toml.write_text( From b652a9018467af1e70f33ff5ebe426397a683e30 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 7 Aug 2026 11:48:00 +0100 Subject: [PATCH 3/5] Clarify util name/docs --- cibuildwheel/options.py | 6 +++--- cibuildwheel/util/helpers.py | 14 ++++++------ unit_test/utils_test.py | 42 ++++++++++++++++++++---------------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 2345f18d2..afac21d06 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -52,8 +52,8 @@ from cibuildwheel.util import resources from cibuildwheel.util.helpers import ( format_safe, + parse_arbitrary_key_value_string, parse_key_value_string, - parse_kw_string, strtobool, unwrap, ) @@ -449,7 +449,7 @@ def parse_inherit(config: str | dict[str, str] | None) -> dict[str, InheritRule] return {} if isinstance(config, str): - parsed = parse_kw_string(config, default_kw_value="append") + parsed = parse_arbitrary_key_value_string(config, default_value="append") inherit_dict = {k: "".join(v) for k, v in parsed.items()} elif isinstance(config, dict): inherit_dict = config @@ -874,7 +874,7 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions: if xbuild_tools == ["\u0000"]: xbuild_tools = None - xbuild_files = parse_kw_string( + xbuild_files = parse_arbitrary_key_value_string( self.reader.get( "xbuild-files", option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False), diff --git a/cibuildwheel/util/helpers.py b/cibuildwheel/util/helpers.py index 659df9356..1121c7244 100644 --- a/cibuildwheel/util/helpers.py +++ b/cibuildwheel/util/helpers.py @@ -147,7 +147,9 @@ def parse_key_value_string( return dict(result) -def parse_kw_string(kw_string: str, default_kw_value: str | None = None) -> dict[str, list[str]]: +def parse_arbitrary_key_value_string( + key_value_string: str, default_value: str | None = None +) -> dict[str, list[str]]: """ Parses a string like @@ -159,9 +161,9 @@ def parse_kw_string(kw_string: str, default_kw_value: str | None = None) -> dict No positional arguments are allowed. Words without a colon attached are interpreted as keys. Keys without a value will be assigned the - default_kw_value if provided, otherwise an empty list. + default_value if provided, otherwise throw an error. """ - shlexer = shlex.shlex(kw_string, posix=True, punctuation_chars=";") + shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";") shlexer.commenters = "" shlexer.whitespace_split = True parts = list(shlexer) @@ -180,12 +182,12 @@ def parse_kw_string(kw_string: str, default_kw_value: str | None = None) -> dict result[field_name] += values else: # no colon, so it's a key (or set of keys) without values - if default_kw_value is None: - msg = f"Failed to parse {kw_string!r}. No value specified for {field_name!r}. Expected ':' followed by a value." + if default_value is None: + msg = f"Failed to parse {key_value_string!r}. No value specified for {field_name!r}. Expected ':' followed by a value." raise ValueError(msg) for key in field: - result[key].append(default_kw_value) + result[key].append(default_value) return dict(result) diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 189f42627..dc106d3c2 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -11,8 +11,8 @@ from cibuildwheel.util.helpers import ( FlexibleVersion, format_safe, + parse_arbitrary_key_value_string, parse_key_value_string, - parse_kw_string, prepare_command, unwrap, unwrap_preserving_paragraphs, @@ -220,53 +220,57 @@ def test_parse_key_value_string_unknown_name() -> None: parse_key_value_string("key: value") -def test_parse_kw_string_basic() -> None: - assert parse_kw_string("before-test: append; after-test: prepend") == { +def test_parse_arbitrary_key_value_string_basic() -> None: + assert parse_arbitrary_key_value_string("before-test: append; after-test: prepend") == { "before-test": ["append"], "after-test": ["prepend"], } -def test_parse_kw_string_multiple_values() -> None: - assert parse_kw_string("package1: some/header.h some/library.a; package2: other/header.h") == { +def test_parse_arbitrary_key_value_string_multiple_values() -> None: + assert parse_arbitrary_key_value_string( + "package1: some/header.h some/library.a; package2: other/header.h" + ) == { "package1": ["some/header.h", "some/library.a"], "package2": ["other/header.h"], } -def test_parse_kw_string_keys_without_values_default() -> None: - assert parse_kw_string("before-build; before-test: prepend", default_kw_value="append") == { +def test_parse_arbitrary_key_value_string_keys_without_values_default() -> None: + assert parse_arbitrary_key_value_string( + "before-build; before-test: prepend", default_value="append" + ) == { "before-build": ["append"], "before-test": ["prepend"], } -def test_parse_kw_string_keys_without_values_no_default() -> None: +def test_parse_arbitrary_key_value_string_keys_without_values_no_default() -> None: with pytest.raises(ValueError, match="No value specified"): - parse_kw_string("before-build") + parse_arbitrary_key_value_string("before-build") -def test_parse_kw_string_empty() -> None: - assert parse_kw_string("") == {} +def test_parse_arbitrary_key_value_string_empty() -> None: + assert parse_arbitrary_key_value_string("") == {} -def test_parse_kw_string_duplicate_keys() -> None: - assert parse_kw_string("key: val1; key: val2") == { +def test_parse_arbitrary_key_value_string_duplicate_keys() -> None: + assert parse_arbitrary_key_value_string("key: val1; key: val2") == { "key": ["val1", "val2"], } -def test_parse_kw_string_key_only_with_colon() -> None: - assert parse_kw_string("key:") == {"key": []} +def test_parse_arbitrary_key_value_string_key_only_with_colon() -> None: + assert parse_arbitrary_key_value_string("key:") == {"key": []} -def test_parse_kw_string_quoted_values() -> None: - assert parse_kw_string('key: "hello world"') == {"key": ["hello world"]} +def test_parse_arbitrary_key_value_string_quoted_values() -> None: + assert parse_arbitrary_key_value_string('key: "hello world"') == {"key": ["hello world"]} -def test_parse_kw_string_multiple_bare_keys_with_default() -> None: +def test_parse_arbitrary_key_value_string_multiple_bare_keys_with_default() -> None: """works, but should remain undocumented""" - assert parse_kw_string("a b c", default_kw_value="yes") == { + assert parse_arbitrary_key_value_string("a b c", default_value="yes") == { "a": ["yes"], "b": ["yes"], "c": ["yes"], From de7e10c7db52d2b403ce7900223e8467f128acb3 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 11 Aug 2026 08:30:57 +0000 Subject: [PATCH 4/5] Document option inheritance across configuration layers Amp-Thread-ID: https://ampcode.com/threads/T-019fefe0-6bbb-736e-b01c-d7f15d2db320 Co-authored-by: Joe Rickerby --- docs/configuration.md | 89 ++++++++++++++++++++++++++++++++++--------- docs/options.md | 12 ++++-- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 552772eb2..6a3576383 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,10 +66,11 @@ placed in `[tool.cibuildwheel]` and are lower case, with dashes, following common [TOML](https://toml.io) practice. Anything placed in subsections named after a platform will only affect those platforms. Platform-specific values replace the corresponding global value for that platform; table options -are not merged key by key. Lists can be used instead of strings for items that -are naturally a list. Multiline strings also work just like in the environment -variables. Environment variable overrides, such as `CIBW_TEST_COMMAND` and -`CIBW_TEST_COMMAND_LINUX`, will take precedence if defined. +are not merged key by key unless you configure [inheritance](#inherit). Lists can +be used instead of strings for items that are naturally a list. Multiline strings +also work just like in the environment variables. Environment variable overrides, +such as `CIBW_TEST_COMMAND` and `CIBW_TEST_COMMAND_LINUX`, take precedence if +defined. The example above using environment variables could have been written like this: @@ -113,9 +114,9 @@ trigger new containers, one per image. The ``output-dir``, ``build``, ``skip``, ``test_skip`` selectors, and architectures cannot be overridden. -You can specify a table of overrides in `inherit={}`, any list or table in this -list will inherit from previous overrides or the main configuration. The valid -options are `"none"` (the default), `"append"`, and `"prepend"`. +By default, values in an override replace values from the main configuration or +earlier overrides. You can instead [extend a list or table option](#inherit) by +setting an `inherit` rule for it. #### Examples: @@ -175,14 +176,50 @@ This example will provide the command `"pyproject-before && pyproject && pyproje on Python 3.11, and will have `environment = {FOO="BAZ", "PYTHON"="MONTY", "HAM"="EGGS"}`. -## Extending existing options {: #inherit } +## Option inheritance {: #inherit } -In the TOML configuration, you can choose how tables and lists are inherited. -By default, all values are overridden completely (`"none"`) but sometimes you'd -rather `"append"` or `"prepend"` to an existing list or table. You can do this -with the `inherit` table in overrides. For example, if you want to add an environment -variable for CPython 3.11, without `inherit` you'd have to repeat all the -original environment variables in the override. With `inherit`, it's just: +As cibuildwheel reads its configuration, each layer normally replaces the value +from the previous layer. The layers, from lowest to highest precedence, are: + +1. cibuildwheel's defaults +2. `[tool.cibuildwheel]` +3. `[tool.cibuildwheel.]` +4. matching `[[tool.cibuildwheel.overrides]]` entries, in order +5. `CIBW_