diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 7bf2a76a..0feff063 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -2,8 +2,7 @@ import argparse 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.macos 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) + for option_name in ["test_command", "before_build"]: + option_values = [getattr(options.build_options(i), option_name) for i in identifiers] - if option_value and ("{python}" in option_value or "{pip}" in option_value): - # Reminder: in an f-string, double braces means literal single brace - msg = ( - f"{option_name}: '{{python}}' and '{{pip}}' are no longer needed, " - "and will be removed in a future release. Simply use 'python' or 'pip' instead." - ) - warnings.append(msg) + if 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 4dcca04d..1fbf80a9 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 b9615923..eb00493e 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 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 + for config in python_configurations: + _, platform_tag = config.identifier.split("-", 1) - 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) + before_all = options.build_options(config.identifier).before_all + docker_image = docker_image_for_python_configuration(config, options) + + step_key = (platform_tag, docker_image, before_all) + + 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 af36e499..980c92cc 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -12,9 +12,9 @@ from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, 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 build(all_options: AllBuildOptions) -> 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 build(all_options: AllBuildOptions) -> 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 b898dcae..902b64fa 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 @@ class ConfigOptions: 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 @@ class ConfigOptions: self.config_options = config_options self.config_platform_options = config_platform_options - self.overrides: Dict[str, Dict[str, Any]] = {} - self.current_override: str = "*" + 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 @@ class ConfigOptions: return global_options, platform_options - def override(self: T, selector: str) -> T: - """ - Start an override scope. - """ - other = copy.copy(self) - other.current_override = selector - return other + @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) + ] - def __call__( + @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 @@ class ConfigOptions: 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,69 +320,253 @@ class ConfigOptions: return result +class Options: + def __init__(self, platform: PlatformName, command_line_arguments: CommandLineArguments): + self.platform = platform + self.command_line_arguments = command_line_arguments + + self.reader = OptionsReader( + self.config_file_path, + platform=platform, + disallow=DISALLOWED_OPTIONS, + ) + + @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_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=" ") + + 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) + + 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) + + return GlobalOptions( + package_dir=package_dir, + output_dir=output_dir, + build_selector=build_selector, + test_selector=test_selector, + architectures=architectures, + ) + + def build_options(self, identifier: Optional[str]) -> BuildOptions: + """ + Compute BuildOptions for a single run configuration. + """ + + 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} + """ + ) + ) + + def check_for_deprecated_options(self) -> None: + build_selector = self.globals.build_selector + test_selector = self.globals.test_selector + + 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) + + 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()) + ] + + build_option_defaults = self.build_options(identifier=None) + + for option_name, default_value in sorted(build_option_defaults._asdict().items()): + if option_name == "globals": + continue + + 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, - 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. - """ + command_line_arguments: CommandLineArguments, +) -> Options: + options = Options(platform=platform, command_line_arguments=command_line_arguments) + options.check_for_deprecated_options() + return options - 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) - - # 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) - - 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) - - return _compute_all_options( - options, args_archs, build_selector, test_selector, platform, package_dir, output_dir - ) +_all_pinned_docker_images: Optional[ConfigParser] = None def _get_pinned_docker_images() -> Mapping[str, Mapping[str, str]]: @@ -305,154 +577,13 @@ def _get_pinned_docker_images() -> Mapping[str, Mapping[str, str]]: '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 + global _all_pinned_docker_images - -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=" && ") - - archs_config_str = args_archs or options("archs", sep=" ") - - 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=" && ") - - 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") - - 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 - - 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 = options(f"musllinux-{build_platform}-image") - - 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, - platform: PlatformName, - package_dir: Path, - output_dir: Path, -) -> Tuple[BuildOptions, Dict[str, BuildOptions]]: - args = (args_archs, build_selector, test_selector, platform, package_dir, output_dir) - - general_build_options = _compute_single_options(options, *args) - - selectors = options.overrides.keys() - build_options_by_selector = { - s: _compute_single_options(options.override(s), *args) for s in selectors - } - - return general_build_options, build_options_by_selector + 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 2158cbac..1e6e5787 100644 --- a/cibuildwheel/typing.py +++ b/cibuildwheel/typing.py @@ -4,9 +4,9 @@ import sys 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 @@ __all__ = ( "Protocol", "Set", "TypedDict", + "OrderedDict", "Union", "assert_never", ) diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index b52e3471..3474d372 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 sys import textwrap import time import urllib.request -from collections import defaultdict from enum import Enum from pathlib import Path from time import sleep -from typing import ( - 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 @@ import tomli 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 @@ class IdentifierSelector: 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 @@ class IdentifierSelector: if not self.requires_python.contains(version): return False - build_patterns = itertools.chain.from_iterable( - bracex.expand(p) for p in self.build_patterns - ) + # 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 - unexpanded_skip_patterns = self.skip_patterns.copy() + should_build = selector_matches(self.build_config, build_id) + should_skip = selector_matches(self.skip_config, build_id) - if not self.prerelease_pythons: - # filter out the prerelease pythons, alongside the user-defined - # skip patterns - unexpanded_skip_patterns += BuildSelector.PRERELEASE_SKIP.split() - - 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 @@ class DependencyConstraints: return self.base_file_path def __repr__(self) -> str: - return f"{self.__class__.__name__}{self.base_file_path!r})" + return f"{self.__class__.__name__}({self.base_file_path!r})" + def __eq__(self, o: object) -> bool: + if not isinstance(o, DependencyConstraints): + return False -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 - - @property - def output_dir(self) -> Path: - return self.general_build_options.output_dir - - @property - def architectures(self) -> Set[Architecture]: - return self.general_build_options.architectures - - @property - def environment(self) -> ParsedEnvironment: - return self.general_build_options.environment - - @property - def before_all(self) -> str: - return self.general_build_options.before_all - - def produce_image_batches( - self, - configurations: List[SC], - platform_tag: str, - platform_arch: str, - ) -> Iterator[Tuple[List[SC], str]]: - - docker_images: Mapping[str, List[SC]] = defaultdict(list) - - for config in configurations: - build_options = self[config.identifier] - images = ( - build_options.manylinux_images - if platform_tag.startswith("manylinux") - else build_options.musllinux_images - ) - assert images is not None - docker_images[images[platform_arch]].append(config) - - for image, configs in docker_images.items(): - # TODO: check for colisions for identifiers in configs - # Some settings (before-all) are not overridable in the same image - yield configs, image - - def check_build_selectors(self) -> None: - hits = Counter[str]() - for sel in self.build_options_by_selector: - bs = BuildSelector(build_config=sel, skip_config="") - hits += Counter(i for i in self.identifiers if bs(i)) - - non_unique_identifers = {idnt for idnt, count in hits.items() if count > 1} - if non_unique_identifers: - msg = "cibuildwheel: error, the windows/macOS selectors must match uniquely" - print(msg, file=sys.stderr) - for sel in self.build_options_by_selector: - bs = BuildSelector(build_config=sel, skip_config="") - for i in non_unique_identifers: - if bs(i): - print(f" {sel}: {i} (nonunique match)") - sys.exit(1) - - def __str__(self) -> str: - results = [] - for option in sorted(self.general_build_options._asdict().keys()): - variations = { - key: value._asdict()[option] - for key, value in self.build_options_by_selector.items() - } - variations["*"] = self.general_build_options._asdict()[option] - if len({repr(v) for v in variations.values()}) == 1: - results.append(f"{option}: {variations['*']!r}") - else: - results.append(f"{option}:") - for key, value in sorted(variations.items()): - results.append(f" {key}: {value!r}") - - return "\n".join(results) + return self.base_file_path == o.base_file_path class NonPlatformWheelError(Exception): diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 25771e44..bd5ad085 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -10,9 +10,9 @@ from zipfile import ZipFile 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 fdd5332b..aa625133 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: