From 08a694485ded60cad4680897874fcf4a940a29c2 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Tue, 12 Oct 2021 02:05:47 +0100 Subject: [PATCH] refactor: Override options redesign * Add test for defaults, fix platform detection * Improve correctness of reader.identifier with block * Fix options test to be multiplatform * 'Refactored by Sourcery' docs: write a section on overrides tests: test docker launches feat: add identifiers to launches test: add test for correct build step generation Signed-off-by: Henry Schreiner --- cibuildwheel/__main__.py | 84 ++- cibuildwheel/docker_container.py | 4 +- cibuildwheel/linux.py | 165 +++-- cibuildwheel/macos.py | 102 +-- cibuildwheel/options.py | 625 +++++++++++------- cibuildwheel/typing.py | 5 +- cibuildwheel/util.py | 210 +----- cibuildwheel/windows.py | 77 +-- docs/options.md | 59 +- unit_test/conftest.py | 24 + unit_test/docker_container_test.py | 22 +- unit_test/linux_build_steps_test.py | 70 ++ unit_test/main_tests/conftest.py | 21 +- unit_test/main_tests/main_options_test.py | 85 ++- unit_test/main_tests/main_platform_test.py | 55 +- .../main_tests/main_requires_python_test.py | 15 +- unit_test/option_prepare_test.py | 163 +++++ ..._build_options_test.py => options_test.py} | 41 +- unit_test/options_toml_test.py | 151 +++-- unit_test/utils.py | 16 + 20 files changed, 1200 insertions(+), 794 deletions(-) create mode 100644 unit_test/linux_build_steps_test.py create mode 100644 unit_test/option_prepare_test.py rename unit_test/{all_build_options_test.py => options_test.py} (52%) create mode 100644 unit_test/utils.py diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 7bf2a76a4..0feff063e 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -2,8 +2,7 @@ import os import sys import textwrap -from pathlib import Path -from typing import List, Set, Union +from typing import List, Optional, Set, Union import cibuildwheel import cibuildwheel.linux @@ -11,17 +10,12 @@ import cibuildwheel.util import cibuildwheel.windows from cibuildwheel.architecture import Architecture, allowed_architectures_check -from cibuildwheel.options import compute_options +from cibuildwheel.options import CommandLineArguments, Options, compute_options from cibuildwheel.typing import PLATFORMS, PlatformName, assert_never -from cibuildwheel.util import ( - AllBuildOptions, - BuildSelector, - Unbuffered, - detect_ci_provider, -) +from cibuildwheel.util import BuildSelector, Unbuffered, detect_ci_provider -def main() -> None: +def main(sys_args: Optional[List[str]] = None) -> None: platform: PlatformName parser = argparse.ArgumentParser( @@ -104,7 +98,7 @@ def main() -> None: help="Enable pre-release Python versions if available.", ) - args = parser.parse_args() + args = parser.parse_args(args=sys_args, namespace=CommandLineArguments()) if args.platform != "auto": platform = args.platform @@ -141,19 +135,21 @@ def main() -> None: print(f"cibuildwheel: Unsupported platform: {platform}", file=sys.stderr) sys.exit(2) - package_dir = Path(args.package_dir) - output_dir = Path( - args.output_dir - if args.output_dir is not None - else os.environ.get("CIBW_OUTPUT_DIR", "wheelhouse") - ) + options = compute_options(platform=platform, command_line_arguments=args) - all_build_options, build_options_by_selector = compute_options( - platform, package_dir, output_dir, args.config_file, args.archs, args.prerelease_pythons - ) + package_dir = options.globals.package_dir + package_files = {"setup.py", "setup.cfg", "pyproject.toml"} + + if not any(package_dir.joinpath(name).exists() for name in package_files): + names = ", ".join(sorted(package_files, reverse=True)) + msg = f"cibuildwheel: Could not find any of {{{names}}} at root of package" + print(msg, file=sys.stderr) + sys.exit(2) identifiers = get_build_identifiers( - platform, all_build_options.build_selector, all_build_options.architectures + platform=platform, + build_selector=options.globals.build_selector, + architectures=options.globals.architectures, ) if args.print_build_identifiers: @@ -161,8 +157,6 @@ 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" @@ -170,22 +164,25 @@ def main() -> None: # Python is buffering by default when running on the CI platforms, giving problems interleaving subprocess call output with unflushed calls to 'print' sys.stdout = Unbuffered(sys.stdout) # type: ignore[no-untyped-call,assignment] - print_preamble(platform, build_options) + print_preamble(platform=platform, options=options, identifiers=identifiers) try: - allowed_architectures_check(platform, build_options.architectures) + options.check_for_invalid_configuration(identifiers) + allowed_architectures_check(platform, options.globals.architectures) except ValueError as err: print("cibuildwheel:", *err.args, file=sys.stderr) sys.exit(4) if not identifiers: print( - f"cibuildwheel: No build identifiers selected: {build_options.build_selector}", + f"cibuildwheel: No build identifiers selected: {options.globals.build_selector}", file=sys.stderr, ) if not args.allow_empty: sys.exit(3) + output_dir = options.globals.output_dir + if not output_dir.exists(): output_dir.mkdir(parents=True) @@ -193,16 +190,16 @@ def main() -> None: "\n{n} wheels produced in {m:.0f} minutes:", output_dir ): if platform == "linux": - cibuildwheel.linux.build(build_options) + cibuildwheel.linux.build(options) elif platform == "windows": - cibuildwheel.windows.build(build_options) + cibuildwheel.windows.build(options) elif platform == "macos": - cibuildwheel.macos.build(build_options) + cibuildwheel.macos.build(options) else: assert_never(platform) -def print_preamble(platform: str, build_options: AllBuildOptions) -> None: +def print_preamble(platform: str, options: Options, identifiers: List[str]) -> None: print( textwrap.dedent( """ @@ -218,9 +215,9 @@ def print_preamble(platform: str, build_options: AllBuildOptions) -> None: print("Build options:") print(f" platform: {platform!r}") - print(textwrap.indent(str(build_options), " ")) + print(textwrap.indent(options.summary(identifiers), " ")) - warnings = detect_warnings(platform, build_options) + warnings = detect_warnings(platform=platform, options=options, identifiers=identifiers) if warnings: print("\nWarnings:") for warning in warnings: @@ -256,21 +253,20 @@ def get_build_identifiers( return [config.identifier for config in python_configurations] -def detect_warnings(platform: str, all_options: AllBuildOptions) -> List[str]: +def detect_warnings(platform: str, options: Options, identifiers: List[str]) -> List[str]: warnings = [] # warn about deprecated {python} and {pip} - 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) + for option_name in ["test_command", "before_build"]: + option_values = [getattr(options.build_options(i), option_name) for i in identifiers] + + if any(o and ("{python}" in o or "{pip}" in o) for o in option_values): + # 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/docker_container.py b/cibuildwheel/docker_container.py index 4dcca04db..1fbf80a99 100644 --- a/cibuildwheel/docker_container.py +++ b/cibuildwheel/docker_container.py @@ -17,7 +17,7 @@ class DockerContainer: An object that represents a running Docker container. Intended for use as a context manager e.g. - `with DockerContainer('ubuntu') as docker:` + `with DockerContainer(docker_image = 'ubuntu') as docker:` A bash shell is running in the remote container. When `call()` is invoked, the command is relayed to the remote shell, and the results are streamed @@ -31,7 +31,7 @@ class DockerContainer: bash_stdout: IO[bytes] def __init__( - self, docker_image: str, simulate_32_bit: bool = False, cwd: Optional[PathOrStr] = None + self, *, docker_image: str, simulate_32_bit: bool = False, cwd: Optional[PathOrStr] = None ): if not docker_image: raise ValueError("Must have a non-empty docker image to run.") diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index b9615923b..eb00493ef 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -1,15 +1,16 @@ import subprocess import sys import textwrap +from collections import OrderedDict from pathlib import Path, PurePath from typing import Iterator, List, NamedTuple, Set from .architecture import Architecture from .docker_container import DockerContainer from .logger import log +from .options import Options from .typing import PathOrStr, assert_never from .util import ( - AllBuildOptions, BuildSelector, NonPlatformWheelError, get_build_verbosity_extra_flags, @@ -53,43 +54,55 @@ def get_python_configurations( ] +def docker_image_for_python_configuration(config: PythonConfiguration, options: Options) -> str: + build_options = options.build_options(config.identifier) + # e.g + # identifier is 'cp310-manylinux_x86_64' + # platform_tag is 'manylinux_x86_64' + # platform_arch is 'x86_64' + _, platform_tag = config.identifier.split("-", 1) + _, platform_arch = platform_tag.split("_", 1) + + assert build_options.manylinux_images is not None + assert build_options.musllinux_images is not None + + return ( + build_options.manylinux_images[platform_arch] + if platform_tag.startswith("manylinux") + else build_options.musllinux_images[platform_arch] + ) + + def get_build_steps( - all_options: AllBuildOptions, python_configurations: List[PythonConfiguration] + options: Options, python_configurations: List[PythonConfiguration] ) -> Iterator[BuildStep]: - platforms = [ - ("cp", "manylinux_x86_64", "x86_64"), - ("cp", "manylinux_i686", "i686"), - ("cp", "manylinux_aarch64", "aarch64"), - ("cp", "manylinux_ppc64le", "ppc64le"), - ("cp", "manylinux_s390x", "s390x"), - ("pp", "manylinux_x86_64", "pypy_x86_64"), - ("pp", "manylinux_aarch64", "pypy_aarch64"), - ("pp", "manylinux_i686", "pypy_i686"), - ("cp", "musllinux_x86_64", "x86_64"), - ("cp", "musllinux_i686", "i686"), - ("cp", "musllinux_aarch64", "aarch64"), - ("cp", "musllinux_ppc64le", "ppc64le"), - ("cp", "musllinux_s390x", "s390x"), - ] + """ + Groups PythonConfigurations into BuildSteps. Each BuildStep represents a + separate Docker container. + """ + steps: OrderedDict[tuple, BuildStep] = OrderedDict() # type: ignore[type-arg] + + for config in python_configurations: + _, platform_tag = config.identifier.split("-", 1) + + before_all = options.build_options(config.identifier).before_all + docker_image = docker_image_for_python_configuration(config, options) - for implementation, platform_tag, platform_arch in platforms: - platform_configs = [ - c - for c in python_configurations - if c.identifier.startswith(implementation) and c.identifier.endswith(platform_tag) - ] - if not platform_configs: - continue + step_key = (platform_tag, docker_image, before_all) - 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) + if step_key in steps: + steps[step_key].platform_configs.append(config) + else: + steps[step_key] = BuildStep( + platform_configs=[config], platform_tag=platform_tag, docker_image=docker_image + ) + + yield from steps.values() def build_on_docker( - all_options: AllBuildOptions, + *, + options: Options, platform_configs: List[PythonConfiguration], docker: DockerContainer, container_project_path: PurePath, @@ -100,16 +113,21 @@ def build_on_docker( log.step("Copying project into Docker...") docker.copy_into(Path.cwd(), container_project_path) - if all_options.before_all: + before_all_options_identifier = platform_configs[0].identifier + before_all_options = options.build_options(before_all_options_identifier) + + if before_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 = all_options.environment.as_dictionary(env, executor=docker.environment_executor) + env = before_all_options.environment.as_dictionary( + env, executor=docker.environment_executor + ) before_all_prepared = prepare_command( - all_options.before_all, + before_all_options.before_all, project=container_project_path, package=container_package_dir, ) @@ -117,12 +135,14 @@ def build_on_docker( for config in platform_configs: log.build_start(config.identifier) - options = all_options[config.identifier] + build_options = options.build_options(config.identifier) dependency_constraint_flags: List[PathOrStr] = [] - if options.dependency_constraints: - constraints_file = options.dependency_constraints.get_for_python_version(config.version) + if build_options.dependency_constraints: + constraints_file = build_options.dependency_constraints.get_for_python_version( + config.version + ) container_constraints_file = PurePath("/constraints.txt") docker.copy_into(constraints_file, container_constraints_file) @@ -136,7 +156,7 @@ def build_on_docker( python_bin = config.path / "bin" env["PATH"] = f'{python_bin}:{env["PATH"]}' - env = options.environment.as_dictionary(env, executor=docker.environment_executor) + env = build_options.environment.as_dictionary(env, executor=docker.environment_executor) # check config python is still on PATH which_python = docker.call(["which", "python"], env=env, capture_output=True).strip() @@ -155,10 +175,10 @@ def build_on_docker( ) sys.exit(1) - if options.before_build: + if build_options.before_build: log.step("Running before_build...") before_build_prepared = prepare_command( - options.before_build, + build_options.before_build, project=container_project_path, package=container_package_dir, ) @@ -171,9 +191,9 @@ def build_on_docker( docker.call(["rm", "-rf", built_wheel_dir]) docker.call(["mkdir", "-p", built_wheel_dir]) - verbosity_flags = get_build_verbosity_extra_flags(options.build_verbosity) + verbosity_flags = get_build_verbosity_extra_flags(build_options.build_verbosity) - if options.build_frontend == "pip": + if build_options.build_frontend == "pip": docker.call( [ "python", @@ -187,7 +207,7 @@ def build_on_docker( ], env=env, ) - elif options.build_frontend == "build": + elif build_options.build_frontend == "build": config_setting = " ".join(verbosity_flags) docker.call( [ @@ -202,7 +222,7 @@ def build_on_docker( env=env, ) else: - assert_never(options.build_frontend) + assert_never(build_options.build_frontend) built_wheel = docker.glob(built_wheel_dir, "*.whl")[0] @@ -213,10 +233,10 @@ def build_on_docker( if built_wheel.name.endswith("none-any.whl"): raise NonPlatformWheelError() - if options.repair_command: + if build_options.repair_command: log.step("Repairing wheel...") repair_command_prepared = prepare_command( - options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir + build_options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir ) docker.call(["sh", "-c", repair_command_prepared], env=env) else: @@ -224,7 +244,7 @@ def build_on_docker( repaired_wheels = docker.glob(repaired_wheel_dir, "*.whl") - if options.test_command and options.test_selector(config.identifier): + if build_options.test_command and build_options.test_selector(config.identifier): log.step("Testing wheel...") # set up a virtual environment to install and test from, to make sure @@ -237,9 +257,9 @@ def build_on_docker( virtualenv_env = env.copy() virtualenv_env["PATH"] = f"{venv_dir / 'bin'}:{virtualenv_env['PATH']}" - if options.before_test: + if build_options.before_test: before_test_prepared = prepare_command( - options.before_test, + build_options.before_test, project=container_project_path, package=container_package_dir, ) @@ -253,17 +273,17 @@ def build_on_docker( # Let's just pick the first one. wheel_to_test = repaired_wheels[0] docker.call( - ["pip", "install", str(wheel_to_test) + options.test_extras], + ["pip", "install", str(wheel_to_test) + build_options.test_extras], env=virtualenv_env, ) # Install any requirements to run the tests - if options.test_requires: - docker.call(["pip", "install", *options.test_requires], env=virtualenv_env) + if build_options.test_requires: + docker.call(["pip", "install", *build_options.test_requires], env=virtualenv_env) # Run the tests from a different directory test_command_prepared = prepare_command( - options.test_command, + build_options.test_command, project=container_project_path, package=container_package_dir, ) @@ -280,11 +300,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, all_options.output_dir) + docker.copy_out(container_output_dir, options.globals.output_dir) log.step_end() -def build(all_options: AllBuildOptions) -> None: +def build(options: Options) -> None: try: # check docker is installed subprocess.run(["docker", "--version"], check=True, stdout=subprocess.DEVNULL) @@ -298,40 +318,43 @@ def build(all_options: AllBuildOptions) -> None: sys.exit(2) python_configurations = get_python_configurations( - all_options.build_selector, all_options.architectures + options.globals.build_selector, options.globals.architectures ) cwd = Path.cwd() - abs_package_dir = all_options.package_dir.resolve() + abs_package_dir = options.globals.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(all_options, python_configurations): + for build_step in get_build_steps(options, python_configurations): try: - log.step(f"Starting Docker image {build_step.docker_image}...") + ids_to_build = [x.identifier for x in build_step.platform_configs] + log.step( + f"Starting Docker image {build_step.docker_image} for {', '.join(ids_to_build)}..." + ) with DockerContainer( - build_step.docker_image, + docker_image=build_step.docker_image, simulate_32_bit=build_step.platform_tag.endswith("i686"), cwd=container_project_path, ) as docker: build_on_docker( - all_options, - build_step.platform_configs, - docker, - container_project_path, - container_package_dir, + options=options, + platform_configs=build_step.platform_configs, + docker=docker, + container_project_path=container_project_path, + container_package_dir=container_package_dir, ) except subprocess.CalledProcessError as error: log.step_end_with_error( f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}" ) - troubleshoot(all_options, error) + troubleshoot(options, error) sys.exit(1) @@ -342,18 +365,18 @@ def _matches_prepared_command(error_cmd: List[str], command_template: str) -> bo return error_cmd[2].startswith(command_prefix) -def troubleshoot(all_options: AllBuildOptions, error: Exception) -> None: +def troubleshoot(options: Options, 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, all_options.general_build_options.repair_command - ) # TODO + error.cmd, options.build_options(None).repair_command + ) # TODO allow matching of overrides too? ): - # the wheel build step failed + # the wheel build step or the repair step failed print("Checking for common errors...") - so_files = list(all_options.package_dir.glob("**/*.so")) + so_files = list(options.globals.package_dir.glob("**/*.so")) if so_files: print( diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index af36e4993..980c92cce 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -12,9 +12,9 @@ from .architecture import Architecture from .environment import ParsedEnvironment from .logger import log +from .options import Options from .typing import Literal, PathOrStr, assert_never from .util import ( - AllBuildOptions, BuildFrontend, BuildSelector, NonPlatformWheelError, @@ -94,22 +94,23 @@ def get_python_configurations( # When running on macOS 11 and x86_64, the reported OS is '10.16', but # there is no such OS - it really means macOS 11. - if get_macos_version() >= (10, 16): - if any(c.identifier.startswith("pp") for c in python_configurations): - # pypy doesn't work on macOS 11 yet - # See https://foss.heptapod.net/pypy/pypy/-/issues/3314 - log.warning( - unwrap( - """ + if get_macos_version() >= (10, 16) and any( + c.identifier.startswith("pp") for c in python_configurations + ): + # pypy doesn't work on macOS 11 yet + # See https://foss.heptapod.net/pypy/pypy/-/issues/3314 + log.warning( + unwrap( + """ PyPy is currently unsupported when building on macOS 11. To build macOS PyPy wheels, build on an older OS, such as macOS 10.15. To silence this warning, deselect PyPy by adding "pp*-macosx*" to your CIBW_SKIP option. """ - ) ) - python_configurations = [ - c for c in python_configurations if not c.identifier.startswith("pp") - ] + ) + python_configurations = [ + c for c in python_configurations if not c.identifier.startswith("pp") + ] return python_configurations @@ -343,52 +344,53 @@ def setup_python( return env -def build(all_options: AllBuildOptions) -> None: +def build(options: Options) -> 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() + python_configurations = get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) try: - if all_options.before_all: + before_all_options_identifier = python_configurations[0].identifier + before_all_options = options.build_options(before_all_options_identifier) + + if before_all_options.before_all: log.step("Running before_all...") - env = all_options.environment.as_dictionary(prev_environment=os.environ) + env = before_all_options.environment.as_dictionary(prev_environment=os.environ) env.setdefault("MACOSX_DEPLOYMENT_TARGET", "10.9") before_all_prepared = prepare_command( - all_options.before_all, project=".", package=all_options.package_dir + before_all_options.before_all, project=".", package=before_all_options.package_dir ) call([before_all_prepared], shell=True, env=env) - python_configurations = get_python_configurations( - all_options.build_selector, all_options.architectures - ) - for config in python_configurations: - options = all_options[config.identifier] + build_options = options.build_options(config.identifier) log.build_start(config.identifier) config_is_arm64 = config.identifier.endswith("arm64") config_is_universal2 = config.identifier.endswith("universal2") dependency_constraint_flags: Sequence[PathOrStr] = [] - if options.dependency_constraints: + if build_options.dependency_constraints: dependency_constraint_flags = [ "-c", - options.dependency_constraints.get_for_python_version(config.version), + build_options.dependency_constraints.get_for_python_version(config.version), ] env = setup_python( config, dependency_constraint_flags, - options.environment, - options.build_frontend, + build_options.environment, + build_options.build_frontend, ) - if options.before_build: + if build_options.before_build: log.step("Running before_build...") before_build_prepared = prepare_command( - options.before_build, project=".", package=options.package_dir + build_options.before_build, project=".", package=build_options.package_dir ) call(before_build_prepared, env=env, shell=True) @@ -397,9 +399,9 @@ def build(all_options: AllBuildOptions) -> None: shutil.rmtree(built_wheel_dir) built_wheel_dir.mkdir(parents=True) - verbosity_flags = get_build_verbosity_extra_flags(options.build_verbosity) + verbosity_flags = get_build_verbosity_extra_flags(build_options.build_verbosity) - if options.build_frontend == "pip": + if build_options.build_frontend == "pip": # Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org # see https://github.com/pypa/cibuildwheel/pull/369 call( @@ -408,18 +410,20 @@ def build(all_options: AllBuildOptions) -> None: "-m", "pip", "wheel", - options.package_dir.resolve(), + build_options.package_dir.resolve(), f"--wheel-dir={built_wheel_dir}", "--no-deps", *verbosity_flags, ], env=env, ) - elif options.build_frontend == "build": + elif build_options.build_frontend == "build": config_setting = " ".join(verbosity_flags) build_env = env.copy() - if options.dependency_constraints: - constr = options.dependency_constraints.get_for_python_version(config.version) + if build_options.dependency_constraints: + constr = build_options.dependency_constraints.get_for_python_version( + config.version + ) build_env["PIP_CONSTRAINT"] = constr.as_uri() build_env["VIRTUALENV_PIP"] = get_pip_version(env) call( @@ -427,7 +431,7 @@ def build(all_options: AllBuildOptions) -> None: "python", "-m", "build", - options.package_dir, + build_options.package_dir, "--wheel", f"--outdir={built_wheel_dir}", f"--config-setting={config_setting}", @@ -435,7 +439,7 @@ def build(all_options: AllBuildOptions) -> None: env=build_env, ) else: - assert_never(options.build_frontend) + assert_never(build_options.build_frontend) built_wheel = next(built_wheel_dir.glob("*.whl")) @@ -446,7 +450,7 @@ def build(all_options: AllBuildOptions) -> None: if built_wheel.name.endswith("none-any.whl"): raise NonPlatformWheelError() - if options.repair_command: + if build_options.repair_command: log.step("Repairing wheel...") if config_is_universal2: @@ -457,7 +461,7 @@ def build(all_options: AllBuildOptions) -> None: delocate_archs = "x86_64" repair_command_prepared = prepare_command( - options.repair_command, + build_options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir, delocate_archs=delocate_archs, @@ -470,7 +474,7 @@ def build(all_options: AllBuildOptions) -> None: log.step_end() - if options.test_command and options.test_selector(config.identifier): + if build_options.test_command and build_options.test_selector(config.identifier): machine_arch = platform.machine() testing_archs: List[Literal["x86_64", "arm64"]] = [] @@ -484,7 +488,7 @@ def build(all_options: AllBuildOptions) -> None: for testing_arch in testing_archs: if config_is_universal2: arch_specific_identifier = f"{config.identifier}:{testing_arch}" - if not options.test_selector(arch_specific_identifier): + if not build_options.test_selector(arch_specific_identifier): continue if machine_arch == "x86_64" and testing_arch == "arm64": @@ -565,31 +569,33 @@ def call_with_arch(args: Sequence[PathOrStr], **kwargs: Any) -> None: # check that we are using the Python from the virtual environment call_with_arch(["which", "python"], env=virtualenv_env) - if options.before_test: + if build_options.before_test: before_test_prepared = prepare_command( - options.before_test, project=".", package=options.package_dir + build_options.before_test, + project=".", + package=build_options.package_dir, ) call_with_arch(before_test_prepared, env=virtualenv_env, shell=True) # install the wheel call_with_arch( - ["pip", "install", f"{repaired_wheel}{options.test_extras}"], + ["pip", "install", f"{repaired_wheel}{build_options.test_extras}"], env=virtualenv_env, ) # test the wheel - if options.test_requires: + if build_options.test_requires: call_with_arch( - ["pip", "install"] + options.test_requires, env=virtualenv_env + ["pip", "install"] + build_options.test_requires, env=virtualenv_env ) # run the tests from $HOME, with an absolute path in the command # (this ensures that Python runs the tests against the installed wheel # and not the repo code) test_command_prepared = prepare_command( - options.test_command, + build_options.test_command, project=Path(".").resolve(), - package=options.package_dir.resolve(), + package=build_options.package_dir.resolve(), ) call_with_arch( test_command_prepared, @@ -602,7 +608,7 @@ def call_with_arch(args: Sequence[PathOrStr], **kwargs: Any) -> None: shutil.rmtree(venv_dir) # we're all done here; move it to output (overwrite existing) - shutil.move(str(repaired_wheel), options.output_dir) + shutil.move(str(repaired_wheel), build_options.output_dir) log.build_end() except subprocess.CalledProcessError as error: log.step_end_with_error( diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index b898dcaef..902b64fa5 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -1,33 +1,116 @@ -import copy import os import sys import traceback from configparser import ConfigParser +from contextlib import contextmanager from pathlib import Path -from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, TypeVar, Union +from typing import ( + Any, + Dict, + Iterator, + List, + Mapping, + NamedTuple, + Optional, + Set, + Tuple, + Union, +) import tomli from packaging.specifiers import SpecifierSet from .architecture import Architecture -from .environment import EnvironmentParseError, parse_environment +from .environment import EnvironmentParseError, ParsedEnvironment, parse_environment from .projectfiles import get_requires_python_str -from .typing import PLATFORMS, PlatformName, TypedDict +from .typing import PLATFORMS, Literal, PlatformName, TypedDict from .util import ( MANYLINUX_ARCHS, MUSLLINUX_ARCHS, BuildFrontend, - BuildOptions, BuildSelector, DependencyConstraints, TestSelector, resources_dir, + selector_matches, strtobool, + unwrap, ) + +class CommandLineArguments: + platform: Literal["auto", "linux", "macos", "windows"] + archs: Optional[str] + output_dir: Optional[str] + config_file: Optional[str] + package_dir: str + print_build_identifiers: bool + allow_empty: bool + prerelease_pythons: bool + + +class GlobalOptions(NamedTuple): + package_dir: Path + output_dir: Path + build_selector: BuildSelector + test_selector: TestSelector + architectures: Set[Architecture] + + +class BuildOptions(NamedTuple): + globals: GlobalOptions + environment: ParsedEnvironment + before_all: str + before_build: Optional[str] + repair_command: str + manylinux_images: Optional[Dict[str, str]] + musllinux_images: Optional[Dict[str, str]] + dependency_constraints: Optional[DependencyConstraints] + test_command: Optional[str] + before_test: Optional[str] + test_requires: List[str] + test_extras: str + build_verbosity: int + build_frontend: BuildFrontend + + @property + def package_dir(self) -> Path: + return self.globals.package_dir + + @property + def output_dir(self) -> Path: + return self.globals.output_dir + + @property + def build_selector(self) -> BuildSelector: + return self.globals.build_selector + + @property + def test_selector(self) -> TestSelector: + return self.globals.test_selector + + @property + def architectures(self) -> Set[Architecture]: + return self.globals.architectures + + Setting = Union[Dict[str, str], List[str], str] +class Override(NamedTuple): + select_pattern: str + options: Dict[str, Setting] + + +MANYLINUX_OPTIONS = {f"manylinux-{build_platform}-image" for build_platform in MANYLINUX_ARCHS} +MUSLLINUX_OPTIONS = {f"musllinux-{build_platform}-image" for build_platform in MUSLLINUX_ARCHS} +DISALLOWED_OPTIONS = { + "linux": {"dependency-versions"}, + "macos": MANYLINUX_OPTIONS | MUSLLINUX_OPTIONS, + "windows": MANYLINUX_OPTIONS | MUSLLINUX_OPTIONS, +} + + class TableFmt(TypedDict): item: str sep: str @@ -59,32 +142,28 @@ def _dig_first(*pairs: Tuple[Mapping[str, Setting], str], ignore_empty: bool = F raise KeyError(key) -T = TypeVar("T", bound="ConfigOptions") - - -class ConfigOptions: +class OptionsReader: """ Gets options from the environment, config or defaults, optionally scoped by the platform. Example: - >>> options = ConfigOptions(package_dir, platform='macos') - >>> options('cool-color') + >>> options_reader = OptionsReader(config_file, platform='macos') + >>> options_reader.get('cool-color') This will return the value of CIBW_COOL_COLOR_MACOS if it exists, otherwise the value of CIBW_COOL_COLOR, otherwise 'tool.cibuildwheel.macos.cool-color' or 'tool.cibuildwheel.cool-color' - from pyproject.toml, or from cibuildwheel/resources/defaults.toml. An + from `config_file`, or from cibuildwheel/resources/defaults.toml. An error is thrown if there are any unexpected keys or sections in tool.cibuildwheel. """ def __init__( self, - package_path: Path, - config_file: Optional[str] = None, + config_file_path: Optional[Path] = None, *, - platform: str, + platform: PlatformName, disallow: Optional[Dict[str, Set[str]]] = None, ) -> None: self.platform = platform @@ -98,14 +177,8 @@ def __init__( config_options: Dict[str, Any] = {} config_platform_options: Dict[str, Any] = {} - if config_file is not None: - config_path = Path(config_file.format(package=package_path)) - config_options, config_platform_options = self._load_file(config_path) - else: - # load pyproject.toml, if it's available - pyproject_toml_path = package_path / "pyproject.toml" - if pyproject_toml_path.exists(): - config_options, config_platform_options = self._load_file(pyproject_toml_path) + if config_file_path is not None: + config_options, config_platform_options = self._load_file(config_file_path) # Validate project config for option_name in config_options: @@ -121,20 +194,25 @@ def __init__( self.config_options = config_options self.config_platform_options = config_platform_options - self.overrides: Dict[str, Dict[str, Any]] = {} - self.current_override: str = "*" + self.overrides: List[Override] = [] + self.current_identifier: Optional[str] = None - overrides = self.config_options.get("overrides") - if overrides is not None: - if not isinstance(overrides, list): + config_overrides = self.config_options.get("overrides") + + if config_overrides is not None: + if not isinstance(config_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 + + for config_override in config_overrides: + select = config_override.pop("select", None) + + if not select: + raise ConfigOptionError('"select" must be set in an override') + + if isinstance(select, list): + select = " ".join(select) + + self.overrides.append(Override(select, config_override)) def _is_valid_global_option(self, name: str) -> bool: """ @@ -170,15 +248,23 @@ def _load_file(self, filename: Path) -> Tuple[Dict[str, Any], Dict[str, Any]]: 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__( + @property + def active_config_overrides(self) -> List[Override]: + if self.current_identifier is None: + return [] + return [ + o for o in self.overrides if selector_matches(o.select_pattern, self.current_identifier) + ] + + @contextmanager + def identifier(self, identifier: Optional[str]) -> Iterator[None]: + self.current_identifier = identifier + try: + yield + finally: + self.current_identifier = None + + def get( self, name: str, *, @@ -204,13 +290,15 @@ def __call__( 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. # 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), + *[(o.options, name) for o in active_config_overrides], (self.config_platform_options, name), (self.config_options, name), (self.default_platform_options, name), @@ -232,227 +320,270 @@ def __call__( return result -def compute_options( - platform: PlatformName, - package_dir: Path, - output_dir: Path, - config_file: Optional[str], - args_archs: Optional[str], - prerelease_pythons: bool, -) -> Tuple[BuildOptions, Dict[str, BuildOptions]]: - """ - Compute the options from the environment and configuration file. - """ +class Options: + def __init__(self, platform: PlatformName, command_line_arguments: CommandLineArguments): + self.platform = platform + self.command_line_arguments = command_line_arguments - manylinux_identifiers = { - f"manylinux-{build_platform}-image" for build_platform in MANYLINUX_ARCHS - } - musllinux_identifiers = { - f"musllinux-{build_platform}-image" for build_platform in MUSLLINUX_ARCHS - } - disallow = { - "linux": {"dependency-versions"}, - "macos": manylinux_identifiers | musllinux_identifiers, - "windows": manylinux_identifiers | musllinux_identifiers, - } - options = ConfigOptions(package_dir, config_file, platform=platform, disallow=disallow) - - build_config = options("build", env_plat=False, sep=" ") or "*" - skip_config = options("skip", env_plat=False, sep=" ") - test_skip = options("test-skip", env_plat=False, sep=" ") - - prerelease_pythons = prerelease_pythons or strtobool( - os.environ.get("CIBW_PRERELEASE_PYTHONS", "0") - ) - - deprecated_selectors("CIBW_BUILD", build_config, error=True) - deprecated_selectors("CIBW_SKIP", skip_config) - deprecated_selectors("CIBW_TEST_SKIP", test_skip) - - package_files = {"setup.py", "setup.cfg", "pyproject.toml"} - - if not any(package_dir.joinpath(name).exists() for name in package_files): - names = ", ".join(sorted(package_files, reverse=True)) - msg = f"cibuildwheel: Could not find any of {{{names}}} at root of package" - print(msg, file=sys.stderr) - sys.exit(2) + self.reader = OptionsReader( + self.config_file_path, + platform=platform, + disallow=DISALLOWED_OPTIONS, + ) - # This is not supported in tool.cibuildwheel, as it comes from a standard location. - # Passing this in as an environment variable will override pyproject.toml, setup.cfg, or setup.py - requires_python_str: Optional[str] = os.environ.get( - "CIBW_PROJECT_REQUIRES_PYTHON" - ) or get_requires_python_str(package_dir) - requires_python = None if requires_python_str is None else SpecifierSet(requires_python_str) + @property + def config_file_path(self) -> Optional[Path]: + args = self.command_line_arguments + + if args.config_file is not None: + return Path(args.config_file.format(package=args.package_dir)) + # return pyproject.toml, if it's available + pyproject_toml_path = Path(args.package_dir) / "pyproject.toml" + if pyproject_toml_path.exists(): + return pyproject_toml_path + + return None + + @property + def package_requires_python_str(self) -> Optional[str]: + if not hasattr(self, "_package_requires_python_str"): + args = self.command_line_arguments + self._package_requires_python_str = get_requires_python_str(Path(args.package_dir)) + return self._package_requires_python_str + + @property + def globals(self) -> GlobalOptions: + args = self.command_line_arguments + package_dir = Path(args.package_dir) + output_dir = Path( + args.output_dir + if args.output_dir is not None + else os.environ.get("CIBW_OUTPUT_DIR", "wheelhouse") + ) - build_selector = BuildSelector( - build_config=build_config, - skip_config=skip_config, - requires_python=requires_python, - prerelease_pythons=prerelease_pythons, - ) - test_selector = TestSelector(skip_config=test_skip) + 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=" ") - return _compute_all_options( - options, args_archs, build_selector, test_selector, platform, package_dir, output_dir - ) + prerelease_pythons = args.prerelease_pythons or strtobool( + os.environ.get("CIBW_PRERELEASE_PYTHONS", "0") + ) + # This is not supported in tool.cibuildwheel, as it comes from a standard location. + # Passing this in as an environment variable will override pyproject.toml, setup.cfg, or setup.py + requires_python_str: Optional[str] = ( + os.environ.get("CIBW_PROJECT_REQUIRES_PYTHON") or self.package_requires_python_str + ) + requires_python = None if requires_python_str is None else SpecifierSet(requires_python_str) -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 + build_selector = BuildSelector( + build_config=build_config, + skip_config=skip_config, + requires_python=requires_python, + prerelease_pythons=prerelease_pythons, + ) + test_selector = TestSelector(skip_config=test_skip) + archs_config_str = args.archs or self.reader.get("archs", sep=" ") + architectures = Architecture.parse_config(archs_config_str, platform=self.platform) -def _compute_single_options( - options: ConfigOptions, - args_archs: Optional[str], - build_selector: BuildSelector, - test_selector: TestSelector, - platform: PlatformName, - package_dir: Path, - output_dir: Path, -) -> BuildOptions: - """ - Compute BuildOptions for a single run configuration. - """ - # Can't be configured per selector - before_all = options("before-all", sep=" && ") + return GlobalOptions( + package_dir=package_dir, + output_dir=output_dir, + build_selector=build_selector, + test_selector=test_selector, + architectures=architectures, + ) - archs_config_str = args_archs or options("archs", sep=" ") + def build_options(self, identifier: Optional[str]) -> BuildOptions: + """ + Compute BuildOptions for a single run configuration. + """ - build_frontend_str = options("build-frontend", env_plat=False) - environment_config = options("environment", table={"item": '{k}="{v}"', "sep": " "}) - before_build = options("before-build", sep=" && ") - repair_command = options("repair-wheel-command", sep=" && ") + with self.reader.identifier(identifier): + before_all = self.reader.get("before-all", sep=" && ") + + build_frontend_str = self.reader.get("build-frontend", env_plat=False) + environment_config = self.reader.get( + "environment", table={"item": '{k}="{v}"', "sep": " "} + ) + before_build = self.reader.get("before-build", sep=" && ") + repair_command = self.reader.get("repair-wheel-command", sep=" && ") + + 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=",") + build_verbosity_str = self.reader.get("build-verbosity") + + build_frontend: BuildFrontend + if build_frontend_str == "build": + build_frontend = "build" + elif build_frontend_str == "pip": + build_frontend = "pip" + else: + msg = f"cibuildwheel: Unrecognised build frontend '{build_frontend_str}', only 'pip' and 'build' are supported" + print(msg, file=sys.stderr) + sys.exit(2) + + try: + environment = parse_environment(environment_config) + except (EnvironmentParseError, ValueError): + print( + f'cibuildwheel: Malformed environment option "{environment_config}"', + file=sys.stderr, + ) + traceback.print_exc(None, sys.stderr) + sys.exit(2) + + if dependency_versions == "pinned": + dependency_constraints: Optional[ + DependencyConstraints + ] = DependencyConstraints.with_defaults() + elif dependency_versions == "latest": + dependency_constraints = None + else: + dependency_versions_path = Path(dependency_versions) + dependency_constraints = DependencyConstraints(dependency_versions_path) + + if test_extras: + test_extras = f"[{test_extras}]" + + try: + build_verbosity = min(3, max(-3, int(build_verbosity_str))) + except ValueError: + build_verbosity = 0 + + manylinux_images: Dict[str, str] = {} + musllinux_images: Dict[str, str] = {} + if self.platform == "linux": + all_pinned_docker_images = _get_pinned_docker_images() + + for build_platform in MANYLINUX_ARCHS: + pinned_images = all_pinned_docker_images[build_platform] + + config_value = self.reader.get( + f"manylinux-{build_platform}-image", ignore_empty=True + ) + + if not config_value: + # default to manylinux2010 if it's available, otherwise manylinux2014 + image = pinned_images.get("manylinux2010") or pinned_images.get( + "manylinux2014" + ) + elif config_value in pinned_images: + image = pinned_images[config_value] + else: + image = config_value + + assert image is not None + manylinux_images[build_platform] = image + + for build_platform in MUSLLINUX_ARCHS: + pinned_images = all_pinned_docker_images[build_platform] + + config_value = self.reader.get(f"musllinux-{build_platform}-image") + + if config_value is None: + image = pinned_images["musllinux_1_1"] + elif config_value in pinned_images: + image = pinned_images[config_value] + else: + image = config_value + + musllinux_images[build_platform] = image + + return BuildOptions( + globals=self.globals, + test_command=test_command, + test_requires=test_requires, + test_extras=test_extras, + before_test=before_test, + before_build=before_build, + before_all=before_all, + build_verbosity=build_verbosity, + repair_command=repair_command, + environment=environment, + dependency_constraints=dependency_constraints, + manylinux_images=manylinux_images or None, + musllinux_images=musllinux_images or None, + build_frontend=build_frontend, + ) + + def check_for_invalid_configuration(self, identifiers: List[str]) -> None: + if self.platform in ["macos", "windows"]: + before_all_values = {self.build_options(i).before_all for i in identifiers} + + if len(before_all_values) > 1: + raise ValueError( + unwrap( + f""" + before_all cannot be set to multiple values. On macOS and Windows, + before_all is only run once, at the start of the build. before_all values + are: {before_all_values!r} + """ + ) + ) - dependency_versions = options("dependency-versions") - test_command = options("test-command", sep=" && ") - before_test = options("before-test", sep=" && ") - test_requires = options("test-requires", sep=" ").split() - test_extras = options("test-extras", sep=",") - build_verbosity_str = options("build-verbosity") + def check_for_deprecated_options(self) -> None: + build_selector = self.globals.build_selector + test_selector = self.globals.test_selector - build_frontend: BuildFrontend - if build_frontend_str == "build": - build_frontend = "build" - elif build_frontend_str == "pip": - build_frontend = "pip" - else: - msg = f"cibuildwheel: Unrecognised build frontend '{build_frontend}', only 'pip' and 'build' are supported" - print(msg, file=sys.stderr) - sys.exit(2) - - try: - environment = parse_environment(environment_config) - except (EnvironmentParseError, ValueError): - print(f'cibuildwheel: Malformed environment option "{environment_config}"', file=sys.stderr) - traceback.print_exc(None, sys.stderr) - sys.exit(2) - - if dependency_versions == "pinned": - dependency_constraints: Optional[ - DependencyConstraints - ] = DependencyConstraints.with_defaults() - elif dependency_versions == "latest": - dependency_constraints = None - else: - dependency_versions_path = Path(dependency_versions) - dependency_constraints = DependencyConstraints(dependency_versions_path) - - if test_extras: - test_extras = f"[{test_extras}]" - - try: - build_verbosity = min(3, max(-3, int(build_verbosity_str))) - except ValueError: - build_verbosity = 0 - - archs = Architecture.parse_config(archs_config_str, platform=platform) - - manylinux_images: Dict[str, str] = {} - musllinux_images: Dict[str, str] = {} - if platform == "linux": - all_pinned_docker_images = _get_pinned_docker_images() - - for build_platform in MANYLINUX_ARCHS: - pinned_images = all_pinned_docker_images[build_platform] - - config_value = options(f"manylinux-{build_platform}-image", ignore_empty=True) - - if not config_value: - # default to manylinux2010 if it's available, otherwise manylinux2014 - image = pinned_images.get("manylinux2010") or pinned_images.get("manylinux2014") - elif config_value in pinned_images: - image = pinned_images[config_value] - else: - image = config_value + deprecated_selectors("CIBW_BUILD", build_selector.build_config, error=True) + deprecated_selectors("CIBW_SKIP", build_selector.skip_config) + deprecated_selectors("CIBW_TEST_SKIP", test_selector.skip_config) - assert image is not None - manylinux_images[build_platform] = image + def summary(self, identifiers: List[str]) -> str: + lines = [ + f"{option_name}: {option_value!r}" + for option_name, option_value in sorted(self.globals._asdict().items()) + ] - for build_platform in MUSLLINUX_ARCHS: - pinned_images = all_pinned_docker_images[build_platform] + build_option_defaults = self.build_options(identifier=None) - config_value = options(f"musllinux-{build_platform}-image") + for option_name, default_value in sorted(build_option_defaults._asdict().items()): + if option_name == "globals": + continue - if config_value is None: - image = pinned_images.get("musllinux_1_1") - elif config_value in pinned_images: - image = pinned_images[config_value] - else: - image = config_value - - musllinux_images[build_platform] = image - - return BuildOptions( - architectures=archs, - package_dir=package_dir, - output_dir=output_dir, - test_command=test_command, - test_requires=test_requires, - test_extras=test_extras, - before_test=before_test, - before_build=before_build, - before_all=before_all, - build_verbosity=build_verbosity, - build_selector=build_selector, - test_selector=test_selector, - repair_command=repair_command, - environment=environment, - dependency_constraints=dependency_constraints, - manylinux_images=manylinux_images or None, - musllinux_images=musllinux_images or None, - build_frontend=build_frontend, - ) - - -def _compute_all_options( - options: ConfigOptions, - args_archs: Optional[str], - build_selector: BuildSelector, - test_selector: TestSelector, + lines.append(f"{option_name}: {default_value!r}") + + # if any identifiers have an overridden value, print that too + for identifier in identifiers: + option_value = self.build_options(identifier=identifier)._asdict()[option_name] + if option_value != default_value: + lines.append(f" {identifier}: {option_value!r}") + + return "\n".join(lines) + + +def compute_options( 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) + command_line_arguments: CommandLineArguments, +) -> Options: + options = Options(platform=platform, command_line_arguments=command_line_arguments) + options.check_for_deprecated_options() + return options + - general_build_options = _compute_single_options(options, *args) +_all_pinned_docker_images: Optional[ConfigParser] = None - 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 _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': '...' } + ... } + """ + global _all_pinned_docker_images + + if _all_pinned_docker_images is None: + 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 deprecated_selectors(name: str, selector: str, *, error: bool = False) -> None: diff --git a/cibuildwheel/typing.py b/cibuildwheel/typing.py index 2158cbaca..1e6e57873 100644 --- a/cibuildwheel/typing.py +++ b/cibuildwheel/typing.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, NoReturn, Set, Union if sys.version_info < (3, 8): - from typing_extensions import Final, Literal, Protocol, TypedDict + from typing_extensions import Final, Literal, OrderedDict, Protocol, TypedDict else: - from typing import Final, Literal, Protocol, TypedDict + from typing import Final, Literal, OrderedDict, Protocol, TypedDict __all__ = ( @@ -21,6 +21,7 @@ "Protocol", "Set", "TypedDict", + "OrderedDict", "Union", "assert_never", ) diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index b52e34712..3474d3723 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -1,5 +1,4 @@ import contextlib -import dataclasses import fnmatch import itertools import os @@ -10,23 +9,10 @@ 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 ( - Counter, - Dict, - Iterable, - Iterator, - List, - Mapping, - NamedTuple, - Optional, - Set, - Tuple, - TypeVar, -) +from typing import Dict, Iterator, List, Optional import bracex import certifi @@ -34,9 +20,7 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version -from .architecture import Architecture -from .environment import ParsedEnvironment -from .typing import Literal, PathOrStr, PlatformName, Protocol +from .typing import Literal, PathOrStr, PlatformName resources_dir = Path(__file__).parent / "resources" @@ -91,6 +75,20 @@ def read_python_configs(config: PlatformName) -> List[Dict[str, str]]: return results +def selector_matches(patterns: str, string: str) -> bool: + """ + Returns True if `string` is matched by any of the wildcard patterns in + `patterns`. + + Matching is according to fnmatch, but with shell-like curly brace + expansion. For example, 'cp{36,37}-*' would match either of 'cp36-*' or + 'cp37-*'. + """ + patterns_list: List[str] = patterns.split() + patterns_list = itertools.chain.from_iterable(bracex.expand(p) for p in patterns_list) # type: ignore[assignment] + return any(fnmatch.fnmatch(string, pat) for pat in patterns_list) + + class IdentifierSelector: """ This class holds a set of build/skip patterns. You call an instance with a @@ -111,8 +109,8 @@ def __init__( requires_python: Optional[SpecifierSet] = None, prerelease_pythons: bool = False, ): - self.build_patterns = build_config.split() - self.skip_patterns = skip_config.split() + self.build_config = build_config + self.skip_config = skip_config self.requires_python = requires_python self.prerelease_pythons = prerelease_pythons @@ -126,30 +124,22 @@ def __call__(self, build_id: str) -> bool: if not self.requires_python.contains(version): return False - build_patterns = itertools.chain.from_iterable( - bracex.expand(p) for p in self.build_patterns - ) - - unexpanded_skip_patterns = self.skip_patterns.copy() + # filter out the prerelease pythons if self.prerelease_pythons is False + if not self.prerelease_pythons and selector_matches( + BuildSelector.PRERELEASE_SKIP, build_id + ): + return False - if not self.prerelease_pythons: - # filter out the prerelease pythons, alongside the user-defined - # skip patterns - unexpanded_skip_patterns += BuildSelector.PRERELEASE_SKIP.split() + should_build = selector_matches(self.build_config, build_id) + should_skip = selector_matches(self.skip_config, build_id) - skip_patterns = itertools.chain.from_iterable( - bracex.expand(p) for p in unexpanded_skip_patterns - ) - - build: bool = any(fnmatch.fnmatch(build_id, pat) for pat in build_patterns) - skip: bool = any(fnmatch.fnmatch(build_id, pat) for pat in skip_patterns) - return build and not skip + return should_build and not should_skip def __repr__(self) -> str: - result = f'{self.__class__.__name__}(build_config={" ".join(self.build_patterns)!r}' + result = f"{self.__class__.__name__}(build_config={self.build_config!r}" - if self.skip_patterns: - result += f', skip_config={" ".join(self.skip_patterns)!r}' + if self.skip_config: + result += f", skip_config={self.skip_config!r}" if self.prerelease_pythons: result += ", prerelease_pythons=True" @@ -236,145 +226,13 @@ def get_for_python_version(self, version: str) -> Path: return self.base_file_path def __repr__(self) -> str: - return f"{self.__class__.__name__}{self.base_file_path!r})" - - -class BuildOptions(NamedTuple): - package_dir: Path - output_dir: Path - build_selector: BuildSelector - architectures: Set[Architecture] - environment: ParsedEnvironment - before_all: str - before_build: Optional[str] - repair_command: str - manylinux_images: Optional[Dict[str, str]] - musllinux_images: Optional[Dict[str, str]] - dependency_constraints: Optional[DependencyConstraints] - test_command: Optional[str] - test_selector: TestSelector - before_test: Optional[str] - test_requires: List[str] - test_extras: str - build_verbosity: int - build_frontend: BuildFrontend - - def __str__(self) -> str: - res = (f"{option}: {value!r}" for option, value in sorted(self._asdict().items())) - 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 + return f"{self.__class__.__name__}({self.base_file_path!r})" - @property - def output_dir(self) -> Path: - return self.general_build_options.output_dir + def __eq__(self, o: object) -> bool: + if not isinstance(o, DependencyConstraints): + return False - @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) + return self.base_file_path == o.base_file_path class NonPlatformWheelError(Exception): diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 25771e44b..bd5ad0852 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -10,9 +10,9 @@ from .architecture import Architecture from .environment import ParsedEnvironment from .logger import log +from .options import Options from .typing import PathOrStr, assert_never from .util import ( - AllBuildOptions, BuildFrontend, BuildSelector, NonPlatformWheelError, @@ -246,50 +246,51 @@ def setup_python( return env -def build(all_options: AllBuildOptions) -> None: +def build(options: Options) -> 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() + python_configurations = get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) try: - if all_options.before_all: + before_all_options_identifier = python_configurations[0].identifier + before_all_options = options.build_options(before_all_options_identifier) + + if before_all_options.before_all: log.step("Running before_all...") - env = all_options.environment.as_dictionary(prev_environment=os.environ) + env = before_all_options.environment.as_dictionary(prev_environment=os.environ) before_all_prepared = prepare_command( - all_options.before_all, project=".", package=all_options.package_dir + before_all_options.before_all, project=".", package=options.globals.package_dir ) shell(before_all_prepared, env=env) - python_configurations = get_python_configurations( - all_options.build_selector, all_options.architectures - ) - for config in python_configurations: - options = all_options[config.identifier] + build_options = options.build_options(config.identifier) log.build_start(config.identifier) dependency_constraint_flags: Sequence[PathOrStr] = [] - if options.dependency_constraints: + if build_options.dependency_constraints: dependency_constraint_flags = [ "-c", - options.dependency_constraints.get_for_python_version(config.version), + build_options.dependency_constraints.get_for_python_version(config.version), ] # install Python env = setup_python( config, dependency_constraint_flags, - options.environment, - options.build_frontend, + build_options.environment, + build_options.build_frontend, ) # run the before_build command - if options.before_build: + if build_options.before_build: log.step("Running before_build...") before_build_prepared = prepare_command( - options.before_build, project=".", package=options.package_dir + build_options.before_build, project=".", package=options.globals.package_dir ) shell(before_build_prepared, env=env) @@ -298,9 +299,9 @@ def build(all_options: AllBuildOptions) -> None: shutil.rmtree(built_wheel_dir) built_wheel_dir.mkdir(parents=True) - verbosity_flags = get_build_verbosity_extra_flags(options.build_verbosity) + verbosity_flags = get_build_verbosity_extra_flags(build_options.build_verbosity) - if options.build_frontend == "pip": + if build_options.build_frontend == "pip": # Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org # see https://github.com/pypa/cibuildwheel/pull/369 call( @@ -309,18 +310,18 @@ def build(all_options: AllBuildOptions) -> None: "-m", "pip", "wheel", - options.package_dir.resolve(), + options.globals.package_dir.resolve(), f"--wheel-dir={built_wheel_dir}", "--no-deps", - *get_build_verbosity_extra_flags(options.build_verbosity), + *get_build_verbosity_extra_flags(build_options.build_verbosity), ], env=env, ) - elif options.build_frontend == "build": + elif build_options.build_frontend == "build": config_setting = " ".join(verbosity_flags) build_env = env.copy() - if options.dependency_constraints: - constraints_path = options.dependency_constraints.get_for_python_version( + if build_options.dependency_constraints: + constraints_path = build_options.dependency_constraints.get_for_python_version( config.version ) # Bug in pip <= 21.1.3 - we can't have a space in the @@ -342,7 +343,7 @@ def build(all_options: AllBuildOptions) -> None: "python", "-m", "build", - options.package_dir, + build_options.package_dir, "--wheel", f"--outdir={built_wheel_dir}", f"--config-setting={config_setting}", @@ -350,7 +351,7 @@ def build(all_options: AllBuildOptions) -> None: env=build_env, ) else: - assert_never(options.build_frontend) + assert_never(build_options.build_frontend) built_wheel = next(built_wheel_dir.glob("*.whl")) @@ -362,10 +363,10 @@ def build(all_options: AllBuildOptions) -> None: if built_wheel.name.endswith("none-any.whl"): raise NonPlatformWheelError() - if options.repair_command: + if build_options.repair_command: log.step("Repairing wheel...") repair_command_prepared = prepare_command( - options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir + build_options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir ) shell(repair_command_prepared, env=env) else: @@ -373,7 +374,7 @@ def build(all_options: AllBuildOptions) -> None: repaired_wheel = next(repaired_wheel_dir.glob("*.whl")) - if options.test_command and options.test_selector(config.identifier): + if build_options.test_command and options.globals.test_selector(config.identifier): log.step("Testing wheel...") # set up a virtual environment to install and test from, to make sure # there are no dependencies that were pulled in at build time. @@ -395,31 +396,31 @@ def build(all_options: AllBuildOptions) -> None: # check that we are using the Python from the virtual environment call(["where", "python"], env=virtualenv_env) - if options.before_test: + if build_options.before_test: before_test_prepared = prepare_command( - options.before_test, + build_options.before_test, project=".", - package=options.package_dir, + package=build_options.package_dir, ) shell(before_test_prepared, env=virtualenv_env) # install the wheel call( - ["pip", "install", str(repaired_wheel) + options.test_extras], + ["pip", "install", str(repaired_wheel) + build_options.test_extras], env=virtualenv_env, ) # test the wheel - if options.test_requires: - call(["pip", "install"] + options.test_requires, env=virtualenv_env) + if build_options.test_requires: + call(["pip", "install"] + build_options.test_requires, env=virtualenv_env) # run the tests from c:\, with an absolute path in the command # (this ensures that Python runs the tests against the installed wheel # and not the repo code) test_command_prepared = prepare_command( - options.test_command, + build_options.test_command, project=Path(".").resolve(), - package=options.package_dir.resolve(), + package=options.globals.package_dir.resolve(), ) shell(test_command_prepared, cwd="c:\\", env=virtualenv_env) @@ -427,7 +428,7 @@ def build(all_options: AllBuildOptions) -> None: shutil.rmtree(venv_dir) # we're all done here; move it to output (remove if already exists) - shutil.move(str(repaired_wheel), options.output_dir) + shutil.move(str(repaired_wheel), build_options.output_dir) log.build_end() except subprocess.CalledProcessError as error: log.step_end_with_error( diff --git a/docs/options.md b/docs/options.md index fdd5332b5..aa625133a 100644 --- a/docs/options.md +++ b/docs/options.md @@ -107,6 +107,56 @@ The complete set of defaults for the current version of cibuildwheel are shown b not want to change a `pyproject.toml` file. You can specify a different file to use with `--config-file` on the command line, as well. +### Configuration overrides {: #overrides } + +One feature specific to the configuration files is the ability to override +settings based on selectors. To use, add a ``tool.cibuildwheel.overrides`` +array, and specify a ``select`` string. Then any options you set will only +apply to items that match that selector. These are applied in order, with later +matches overriding earlier ones if multiple selectors match. Environment +variables always override static configuration. + +A few of the options below have special handling in overrides. A different +`before-all` will trigger a new docker launch on Linux, and cannot be +overridden on macOS or Windows. Overriding the image on linux will also +generate new docker launches, one per image. Some commands are not supported; +`output-dir`, build/skip/test_skip selectors, and architectures cannot be +overridden. + +##### Examples: + +```toml +[tool.cibuildwheel.linux] +before-all = "yum install mylib" +test-command = "echo 'installed'" + +[[tool.cibuildwheel.overrides]] +select = "*-musllinux*" +before-all = "apk add mylib" +``` + +This example will override the before-all command on musllinux only, but will +still run the test-command. Note the double brackets, this is an array in TOML, +which means it can be given multiple times. + +```toml +[tool.cibuildwheel] +# Normal options, etc. +manylinux-x86_64-image = "manylinux2010" + +[[tool.cibuildwheel.overrides]] +select = "cp36-*" +manylinux-x86_64-image = "manylinux1" + +[[tool.cibuildwheel.overrides]] +select = "cp310-*" +manylinux-x86_64-image = "manylinux2014" +``` + +This example will build CPython 3.6 wheels on manylinux1, CPython 3.7-3.9 +images on manylinux2010, and CPython 3.10 wheels on manylinux2014. + + ## Options summary
@@ -305,7 +355,8 @@ If not listed above, `auto` is the same as `native`. Platform-specific environment variables are also available:
`CIBW_ARCHS_MACOS` | `CIBW_ARCHS_WINDOWS` | `CIBW_ARCHS_LINUX` -This option can also be set using the [command-line option](#command-line) `--archs`. +This option can also be set using the [command-line option](#command-line) +`--archs`. This option cannot be set in an `overrides` section in `pyproject.toml`. #### Examples @@ -564,6 +615,10 @@ The placeholder `{package}` can be used here; it will be replaced by the path to On Windows and macOS, the version of Python available inside `CIBW_BEFORE_ALL` is whatever is available on the host machine. On Linux, a modern Python version is available on PATH. +This option has special behavior in the overrides section in `pyproject.toml`. +On linux, overriding it triggers a new docker launch. It cannot be overridden +on macOS and Windows. + Platform-specific environment variables also available:
`CIBW_BEFORE_ALL_MACOS` | `CIBW_BEFORE_ALL_WINDOWS` | `CIBW_BEFORE_ALL_LINUX` @@ -1144,6 +1199,8 @@ This will skip testing on any identifiers that match the given skip patterns (se With macOS `universal2` wheels, you can also skip the individual archs inside the wheel using an `:arch` suffix. For example, `cp39-macosx_universal2:x86_64` or `cp39-macosx_universal2:arm64`. +This option is not supported in the overrides section in `pyproject.toml`. + #### Examples !!! tab examples "Environment variables" diff --git a/unit_test/conftest.py b/unit_test/conftest.py index 89d56ffaa..26a28c474 100644 --- a/unit_test/conftest.py +++ b/unit_test/conftest.py @@ -1,5 +1,10 @@ +import sys +from pathlib import Path + import pytest +MOCK_PACKAGE_DIR = Path("some_package_dir") + def pytest_addoption(parser): parser.addoption("--run-docker", action="store_true", default=False, help="run docker tests") @@ -17,3 +22,22 @@ def pytest_collection_modifyitems(config, items): for item in items: if "docker" in item.keywords: item.add_marker(skip_docker) + + +@pytest.fixture +def fake_package_dir(monkeypatch): + """ + Monkey-patch enough for the main() function to run + """ + real_path_exists = Path.exists + + def mock_path_exists(path): + if path == MOCK_PACKAGE_DIR / "setup.py": + return True + else: + return real_path_exists(path) + + args = ["cibuildwheel", str(MOCK_PACKAGE_DIR)] + monkeypatch.setattr(Path, "exists", mock_path_exists) + monkeypatch.setattr(sys, "argv", args) + return args diff --git a/unit_test/docker_container_test.py b/unit_test/docker_container_test.py index 2b49eda02..37c5cca75 100644 --- a/unit_test/docker_container_test.py +++ b/unit_test/docker_container_test.py @@ -25,19 +25,19 @@ @pytest.mark.docker def test_simple(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assert container.call(["echo", "hello"], capture_output=True) == "hello\n" @pytest.mark.docker def test_no_lf(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assert container.call(["printf", "hello"], capture_output=True) == "hello" @pytest.mark.docker def test_environment(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assert ( container.call( ["sh", "-c", "echo $TEST_VAR"], env={"TEST_VAR": "1"}, capture_output=True @@ -48,14 +48,16 @@ def test_environment(): @pytest.mark.docker def test_cwd(): - with DockerContainer(DEFAULT_IMAGE, cwd="/cibuildwheel/working_directory") as container: + with DockerContainer( + docker_image=DEFAULT_IMAGE, cwd="/cibuildwheel/working_directory" + ) as container: assert container.call(["pwd"], capture_output=True) == "/cibuildwheel/working_directory\n" assert container.call(["pwd"], capture_output=True, cwd="/opt") == "/opt\n" @pytest.mark.docker def test_container_removed(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: docker_containers_listing = subprocess.run( "docker container ls", shell=True, @@ -88,7 +90,7 @@ def test_large_environment(): "d": "0" * long_env_var_length, } - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: # check the length of d assert ( container.call(["sh", "-c", "echo ${#d}"], env=large_environment, capture_output=True) @@ -98,7 +100,7 @@ def test_large_environment(): @pytest.mark.docker def test_binary_output(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: # note: the below embedded snippets are in python2 # check that we can pass though arbitrary binary data without erroring @@ -149,7 +151,7 @@ def test_binary_output(): @pytest.mark.docker def test_file_operations(tmp_path: Path): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: # test copying a file in test_binary_data = bytes(random.randrange(256) for _ in range(1000)) original_test_file = tmp_path / "test.dat" @@ -165,7 +167,7 @@ def test_file_operations(tmp_path: Path): @pytest.mark.docker def test_dir_operations(tmp_path: Path): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: test_binary_data = bytes(random.randrange(256) for _ in range(1000)) original_test_file = tmp_path / "test.dat" original_test_file.write_bytes(test_binary_data) @@ -195,6 +197,6 @@ def test_dir_operations(tmp_path: Path): @pytest.mark.docker def test_environment_executor(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assignment = EnvironmentAssignment("TEST=$(echo 42)") assert assignment.evaluated_value({}, container.environment_executor) == "42" diff --git a/unit_test/linux_build_steps_test.py b/unit_test/linux_build_steps_test.py new file mode 100644 index 000000000..29bc65a4a --- /dev/null +++ b/unit_test/linux_build_steps_test.py @@ -0,0 +1,70 @@ +import textwrap +from pathlib import Path +from pprint import pprint + +import cibuildwheel.docker_container +import cibuildwheel.linux +from cibuildwheel.options import Options + +from .utils import get_default_command_line_arguments + + +def test_linux_container_split(tmp_path: Path, monkeypatch): + """ + Tests splitting linux builds by docker image and before_all + """ + + args = get_default_command_line_arguments() + args.platform = "linux" + + (tmp_path / "pyproject.toml").write_text( + textwrap.dedent( + """ + [tool.cibuildwheel] + manylinux-x86_64-image = "normal_docker_image" + manylinux-i686-image = "normal_docker_image" + build = "*-manylinux_x86_64" + skip = "pp*" + archs = "x86_64 i686" + + [[tool.cibuildwheel.overrides]] + select = "cp{38,39,310}-*" + manylinux-x86_64-image = "other_docker_image" + manylinux-i686-image = "other_docker_image" + + [[tool.cibuildwheel.overrides]] + select = "cp39-*" + before-all = "echo 'a cp39-only command'" + """ + ) + ) + + monkeypatch.chdir(tmp_path) + options = Options("linux", command_line_arguments=args) + + python_configurations = cibuildwheel.linux.get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) + + build_steps = list(cibuildwheel.linux.get_build_steps(options, python_configurations)) + + # helper functions to extract test info + def identifiers(step): + return [c.identifier for c in step.platform_configs] + + def before_alls(step): + return [options.build_options(c.identifier).before_all for c in step.platform_configs] + + pprint(build_steps) + + assert build_steps[0].docker_image == "normal_docker_image" + assert identifiers(build_steps[0]) == ["cp36-manylinux_x86_64", "cp37-manylinux_x86_64"] + assert before_alls(build_steps[0]) == ["", ""] + + assert build_steps[1].docker_image == "other_docker_image" + assert identifiers(build_steps[1]) == ["cp38-manylinux_x86_64", "cp310-manylinux_x86_64"] + assert before_alls(build_steps[1]) == ["", ""] + + assert build_steps[2].docker_image == "other_docker_image" + assert identifiers(build_steps[2]) == ["cp39-manylinux_x86_64"] + assert before_alls(build_steps[2]) == ["echo 'a cp39-only command'"] diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py index 0cf2a77d0..973012cfb 100644 --- a/unit_test/main_tests/conftest.py +++ b/unit_test/main_tests/conftest.py @@ -15,9 +15,6 @@ def __call__(self, *args, **kwargs): self.kwargs = kwargs -MOCK_PACKAGE_DIR = Path("some_package_dir") - - @pytest.fixture(autouse=True) def mock_protection(monkeypatch): """ @@ -41,22 +38,8 @@ def ignore_call(*args, **kwargs): @pytest.fixture(autouse=True) -def fake_package_dir(monkeypatch): - """ - Monkey-patch enough for the main() function to run - """ - real_path_exists = Path.exists - - def mock_path_exists(path): - if path == MOCK_PACKAGE_DIR / "setup.py": - return True - else: - return real_path_exists(path) - - args = ["cibuildwheel", str(MOCK_PACKAGE_DIR)] - monkeypatch.setattr(Path, "exists", mock_path_exists) - monkeypatch.setattr(sys, "argv", args) - return args +def fake_package_dir_autouse(fake_package_dir): + pass @pytest.fixture(autouse=True) diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index cebc79779..b956d59e1 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -3,10 +3,12 @@ from pathlib import Path import pytest +import tomli from cibuildwheel.__main__ import main from cibuildwheel.environment import ParsedEnvironment -from cibuildwheel.util import BuildSelector +from cibuildwheel.options import BuildOptions, _get_pinned_docker_images +from cibuildwheel.util import BuildSelector, resources_dir # CIBW_PLATFORM is tested in main_platform_test.py @@ -18,13 +20,13 @@ def test_output_dir(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].general_build_options.output_dir == OUTPUT_DIR + assert intercepted_build_args.args[0].globals.output_dir == OUTPUT_DIR def test_output_dir_default(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].general_build_options.output_dir == Path("wheelhouse") + assert intercepted_build_args.args[0].globals.output_dir == Path("wheelhouse") @pytest.mark.parametrize("also_set_environment", [False, True]) @@ -37,7 +39,7 @@ def test_output_dir_argument(also_set_environment, platform, intercepted_build_a main() - assert intercepted_build_args.args[0].general_build_options.output_dir == OUTPUT_DIR + assert intercepted_build_args.args[0].globals.output_dir == OUTPUT_DIR def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_empty): @@ -49,7 +51,7 @@ def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_emp main() - intercepted_build_selector = intercepted_build_args.args[0].general_build_options.build_selector + intercepted_build_selector = intercepted_build_args.args[0].globals.build_selector assert isinstance(intercepted_build_selector, BuildSelector) assert intercepted_build_selector("build24-this") assert not intercepted_build_selector("skip65-that") @@ -97,13 +99,15 @@ def test_manylinux_images( main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) + if platform == "linux": assert fnmatch( - intercepted_build_args.args[0].general_build_options.manylinux_images[architecture], + build_options.manylinux_images[architecture], full_image, ) else: - assert intercepted_build_args.args[0].general_build_options.manylinux_images is None + assert build_options.manylinux_images is None def get_default_repair_command(platform): @@ -131,8 +135,10 @@ def test_repair_command( main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) + expected_repair = repair_command or get_default_repair_command(platform) - assert intercepted_build_args.args[0].general_build_options.repair_command == expected_repair + assert build_options.repair_command == expected_repair @pytest.mark.parametrize( @@ -150,7 +156,9 @@ def test_environment(environment, platform_specific, platform, intercepted_build main() - intercepted_environment = intercepted_build_args.args[0].general_build_options.environment + build_options = intercepted_build_args.args[0].build_options(identifier=None) + intercepted_environment = build_options.environment + assert isinstance(intercepted_environment, ParsedEnvironment) assert intercepted_environment.as_dictionary(prev_environment={}) == environment @@ -169,10 +177,9 @@ def test_test_requires( main() - assert ( - intercepted_build_args.args[0].general_build_options.test_requires - == (test_requires or "").split() - ) + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.test_requires == (test_requires or "").split() @pytest.mark.parametrize("test_extras", [None, "extras"]) @@ -187,9 +194,9 @@ def test_test_extras(test_extras, platform_specific, platform, intercepted_build main() - assert intercepted_build_args.args[0].general_build_options.test_extras == ( - "[" + test_extras + "]" if test_extras else "" - ) + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.test_extras == ("[" + test_extras + "]" if test_extras else "") @pytest.mark.parametrize("test_command", [None, "test --command"]) @@ -206,7 +213,9 @@ def test_test_command( main() - assert intercepted_build_args.args[0].general_build_options.test_command == (test_command or "") + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.test_command == (test_command or "") @pytest.mark.parametrize("before_build", [None, "before --build"]) @@ -223,7 +232,8 @@ def test_before_build( main() - assert intercepted_build_args.args[0].general_build_options.before_build == (before_build or "") + build_options = intercepted_build_args.args[0].build_options(identifier=None) + assert build_options.before_build == (before_build or "") @pytest.mark.parametrize("build_verbosity", [None, 0, 2, -2, 4, -4]) @@ -239,11 +249,10 @@ def test_build_verbosity( monkeypatch.setenv("CIBW_BUILD_VERBOSITY", str(build_verbosity)) main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) expected_verbosity = max(-3, min(3, int(build_verbosity or 0))) - assert ( - intercepted_build_args.args[0].general_build_options.build_verbosity == expected_verbosity - ) + assert build_options.build_verbosity == expected_verbosity @pytest.mark.parametrize( @@ -294,4 +303,36 @@ def test_before_all(before_all, platform_specific, platform, intercepted_build_a main() - assert intercepted_build_args.args[0].general_build_options.before_all == (before_all or "") + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.before_all == (before_all or "") + + +def test_defaults(platform, intercepted_build_args): + main() + + build_options: BuildOptions = intercepted_build_args.args[0].build_options(identifier=None) + defaults_config_path = resources_dir / "defaults.toml" + with defaults_config_path.open("rb") as f: + defaults_toml = tomli.load(f) + + root_defaults = defaults_toml["tool"]["cibuildwheel"] + platform_defaults = defaults_toml["tool"]["cibuildwheel"][platform] + + defaults = {} + defaults.update(root_defaults) + defaults.update(platform_defaults) + + # test a few options + assert build_options.before_all == defaults["before-all"] + repair_wheel_default = defaults["repair-wheel-command"] + if isinstance(repair_wheel_default, list): + repair_wheel_default = " && ".join(repair_wheel_default) + assert build_options.repair_command == repair_wheel_default + assert build_options.build_frontend == defaults["build-frontend"] + + if platform == "linux": + assert build_options.manylinux_images + pinned_images = _get_pinned_docker_images() + default_x86_64_image = pinned_images["x86_64"][defaults["manylinux-x86_64-image"]] + assert build_options.manylinux_images["x86_64"] == default_x86_64_image diff --git a/unit_test/main_tests/main_platform_test.py b/unit_test/main_tests/main_platform_test.py index 2766c143a..ae676791c 100644 --- a/unit_test/main_tests/main_platform_test.py +++ b/unit_test/main_tests/main_platform_test.py @@ -5,7 +5,7 @@ from cibuildwheel.__main__ import main from cibuildwheel.architecture import Architecture -from .conftest import MOCK_PACKAGE_DIR +from ..conftest import MOCK_PACKAGE_DIR def test_unknown_platform_non_ci(monkeypatch, capsys): @@ -58,26 +58,29 @@ def test_platform_argument(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].package_dir == MOCK_PACKAGE_DIR + options = intercepted_build_args.args[0] + + assert options.globals.package_dir == MOCK_PACKAGE_DIR def test_platform_environment(platform, intercepted_build_args, monkeypatch): main() + options = intercepted_build_args.args[0] - assert intercepted_build_args.args[0].package_dir == MOCK_PACKAGE_DIR + assert options.globals.package_dir == MOCK_PACKAGE_DIR def test_archs_default(platform, intercepted_build_args, monkeypatch): main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == {Architecture.x86_64, Architecture.i686} + assert options.globals.architectures == {Architecture.x86_64, Architecture.i686} elif platform == "windows": - assert build_options.architectures == {Architecture.AMD64, Architecture.x86} + assert options.globals.architectures == {Architecture.AMD64, Architecture.x86} else: - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} @pytest.mark.parametrize("use_env_var", [False, True]) @@ -96,8 +99,8 @@ def test_archs_argument(platform, intercepted_build_args, monkeypatch, use_env_v else: main() - build_options = intercepted_build_args.args[0] - assert build_options.architectures == {Architecture.ppc64le} + options = intercepted_build_args.args[0] + assert options.globals.architectures == {Architecture.ppc64le} def test_archs_platform_specific(platform, intercepted_build_args, monkeypatch): @@ -107,38 +110,38 @@ def test_archs_platform_specific(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS_MACOS", "x86_64") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == {Architecture.ppc64le} + assert options.globals.architectures == {Architecture.ppc64le} elif platform == "windows": - assert build_options.architectures == {Architecture.x86} + assert options.globals.architectures == {Architecture.x86} elif platform == "macos": - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} def test_archs_platform_native(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS", "native") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform in {"linux", "macos"}: - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} elif platform == "windows": - assert build_options.architectures == {Architecture.AMD64} + assert options.globals.architectures == {Architecture.AMD64} def test_archs_platform_auto64(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS", "auto64") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform in {"linux", "macos"}: - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} elif platform == "windows": - assert build_options.architectures == {Architecture.AMD64} + assert options.globals.architectures == {Architecture.AMD64} def test_archs_platform_auto32(platform, intercepted_build_args, monkeypatch): @@ -152,22 +155,22 @@ def test_archs_platform_auto32(platform, intercepted_build_args, monkeypatch): else: main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == {Architecture.i686} + assert options.globals.architectures == {Architecture.i686} elif platform == "windows": - assert build_options.architectures == {Architecture.x86} + assert options.globals.architectures == {Architecture.x86} def test_archs_platform_all(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS", "all") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == { + assert options.globals.architectures == { Architecture.x86_64, Architecture.i686, Architecture.aarch64, @@ -175,9 +178,9 @@ def test_archs_platform_all(platform, intercepted_build_args, monkeypatch): Architecture.s390x, } elif platform == "windows": - assert build_options.architectures == {Architecture.x86, Architecture.AMD64} + assert options.globals.architectures == {Architecture.x86, Architecture.AMD64} elif platform == "macos": - assert build_options.architectures == { + assert options.globals.architectures == { Architecture.x86_64, Architecture.arm64, Architecture.universal2, diff --git a/unit_test/main_tests/main_requires_python_test.py b/unit_test/main_tests/main_requires_python_test.py index 6c39b1205..0edb09375 100644 --- a/unit_test/main_tests/main_requires_python_test.py +++ b/unit_test/main_tests/main_requires_python_test.py @@ -27,7 +27,8 @@ def test_no_override(platform, monkeypatch, intercepted_build_args): main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector("cp39-win32") assert intercepted_build_selector("cp36-win32") @@ -40,7 +41,8 @@ def test_override_env(platform, monkeypatch, intercepted_build_args): main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") @@ -61,7 +63,8 @@ def test_override_setup_cfg(platform, monkeypatch, intercepted_build_args, fake_ main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") @@ -82,7 +85,8 @@ def test_override_pyproject_toml(platform, monkeypatch, intercepted_build_args, main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") @@ -107,7 +111,8 @@ def test_override_setup_py_simple(platform, monkeypatch, intercepted_build_args, main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.7") diff --git a/unit_test/option_prepare_test.py b/unit_test/option_prepare_test.py new file mode 100644 index 000000000..98a498cd7 --- /dev/null +++ b/unit_test/option_prepare_test.py @@ -0,0 +1,163 @@ +import platform as platform_module +import subprocess +from contextlib import contextmanager +from pathlib import Path +from typing import cast +from unittest import mock + +import pytest + +from cibuildwheel import linux, util +from cibuildwheel.__main__ import main + +ALL_IDS = {"cp36", "cp37", "cp38", "cp39", "cp310", "pp37"} + + +@pytest.fixture +def mock_build_docker(monkeypatch): + def fail_on_call(*args, **kwargs): + raise RuntimeError("This should never be called") + + def ignore_call(*args, **kwargs): + pass + + @contextmanager + def nullcontext(enter_result=None): + yield enter_result + + def ignore_context_call(*args, **kwargs): + return nullcontext(kwargs) + + monkeypatch.setenv("CIBW_PLATFORM", "linux") + monkeypatch.setattr(platform_module, "machine", lambda: "x86_64") + + monkeypatch.setattr(subprocess, "Popen", fail_on_call) + monkeypatch.setattr(subprocess, "run", ignore_call) + monkeypatch.setattr(util, "download", fail_on_call) + monkeypatch.setattr("cibuildwheel.linux.DockerContainer", ignore_context_call) + + monkeypatch.setattr("cibuildwheel.linux.build_on_docker", mock.Mock(spec=linux.build_on_docker)) + monkeypatch.setattr("cibuildwheel.util.print_new_wheels", ignore_context_call) + + +def test_build_default_launches(mock_build_docker, fake_package_dir): + + main(["--platform=linux"]) + build_on_docker = cast(mock.Mock, linux.build_on_docker) + + assert build_on_docker.call_count == 4 + + # In Python 3.8+, this can be simplified to [0].kwargs + kwargs = build_on_docker.call_args_list[0][1] + assert "quay.io/pypa/manylinux2010_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_x86_64" for x in ALL_IDS} + + kwargs = build_on_docker.call_args_list[1][1] + assert "quay.io/pypa/manylinux2010_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_i686" for x in ALL_IDS} + + kwargs = build_on_docker.call_args_list[2][1] + assert "quay.io/pypa/musllinux_1_1_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == { + f"{x}-musllinux_x86_64" for x in ALL_IDS for x in ALL_IDS if "pp" not in x + } + + kwargs = build_on_docker.call_args_list[3][1] + assert "quay.io/pypa/musllinux_1_1_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-musllinux_i686" for x in ALL_IDS if "pp" not in x} + + +def test_build_with_override_launches(mock_build_docker, monkeypatch, tmp_path): + pkg_dir = tmp_path / "cibw_package" + pkg_dir.mkdir() + + cibw_toml = pkg_dir / "pyproject.toml" + cibw_toml.write_text( + """ +[tool.cibuildwheel] +manylinux-x86_64-image = "manylinux2014" + +# Before Python 3.10, manylinux2010 is the most compatible +[[tool.cibuildwheel.overrides]] +select = "cp3?-*" +manylinux-x86_64-image = "manylinux2010" +manylinux-i686-image = "manylinux2010" + +[[tool.cibuildwheel.overrides]] +select = "cp36-manylinux_x86_64" +before-all = "true" +""" + ) + + monkeypatch.chdir(pkg_dir) + main(["--platform=linux"]) + build_on_docker = cast(mock.Mock, linux.build_on_docker) + + assert build_on_docker.call_count == 6 + + kwargs = build_on_docker.call_args_list[0][1] + assert "quay.io/pypa/manylinux2010_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {"cp36-manylinux_x86_64"} + assert kwargs["options"].build_options("cp36-manylinux_x86_64").before_all == "true" + + kwargs = build_on_docker.call_args_list[1][1] + assert "quay.io/pypa/manylinux2010_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_x86_64" for x in ALL_IDS - {"cp36", "cp310", "pp37"}} + assert kwargs["options"].build_options("cp37-manylinux_x86_64").before_all == "" + + kwargs = build_on_docker.call_args_list[2][1] + assert "quay.io/pypa/manylinux2014_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {"cp310-manylinux_x86_64", "pp37-manylinux_x86_64"} + + kwargs = build_on_docker.call_args_list[3][1] + assert "quay.io/pypa/manylinux2010_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_i686" for x in ALL_IDS} + + kwargs = build_on_docker.call_args_list[4][1] + assert "quay.io/pypa/musllinux_1_1_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == { + f"{x}-musllinux_x86_64" for x in ALL_IDS for x in ALL_IDS if "pp" not in x + } + + kwargs = build_on_docker.call_args_list[5][1] + assert "quay.io/pypa/musllinux_1_1_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-musllinux_i686" for x in ALL_IDS if "pp" not in x} diff --git a/unit_test/all_build_options_test.py b/unit_test/options_test.py similarity index 52% rename from unit_test/all_build_options_test.py rename to unit_test/options_test.py index 7f232a334..abaf04c9f 100644 --- a/unit_test/all_build_options_test.py +++ b/unit_test/options_test.py @@ -1,9 +1,10 @@ -from pathlib import Path +import platform as platform_module 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 +from cibuildwheel.options import Options, _get_pinned_docker_images + +from .utils import get_default_command_line_arguments PYPROJECT_1 = """ [tool.cibuildwheel] @@ -24,38 +25,44 @@ """ -def test_all_build_options_1(tmp_path): +def test_options_1(tmp_path, monkeypatch): 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 - ) + args = get_default_command_line_arguments() + args.package_dir = str(tmp_path) + + monkeypatch.setattr(platform_module, "machine", lambda: "x86_64") + + options = Options(platform="linux", command_line_arguments=args) identifiers = get_build_identifiers( - "linux", all_build_options.build_selector, all_build_options.architectures + platform="linux", + build_selector=options.globals.build_selector, + architectures=options.globals.architectures, ) - build_options = AllBuildOptions(all_build_options, build_options_by_selector, identifiers) - override_display = """\ -test_command: - *: 'pyproject' - cp37*: 'pyproject-override'""" +test_command: 'pyproject' + cp37-manylinux_x86_64: 'pyproject-override'""" + + print(options.summary(identifiers)) + + assert override_display in options.summary(identifiers) - assert override_display in str(build_options) + default_build_options = options.build_options(identifier=None) - assert build_options.environment == parse_environment('FOO="BAR"') + assert default_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"] + local = options.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"] + local = options.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/options_toml_test.py b/unit_test/options_toml_test.py index e2673d768..06ab6e662 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -1,6 +1,8 @@ +from pathlib import Path + import pytest -from cibuildwheel.options import ConfigOptionError, ConfigOptions, _dig_first +from cibuildwheel.options import ConfigOptionError, OptionsReader, _dig_first PYPROJECT_1 = """ [tool.cibuildwheel] @@ -28,39 +30,43 @@ def platform(request): @pytest.mark.parametrize("fname", ["pyproject.toml", "cibuildwheel.toml"]) def test_simple_settings(tmp_path, platform, fname): - with tmp_path.joinpath(fname).open("w") as f: - f.write(PYPROJECT_1) + config_file_path: Path = tmp_path / fname + config_file_path.write_text(PYPROJECT_1) - options = ConfigOptions(tmp_path, f"{{package}}/{fname}", platform=platform) + options_reader = OptionsReader(config_file_path, platform=platform) - assert options("build", env_plat=False, sep=" ") == "cp39*" + assert options_reader.get("build", env_plat=False, sep=" ") == "cp39*" - assert options("test-command") == "pyproject" - assert options("archs", sep=" ") == "auto" + assert options_reader.get("test-command") == "pyproject" + assert options_reader.get("archs", sep=" ") == "auto" assert ( - options("test-requires", sep=" ") + options_reader.get("test-requires", sep=" ") == {"windows": "something", "macos": "else", "linux": "other many"}[platform] ) # Also testing options for support for both lists and tables assert ( - options("environment", table={"item": '{k}="{v}"', "sep": " "}) == 'THING="OTHER" FOO="BAR"' + options_reader.get("environment", table={"item": '{k}="{v}"', "sep": " "}) + == 'THING="OTHER" FOO="BAR"' ) assert ( - options("environment", sep="x", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("environment", sep="x", table={"item": '{k}="{v}"', "sep": " "}) == 'THING="OTHER" FOO="BAR"' ) - assert options("test-extras", sep=",") == "one,two" - assert options("test-extras", sep=",", table={"item": '{k}="{v}"', "sep": " "}) == "one,two" + assert options_reader.get("test-extras", sep=",") == "one,two" + assert ( + options_reader.get("test-extras", sep=",", table={"item": '{k}="{v}"', "sep": " "}) + == "one,two" + ) - assert options("manylinux-x86_64-image") == "manylinux1" - assert options("manylinux-i686-image") == "manylinux2010" + assert options_reader.get("manylinux-x86_64-image") == "manylinux1" + assert options_reader.get("manylinux-i686-image") == "manylinux2010" with pytest.raises(ConfigOptionError): - options("environment", sep=" ") + options_reader.get("environment", sep=" ") with pytest.raises(ConfigOptionError): - options("test-extras", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("test-extras", table={"item": '{k}="{v}"', "sep": " "}) def test_envvar_override(tmp_path, platform, monkeypatch): @@ -70,44 +76,46 @@ def test_envvar_override(tmp_path, platform, monkeypatch): monkeypatch.setenv("CIBW_TEST_REQUIRES", "docs") monkeypatch.setenv("CIBW_TEST_REQUIRES_LINUX", "scod") - with tmp_path.joinpath("pyproject.toml").open("w") as f: - f.write(PYPROJECT_1) + config_file_path: Path = tmp_path / "pyproject.toml" + config_file_path.write_text(PYPROJECT_1) - options = ConfigOptions(tmp_path, platform=platform) + options_reader = OptionsReader(config_file_path, platform=platform) - assert options("archs", sep=" ") == "auto" + assert options_reader.get("archs", sep=" ") == "auto" - assert options("build", sep=" ") == "cp38*" - assert options("manylinux-x86_64-image") == "manylinux2014" - assert options("manylinux-i686-image") == "manylinux2010" + assert options_reader.get("build", sep=" ") == "cp38*" + assert options_reader.get("manylinux-x86_64-image") == "manylinux2014" + assert options_reader.get("manylinux-i686-image") == "manylinux2010" assert ( - options("test-requires", sep=" ") + options_reader.get("test-requires", sep=" ") == {"windows": "docs", "macos": "docs", "linux": "scod"}[platform] ) - assert options("test-command") == "mytest" + assert options_reader.get("test-command") == "mytest" def test_project_global_override_default_platform(tmp_path, platform): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] repair-wheel-command = "repair-project-global" """ ) - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == "repair-project-global" + options_reader = OptionsReader(pyproject_toml, platform=platform) + assert options_reader.get("repair-wheel-command") == "repair-project-global" def test_env_global_override_default_platform(tmp_path, platform, monkeypatch): monkeypatch.setenv("CIBW_REPAIR_WHEEL_COMMAND", "repair-env-global") - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == "repair-env-global" + options_reader = OptionsReader(platform=platform) + assert options_reader.get("repair-wheel-command") == "repair-env-global" def test_env_global_override_project_platform(tmp_path, platform, monkeypatch): monkeypatch.setenv("CIBW_REPAIR_WHEEL_COMMAND", "repair-env-global") - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.linux] repair-wheel-command = "repair-project-linux" @@ -117,12 +125,13 @@ def test_env_global_override_project_platform(tmp_path, platform, monkeypatch): repair-wheel-command = "repair-project-macos" """ ) - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == "repair-env-global" + options_reader = OptionsReader(pyproject_toml, platform=platform) + assert options_reader.get("repair-wheel-command") == "repair-env-global" def test_global_platform_order(tmp_path, platform): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.linux] repair-wheel-command = "repair-project-linux" @@ -134,14 +143,15 @@ def test_global_platform_order(tmp_path, platform): repair-wheel-command = "repair-project-global" """ ) - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == f"repair-project-{platform}" + options_reader = OptionsReader(pyproject_toml, platform=platform) + assert options_reader.get("repair-wheel-command") == f"repair-project-{platform}" def test_unexpected_key(tmp_path): # Note that platform contents are only checked when running # for that platform. - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] repairs-wheel-command = "repair-project-linux" @@ -149,49 +159,53 @@ def test_unexpected_key(tmp_path): ) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform="linux") + OptionsReader(pyproject_toml, platform="linux") def test_unexpected_table(tmp_path): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.linus] repair-wheel-command = "repair-project-linux" """ ) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform="linux") + OptionsReader(pyproject_toml, platform="linux") def test_unsupported_join(tmp_path): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] build = ["1", "2"] """ ) - options = ConfigOptions(tmp_path, platform="linux") + options_reader = OptionsReader(pyproject_toml, platform="linux") - assert "1, 2" == options("build", sep=", ") + assert "1, 2" == options_reader.get("build", sep=", ") with pytest.raises(ConfigOptionError): - options("build") + options_reader.get("build") def test_disallowed_a(tmp_path): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.windows] manylinux-x86_64-image = "manylinux1" """ ) disallow = {"windows": {"manylinux-x86_64-image"}} - ConfigOptions(tmp_path, platform="linux", disallow=disallow) + OptionsReader(pyproject_toml, platform="linux", disallow=disallow) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform="windows", disallow=disallow) + OptionsReader(pyproject_toml, platform="windows", disallow=disallow) def test_environment_override_empty(tmp_path, monkeypatch): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] manylinux-i686-image = "manylinux1" @@ -202,15 +216,15 @@ def test_environment_override_empty(tmp_path, monkeypatch): monkeypatch.setenv("CIBW_MANYLINUX_I686_IMAGE", "") monkeypatch.setenv("CIBW_MANYLINUX_AARCH64_IMAGE", "manylinux1") - options = ConfigOptions(tmp_path, platform="linux") + options_reader = OptionsReader(pyproject_toml, platform="linux") - assert options("manylinux-x86_64-image") == "" - assert options("manylinux-i686-image") == "" - assert options("manylinux-aarch64-image") == "manylinux1" + assert options_reader.get("manylinux-x86_64-image") == "" + assert options_reader.get("manylinux-i686-image") == "" + assert options_reader.get("manylinux-aarch64-image") == "manylinux1" - assert options("manylinux-x86_64-image", ignore_empty=True) == "manylinux2010" - assert options("manylinux-i686-image", ignore_empty=True) == "manylinux1" - assert options("manylinux-aarch64-image", ignore_empty=True) == "manylinux1" + assert options_reader.get("manylinux-x86_64-image", ignore_empty=True) == "manylinux2010" + assert options_reader.get("manylinux-i686-image", ignore_empty=True) == "manylinux1" + assert options_reader.get("manylinux-aarch64-image", ignore_empty=True) == "manylinux1" @pytest.mark.parametrize("ignore_empty", (True, False)) @@ -268,26 +282,31 @@ def test_dig_first(ignore_empty): def test_pyproject_2(tmp_path, platform): - with tmp_path.joinpath("pyproject.toml").open("w") as f: - f.write(PYPROJECT_2) + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text(PYPROJECT_2) + + options_reader = OptionsReader(config_file_path=pyproject_toml, platform=platform) + assert options_reader.get("test-command") == "pyproject" - 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" + with options_reader.identifier("random"): + assert options_reader.get("test-command") == "pyproject" + + with options_reader.identifier("cp37-something"): + assert options_reader.get("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( - """\ + pyproject_toml: Path = tmp_path / "pyproject.toml" + + pyproject_toml.write_text( + """\ [tool.cibuildwheel] build = ["cp38*", "cp37*"] [tool.cibuildwheel.overrides] select = "cp37*" test-command = "pyproject-override" """ - ) + ) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform=platform) + OptionsReader(config_file_path=pyproject_toml, platform=platform) diff --git a/unit_test/utils.py b/unit_test/utils.py new file mode 100644 index 000000000..0f7359166 --- /dev/null +++ b/unit_test/utils.py @@ -0,0 +1,16 @@ +from cibuildwheel.options import CommandLineArguments + + +def get_default_command_line_arguments() -> CommandLineArguments: + defaults = CommandLineArguments() + + defaults.platform = "auto" + defaults.allow_empty = False + defaults.archs = None + defaults.config_file = None + defaults.output_dir = None + defaults.package_dir = "." + defaults.prerelease_pythons = False + defaults.print_build_identifiers = False + + return defaults