From ca08f5fbcfa265bd9adddc04a4ba399f56d35ec0 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Tue, 3 Sep 2024 16:05:00 +0100 Subject: [PATCH] Customise merge behaviour depending on option. Fix #1803 (#1984) Fix for #1803, where the method used for merging the config-settings option - string concatenation - was of little use. I broke out the TableFmt object into a protocol-like object called OptionFormat, where the formatting and merge rules can be customised more deeply. This is more explicit, and lets us be a bit more precise with how TOML object get converted into the options. I intend to use the flexibility of the OptionsFormat structure to tackle the remaining abiguity around environment variable quoting, as seen in the xfails in the options_test.py unit test and discussed in #1271. Co-authored-by: Matthieu Darbois --- cibuildwheel/options.py | 272 ++++++++++++++++++++++----------- unit_test/options_test.py | 3 +- unit_test/options_toml_test.py | 124 ++++++++------- 3 files changed, 251 insertions(+), 148 deletions(-) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index cb3606798..66da2ad49 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -9,15 +9,15 @@ import functools import shlex import textwrap -from collections.abc import Callable, Generator, Iterable, Iterator, Set +from collections.abc import Generator, Iterable, Set from pathlib import Path -from typing import Any, Literal, Mapping, Sequence, TypedDict, Union # noqa: TID251 +from typing import Any, Literal, Mapping, Sequence, Union # noqa: TID251 from packaging.specifiers import SpecifierSet from . import errors from ._compat import tomllib -from ._compat.typing import NotRequired, assert_never +from ._compat.typing import assert_never from .architecture import Architecture from .environment import EnvironmentParseError, ParsedEnvironment, parse_environment from .logger import log @@ -118,13 +118,16 @@ def architectures(self) -> set[Architecture]: return self.globals.architectures -Setting = Union[Mapping[str, str], Sequence[str], str, int, bool] +SettingLeaf = Union[str, int, bool] +SettingList = Sequence[SettingLeaf] +SettingTable = Mapping[str, Union[SettingLeaf, SettingList]] +SettingValue = Union[SettingTable, SettingList, SettingLeaf] @dataclasses.dataclass(frozen=True) class Override: select_pattern: str - options: dict[str, Setting] + options: dict[str, SettingValue] inherit: dict[str, InheritRule] @@ -137,20 +140,121 @@ class Override: } -class TableFmt(TypedDict): - # a format string, used with '.format', with `k` and `v` parameters - # e.g. "{k}={v}" - item: str - # the string that is inserted between items - # e.g. " " - sep: str - # a quoting function that, if supplied, is called to quote each value - # e.g. shlex.quote - quote: NotRequired[Callable[[str], str]] +class OptionsReaderError(errors.ConfigurationError): + pass -class ConfigOptionError(KeyError): - pass +class OptionFormat: + """ + Base class for option format specifiers. These objects describe how values + can be parsed from rich TOML values and how they're merged together. + """ + + class NotSupported(Exception): + pass + + def format_list(self, value: SettingList) -> str: # noqa: ARG002 + raise OptionFormat.NotSupported + + def format_table(self, table: SettingTable) -> str: # noqa: ARG002 + raise OptionFormat.NotSupported + + def merge_values(self, before: str, after: str) -> str: # noqa: ARG002 + raise OptionFormat.NotSupported + + +class ListFormat(OptionFormat): + """ + A format that joins lists with a separator. + """ + + def __init__(self, sep: str) -> None: + self.sep = sep + + def format_list(self, value: SettingList) -> str: + return self.sep.join(str(v) for v in value) + + def merge_values(self, before: str, after: str) -> str: + return f"{before}{self.sep}{after}" + + +class ShlexTableFormat(OptionFormat): + """ + The standard table format uses shlex.quote to quote values and shlex.split + to unquote and split them. When merging values, keys in before are + replaced by keys in after. + """ + + def __init__(self, sep: str = " ", pair_sep: str = "=", allow_merge: bool = True) -> None: + self.sep = sep + self.pair_sep = pair_sep + self.allow_merge = allow_merge + + def format_table(self, table: SettingTable) -> str: + assignments: list[tuple[str, str]] = [] + + for k, v in table.items(): + if shlex.split(k) != [k]: + msg = f"Invalid table key: {k}" + raise OptionsReaderError(msg) + + if isinstance(v, str): + assignments.append((k, v)) + elif isinstance(v, Sequence): + for inner_v in v: + assignments.append((k, str(inner_v))) + else: + assignments.append((k, str(v))) + + return self.sep.join(f"{k}{self.pair_sep}{shlex.quote(v)}" for k, v in assignments) + + def merge_values(self, before: str, after: str) -> str: + if not self.allow_merge: + raise OptionFormat.NotSupported + + before_dict = self.parse_table(before) + after_dict = self.parse_table(after) + + return self.format_table({**before_dict, **after_dict}) + + def parse_table(self, table: str) -> Mapping[str, str | Sequence[str]]: + assignments: list[tuple[str, str]] = [] + + for assignment_str in shlex.split(table): + key, sep, value = assignment_str.partition(self.pair_sep) + + if not sep: + msg = f"malformed option with value {assignment_str!r}" + raise OptionsReaderError(msg) + + assignments.append((key, value)) + + result: dict[str, str | list[str]] = {} + + for key, value in assignments: + if key in result: + existing_value = result[key] + if isinstance(existing_value, list): + result[key] = [*existing_value, value] + else: + result[key] = [existing_value, value] + else: + result[key] = value + + return result + + +class EnvironmentFormat(OptionFormat): + """ + The environment format accepts a table of environment variables, where the + values may contain variables or command substitutions. + """ + + def format_table(self, table: SettingTable) -> str: + return " ".join(f'{k}="{v}"' for k, v in table.items()) + + def merge_values(self, before: str, after: str) -> str: + return f"{before} {after}" class InheritRule(enum.Enum): @@ -160,10 +264,9 @@ class InheritRule(enum.Enum): def _resolve_cascade( - *pairs: tuple[Setting | None, InheritRule], + *pairs: tuple[SettingValue | None, InheritRule], ignore_empty: bool = False, - list_sep: str | None = None, - table_format: TableFmt | None = None, + option_format: OptionFormat | None = None, ) -> str: """ Given a cascade of values with inherit rules, resolve them into a single @@ -186,13 +289,6 @@ def _resolve_cascade( result: str | None = None - if table_format is not None: - merge_sep = table_format["sep"] - elif list_sep is not None: - merge_sep = list_sep - else: - merge_sep = None - for value, rule in pairs: if value is None: continue @@ -200,14 +296,9 @@ def _resolve_cascade( if ignore_empty and not value and value is not False: continue - value_string = _stringify_setting(value, list_sep, table_format) + value_string = _stringify_setting(value, option_format=option_format) - result = _merge_values( - result, - value_string, - rule=rule, - merge_sep=merge_sep, - ) + result = _apply_inherit_rule(result, value_string, rule=rule, option_format=option_format) if result is None: msg = "a setting should at least have a default value" @@ -217,7 +308,9 @@ def _resolve_cascade( # pylint: disable-next=inconsistent-return-statements -def _merge_values(before: str | None, after: str, rule: InheritRule, merge_sep: str | None) -> str: +def _apply_inherit_rule( + before: str | None, after: str, rule: InheritRule, option_format: OptionFormat | None +) -> str: if rule == InheritRule.NONE: return after @@ -230,34 +323,39 @@ def _merge_values(before: str | None, after: str, rule: InheritRule, merge_sep: # if after is an empty string, we shouldn't add any separator return before - if not merge_sep: + if not option_format: msg = f"Don't know how to merge {before!r} and {after!r} with {rule}" - raise ConfigOptionError(msg) + raise OptionsReaderError(msg) if rule == InheritRule.APPEND: - return f"{before}{merge_sep}{after}" + return option_format.merge_values(before, after) elif rule == InheritRule.PREPEND: - return f"{after}{merge_sep}{before}" + return option_format.merge_values(after, before) else: assert_never(rule) def _stringify_setting( - setting: Setting, list_sep: str | None, table_format: TableFmt | None + setting: SettingValue, + option_format: OptionFormat | None, ) -> str: if isinstance(setting, Mapping): - if table_format is None: + try: + if option_format is None: + raise OptionFormat.NotSupported + return option_format.format_table(setting) + except OptionFormat.NotSupported: msg = f"Error converting {setting!r} to a string: this setting doesn't accept a table" - raise ConfigOptionError(msg) - return table_format["sep"].join( - item for k, v in setting.items() for item in _inner_fmt(k, v, table_format) - ) + raise OptionsReaderError(msg) from None if not isinstance(setting, str) and isinstance(setting, Sequence): - if list_sep is None: + try: + if option_format is None: + raise OptionFormat.NotSupported + return option_format.format_list(setting) + except OptionFormat.NotSupported: msg = f"Error converting {setting!r} to a string: this setting doesn't accept a list" - raise ConfigOptionError(msg) - return list_sep.join(setting) + raise OptionsReaderError(msg) from None if isinstance(setting, (bool, int)): return str(setting) @@ -323,14 +421,14 @@ def __init__( if config_overrides is not None: if not isinstance(config_overrides, list): msg = "'tool.cibuildwheel.overrides' must be a list" - raise ConfigOptionError(msg) + 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 ConfigOptionError(msg) + raise OptionsReaderError(msg) if isinstance(select, list): select = " ".join(select) @@ -340,7 +438,7 @@ def __init__( i in {"none", "append", "prepend"} for i in inherit.values() ): msg = "'inherit' must be a dict containing only {'none', 'append', 'prepend'} values" - raise ConfigOptionError(msg) + raise OptionsReaderError(msg) inherit_enum = {k: InheritRule[v.upper()] for k, v in inherit.items()} @@ -358,7 +456,7 @@ def _validate_global_option(self, name: str) -> None: matches = difflib.get_close_matches(name, allowed_option_names, 1, 0.7) if matches: msg += f" Perhaps you meant {matches[0]!r}?" - raise ConfigOptionError(msg) + raise OptionsReaderError(msg) def _validate_platform_option(self, name: str) -> None: """ @@ -368,7 +466,7 @@ def _validate_platform_option(self, name: str) -> None: disallowed_platform_options = self.disallow.get(self.platform, set()) if name in disallowed_platform_options: msg = f"{name!r} is not allowed in {disallowed_platform_options}" - raise ConfigOptionError(msg) + raise OptionsReaderError(msg) allowed_option_names = self.default_options.keys() | self.default_platform_options.keys() @@ -377,7 +475,7 @@ def _validate_platform_option(self, name: str) -> None: matches = difflib.get_close_matches(name, allowed_option_names, 1, 0.7) if matches: msg += f" Perhaps you meant {matches[0]!r}?" - raise ConfigOptionError(msg) + raise OptionsReaderError(msg) def _load_file(self, filename: Path) -> tuple[dict[str, Any], dict[str, Any]]: """ @@ -412,8 +510,7 @@ def get( name: str, *, env_plat: bool = True, - list_sep: str | None = None, - table_format: TableFmt | None = None, + option_format: OptionFormat | None = None, ignore_empty: bool = False, ) -> str: """ @@ -429,7 +526,7 @@ def get( if name not in self.default_options and name not in self.default_platform_options: msg = f"{name!r} must be in cibuildwheel/resources/defaults.toml file to be accessed." - raise ConfigOptionError(msg) + raise OptionsReaderError(msg) # Environment variable form envvar = f"CIBW_{name.upper().replace('-', '_')}" @@ -449,26 +546,10 @@ def get( (self.env.get(envvar), InheritRule.NONE), (self.env.get(plat_envvar) if env_plat else None, InheritRule.NONE), ignore_empty=ignore_empty, - list_sep=list_sep, - table_format=table_format, + option_format=option_format, ) -def _inner_fmt(k: str, v: Any, table: TableFmt) -> Iterator[str]: - quote_function = table.get("quote", lambda a: a) - - if isinstance(v, list): - for inner_v in v: - qv = quote_function(inner_v) - yield table["item"].format(k=k, v=qv) - elif isinstance(v, bool): - qv = quote_function(str(v)) - yield table["item"].format(k=k, v=qv) - else: - qv = quote_function(v) - yield table["item"].format(k=k, v=qv) - - class Options: def __init__( self, @@ -513,9 +594,11 @@ def globals(self) -> GlobalOptions: package_dir = args.package_dir output_dir = args.output_dir - build_config = self.reader.get("build", env_plat=False, list_sep=" ") or "*" - skip_config = self.reader.get("skip", env_plat=False, list_sep=" ") - test_skip = self.reader.get("test-skip", env_plat=False, list_sep=" ") + build_config = ( + self.reader.get("build", env_plat=False, option_format=ListFormat(sep=" ")) or "*" + ) + skip_config = self.reader.get("skip", env_plat=False, option_format=ListFormat(sep=" ")) + test_skip = self.reader.get("test-skip", env_plat=False, option_format=ListFormat(sep=" ")) free_threaded_support = strtobool( self.reader.get("free-threaded-support", env_plat=False, ignore_empty=True) @@ -534,7 +617,7 @@ def globals(self) -> GlobalOptions: ) requires_python = None if requires_python_str is None else SpecifierSet(requires_python_str) - archs_config_str = args.archs or self.reader.get("archs", list_sep=" ") + archs_config_str = args.archs or self.reader.get("archs", option_format=ListFormat(sep=" ")) architectures = Architecture.parse_config(archs_config_str, platform=self.platform) # Process `--only` @@ -569,30 +652,33 @@ def build_options(self, identifier: str | None) -> BuildOptions: """ with self.reader.identifier(identifier): - before_all = self.reader.get("before-all", list_sep=" && ") - - environment_config = self.reader.get( - "environment", table_format={"item": '{k}="{v}"', "sep": " "} + before_all = self.reader.get("before-all", option_format=ListFormat(sep=" && ")) + + environment_config = self.reader.get("environment", option_format=EnvironmentFormat()) + environment_pass = self.reader.get( + "environment-pass", option_format=ListFormat(sep=" ") + ).split() + before_build = self.reader.get("before-build", option_format=ListFormat(sep=" && ")) + repair_command = self.reader.get( + "repair-wheel-command", option_format=ListFormat(sep=" && ") ) - environment_pass = self.reader.get("environment-pass", list_sep=" ").split() - before_build = self.reader.get("before-build", list_sep=" && ") - repair_command = self.reader.get("repair-wheel-command", list_sep=" && ") config_settings = self.reader.get( - "config-settings", - table_format={"item": "{k}={v}", "sep": " ", "quote": shlex.quote}, + "config-settings", option_format=ShlexTableFormat(sep=" ", pair_sep="=") ) dependency_versions = self.reader.get("dependency-versions") - test_command = self.reader.get("test-command", list_sep=" && ") - before_test = self.reader.get("before-test", list_sep=" && ") - test_requires = self.reader.get("test-requires", list_sep=" ").split() - test_extras = self.reader.get("test-extras", list_sep=",") + test_command = self.reader.get("test-command", option_format=ListFormat(sep=" && ")) + before_test = self.reader.get("before-test", option_format=ListFormat(sep=" && ")) + test_requires = self.reader.get( + "test-requires", option_format=ListFormat(sep=" ") + ).split() + test_extras = self.reader.get("test-extras", option_format=ListFormat(sep=",")) build_verbosity_str = self.reader.get("build-verbosity") build_frontend_str = self.reader.get( "build-frontend", env_plat=False, - table_format={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote}, + option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False), ) build_frontend: BuildFrontendConfig | None if not build_frontend_str or build_frontend_str == "default": @@ -674,7 +760,7 @@ def build_options(self, identifier: str | None) -> BuildOptions: container_engine_str = self.reader.get( "container-engine", - table_format={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote}, + option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False), ) try: diff --git a/unit_test/options_test.py b/unit_test/options_test.py index 5320c06d9..c19c6619f 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -10,7 +10,6 @@ from cibuildwheel import errors from cibuildwheel.__main__ import get_build_identifiers, get_platform_module from cibuildwheel.bashlex_eval import local_environment_executor -from cibuildwheel.environment import parse_environment from cibuildwheel.options import ( CommandLineArguments, Options, @@ -66,7 +65,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.as_dictionary(prev_environment={}) == {"FOO": "BAR"} all_pinned_container_images = _get_pinned_container_images() pinned_x86_64_container_image = all_pinned_container_images["x86_64"] diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index 6d3fc50e9..da21d884b 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -4,7 +4,15 @@ import pytest -from cibuildwheel.options import ConfigOptionError, InheritRule, OptionsReader, _resolve_cascade +from cibuildwheel.options import ( + EnvironmentFormat, + InheritRule, + ListFormat, + OptionsReader, + OptionsReaderError, + ShlexTableFormat, + _resolve_cascade, +) PYPROJECT_1 = """ [tool.cibuildwheel] @@ -37,42 +45,32 @@ def test_simple_settings(tmp_path, platform, fname): options_reader = OptionsReader(config_file_path, platform=platform, env={}) - assert options_reader.get("build", env_plat=False, list_sep=" ") == "cp39*" + assert options_reader.get("build", option_format=ListFormat(" "), env_plat=False) == "cp39*" assert options_reader.get("test-command") == "pyproject" - assert options_reader.get("archs", list_sep=" ") == "auto" + assert options_reader.get("archs", option_format=ListFormat(" ")) == "auto" assert ( - options_reader.get("test-requires", list_sep=" ") + options_reader.get("test-requires", option_format=ListFormat(" ")) == {"windows": "something", "macos": "else", "linux": "other many"}[platform] ) # Also testing options for support for both lists and tables assert ( - options_reader.get("environment", table_format={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("environment", option_format=EnvironmentFormat()) == 'THING="OTHER" FOO="BAR"' ) - assert ( - options_reader.get( - "environment", list_sep="x", table_format={"item": '{k}="{v}"', "sep": " "} - ) - == 'THING="OTHER" FOO="BAR"' - ) - assert options_reader.get("test-extras", list_sep=",") == "one,two" - assert ( - options_reader.get( - "test-extras", list_sep=",", table_format={"item": '{k}="{v}"', "sep": " "} - ) - == "one,two" - ) + assert options_reader.get("test-extras", option_format=ListFormat(",")) == "one,two" assert options_reader.get("manylinux-x86_64-image") == "manylinux1" assert options_reader.get("manylinux-i686-image") == "manylinux2014" - with pytest.raises(ConfigOptionError): - options_reader.get("environment", list_sep=" ") + with pytest.raises(OptionsReaderError): + # fails because the option is a table and the option_format only works with lists + options_reader.get("environment", option_format=ListFormat(" ")) - with pytest.raises(ConfigOptionError): - options_reader.get("test-extras", table_format={"item": '{k}="{v}"', "sep": " "}) + with pytest.raises(OptionsReaderError): + # fails because the option is a list and the option_format only works with tables + options_reader.get("test-extras", option_format=ShlexTableFormat()) def test_envvar_override(tmp_path, platform): @@ -91,14 +89,14 @@ def test_envvar_override(tmp_path, platform): }, ) - assert options_reader.get("archs", list_sep=" ") == "auto" + assert options_reader.get("archs", option_format=ListFormat(" ")) == "auto" - assert options_reader.get("build", list_sep=" ") == "cp38*" + assert options_reader.get("build") == "cp38*" assert options_reader.get("manylinux-x86_64-image") == "manylinux_2_24" assert options_reader.get("manylinux-i686-image") == "manylinux2014" assert ( - options_reader.get("test-requires", list_sep=" ") + options_reader.get("test-requires", option_format=ListFormat(" ")) == {"windows": "docs", "macos": "docs", "linux": "scod"}[platform] ) assert options_reader.get("test-command") == "mytest" @@ -174,7 +172,7 @@ def test_unexpected_key(tmp_path): """ ) - with pytest.raises(ConfigOptionError) as excinfo: + with pytest.raises(OptionsReaderError) as excinfo: OptionsReader(pyproject_toml, platform="linux", env={}) assert "repair-wheel-command" in str(excinfo.value) @@ -191,7 +189,7 @@ def test_underscores_in_key(tmp_path): """ ) - with pytest.raises(ConfigOptionError) as excinfo: + with pytest.raises(OptionsReaderError) as excinfo: OptionsReader(pyproject_toml, platform="linux", env={}) assert "repair-wheel-command" in str(excinfo.value) @@ -205,7 +203,7 @@ def test_unexpected_table(tmp_path): repair-wheel-command = "repair-project-linux" """ ) - with pytest.raises(ConfigOptionError): + with pytest.raises(OptionsReaderError): OptionsReader(pyproject_toml, platform="linux", env={}) @@ -219,8 +217,8 @@ def test_unsupported_join(tmp_path): ) options_reader = OptionsReader(pyproject_toml, platform="linux", env={}) - assert options_reader.get("build", list_sep=", ") == "1, 2" - with pytest.raises(ConfigOptionError): + assert options_reader.get("build", option_format=ListFormat(", ")) == "1, 2" + with pytest.raises(OptionsReaderError): options_reader.get("build") @@ -234,7 +232,7 @@ def test_disallowed_a(tmp_path): ) disallow = {"windows": {"manylinux-x86_64-image"}} OptionsReader(pyproject_toml, platform="linux", disallow=disallow, env={}) - with pytest.raises(ConfigOptionError): + with pytest.raises(OptionsReaderError): OptionsReader(pyproject_toml, platform="windows", disallow=disallow, env={}) @@ -305,7 +303,7 @@ def test_resolve_cascade_merge_list(ignore_empty, rule): (["b1", "b2"], rule), (None, InheritRule.NONE), ignore_empty=ignore_empty, - list_sep=" ", + option_format=ListFormat(" "), ) if not ignore_empty: @@ -326,24 +324,34 @@ def test_resolve_cascade_merge_dict(rule): (None, InheritRule.NONE), ({"value": "override"}, rule), (None, InheritRule.NONE), - table_format={"item": "{k}={v}", "sep": " "}, + option_format=ShlexTableFormat(), ) if rule == InheritRule.PREPEND: - assert answer == "value=override value=a1 base=b1" + assert answer == "value=a1 base=b1" elif rule == InheritRule.NONE: assert answer == "value=override" elif rule == InheritRule.APPEND: - assert answer == "value=a1 base=b1 value=override" + assert answer == "value=override base=b1" + + +def test_resolve_cascade_merge_strings(): + answer = _resolve_cascade( + ("value=a1 base=b1", InheritRule.NONE), + ("value=override", InheritRule.APPEND), + option_format=ShlexTableFormat(), + ) + assert answer == "value=override base=b1" def test_resolve_cascade_merge_different_types(): answer = _resolve_cascade( - ({"value": "a1", "base": "b1"}, InheritRule.NONE), + ("value=a1 base=b1", InheritRule.NONE), ({"value": "override"}, InheritRule.APPEND), - table_format={"item": "{k}={v}", "sep": " "}, + ("extra_string_var=c1", InheritRule.APPEND), + option_format=ShlexTableFormat(), ) - assert answer == "value=a1 base=b1 value=override" + assert answer == "value=override base=b1 extra_string_var=c1" PYPROJECT_2 = """ @@ -387,28 +395,28 @@ def test_pyproject_2(tmp_path, platform): pyproject_toml.write_text(PYPROJECT_2) options_reader = OptionsReader(config_file_path=pyproject_toml, platform=platform, env={}) - assert options_reader.get("test-command", list_sep=" && ") == "pyproject" + assert options_reader.get("test-command", option_format=ListFormat(" && ")) == "pyproject" with options_reader.identifier("random"): - assert options_reader.get("test-command", list_sep=" && ") == "pyproject" + assert options_reader.get("test-command", option_format=ListFormat(" && ")) == "pyproject" with options_reader.identifier("cp37-something"): assert ( - options_reader.get("test-command", list_sep=" && ") + options_reader.get("test-command", option_format=ListFormat(" && ")) == "pyproject-override && override2 && pyproject" ) assert ( - options_reader.get("environment", table_format={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("environment", option_format=EnvironmentFormat()) == 'FOO="BAR" HAM="EGGS" FOO="BAZ" PYTHON="MONTY"' ) with options_reader.identifier("cp37-final"): assert ( - options_reader.get("test-command", list_sep=" && ") + options_reader.get("test-command", option_format=ListFormat(" && ")) == "extra-prepend && pyproject-override && override2 && pyproject && pyproject-finalize && finalize2 && extra-finalize" ) assert ( - options_reader.get("environment", table_format={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("environment", option_format=EnvironmentFormat()) == 'FOO="BAR" HAM="EGGS" FOO="BAZ" PYTHON="MONTY"' ) @@ -426,7 +434,7 @@ def test_overrides_not_a_list(tmp_path, platform): """ ) - with pytest.raises(ConfigOptionError): + with pytest.raises(OptionsReaderError): OptionsReader(config_file_path=pyproject_toml, platform=platform, env={}) @@ -442,8 +450,8 @@ def test_config_settings(tmp_path): options_reader = OptionsReader(config_file_path=pyproject_toml, platform="linux", env={}) assert ( - options_reader.get("config-settings", table_format={"item": '{k}="{v}"', "sep": " "}) - == 'example="one" other="two" other="three"' + options_reader.get("config-settings", option_format=ShlexTableFormat(pair_sep="=", sep=" ")) + == "example=one other=two other=three" ) @@ -458,10 +466,8 @@ def test_pip_config_settings(tmp_path): options_reader = OptionsReader(config_file_path=pyproject_toml, platform="linux", env={}) assert ( - options_reader.get( - "config-settings", table_format={"item": "--config-settings='{k}=\"{v}\"'", "sep": " "} - ) - == "--config-settings='--build-option=\"--use-mypyc\"'" + options_reader.get("config-settings", option_format=ShlexTableFormat(sep=" ", pair_sep="=")) + == "--build-option=--use-mypyc" ) @@ -471,12 +477,16 @@ def test_overrides_inherit(tmp_path): """\ [tool.cibuildwheel] before-all = ["before-all"] +config-settings = {key1="value1", key2="value2", empty=""} [[tool.cibuildwheel.overrides]] select = "cp37*" inherit.before-all = "append" before-all = ["override1"] +inherit.config-settings = "append" +config-settings = {key3="value3", key2="override2"} + [[tool.cibuildwheel.overrides]] select = "cp37*" inherit.before-all = "prepend" @@ -486,9 +496,17 @@ def test_overrides_inherit(tmp_path): options_reader = OptionsReader(config_file_path=pyproject_toml, platform="linux", env={}) with options_reader.identifier("cp38-something"): - assert options_reader.get("before-all", list_sep=" && ") == "before-all" + assert options_reader.get("before-all", option_format=ListFormat(" && ")) == "before-all" + assert ( + options_reader.get("config-settings", option_format=ShlexTableFormat()) + == "key1=value1 key2=value2 empty=''" + ) with options_reader.identifier("cp37-something"): assert ( - options_reader.get("before-all", list_sep=" && ") + options_reader.get("before-all", option_format=ListFormat(" && ")) == "override2 && before-all && override1" ) + assert ( + options_reader.get("config-settings", option_format=ShlexTableFormat()) + == "key1=value1 key2=override2 empty='' key3=value3" + )