diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 097b20d7..d03660a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,6 +59,7 @@ repos: - tomli - types-certifi - types-click + - types-dataclasses - types-jinja2 - types-pyyaml - types-requests diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 0623624d..c6a532f9 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -2,7 +2,6 @@ import argparse import os import sys import textwrap -from pathlib import Path from typing import List, Set, Union import cibuildwheel @@ -11,14 +10,9 @@ 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 ( - BuildOptions, - BuildSelector, - Unbuffered, - detect_ci_provider, -) +from cibuildwheel.util import BuildSelector, Unbuffered, detect_ci_provider def main() -> None: @@ -104,7 +98,7 @@ def main() -> None: help="Enable pre-release Python versions if available.", ) - args = parser.parse_args() + args = parser.parse_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) - build_options = 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, build_options.build_selector, build_options.architectures + platform=platform, + build_selector=options.globals.build_selector, + architectures=options.globals.architectures, ) if args.print_build_identifiers: @@ -168,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) @@ -191,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: BuildOptions) -> None: +def print_preamble(platform: str, options: Options, identifiers: List[str]) -> None: print( textwrap.dedent( """ @@ -216,9 +215,9 @@ def print_preamble(platform: str, build_options: BuildOptions) -> 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: @@ -254,14 +253,14 @@ def get_build_identifiers( return [config.identifier for config in python_configurations] -def detect_warnings(platform: str, build_options: BuildOptions) -> List[str]: +def detect_warnings(platform: str, options: Options, identifiers: List[str]) -> List[str]: warnings = [] # warn about deprecated {python} and {pip} for option_name in ["test_command", "before_build"]: - option_value = getattr(build_options, option_name) + 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): + 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, " 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/environment.py b/cibuildwheel/environment.py index 2d9ddc1f..748398c5 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -1,3 +1,4 @@ +import dataclasses from typing import Dict, List, Mapping, Optional import bashlex @@ -60,10 +61,15 @@ class EnvironmentAssignment: def __repr__(self) -> str: return f"{self.name}={self.value}" + def __eq__(self, other: object) -> bool: + if isinstance(other, EnvironmentAssignment): + return self.name == other.name and self.value == other.value + return False + +@dataclasses.dataclass class ParsedEnvironment: - def __init__(self, assignments: List[EnvironmentAssignment]): - self.assignments = assignments + assignments: List[EnvironmentAssignment] def as_dictionary( self, @@ -82,7 +88,7 @@ class ParsedEnvironment: return [a.as_shell_assignment() for a in self.assignments] def __repr__(self) -> str: - return f"ParsedEnvironment({[repr(a) for a in self.assignments]!r})" + return f"{self.__class__.__name__}({[repr(a) for a in self.assignments]!r})" def parse_environment(env_string: str) -> ParsedEnvironment: diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index e2d6045f..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 ( - BuildOptions, BuildSelector, NonPlatformWheelError, get_build_verbosity_extra_flags, @@ -53,48 +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( - options: BuildOptions, 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: - assert options.manylinux_images is not None - assert options.musllinux_images is not None + for config in python_configurations: + _, platform_tag = config.identifier.split("-", 1) - docker_image = ( - options.manylinux_images[platform_arch] - if platform_tag.startswith("manylinux") - else options.musllinux_images[platform_arch] - ) + before_all = options.build_options(config.identifier).before_all + docker_image = docker_image_for_python_configuration(config, options) - 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) - yield BuildStep(platform_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( - options: BuildOptions, + *, + options: Options, platform_configs: List[PythonConfiguration], docker: DockerContainer, container_project_path: PurePath, @@ -105,16 +113,21 @@ def build_on_docker( log.step("Copying project into Docker...") docker.copy_into(Path.cwd(), container_project_path) - if 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 = 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( - options.before_all, + before_all_options.before_all, project=container_project_path, package=container_package_dir, ) @@ -122,11 +135,14 @@ def build_on_docker( for config in platform_configs: log.build_start(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) @@ -140,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() @@ -159,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, ) @@ -175,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", @@ -191,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( [ @@ -206,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] @@ -217,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: @@ -228,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 @@ -241,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, ) @@ -257,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, ) @@ -284,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, options.output_dir) + docker.copy_out(container_output_dir, options.globals.output_dir) log.step_end() -def build(options: BuildOptions) -> None: +def build(options: Options) -> None: try: # check docker is installed subprocess.run(["docker", "--version"], check=True, stdout=subprocess.DEVNULL) @@ -301,12 +317,12 @@ def build(options: BuildOptions) -> None: ) sys.exit(2) - assert options.manylinux_images is not None - assert options.musllinux_images is not None - python_configurations = get_python_configurations(options.build_selector, options.architectures) + python_configurations = get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) cwd = Path.cwd() - abs_package_dir = 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") @@ -315,20 +331,23 @@ def build(options: BuildOptions) -> None: 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( - 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: @@ -346,16 +365,18 @@ def _matches_prepared_command(error_cmd: List[str], command_template: str) -> bo return error_cmd[2].startswith(command_prefix) -def troubleshoot(options: BuildOptions, error: Exception) -> None: +def troubleshoot(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, options.repair_command) + or _matches_prepared_command( + 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(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 4a43effc..f2e377de 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -12,10 +12,10 @@ 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 ( BuildFrontend, - BuildOptions, BuildSelector, NonPlatformWheelError, download, @@ -322,49 +322,53 @@ def setup_python( return env -def build(options: BuildOptions) -> 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" + python_configurations = get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) + try: - if 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 = 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( - options.before_all, project=".", package=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( - options.build_selector, options.architectures - ) - for config in python_configurations: + 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) @@ -373,9 +377,9 @@ def build(options: BuildOptions) -> 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( @@ -384,18 +388,20 @@ def build(options: BuildOptions) -> 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( @@ -403,7 +409,7 @@ def build(options: BuildOptions) -> None: "python", "-m", "build", - options.package_dir, + build_options.package_dir, "--wheel", f"--outdir={built_wheel_dir}", f"--config-setting={config_setting}", @@ -411,7 +417,7 @@ def build(options: BuildOptions) -> None: env=build_env, ) else: - assert_never(options.build_frontend) + assert_never(build_options.build_frontend) built_wheel = next(built_wheel_dir.glob("*.whl")) @@ -422,7 +428,7 @@ def build(options: BuildOptions) -> 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: @@ -433,7 +439,7 @@ def build(options: BuildOptions) -> 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, @@ -446,7 +452,7 @@ def build(options: BuildOptions) -> 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"]] = [] @@ -460,7 +466,7 @@ def build(options: BuildOptions) -> 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": @@ -541,31 +547,33 @@ def build(options: BuildOptions) -> 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, @@ -578,7 +586,7 @@ def build(options: BuildOptions) -> 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 6e6d1984..902b64fa 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -2,31 +2,115 @@ 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, 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 @@ -58,29 +142,28 @@ def _dig_first(*pairs: Tuple[Mapping[str, Setting], str], ignore_empty: bool = F raise KeyError(key) -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 @@ -94,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: @@ -117,12 +194,32 @@ class ConfigOptions: self.config_options = config_options self.config_platform_options = config_platform_options + self.overrides: List[Override] = [] + self.current_identifier: Optional[str] = None + + 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 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: """ Returns True if an option with this name is allowed in the [tool.cibuildwheel] section of a config file. """ - allowed_option_names = self.default_options.keys() | PLATFORMS + allowed_option_names = self.default_options.keys() | PLATFORMS | {"overrides"} return name in allowed_option_names @@ -151,7 +248,23 @@ class ConfigOptions: return global_options, platform_options - 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, *, @@ -177,11 +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. result = _dig_first( (os.environ if env_plat else {}, plat_envvar), # type: ignore[arg-type] (os.environ, envvar), + *[(o.options, name) for o in active_config_overrides], (self.config_platform_options, name), (self.config_options, name), (self.default_platform_options, name), @@ -203,198 +320,270 @@ 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, -) -> BuildOptions: + command_line_arguments: CommandLineArguments, +) -> Options: + options = Options(platform=platform, command_line_arguments=command_line_arguments) + options.check_for_deprecated_options() + return options + + +_all_pinned_docker_images: Optional[ConfigParser] = None + + +def _get_pinned_docker_images() -> Mapping[str, Mapping[str, str]]: """ - Compute the options from the environment and configuration file. + 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 - 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_single_options( - options, args_archs, build_selector, test_selector, platform, package_dir, output_dir - ) - - -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": + 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) - # all_pinned_docker_images looks like a dict of dicts, e.g. - # { 'x86_64': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'}, - # 'i686': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'}, - # 'pypy_x86_64': {'manylinux2010': '...' } - # ... } - - 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 - - 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, - ) + _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 b9bd207b..1e6e5787 100644 --- a/cibuildwheel/typing.py +++ b/cibuildwheel/typing.py @@ -4,22 +4,25 @@ 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__ = ( "Final", "Literal", - "TypedDict", - "Set", - "Union", - "PopenBytes", + "PLATFORMS", "PathOrStr", "PlatformName", "Protocol", "PLATFORMS", + "PopenBytes", + "Protocol", + "Set", + "TypedDict", + "OrderedDict", + "Union", "assert_never", ) diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 30b8ed54..3474d372 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -12,7 +12,7 @@ import urllib.request from enum import Enum from pathlib import Path from time import sleep -from typing import Dict, Iterator, List, NamedTuple, Optional, Set +from typing import Dict, Iterator, List, Optional import bracex import certifi @@ -20,8 +20,6 @@ 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 resources_dir = Path(__file__).parent / "resources" @@ -77,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 @@ -97,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 @@ -112,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" @@ -222,32 +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) + return self.base_file_path == o.base_file_path class NonPlatformWheelError(Exception): diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 33e3b236..bd5ad085 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -10,10 +10,10 @@ 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 ( BuildFrontend, - BuildOptions, BuildSelector, NonPlatformWheelError, download, @@ -246,47 +246,51 @@ def setup_python( return env -def build(options: BuildOptions) -> 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" + python_configurations = get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) + try: - if 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 = options.environment.as_dictionary(prev_environment=os.environ) + env = before_all_options.environment.as_dictionary(prev_environment=os.environ) before_all_prepared = prepare_command( - options.before_all, project=".", package=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( - options.build_selector, options.architectures - ) - for config in python_configurations: + 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) @@ -295,9 +299,9 @@ def build(options: BuildOptions) -> 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( @@ -306,18 +310,18 @@ def build(options: BuildOptions) -> 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 @@ -339,7 +343,7 @@ def build(options: BuildOptions) -> None: "python", "-m", "build", - options.package_dir, + build_options.package_dir, "--wheel", f"--outdir={built_wheel_dir}", f"--config-setting={config_setting}", @@ -347,7 +351,7 @@ def build(options: BuildOptions) -> None: env=build_env, ) else: - assert_never(options.build_frontend) + assert_never(build_options.build_frontend) built_wheel = next(built_wheel_dir.glob("*.whl")) @@ -359,10 +363,10 @@ def build(options: BuildOptions) -> 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: @@ -370,7 +374,7 @@ def build(options: BuildOptions) -> 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. @@ -392,31 +396,31 @@ def build(options: BuildOptions) -> 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) @@ -424,7 +428,7 @@ def build(options: BuildOptions) -> 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: