diff --git a/bin/generate_schema.py b/bin/generate_schema.py index 08680b831..a7007886a 100755 --- a/bin/generate_schema.py +++ b/bin/generate_schema.py @@ -17,6 +17,14 @@ additionalProperties: false description: cibuildwheel's settings. type: object +defines: + inherit: + enum: + - none + - prepend + - append + default: none + description: How to inherit the parent's value. properties: archs: description: Change the architectures built on your machine by default. @@ -153,9 +161,6 @@ schema = yaml.safe_load(starter) -if args.schemastore: - schema["$id"] += "#" - string_array = yaml.safe_load( """ - type: string @@ -214,19 +219,28 @@ additionalProperties: false properties: select: {} + inherit: + type: object + additionalProperties: false + properties: + before-all: {"$ref": "#/defines/inherit"} + before-build: {"$ref": "#/defines/inherit"} + before-test: {"$ref": "#/defines/inherit"} + config-settings: {"$ref": "#/defines/inherit"} + container-engine: {"$ref": "#/defines/inherit"} + environment: {"$ref": "#/defines/inherit"} + environment-pass: {"$ref": "#/defines/inherit"} + repair-wheel-command: {"$ref": "#/defines/inherit"} + test-command: {"$ref": "#/defines/inherit"} + test-extras: {"$ref": "#/defines/inherit"} + test-requires: {"$ref": "#/defines/inherit"} """ ) for key, value in schema["properties"].items(): value["title"] = f'CIBW_{key.replace("-", "_").upper()}' -if args.schemastore: - non_global_options = { - k: {"$ref": f"#/properties/tool/properties/cibuildwheel/properties/{k}"} - for k in schema["properties"] - } -else: - non_global_options = {k: {"$ref": f"#/properties/{k}"} for k in schema["properties"]} +non_global_options = {k: {"$ref": f"#/properties/{k}"} for k in schema["properties"]} del non_global_options["build"] del non_global_options["skip"] del non_global_options["container-engine"] @@ -273,27 +287,11 @@ def as_object(d: dict[str, Any]) -> dict[str, Any]: schema["properties"]["overrides"] = overrides schema["properties"] |= oses -if not args.schemastore: - print(json.dumps(schema, indent=2)) - raise SystemExit(0) - -schema_store_txt = """ -$id: https://json.schemastore.org/cibuildwheel.json -$schema: http://json-schema.org/draft-07/schema# -additionalProperties: false -description: cibuildwheel's toml file, generated with ./bin/generate_schema.py --schemastore from cibuildwheel. -type: object -properties: - tool: - type: object - properties: - cibuildwheel: - type: object -""" -schema_store = yaml.safe_load(schema_store_txt) - -schema_store["properties"]["tool"]["properties"]["cibuildwheel"]["properties"] = schema[ - "properties" -] +if args.schemastore: + schema["$id"] = "https://json.schemastore.org/partial-cibuildwheel.json" + schema["$schema"] = "http://json-schema.org/draft-07/schema#" + schema[ + "description" + ] = "cibuildwheel's toml file, generated with ./bin/generate_schema.py --schemastore from cibuildwheel." -print(json.dumps(schema_store, indent=2)) +print(json.dumps(schema, indent=2)) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 418b19f52..a69664554 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -5,19 +5,20 @@ import contextlib import dataclasses import difflib +import enum import functools import shlex import sys import textwrap import traceback -from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Set +from collections.abc import Callable, Generator, Iterable, Iterator, Set from pathlib import Path -from typing import Any, Dict, List, Literal, TypedDict, Union +from typing import Any, Literal, Mapping, Sequence, TypedDict, Union # noqa: TID251 from packaging.specifiers import SpecifierSet from ._compat import tomllib -from ._compat.typing import NotRequired +from ._compat.typing import NotRequired, assert_never from .architecture import Architecture from .environment import EnvironmentParseError, ParsedEnvironment, parse_environment from .logger import log @@ -116,13 +117,14 @@ def architectures(self) -> set[Architecture]: return self.globals.architectures -Setting = Union[Dict[str, str], List[str], str, int] +Setting = Union[Mapping[str, str], Sequence[str], str, int] @dataclasses.dataclass(frozen=True) class Override: select_pattern: str options: dict[str, Setting] + inherit: dict[str, InheritRule] MANYLINUX_OPTIONS = {f"manylinux-{build_platform}-image" for build_platform in MANYLINUX_ARCHS} @@ -150,28 +152,115 @@ class ConfigOptionError(KeyError): pass -def _dig_first(*pairs: tuple[Mapping[str, Setting], str], ignore_empty: bool = False) -> Setting: +class InheritRule(enum.Enum): + NONE = enum.auto() + APPEND = enum.auto() + PREPEND = enum.auto() + + +def _resolve_cascade( + *pairs: tuple[Setting | None, InheritRule], + ignore_empty: bool = False, + list_sep: str | None = None, + table_format: TableFmt | None = None, +) -> str: """ - Return the first dict item that matches from pairs of dicts and keys. - Will throw a KeyError if missing. + Given a cascade of values with inherit rules, resolve them into a single + value. + + 'None' values mean that the option was not set at that level, and are + ignored. If `ignore_empty` is True, empty values are ignored too. - _dig_first((dict1, "key1"), (dict2, "key2"), ...) + Values start with defaults, followed by more specific rules. If rules are + NONE, the last non-null value is returned. If a rule is APPEND or PREPEND, + the value is concatenated with the previous value. + + The following idiom can be used to get the first matching value: + + _resolve_cascade(("value1", Inherit.NONE), ("value2", Inherit.NONE), ...))) """ if not pairs: msg = "pairs cannot be empty" raise ValueError(msg) - for dict_like, key in pairs: - if key in dict_like: - value = dict_like[key] + result: str | None = None - if ignore_empty and value == "": - continue + 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 - return value + for value, rule in pairs: + if value is None: + continue + + if ignore_empty and not value: + continue - last_key = pairs[-1][1] - raise KeyError(last_key) + value_string = _stringify_setting(value, list_sep, table_format) + + result = _merge_values( + result, + value_string, + rule=rule, + merge_sep=merge_sep, + ) + + if result is None: + msg = "a setting should at least have a default value" + raise ValueError(msg) + + return result + + +def _merge_values(before: str | None, after: str, rule: InheritRule, merge_sep: str | None) -> str: + if rule == InheritRule.NONE: + return after + + if not before: + # if before is None, we can just return after + # if before is an empty string, we shouldn't add any separator + return after + + if not after: + # if after is an empty string, we shouldn't add any separator + return before + + if not merge_sep: + msg = f"Don't know how to merge {before!r} and {after!r} with {rule}" + raise ConfigOptionError(msg) + + if rule == InheritRule.APPEND: + return f"{before}{merge_sep}{after}" + elif rule == InheritRule.PREPEND: + return f"{after}{merge_sep}{before}" + else: + assert_never(rule) + + +def _stringify_setting( + setting: Setting, list_sep: str | None, table_format: TableFmt | None +) -> str: + if isinstance(setting, Mapping): + if table_format is None: + 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) + ) + + if not isinstance(setting, str) and isinstance(setting, Sequence): + if list_sep is None: + msg = f"Error converting {setting!r} to a string: this setting doesn't accept a list" + raise ConfigOptionError(msg) + return list_sep.join(setting) + + if isinstance(setting, int): + return str(setting) + + return setting class OptionsReader: @@ -244,7 +333,16 @@ def __init__( if isinstance(select, list): select = " ".join(select) - self.overrides.append(Override(select, config_override)) + 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 ConfigOptionError(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: """ @@ -312,8 +410,8 @@ def get( name: str, *, env_plat: bool = True, - sep: str | None = None, - table: TableFmt | None = None, + list_sep: str | None = None, + table_format: TableFmt | None = None, ignore_empty: bool = False, ) -> str: """ @@ -335,41 +433,24 @@ def get( envvar = f"CIBW_{name.upper().replace('-', '_')}" plat_envvar = f"{envvar}_{self.platform.upper()}" - # later overrides take precedence over earlier ones, so reverse the list - active_config_overrides = reversed(self.active_config_overrides) - - # get the option from the environment, then the config file, then finally the default. + # get the option from the default, then the config file, then finally the environment. # platform-specific options are preferred, if they're allowed. - result = _dig_first( - (self.env if env_plat else {}, plat_envvar), - (self.env, envvar), - *[(o.options, name) for o in active_config_overrides], - (self.config_platform_options, name), - (self.config_options, name), - (self.default_platform_options, name), - (self.default_options, name), + 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), + *[ + (o.options.get(name), o.inherit.get(name, InheritRule.NONE)) + for o in self.active_config_overrides + ], + (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, ) - if isinstance(result, dict): - if table is None: - msg = f"{name!r} does not accept a table" - raise ConfigOptionError(msg) - return table["sep"].join( - item for k, v in result.items() for item in _inner_fmt(k, v, table) - ) - - if isinstance(result, list): - if sep is None: - msg = f"{name!r} does not accept a list" - raise ConfigOptionError(msg) - return sep.join(result) - - if isinstance(result, int): - return str(result) - - return result - def _inner_fmt(k: str, v: Any, table: TableFmt) -> Iterator[str]: quote_function = table.get("quote", lambda a: a) @@ -430,9 +511,9 @@ def globals(self) -> GlobalOptions: package_dir = args.package_dir output_dir = args.output_dir - build_config = self.reader.get("build", env_plat=False, sep=" ") or "*" - skip_config = self.reader.get("skip", env_plat=False, sep=" ") - test_skip = self.reader.get("test-skip", env_plat=False, sep=" ") + 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=" ") prerelease_pythons = args.prerelease_pythons or strtobool( self.env.get("CIBW_PRERELEASE_PYTHONS", "0") @@ -445,7 +526,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", sep=" ") + archs_config_str = args.archs or self.reader.get("archs", list_sep=" ") architectures = Architecture.parse_config(archs_config_str, platform=self.platform) # Process `--only` @@ -464,7 +545,8 @@ def globals(self) -> GlobalOptions: test_selector = TestSelector(skip_config=test_skip) container_engine_str = self.reader.get( - "container-engine", table={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote} + "container-engine", + table_format={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote}, ) try: @@ -489,29 +571,30 @@ def build_options(self, identifier: str | None) -> BuildOptions: """ with self.reader.identifier(identifier): - before_all = self.reader.get("before-all", sep=" && ") + before_all = self.reader.get("before-all", list_sep=" && ") environment_config = self.reader.get( - "environment", table={"item": '{k}="{v}"', "sep": " "} + "environment", table_format={"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=" && ") + 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={"item": "{k}={v}", "sep": " ", "quote": shlex.quote} + "config-settings", + table_format={"item": "{k}={v}", "sep": " ", "quote": shlex.quote}, ) dependency_versions = self.reader.get("dependency-versions") - test_command = self.reader.get("test-command", sep=" && ") - before_test = self.reader.get("before-test", sep=" && ") - test_requires = self.reader.get("test-requires", sep=" ").split() - test_extras = self.reader.get("test-extras", sep=",") + 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=",") build_verbosity_str = self.reader.get("build-verbosity") build_frontend_str = self.reader.get( "build-frontend", env_plat=False, - table={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote}, + table_format={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote}, ) build_frontend: BuildFrontendConfig | None if not build_frontend_str or build_frontend_str == "default": diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json index 7e9c3901d..1dd890990 100644 --- a/cibuildwheel/resources/cibuildwheel.schema.json +++ b/cibuildwheel/resources/cibuildwheel.schema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "cibuildwheel's settings.", "type": "object", + "defines": { + "inherit": { + "enum": [ + "none", + "prepend", + "append" + ], + "default": "none", + "description": "How to inherit the parent's value." + } + }, "properties": { "archs": { "description": "Change the architectures built on your machine by default.", @@ -423,6 +434,45 @@ } ] }, + "inherit": { + "type": "object", + "additionalProperties": false, + "properties": { + "before-all": { + "$ref": "#/defines/inherit" + }, + "before-build": { + "$ref": "#/defines/inherit" + }, + "before-test": { + "$ref": "#/defines/inherit" + }, + "config-settings": { + "$ref": "#/defines/inherit" + }, + "container-engine": { + "$ref": "#/defines/inherit" + }, + "environment": { + "$ref": "#/defines/inherit" + }, + "environment-pass": { + "$ref": "#/defines/inherit" + }, + "repair-wheel-command": { + "$ref": "#/defines/inherit" + }, + "test-command": { + "$ref": "#/defines/inherit" + }, + "test-extras": { + "$ref": "#/defines/inherit" + }, + "test-requires": { + "$ref": "#/defines/inherit" + } + } + }, "before-all": { "$ref": "#/properties/before-all" }, diff --git a/docs/options.md b/docs/options.md index a339e28ee..b7d8008ed 100644 --- a/docs/options.md +++ b/docs/options.md @@ -135,6 +135,10 @@ trigger new containers, one per image. Some commands are not supported; `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"`. + ##### Examples: ```toml @@ -169,6 +173,69 @@ This example will build CPython 3.6 wheels on manylinux1, CPython 3.7-3.9 wheels on manylinux2010, and manylinux2014 wheels for any newer Python (like 3.10). +```toml +[tool.cibuildwheel] +environment = {FOO="BAR", "HAM"="EGGS"} +test-command = ["pyproject"] + +[[tool.cibuildwheel.overrides]] +select = "cp311*" + +inherit.test-command = "prepend" +test-command = ["pyproject-before"] + +inherit.environment="append" +environment = {FOO="BAZ", "PYTHON"="MONTY"} + +[[tool.cibuildwheel.overrides]] +select = "cp311*" +inherit.test-command = "append" +test-command = ["pyproject-after"] +``` + +This example will provide the command `"pyproject-before && pyproject && pyproject-after"` +on Python 3.11, and will have `environment = {FOO="BAZ", "PYTHON"="MONTY", "HAM"="EGGS"}`. + + +### Extending existing options {: #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: + +```toml +[[tool.cibuildwheel.overrides]] +select = "cp311*" +inherit.environment = "append" +environment.NEWVAR = "Added!" +``` + +For a table, `"append"` will replace a key if it exists, while `"prepend"` will +only add a new key, older keys take precedence. + +Lists are also supported (and keep in mind that commands are lists). For +example, you can print a message before and after a wheel is repaired: + +```toml +[[tool.cibuildwheel.overrides]] +select = "*" +inherit.repair-wheel-command = "prepend" +repair-wheel-command = "echo 'Before repair'" + +[[tool.cibuildwheel.overrides]] +select = "*" +inherit.repair-wheel-command = "append" +repair-wheel-command = "echo 'After repair'" +``` + +As seen in this example, you can have multiple overrides match - they match top +to bottom, with the config being accumulated. If you need platform-specific +inheritance, you can use `select = "*-????linux_*"` for Linux, `select = +"*-win_*"` for Windows, and `select = "*-macosx_*"` for macOS. As always, +environment variables will completely override any TOML configuration. ## Options summary diff --git a/unit_test/options_test.py b/unit_test/options_test.py index c2163090f..cf03f8548 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -347,3 +347,71 @@ def test_build_frontend_option(tmp_path: Path, toml_assignment, result_name, res assert parsed_build_frontend.args == result_args else: assert parsed_build_frontend is None + + +def test_override_inherit_environment(tmp_path: Path): + args = CommandLineArguments.defaults() + args.package_dir = tmp_path + + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + textwrap.dedent( + """\ + [tool.cibuildwheel] + environment = {FOO="BAR", "HAM"="EGGS"} + + [[tool.cibuildwheel.overrides]] + select = "cp37*" + inherit.environment = "append" + environment = {FOO="BAZ", "PYTHON"="MONTY"} + """ + ) + ) + + options = Options(platform="linux", command_line_arguments=args, env={}) + parsed_environment = options.build_options(identifier=None).environment + assert parsed_environment.as_dictionary(prev_environment={}) == { + "FOO": "BAR", + "HAM": "EGGS", + } + + assert options.build_options("cp37-manylinux_x86_64").environment.as_dictionary( + prev_environment={} + ) == { + "FOO": "BAZ", + "HAM": "EGGS", + "PYTHON": "MONTY", + } + + +def test_override_inherit_environment_with_references(tmp_path: Path): + args = CommandLineArguments.defaults() + args.package_dir = tmp_path + + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + textwrap.dedent( + """\ + [tool.cibuildwheel] + environment = {PATH="/opt/bin:$PATH"} + + [[tool.cibuildwheel.overrides]] + select = "cp37*" + inherit.environment = "append" + environment = {PATH="/opt/local/bin:$PATH"} + """ + ) + ) + + options = Options(platform="linux", command_line_arguments=args, env={"MONTY": "PYTHON"}) + parsed_environment = options.build_options(identifier=None).environment + prev_environment = {"PATH": "/usr/bin:/bin"} + assert parsed_environment.as_dictionary(prev_environment=prev_environment) == { + "PATH": "/opt/bin:/usr/bin:/bin", + } + + assert options.build_options("cp37-manylinux_x86_64").environment.as_dictionary( + prev_environment=prev_environment + ) == { + "PATH": "/opt/local/bin:/opt/bin:/usr/bin:/bin", + } diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index 4d5b4f43e..6d3fc50e9 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -4,7 +4,7 @@ import pytest -from cibuildwheel.options import ConfigOptionError, OptionsReader, _dig_first +from cibuildwheel.options import ConfigOptionError, InheritRule, OptionsReader, _resolve_cascade PYPROJECT_1 = """ [tool.cibuildwheel] @@ -37,27 +37,31 @@ 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, sep=" ") == "cp39*" + assert options_reader.get("build", env_plat=False, list_sep=" ") == "cp39*" assert options_reader.get("test-command") == "pyproject" - assert options_reader.get("archs", sep=" ") == "auto" + assert options_reader.get("archs", list_sep=" ") == "auto" assert ( - options_reader.get("test-requires", sep=" ") + options_reader.get("test-requires", list_sep=" ") == {"windows": "something", "macos": "else", "linux": "other many"}[platform] ) # Also testing options for support for both lists and tables assert ( - options_reader.get("environment", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("environment", table_format={"item": '{k}="{v}"', "sep": " "}) == 'THING="OTHER" FOO="BAR"' ) assert ( - options_reader.get("environment", sep="x", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get( + "environment", list_sep="x", table_format={"item": '{k}="{v}"', "sep": " "} + ) == 'THING="OTHER" FOO="BAR"' ) - assert options_reader.get("test-extras", sep=",") == "one,two" + assert options_reader.get("test-extras", list_sep=",") == "one,two" assert ( - options_reader.get("test-extras", sep=",", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get( + "test-extras", list_sep=",", table_format={"item": '{k}="{v}"', "sep": " "} + ) == "one,two" ) @@ -65,10 +69,10 @@ def test_simple_settings(tmp_path, platform, fname): assert options_reader.get("manylinux-i686-image") == "manylinux2014" with pytest.raises(ConfigOptionError): - options_reader.get("environment", sep=" ") + options_reader.get("environment", list_sep=" ") with pytest.raises(ConfigOptionError): - options_reader.get("test-extras", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("test-extras", table_format={"item": '{k}="{v}"', "sep": " "}) def test_envvar_override(tmp_path, platform): @@ -87,14 +91,14 @@ def test_envvar_override(tmp_path, platform): }, ) - assert options_reader.get("archs", sep=" ") == "auto" + assert options_reader.get("archs", list_sep=" ") == "auto" - assert options_reader.get("build", sep=" ") == "cp38*" + assert options_reader.get("build", list_sep=" ") == "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", sep=" ") + options_reader.get("test-requires", list_sep=" ") == {"windows": "docs", "macos": "docs", "linux": "scod"}[platform] ) assert options_reader.get("test-command") == "mytest" @@ -215,7 +219,7 @@ def test_unsupported_join(tmp_path): ) options_reader = OptionsReader(pyproject_toml, platform="linux", env={}) - assert options_reader.get("build", sep=", ") == "1, 2" + assert options_reader.get("build", list_sep=", ") == "1, 2" with pytest.raises(ConfigOptionError): options_reader.get("build") @@ -262,47 +266,92 @@ def test_environment_override_empty(tmp_path): assert options_reader.get("manylinux-aarch64-image", ignore_empty=True) == "manylinux1" -@pytest.mark.parametrize("ignore_empty", [True, False]) -def test_dig_first(ignore_empty): - d1 = {"random": "thing"} - d2 = {"this": "that", "empty": ""} - d3 = {"other": "hi"} - d4 = {"this": "d4", "empty": "not"} - - answer = _dig_first( - (d1, "empty"), - (d2, "empty"), - (d3, "empty"), - (d4, "empty"), +@pytest.mark.parametrize("ignore_empty", [True, False], ids=["ignore_empty", "no_ignore_empty"]) +def test_resolve_cascade(ignore_empty): + answer = _resolve_cascade( + ("not", InheritRule.NONE), + (None, InheritRule.NONE), + ("", InheritRule.NONE), + (None, InheritRule.NONE), ignore_empty=ignore_empty, ) assert answer == ("not" if ignore_empty else "") - answer = _dig_first( - (d1, "this"), - (d2, "this"), - (d3, "this"), - (d4, "this"), + answer = _resolve_cascade( + ("d4", InheritRule.NONE), + (None, InheritRule.NONE), + ("that", InheritRule.NONE), + (None, InheritRule.NONE), ignore_empty=ignore_empty, ) assert answer == "that" - with pytest.raises(KeyError): - _dig_first( - (d1, "this"), - (d2, "other"), - (d3, "this"), - (d4, "other"), + with pytest.raises(ValueError, match="a setting should at least have a default value"): + _resolve_cascade( + (None, InheritRule.NONE), + (None, InheritRule.NONE), + (None, InheritRule.NONE), + (None, InheritRule.NONE), ignore_empty=ignore_empty, ) +@pytest.mark.parametrize("ignore_empty", [True, False], ids=["ignore_empty", "no_ignore_empty"]) +@pytest.mark.parametrize("rule", [InheritRule.PREPEND, InheritRule.NONE, InheritRule.APPEND]) +def test_resolve_cascade_merge_list(ignore_empty, rule): + answer = _resolve_cascade( + (["a1", "a2"], InheritRule.NONE), + ([], InheritRule.NONE), + (["b1", "b2"], rule), + (None, InheritRule.NONE), + ignore_empty=ignore_empty, + list_sep=" ", + ) + + if not ignore_empty: + assert answer == "b1 b2" + else: + if rule == InheritRule.PREPEND: + assert answer == "b1 b2 a1 a2" + elif rule == InheritRule.NONE: + assert answer == "b1 b2" + elif rule == InheritRule.APPEND: + assert answer == "a1 a2 b1 b2" + + +@pytest.mark.parametrize("rule", [InheritRule.PREPEND, InheritRule.NONE, InheritRule.APPEND]) +def test_resolve_cascade_merge_dict(rule): + answer = _resolve_cascade( + ({"value": "a1", "base": "b1"}, InheritRule.NONE), + (None, InheritRule.NONE), + ({"value": "override"}, rule), + (None, InheritRule.NONE), + table_format={"item": "{k}={v}", "sep": " "}, + ) + + if rule == InheritRule.PREPEND: + assert answer == "value=override value=a1 base=b1" + elif rule == InheritRule.NONE: + assert answer == "value=override" + elif rule == InheritRule.APPEND: + assert answer == "value=a1 base=b1 value=override" + + +def test_resolve_cascade_merge_different_types(): + answer = _resolve_cascade( + ({"value": "a1", "base": "b1"}, InheritRule.NONE), + ({"value": "override"}, InheritRule.APPEND), + table_format={"item": "{k}={v}", "sep": " "}, + ) + assert answer == "value=a1 base=b1 value=override" + + PYPROJECT_2 = """ [tool.cibuildwheel] build = ["cp38*", "cp37*"] -environment = {FOO="BAR"} +environment = {FOO="BAR", "HAM"="EGGS"} -test-command = "pyproject" +test-command = ["pyproject"] manylinux-x86_64-image = "manylinux1" @@ -311,8 +360,25 @@ def test_dig_first(ignore_empty): [[tool.cibuildwheel.overrides]] select = "cp37*" -test-command = "pyproject-override" +inherit = {test-command="prepend", environment="append"} +test-command = ["pyproject-override", "override2"] manylinux-x86_64-image = "manylinux2014" +environment = {FOO="BAZ", "PYTHON"="MONTY"} + +[[tool.cibuildwheel.overrides]] +select = "*-final" +inherit = {test-command="append"} +test-command = ["pyproject-finalize", "finalize2"] + +[[tool.cibuildwheel.overrides]] +select = "*-final" +inherit = {test-command="append"} +test-command = ["extra-finalize"] + +[[tool.cibuildwheel.overrides]] +select = "*-final" +inherit = {test-command="prepend"} +test-command = ["extra-prepend"] """ @@ -321,13 +387,30 @@ 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") == "pyproject" + assert options_reader.get("test-command", list_sep=" && ") == "pyproject" with options_reader.identifier("random"): - assert options_reader.get("test-command") == "pyproject" + assert options_reader.get("test-command", list_sep=" && ") == "pyproject" with options_reader.identifier("cp37-something"): - assert options_reader.get("test-command") == "pyproject-override" + assert ( + options_reader.get("test-command", list_sep=" && ") + == "pyproject-override && override2 && pyproject" + ) + assert ( + options_reader.get("environment", table_format={"item": '{k}="{v}"', "sep": " "}) + == 'FOO="BAR" HAM="EGGS" FOO="BAZ" PYTHON="MONTY"' + ) + + with options_reader.identifier("cp37-final"): + assert ( + options_reader.get("test-command", list_sep=" && ") + == "extra-prepend && pyproject-override && override2 && pyproject && pyproject-finalize && finalize2 && extra-finalize" + ) + assert ( + options_reader.get("environment", table_format={"item": '{k}="{v}"', "sep": " "}) + == 'FOO="BAR" HAM="EGGS" FOO="BAZ" PYTHON="MONTY"' + ) def test_overrides_not_a_list(tmp_path, platform): @@ -359,7 +442,7 @@ def test_config_settings(tmp_path): options_reader = OptionsReader(config_file_path=pyproject_toml, platform="linux", env={}) assert ( - options_reader.get("config-settings", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("config-settings", table_format={"item": '{k}="{v}"', "sep": " "}) == 'example="one" other="two" other="three"' ) @@ -376,7 +459,36 @@ 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={"item": "--config-settings='{k}=\"{v}\"'", "sep": " "} + "config-settings", table_format={"item": "--config-settings='{k}=\"{v}\"'", "sep": " "} ) == "--config-settings='--build-option=\"--use-mypyc\"'" ) + + +def test_overrides_inherit(tmp_path): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +before-all = ["before-all"] + +[[tool.cibuildwheel.overrides]] +select = "cp37*" +inherit.before-all = "append" +before-all = ["override1"] + +[[tool.cibuildwheel.overrides]] +select = "cp37*" +inherit.before-all = "prepend" +before-all = ["override2"] +""" + ) + + 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" + with options_reader.identifier("cp37-something"): + assert ( + options_reader.get("before-all", list_sep=" && ") + == "override2 && before-all && override1" + ) diff --git a/unit_test/validate_schema_test.py b/unit_test/validate_schema_test.py index 66c95f25a..581ac9a7b 100644 --- a/unit_test/validate_schema_test.py +++ b/unit_test/validate_schema_test.py @@ -72,6 +72,50 @@ def test_overrides_only_select(): validator(example) +def test_overrides_valid_inherit(): + example = tomllib.loads( + """ + [[tool.cibuildwheel.overrides]] + inherit.repair-wheel-command = "append" + select = "somestring" + repair-wheel-command = ["something"] + """ + ) + + validator = validate_pyproject.api.Validator() + assert validator(example) is not None + + +def test_overrides_invalid_inherit(): + example = tomllib.loads( + """ + [[tool.cibuildwheel.overrides]] + inherit.something = "append" + select = "somestring" + repair-wheel-command = "something" + """ + ) + + validator = validate_pyproject.api.Validator() + with pytest.raises(validate_pyproject.error_reporting.ValidationError): + validator(example) + + +def test_overrides_invalid_inherit_value(): + example = tomllib.loads( + """ + [[tool.cibuildwheel.overrides]] + inherit.repair-wheel-command = "nothing" + select = "somestring" + repair-wheel-command = "something" + """ + ) + + validator = validate_pyproject.api.Validator() + with pytest.raises(validate_pyproject.error_reporting.ValidationError): + validator(example) + + def test_docs_examples(): """ Parse out all the configuration examples, build valid TOML out of them, and