diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 097b20d7..d03660a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,6 +59,7 @@ repos: - tomli - types-certifi - types-click + - types-dataclasses - types-jinja2 - types-pyyaml - types-requests diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 0623624d..7bf2a76a 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -14,7 +14,7 @@ from cibuildwheel.architecture import Architecture, allowed_architectures_check from cibuildwheel.options import compute_options from cibuildwheel.typing import PLATFORMS, PlatformName, assert_never from cibuildwheel.util import ( - BuildOptions, + AllBuildOptions, BuildSelector, Unbuffered, detect_ci_provider, @@ -148,12 +148,12 @@ def main() -> None: else os.environ.get("CIBW_OUTPUT_DIR", "wheelhouse") ) - build_options = compute_options( + all_build_options, build_options_by_selector = compute_options( platform, package_dir, output_dir, args.config_file, args.archs, args.prerelease_pythons ) identifiers = get_build_identifiers( - platform, build_options.build_selector, build_options.architectures + platform, all_build_options.build_selector, all_build_options.architectures ) if args.print_build_identifiers: @@ -161,6 +161,8 @@ def main() -> None: print(identifier) sys.exit(0) + build_options = AllBuildOptions(all_build_options, build_options_by_selector, identifiers) + # Add CIBUILDWHEEL environment variable # This needs to be passed on to the docker container in linux.py os.environ["CIBUILDWHEEL"] = "1" @@ -200,7 +202,7 @@ def main() -> None: assert_never(platform) -def print_preamble(platform: str, build_options: BuildOptions) -> None: +def print_preamble(platform: str, build_options: AllBuildOptions) -> None: print( textwrap.dedent( """ @@ -254,20 +256,21 @@ def get_build_identifiers( return [config.identifier for config in python_configurations] -def detect_warnings(platform: str, build_options: BuildOptions) -> List[str]: +def detect_warnings(platform: str, all_options: AllBuildOptions) -> List[str]: warnings = [] # warn about deprecated {python} and {pip} - for option_name in ["test_command", "before_build"]: - option_value = getattr(build_options, option_name) + for build_options in all_options.values(): + for option_name in ["test_command", "before_build"]: + option_value = getattr(build_options, option_name) - if option_value and ("{python}" in option_value or "{pip}" in option_value): - # Reminder: in an f-string, double braces means literal single brace - msg = ( - f"{option_name}: '{{python}}' and '{{pip}}' are no longer needed, " - "and will be removed in a future release. Simply use 'python' or 'pip' instead." - ) - warnings.append(msg) + if option_value and ("{python}" in option_value or "{pip}" in option_value): + # Reminder: in an f-string, double braces means literal single brace + msg = ( + f"{option_name}: '{{python}}' and '{{pip}}' are no longer needed, " + "and will be removed in a future release. Simply use 'python' or 'pip' instead." + ) + warnings.append(msg) return warnings diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py index 2d9ddc1f..748398c5 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -1,3 +1,4 @@ +import dataclasses from typing import Dict, List, Mapping, Optional import bashlex @@ -60,10 +61,15 @@ class EnvironmentAssignment: def __repr__(self) -> str: return f"{self.name}={self.value}" + def __eq__(self, other: object) -> bool: + if isinstance(other, EnvironmentAssignment): + return self.name == other.name and self.value == other.value + return False + +@dataclasses.dataclass class ParsedEnvironment: - def __init__(self, assignments: List[EnvironmentAssignment]): - self.assignments = assignments + assignments: List[EnvironmentAssignment] def as_dictionary( self, @@ -82,7 +88,7 @@ class ParsedEnvironment: return [a.as_shell_assignment() for a in self.assignments] def __repr__(self) -> str: - return f"ParsedEnvironment({[repr(a) for a in self.assignments]!r})" + return f"{self.__class__.__name__}({[repr(a) for a in self.assignments]!r})" def parse_environment(env_string: str) -> ParsedEnvironment: diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index e2d6045f..b9615923 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -9,7 +9,7 @@ from .docker_container import DockerContainer from .logger import log from .typing import PathOrStr, assert_never from .util import ( - BuildOptions, + AllBuildOptions, BuildSelector, NonPlatformWheelError, get_build_verbosity_extra_flags, @@ -54,7 +54,7 @@ def get_python_configurations( def get_build_steps( - options: BuildOptions, python_configurations: List[PythonConfiguration] + all_options: AllBuildOptions, python_configurations: List[PythonConfiguration] ) -> Iterator[BuildStep]: platforms = [ ("cp", "manylinux_x86_64", "x86_64"), @@ -73,15 +73,6 @@ def get_build_steps( ] for implementation, platform_tag, platform_arch in platforms: - assert options.manylinux_images is not None - assert options.musllinux_images is not None - - docker_image = ( - options.manylinux_images[platform_arch] - if platform_tag.startswith("manylinux") - else options.musllinux_images[platform_arch] - ) - platform_configs = [ c for c in python_configurations @@ -90,11 +81,15 @@ def get_build_steps( if not platform_configs: continue - yield BuildStep(platform_configs, platform_tag, docker_image) + for local_configs, docker_image in all_options.produce_image_batches( + platform_configs, platform_tag, platform_arch + ): + # TODO: Validate that the options are not invalid for these selectors + yield BuildStep(local_configs, platform_tag, docker_image) def build_on_docker( - options: BuildOptions, + all_options: AllBuildOptions, platform_configs: List[PythonConfiguration], docker: DockerContainer, container_project_path: PurePath, @@ -105,16 +100,16 @@ def build_on_docker( log.step("Copying project into Docker...") docker.copy_into(Path.cwd(), container_project_path) - if options.before_all: + if all_options.before_all: log.step("Running before_all...") env = docker.get_environment() env["PATH"] = f'/opt/python/cp38-cp38/bin:{env["PATH"]}' env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" - env = options.environment.as_dictionary(env, executor=docker.environment_executor) + env = all_options.environment.as_dictionary(env, executor=docker.environment_executor) before_all_prepared = prepare_command( - options.before_all, + all_options.before_all, project=container_project_path, package=container_package_dir, ) @@ -122,6 +117,7 @@ def build_on_docker( for config in platform_configs: log.build_start(config.identifier) + options = all_options[config.identifier] dependency_constraint_flags: List[PathOrStr] = [] @@ -284,11 +280,11 @@ def build_on_docker( log.step("Copying wheels back to host...") # copy the output back into the host - docker.copy_out(container_output_dir, options.output_dir) + docker.copy_out(container_output_dir, all_options.output_dir) log.step_end() -def build(options: BuildOptions) -> None: +def build(all_options: AllBuildOptions) -> None: try: # check docker is installed subprocess.run(["docker", "--version"], check=True, stdout=subprocess.DEVNULL) @@ -301,19 +297,19 @@ def build(options: BuildOptions) -> None: ) sys.exit(2) - assert options.manylinux_images is not None - assert options.musllinux_images is not None - python_configurations = get_python_configurations(options.build_selector, options.architectures) + python_configurations = get_python_configurations( + all_options.build_selector, all_options.architectures + ) cwd = Path.cwd() - abs_package_dir = options.package_dir.resolve() + abs_package_dir = all_options.package_dir.resolve() if cwd != abs_package_dir and cwd not in abs_package_dir.parents: raise Exception("package_dir must be inside the working directory") container_project_path = PurePath("/project") container_package_dir = container_project_path / abs_package_dir.relative_to(cwd) - for build_step in get_build_steps(options, python_configurations): + for build_step in get_build_steps(all_options, python_configurations): try: log.step(f"Starting Docker image {build_step.docker_image}...") @@ -324,7 +320,7 @@ def build(options: BuildOptions) -> None: ) as docker: build_on_docker( - options, + all_options, build_step.platform_configs, docker, container_project_path, @@ -335,7 +331,7 @@ def build(options: BuildOptions) -> None: log.step_end_with_error( f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}" ) - troubleshoot(options, error) + troubleshoot(all_options, error) sys.exit(1) @@ -346,16 +342,18 @@ def _matches_prepared_command(error_cmd: List[str], command_template: str) -> bo return error_cmd[2].startswith(command_prefix) -def troubleshoot(options: BuildOptions, error: Exception) -> None: +def troubleshoot(all_options: AllBuildOptions, error: Exception) -> None: if isinstance(error, subprocess.CalledProcessError) and ( error.cmd[0:4] == ["python", "-m", "pip", "wheel"] or error.cmd[0:3] == ["python", "-m", "build"] - or _matches_prepared_command(error.cmd, options.repair_command) + or _matches_prepared_command( + error.cmd, all_options.general_build_options.repair_command + ) # TODO ): # the wheel build step failed print("Checking for common errors...") - so_files = list(options.package_dir.glob("**/*.so")) + so_files = list(all_options.package_dir.glob("**/*.so")) if so_files: print( diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index d8f6dbc5..af36e499 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -14,8 +14,8 @@ from .environment import ParsedEnvironment from .logger import log from .typing import Literal, PathOrStr, assert_never from .util import ( + AllBuildOptions, BuildFrontend, - BuildOptions, BuildSelector, NonPlatformWheelError, download, @@ -343,26 +343,29 @@ def setup_python( return env -def build(options: BuildOptions) -> None: +def build(all_options: AllBuildOptions) -> None: temp_dir = Path(tempfile.mkdtemp(prefix="cibuildwheel")) built_wheel_dir = temp_dir / "built_wheel" repaired_wheel_dir = temp_dir / "repaired_wheel" + all_options.check_build_selectors() + try: - if options.before_all: + if all_options.before_all: log.step("Running before_all...") - env = options.environment.as_dictionary(prev_environment=os.environ) + env = all_options.environment.as_dictionary(prev_environment=os.environ) env.setdefault("MACOSX_DEPLOYMENT_TARGET", "10.9") before_all_prepared = prepare_command( - options.before_all, project=".", package=options.package_dir + all_options.before_all, project=".", package=all_options.package_dir ) call([before_all_prepared], shell=True, env=env) python_configurations = get_python_configurations( - options.build_selector, options.architectures + all_options.build_selector, all_options.architectures ) for config in python_configurations: + options = all_options[config.identifier] log.build_start(config.identifier) config_is_arm64 = config.identifier.endswith("arm64") diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 6e6d1984..b898dcae 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -1,9 +1,10 @@ +import copy import os import sys import traceback from configparser import ConfigParser from pathlib import Path -from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, TypeVar, Union import tomli from packaging.specifiers import SpecifierSet @@ -58,6 +59,9 @@ def _dig_first(*pairs: Tuple[Mapping[str, Setting], str], ignore_empty: bool = F raise KeyError(key) +T = TypeVar("T", bound="ConfigOptions") + + class ConfigOptions: """ Gets options from the environment, config or defaults, optionally scoped @@ -117,12 +121,27 @@ class ConfigOptions: self.config_options = config_options self.config_platform_options = config_platform_options + self.overrides: Dict[str, Dict[str, Any]] = {} + self.current_override: str = "*" + + overrides = self.config_options.get("overrides") + if overrides is not None: + if not isinstance(overrides, list): + raise ConfigOptionError('"tool.cibuildwheel.overrides" must be a list') + for override in overrides: + selector = override.pop("select") + if isinstance(selector, list): + selector = " ".join(selector) + if selector in {"", "*"}: + raise ConfigOptionError("select all must not be used in an override") + self.overrides[selector.strip()] = override + def _is_valid_global_option(self, name: str) -> bool: """ Returns True if an option with this name is allowed in the [tool.cibuildwheel] section of a config file. """ - allowed_option_names = self.default_options.keys() | PLATFORMS + allowed_option_names = self.default_options.keys() | PLATFORMS | {"overrides"} return name in allowed_option_names @@ -151,6 +170,14 @@ class ConfigOptions: return global_options, platform_options + def override(self: T, selector: str) -> T: + """ + Start an override scope. + """ + other = copy.copy(self) + other.current_override = selector + return other + def __call__( self, name: str, @@ -179,9 +206,11 @@ class ConfigOptions: # get the option from the environment, then the config file, then finally the default. # platform-specific options are preferred, if they're allowed. + empty: Dict[str, Any] = {} result = _dig_first( (os.environ if env_plat else {}, plat_envvar), # type: ignore[arg-type] (os.environ, envvar), + (self.overrides.get(self.current_override, empty), name), (self.config_platform_options, name), (self.config_options, name), (self.default_platform_options, name), @@ -210,7 +239,7 @@ def compute_options( config_file: Optional[str], args_archs: Optional[str], prerelease_pythons: bool, -) -> BuildOptions: +) -> Tuple[BuildOptions, Dict[str, BuildOptions]]: """ Compute the options from the environment and configuration file. """ @@ -263,11 +292,25 @@ def compute_options( ) test_selector = TestSelector(skip_config=test_skip) - return _compute_single_options( + return _compute_all_options( options, args_archs, build_selector, test_selector, platform, package_dir, output_dir ) +def _get_pinned_docker_images() -> Mapping[str, Mapping[str, str]]: + """ + This looks like a dict of dicts, e.g. + { 'x86_64': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'}, + 'i686': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'}, + 'pypy_x86_64': {'manylinux2010': '...' } + ... } + """ + pinned_docker_images_file = resources_dir / "pinned_docker_images.cfg" + all_pinned_docker_images = ConfigParser() + all_pinned_docker_images.read(pinned_docker_images_file) + return all_pinned_docker_images + + def _compute_single_options( options: ConfigOptions, args_archs: Optional[str], @@ -337,14 +380,7 @@ def _compute_single_options( manylinux_images: Dict[str, str] = {} musllinux_images: Dict[str, str] = {} if platform == "linux": - pinned_docker_images_file = resources_dir / "pinned_docker_images.cfg" - all_pinned_docker_images = ConfigParser() - all_pinned_docker_images.read(pinned_docker_images_file) - # all_pinned_docker_images looks like a dict of dicts, e.g. - # { 'x86_64': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'}, - # 'i686': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'}, - # 'pypy_x86_64': {'manylinux2010': '...' } - # ... } + all_pinned_docker_images = _get_pinned_docker_images() for build_platform in MANYLINUX_ARCHS: pinned_images = all_pinned_docker_images[build_platform] @@ -359,6 +395,7 @@ def _compute_single_options( else: image = config_value + assert image is not None manylinux_images[build_platform] = image for build_platform in MUSLLINUX_ARCHS: @@ -397,6 +434,27 @@ def _compute_single_options( ) +def _compute_all_options( + options: ConfigOptions, + args_archs: Optional[str], + build_selector: BuildSelector, + test_selector: TestSelector, + platform: PlatformName, + package_dir: Path, + output_dir: Path, +) -> Tuple[BuildOptions, Dict[str, BuildOptions]]: + args = (args_archs, build_selector, test_selector, platform, package_dir, output_dir) + + general_build_options = _compute_single_options(options, *args) + + selectors = options.overrides.keys() + build_options_by_selector = { + s: _compute_single_options(options.override(s), *args) for s in selectors + } + + return general_build_options, build_options_by_selector + + def deprecated_selectors(name: str, selector: str, *, error: bool = False) -> None: if "p2" in selector or "p35" in selector: msg = f"cibuildwheel 2.x no longer supports Python < 3.6. Please use the 1.x series or update {name}" diff --git a/cibuildwheel/typing.py b/cibuildwheel/typing.py index b9bd207b..2158cbac 100644 --- a/cibuildwheel/typing.py +++ b/cibuildwheel/typing.py @@ -12,14 +12,16 @@ else: __all__ = ( "Final", "Literal", - "TypedDict", - "Set", - "Union", - "PopenBytes", + "PLATFORMS", "PathOrStr", "PlatformName", "Protocol", "PLATFORMS", + "PopenBytes", + "Protocol", + "Set", + "TypedDict", + "Union", "assert_never", ) diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 30b8ed54..b52e3471 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -1,4 +1,5 @@ import contextlib +import dataclasses import fnmatch import itertools import os @@ -9,10 +10,23 @@ import sys import textwrap import time import urllib.request +from collections import defaultdict from enum import Enum from pathlib import Path from time import sleep -from typing import Dict, Iterator, List, NamedTuple, Optional, Set +from typing import ( + Counter, + Dict, + Iterable, + Iterator, + List, + Mapping, + NamedTuple, + Optional, + Set, + Tuple, + TypeVar, +) import bracex import certifi @@ -22,7 +36,7 @@ from packaging.version import Version from .architecture import Architecture from .environment import ParsedEnvironment -from .typing import Literal, PathOrStr, PlatformName +from .typing import Literal, PathOrStr, PlatformName, Protocol resources_dir = Path(__file__).parent / "resources" @@ -250,6 +264,119 @@ class BuildOptions(NamedTuple): return "\n".join(res) +class SimpleConfig(Protocol): + @property + def identifier(self) -> str: + ... + + +SC = TypeVar("SC", bound=SimpleConfig) +T = TypeVar("T", bound="AllBuildOptions") + + +@dataclasses.dataclass +class AllBuildOptions: + general_build_options: BuildOptions + build_options_by_selector: Dict[str, BuildOptions] + identifiers: List[str] + + def __getitem__(self, identifier: str) -> BuildOptions: + for sel in self.build_options_by_selector: + bs = BuildSelector(build_config=sel, skip_config="") + if bs(identifier): + return self.build_options_by_selector[sel] + + return self.general_build_options + + def values(self) -> Iterable[BuildOptions]: + return itertools.chain( + [self.general_build_options], self.build_options_by_selector.values() + ) + + # These values are not overridable in some cases + @property + def package_dir(self) -> Path: + return self.general_build_options.package_dir + + @property + def build_selector(self) -> BuildSelector: + return self.general_build_options.build_selector + + @property + def output_dir(self) -> Path: + return self.general_build_options.output_dir + + @property + def architectures(self) -> Set[Architecture]: + return self.general_build_options.architectures + + @property + def environment(self) -> ParsedEnvironment: + return self.general_build_options.environment + + @property + def before_all(self) -> str: + return self.general_build_options.before_all + + def produce_image_batches( + self, + configurations: List[SC], + platform_tag: str, + platform_arch: str, + ) -> Iterator[Tuple[List[SC], str]]: + + docker_images: Mapping[str, List[SC]] = defaultdict(list) + + for config in configurations: + build_options = self[config.identifier] + images = ( + build_options.manylinux_images + if platform_tag.startswith("manylinux") + else build_options.musllinux_images + ) + assert images is not None + docker_images[images[platform_arch]].append(config) + + for image, configs in docker_images.items(): + # TODO: check for colisions for identifiers in configs + # Some settings (before-all) are not overridable in the same image + yield configs, image + + def check_build_selectors(self) -> None: + hits = Counter[str]() + for sel in self.build_options_by_selector: + bs = BuildSelector(build_config=sel, skip_config="") + hits += Counter(i for i in self.identifiers if bs(i)) + + non_unique_identifers = {idnt for idnt, count in hits.items() if count > 1} + if non_unique_identifers: + msg = "cibuildwheel: error, the windows/macOS selectors must match uniquely" + print(msg, file=sys.stderr) + for sel in self.build_options_by_selector: + bs = BuildSelector(build_config=sel, skip_config="") + for i in non_unique_identifers: + if bs(i): + print(f" {sel}: {i} (nonunique match)") + sys.exit(1) + + def __str__(self) -> str: + results = [] + for option in sorted(self.general_build_options._asdict().keys()): + variations = { + key: value._asdict()[option] + for key, value in self.build_options_by_selector.items() + } + variations["*"] = self.general_build_options._asdict()[option] + if len({repr(v) for v in variations.values()}) == 1: + results.append(f"{option}: {variations['*']!r}") + else: + results.append(f"{option}:") + for key, value in sorted(variations.items()): + results.append(f" {key}: {value!r}") + + return "\n".join(results) + + class NonPlatformWheelError(Exception): def __init__(self) -> None: message = textwrap.dedent( diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 33e3b236..25771e44 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -12,8 +12,8 @@ from .environment import ParsedEnvironment from .logger import log from .typing import PathOrStr, assert_never from .util import ( + AllBuildOptions, BuildFrontend, - BuildOptions, BuildSelector, NonPlatformWheelError, download, @@ -246,25 +246,28 @@ def setup_python( return env -def build(options: BuildOptions) -> None: +def build(all_options: AllBuildOptions) -> None: temp_dir = Path(tempfile.mkdtemp(prefix="cibuildwheel")) built_wheel_dir = temp_dir / "built_wheel" repaired_wheel_dir = temp_dir / "repaired_wheel" + all_options.check_build_selectors() + try: - if options.before_all: + if all_options.before_all: log.step("Running before_all...") - env = options.environment.as_dictionary(prev_environment=os.environ) + env = all_options.environment.as_dictionary(prev_environment=os.environ) before_all_prepared = prepare_command( - options.before_all, project=".", package=options.package_dir + all_options.before_all, project=".", package=all_options.package_dir ) shell(before_all_prepared, env=env) python_configurations = get_python_configurations( - options.build_selector, options.architectures + all_options.build_selector, all_options.architectures ) for config in python_configurations: + options = all_options[config.identifier] log.build_start(config.identifier) dependency_constraint_flags: Sequence[PathOrStr] = [] diff --git a/setup.cfg b/setup.cfg index 5cc57757..4efda9b6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ install_requires = certifi packaging tomli + dataclasses;python_version < '3.7' typing_extensions;python_version < '3.8' python_requires = >=3.6 include_package_data = True diff --git a/unit_test/all_build_options_test.py b/unit_test/all_build_options_test.py new file mode 100644 index 00000000..7f232a33 --- /dev/null +++ b/unit_test/all_build_options_test.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from cibuildwheel.__main__ import get_build_identifiers +from cibuildwheel.environment import parse_environment +from cibuildwheel.options import _get_pinned_docker_images, compute_options +from cibuildwheel.util import AllBuildOptions + +PYPROJECT_1 = """ +[tool.cibuildwheel] +build = ["cp38*", "cp37*"] +environment = {FOO="BAR"} + +test-command = "pyproject" + +manylinux-x86_64-image = "manylinux1" + +[tool.cibuildwheel.macos] +test-requires = "else" + +[[tool.cibuildwheel.overrides]] +select = "cp37*" +test-command = "pyproject-override" +manylinux-x86_64-image = "manylinux2014" +""" + + +def test_all_build_options_1(tmp_path): + with tmp_path.joinpath("pyproject.toml").open("w") as f: + f.write(PYPROJECT_1) + + all_build_options, build_options_by_selector = compute_options( + "linux", tmp_path, Path("dist"), None, None, False + ) + + identifiers = get_build_identifiers( + "linux", all_build_options.build_selector, all_build_options.architectures + ) + + build_options = AllBuildOptions(all_build_options, build_options_by_selector, identifiers) + + override_display = """\ +test_command: + *: 'pyproject' + cp37*: 'pyproject-override'""" + + assert override_display in str(build_options) + + assert build_options.environment == parse_environment('FOO="BAR"') + + all_pinned_docker_images = _get_pinned_docker_images() + pinned_x86_64_docker_image = all_pinned_docker_images["x86_64"] + + local = build_options["cp38-manylinux_x86_64"] + assert local.manylinux_images is not None + assert local.test_command == "pyproject" + assert local.manylinux_images["x86_64"] == pinned_x86_64_docker_image["manylinux1"] + + local = build_options["cp37-manylinux_x86_64"] + assert local.manylinux_images is not None + assert local.test_command == "pyproject-override" + assert local.manylinux_images["x86_64"] == pinned_x86_64_docker_image["manylinux2014"] diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index d437cbd2..cebc7977 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -18,13 +18,13 @@ def test_output_dir(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].output_dir == OUTPUT_DIR + assert intercepted_build_args.args[0].general_build_options.output_dir == OUTPUT_DIR def test_output_dir_default(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].output_dir == Path("wheelhouse") + assert intercepted_build_args.args[0].general_build_options.output_dir == Path("wheelhouse") @pytest.mark.parametrize("also_set_environment", [False, True]) @@ -37,7 +37,7 @@ def test_output_dir_argument(also_set_environment, platform, intercepted_build_a main() - assert intercepted_build_args.args[0].output_dir == OUTPUT_DIR + assert intercepted_build_args.args[0].general_build_options.output_dir == OUTPUT_DIR def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_empty): @@ -49,7 +49,7 @@ def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_emp main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + intercepted_build_selector = intercepted_build_args.args[0].general_build_options.build_selector assert isinstance(intercepted_build_selector, BuildSelector) assert intercepted_build_selector("build24-this") assert not intercepted_build_selector("skip65-that") @@ -98,9 +98,12 @@ def test_manylinux_images( main() if platform == "linux": - assert fnmatch(intercepted_build_args.args[0].manylinux_images[architecture], full_image) + assert fnmatch( + intercepted_build_args.args[0].general_build_options.manylinux_images[architecture], + full_image, + ) else: - assert intercepted_build_args.args[0].manylinux_images is None + assert intercepted_build_args.args[0].general_build_options.manylinux_images is None def get_default_repair_command(platform): @@ -129,7 +132,7 @@ def test_repair_command( main() expected_repair = repair_command or get_default_repair_command(platform) - assert intercepted_build_args.args[0].repair_command == expected_repair + assert intercepted_build_args.args[0].general_build_options.repair_command == expected_repair @pytest.mark.parametrize( @@ -147,7 +150,7 @@ def test_environment(environment, platform_specific, platform, intercepted_build main() - intercepted_environment = intercepted_build_args.args[0].environment + intercepted_environment = intercepted_build_args.args[0].general_build_options.environment assert isinstance(intercepted_environment, ParsedEnvironment) assert intercepted_environment.as_dictionary(prev_environment={}) == environment @@ -166,7 +169,10 @@ def test_test_requires( main() - assert intercepted_build_args.args[0].test_requires == (test_requires or "").split() + assert ( + intercepted_build_args.args[0].general_build_options.test_requires + == (test_requires or "").split() + ) @pytest.mark.parametrize("test_extras", [None, "extras"]) @@ -181,7 +187,7 @@ def test_test_extras(test_extras, platform_specific, platform, intercepted_build main() - assert intercepted_build_args.args[0].test_extras == ( + assert intercepted_build_args.args[0].general_build_options.test_extras == ( "[" + test_extras + "]" if test_extras else "" ) @@ -200,7 +206,7 @@ def test_test_command( main() - assert intercepted_build_args.args[0].test_command == (test_command or "") + assert intercepted_build_args.args[0].general_build_options.test_command == (test_command or "") @pytest.mark.parametrize("before_build", [None, "before --build"]) @@ -217,7 +223,7 @@ def test_before_build( main() - assert intercepted_build_args.args[0].before_build == (before_build or "") + assert intercepted_build_args.args[0].general_build_options.before_build == (before_build or "") @pytest.mark.parametrize("build_verbosity", [None, 0, 2, -2, 4, -4]) @@ -235,7 +241,9 @@ def test_build_verbosity( main() expected_verbosity = max(-3, min(3, int(build_verbosity or 0))) - assert intercepted_build_args.args[0].build_verbosity == expected_verbosity + assert ( + intercepted_build_args.args[0].general_build_options.build_verbosity == expected_verbosity + ) @pytest.mark.parametrize( @@ -286,4 +294,4 @@ def test_before_all(before_all, platform_specific, platform, intercepted_build_a main() - assert intercepted_build_args.args[0].before_all == (before_all or "") + assert intercepted_build_args.args[0].general_build_options.before_all == (before_all or "") diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index d002d209..e2673d76 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -246,3 +246,48 @@ def test_dig_first(ignore_empty): (d4, "other"), ignore_empty=ignore_empty, ) + + +PYPROJECT_2 = """ +[tool.cibuildwheel] +build = ["cp38*", "cp37*"] +environment = {FOO="BAR"} + +test-command = "pyproject" + +manylinux-x86_64-image = "manylinux1" + +[tool.cibuildwheel.macos] +test-requires = "else" + +[[tool.cibuildwheel.overrides]] +select = "cp37*" +test-command = "pyproject-override" +manylinux-x86_64-image = "manylinux2014" +""" + + +def test_pyproject_2(tmp_path, platform): + with tmp_path.joinpath("pyproject.toml").open("w") as f: + f.write(PYPROJECT_2) + + options = ConfigOptions(tmp_path, platform=platform) + assert options("test-command") == "pyproject" + assert options.override("random")("test-command") == "pyproject" + assert options.override("cp37*")("test-command") == "pyproject-override" + + +def test_overrides_not_a_list(tmp_path, platform): + with tmp_path.joinpath("pyproject.toml").open("w") as f: + f.write( + """\ +[tool.cibuildwheel] +build = ["cp38*", "cp37*"] +[tool.cibuildwheel.overrides] +select = "cp37*" +test-command = "pyproject-override" +""" + ) + + with pytest.raises(ConfigOptionError): + ConfigOptions(tmp_path, platform=platform)