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:
`CIBW_ARCHS_MACOS` | `CIBW_ARCHS_WINDOWS` | `CIBW_ARCHS_LINUX` -This option can also be set using the [command-line option](#command-line) `--archs`. +This option can also be set using the [command-line option](#command-line) +`--archs`. This option cannot be set in an `overrides` section in `pyproject.toml`. #### Examples @@ -564,6 +615,10 @@ The placeholder `{package}` can be used here; it will be replaced by the path to On Windows and macOS, the version of Python available inside `CIBW_BEFORE_ALL` is whatever is available on the host machine. On Linux, a modern Python version is available on PATH. +This option has special behavior in the overrides section in `pyproject.toml`. +On linux, overriding it triggers a new docker launch. It cannot be overridden +on macOS and Windows. + Platform-specific environment variables also available:
`CIBW_BEFORE_ALL_MACOS` | `CIBW_BEFORE_ALL_WINDOWS` | `CIBW_BEFORE_ALL_LINUX` @@ -1144,6 +1199,8 @@ This will skip testing on any identifiers that match the given skip patterns (se With macOS `universal2` wheels, you can also skip the individual archs inside the wheel using an `:arch` suffix. For example, `cp39-macosx_universal2:x86_64` or `cp39-macosx_universal2:arm64`. +This option is not supported in the overrides section in `pyproject.toml`. + #### Examples !!! tab examples "Environment variables" diff --git a/setup.cfg b/setup.cfg index 5cc57757..4efda9b6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ install_requires = certifi packaging tomli + dataclasses;python_version < '3.7' typing_extensions;python_version < '3.8' python_requires = >=3.6 include_package_data = True diff --git a/unit_test/conftest.py b/unit_test/conftest.py index 89d56ffa..26a28c47 100644 --- a/unit_test/conftest.py +++ b/unit_test/conftest.py @@ -1,5 +1,10 @@ +import sys +from pathlib import Path + import pytest +MOCK_PACKAGE_DIR = Path("some_package_dir") + def pytest_addoption(parser): parser.addoption("--run-docker", action="store_true", default=False, help="run docker tests") @@ -17,3 +22,22 @@ def pytest_collection_modifyitems(config, items): for item in items: if "docker" in item.keywords: item.add_marker(skip_docker) + + +@pytest.fixture +def fake_package_dir(monkeypatch): + """ + Monkey-patch enough for the main() function to run + """ + real_path_exists = Path.exists + + def mock_path_exists(path): + if path == MOCK_PACKAGE_DIR / "setup.py": + return True + else: + return real_path_exists(path) + + args = ["cibuildwheel", str(MOCK_PACKAGE_DIR)] + monkeypatch.setattr(Path, "exists", mock_path_exists) + monkeypatch.setattr(sys, "argv", args) + return args diff --git a/unit_test/docker_container_test.py b/unit_test/docker_container_test.py index 2b49eda0..37c5cca7 100644 --- a/unit_test/docker_container_test.py +++ b/unit_test/docker_container_test.py @@ -25,19 +25,19 @@ elif pm == "s390x": @pytest.mark.docker def test_simple(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assert container.call(["echo", "hello"], capture_output=True) == "hello\n" @pytest.mark.docker def test_no_lf(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assert container.call(["printf", "hello"], capture_output=True) == "hello" @pytest.mark.docker def test_environment(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assert ( container.call( ["sh", "-c", "echo $TEST_VAR"], env={"TEST_VAR": "1"}, capture_output=True @@ -48,14 +48,16 @@ def test_environment(): @pytest.mark.docker def test_cwd(): - with DockerContainer(DEFAULT_IMAGE, cwd="/cibuildwheel/working_directory") as container: + with DockerContainer( + docker_image=DEFAULT_IMAGE, cwd="/cibuildwheel/working_directory" + ) as container: assert container.call(["pwd"], capture_output=True) == "/cibuildwheel/working_directory\n" assert container.call(["pwd"], capture_output=True, cwd="/opt") == "/opt\n" @pytest.mark.docker def test_container_removed(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: docker_containers_listing = subprocess.run( "docker container ls", shell=True, @@ -88,7 +90,7 @@ def test_large_environment(): "d": "0" * long_env_var_length, } - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: # check the length of d assert ( container.call(["sh", "-c", "echo ${#d}"], env=large_environment, capture_output=True) @@ -98,7 +100,7 @@ def test_large_environment(): @pytest.mark.docker def test_binary_output(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: # note: the below embedded snippets are in python2 # check that we can pass though arbitrary binary data without erroring @@ -149,7 +151,7 @@ def test_binary_output(): @pytest.mark.docker def test_file_operations(tmp_path: Path): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: # test copying a file in test_binary_data = bytes(random.randrange(256) for _ in range(1000)) original_test_file = tmp_path / "test.dat" @@ -165,7 +167,7 @@ def test_file_operations(tmp_path: Path): @pytest.mark.docker def test_dir_operations(tmp_path: Path): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: test_binary_data = bytes(random.randrange(256) for _ in range(1000)) original_test_file = tmp_path / "test.dat" original_test_file.write_bytes(test_binary_data) @@ -195,6 +197,6 @@ def test_dir_operations(tmp_path: Path): @pytest.mark.docker def test_environment_executor(): - with DockerContainer(DEFAULT_IMAGE) as container: + with DockerContainer(docker_image=DEFAULT_IMAGE) as container: assignment = EnvironmentAssignment("TEST=$(echo 42)") assert assignment.evaluated_value({}, container.environment_executor) == "42" diff --git a/unit_test/linux_build_steps_test.py b/unit_test/linux_build_steps_test.py new file mode 100644 index 00000000..29bc65a4 --- /dev/null +++ b/unit_test/linux_build_steps_test.py @@ -0,0 +1,70 @@ +import textwrap +from pathlib import Path +from pprint import pprint + +import cibuildwheel.docker_container +import cibuildwheel.linux +from cibuildwheel.options import Options + +from .utils import get_default_command_line_arguments + + +def test_linux_container_split(tmp_path: Path, monkeypatch): + """ + Tests splitting linux builds by docker image and before_all + """ + + args = get_default_command_line_arguments() + args.platform = "linux" + + (tmp_path / "pyproject.toml").write_text( + textwrap.dedent( + """ + [tool.cibuildwheel] + manylinux-x86_64-image = "normal_docker_image" + manylinux-i686-image = "normal_docker_image" + build = "*-manylinux_x86_64" + skip = "pp*" + archs = "x86_64 i686" + + [[tool.cibuildwheel.overrides]] + select = "cp{38,39,310}-*" + manylinux-x86_64-image = "other_docker_image" + manylinux-i686-image = "other_docker_image" + + [[tool.cibuildwheel.overrides]] + select = "cp39-*" + before-all = "echo 'a cp39-only command'" + """ + ) + ) + + monkeypatch.chdir(tmp_path) + options = Options("linux", command_line_arguments=args) + + python_configurations = cibuildwheel.linux.get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) + + build_steps = list(cibuildwheel.linux.get_build_steps(options, python_configurations)) + + # helper functions to extract test info + def identifiers(step): + return [c.identifier for c in step.platform_configs] + + def before_alls(step): + return [options.build_options(c.identifier).before_all for c in step.platform_configs] + + pprint(build_steps) + + assert build_steps[0].docker_image == "normal_docker_image" + assert identifiers(build_steps[0]) == ["cp36-manylinux_x86_64", "cp37-manylinux_x86_64"] + assert before_alls(build_steps[0]) == ["", ""] + + assert build_steps[1].docker_image == "other_docker_image" + assert identifiers(build_steps[1]) == ["cp38-manylinux_x86_64", "cp310-manylinux_x86_64"] + assert before_alls(build_steps[1]) == ["", ""] + + assert build_steps[2].docker_image == "other_docker_image" + assert identifiers(build_steps[2]) == ["cp39-manylinux_x86_64"] + assert before_alls(build_steps[2]) == ["echo 'a cp39-only command'"] diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py index 0cf2a77d..973012cf 100644 --- a/unit_test/main_tests/conftest.py +++ b/unit_test/main_tests/conftest.py @@ -15,9 +15,6 @@ class ArgsInterceptor: self.kwargs = kwargs -MOCK_PACKAGE_DIR = Path("some_package_dir") - - @pytest.fixture(autouse=True) def mock_protection(monkeypatch): """ @@ -41,22 +38,8 @@ def mock_protection(monkeypatch): @pytest.fixture(autouse=True) -def fake_package_dir(monkeypatch): - """ - Monkey-patch enough for the main() function to run - """ - real_path_exists = Path.exists - - def mock_path_exists(path): - if path == MOCK_PACKAGE_DIR / "setup.py": - return True - else: - return real_path_exists(path) - - args = ["cibuildwheel", str(MOCK_PACKAGE_DIR)] - monkeypatch.setattr(Path, "exists", mock_path_exists) - monkeypatch.setattr(sys, "argv", args) - return args +def fake_package_dir_autouse(fake_package_dir): + pass @pytest.fixture(autouse=True) diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index d437cbd2..b956d59e 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -3,10 +3,12 @@ from fnmatch import fnmatch from pathlib import Path import pytest +import tomli from cibuildwheel.__main__ import main from cibuildwheel.environment import ParsedEnvironment -from cibuildwheel.util import BuildSelector +from cibuildwheel.options import BuildOptions, _get_pinned_docker_images +from cibuildwheel.util import BuildSelector, resources_dir # CIBW_PLATFORM is tested in main_platform_test.py @@ -18,13 +20,13 @@ def test_output_dir(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].output_dir == OUTPUT_DIR + assert intercepted_build_args.args[0].globals.output_dir == OUTPUT_DIR def test_output_dir_default(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].output_dir == Path("wheelhouse") + assert intercepted_build_args.args[0].globals.output_dir == Path("wheelhouse") @pytest.mark.parametrize("also_set_environment", [False, True]) @@ -37,7 +39,7 @@ def test_output_dir_argument(also_set_environment, platform, intercepted_build_a main() - assert intercepted_build_args.args[0].output_dir == OUTPUT_DIR + assert intercepted_build_args.args[0].globals.output_dir == OUTPUT_DIR def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_empty): @@ -49,7 +51,7 @@ def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_emp main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + intercepted_build_selector = intercepted_build_args.args[0].globals.build_selector assert isinstance(intercepted_build_selector, BuildSelector) assert intercepted_build_selector("build24-this") assert not intercepted_build_selector("skip65-that") @@ -97,10 +99,15 @@ def test_manylinux_images( main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) + if platform == "linux": - assert fnmatch(intercepted_build_args.args[0].manylinux_images[architecture], full_image) + assert fnmatch( + build_options.manylinux_images[architecture], + full_image, + ) else: - assert intercepted_build_args.args[0].manylinux_images is None + assert build_options.manylinux_images is None def get_default_repair_command(platform): @@ -128,8 +135,10 @@ def test_repair_command( main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) + expected_repair = repair_command or get_default_repair_command(platform) - assert intercepted_build_args.args[0].repair_command == expected_repair + assert build_options.repair_command == expected_repair @pytest.mark.parametrize( @@ -147,7 +156,9 @@ def test_environment(environment, platform_specific, platform, intercepted_build main() - intercepted_environment = intercepted_build_args.args[0].environment + build_options = intercepted_build_args.args[0].build_options(identifier=None) + intercepted_environment = build_options.environment + assert isinstance(intercepted_environment, ParsedEnvironment) assert intercepted_environment.as_dictionary(prev_environment={}) == environment @@ -166,7 +177,9 @@ def test_test_requires( main() - assert intercepted_build_args.args[0].test_requires == (test_requires or "").split() + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.test_requires == (test_requires or "").split() @pytest.mark.parametrize("test_extras", [None, "extras"]) @@ -181,9 +194,9 @@ def test_test_extras(test_extras, platform_specific, platform, intercepted_build main() - assert intercepted_build_args.args[0].test_extras == ( - "[" + test_extras + "]" if test_extras else "" - ) + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.test_extras == ("[" + test_extras + "]" if test_extras else "") @pytest.mark.parametrize("test_command", [None, "test --command"]) @@ -200,7 +213,9 @@ def test_test_command( main() - assert intercepted_build_args.args[0].test_command == (test_command or "") + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.test_command == (test_command or "") @pytest.mark.parametrize("before_build", [None, "before --build"]) @@ -217,7 +232,8 @@ def test_before_build( main() - assert intercepted_build_args.args[0].before_build == (before_build or "") + build_options = intercepted_build_args.args[0].build_options(identifier=None) + assert build_options.before_build == (before_build or "") @pytest.mark.parametrize("build_verbosity", [None, 0, 2, -2, 4, -4]) @@ -233,9 +249,10 @@ def test_build_verbosity( monkeypatch.setenv("CIBW_BUILD_VERBOSITY", str(build_verbosity)) main() + build_options = intercepted_build_args.args[0].build_options(identifier=None) expected_verbosity = max(-3, min(3, int(build_verbosity or 0))) - assert intercepted_build_args.args[0].build_verbosity == expected_verbosity + assert build_options.build_verbosity == expected_verbosity @pytest.mark.parametrize( @@ -286,4 +303,36 @@ def test_before_all(before_all, platform_specific, platform, intercepted_build_a main() - assert intercepted_build_args.args[0].before_all == (before_all or "") + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + assert build_options.before_all == (before_all or "") + + +def test_defaults(platform, intercepted_build_args): + main() + + build_options: BuildOptions = intercepted_build_args.args[0].build_options(identifier=None) + defaults_config_path = resources_dir / "defaults.toml" + with defaults_config_path.open("rb") as f: + defaults_toml = tomli.load(f) + + root_defaults = defaults_toml["tool"]["cibuildwheel"] + platform_defaults = defaults_toml["tool"]["cibuildwheel"][platform] + + defaults = {} + defaults.update(root_defaults) + defaults.update(platform_defaults) + + # test a few options + assert build_options.before_all == defaults["before-all"] + repair_wheel_default = defaults["repair-wheel-command"] + if isinstance(repair_wheel_default, list): + repair_wheel_default = " && ".join(repair_wheel_default) + assert build_options.repair_command == repair_wheel_default + assert build_options.build_frontend == defaults["build-frontend"] + + if platform == "linux": + assert build_options.manylinux_images + pinned_images = _get_pinned_docker_images() + default_x86_64_image = pinned_images["x86_64"][defaults["manylinux-x86_64-image"]] + assert build_options.manylinux_images["x86_64"] == default_x86_64_image diff --git a/unit_test/main_tests/main_platform_test.py b/unit_test/main_tests/main_platform_test.py index 2766c143..ae676791 100644 --- a/unit_test/main_tests/main_platform_test.py +++ b/unit_test/main_tests/main_platform_test.py @@ -5,7 +5,7 @@ import pytest from cibuildwheel.__main__ import main from cibuildwheel.architecture import Architecture -from .conftest import MOCK_PACKAGE_DIR +from ..conftest import MOCK_PACKAGE_DIR def test_unknown_platform_non_ci(monkeypatch, capsys): @@ -58,26 +58,29 @@ def test_platform_argument(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].package_dir == MOCK_PACKAGE_DIR + options = intercepted_build_args.args[0] + + assert options.globals.package_dir == MOCK_PACKAGE_DIR def test_platform_environment(platform, intercepted_build_args, monkeypatch): main() + options = intercepted_build_args.args[0] - assert intercepted_build_args.args[0].package_dir == MOCK_PACKAGE_DIR + assert options.globals.package_dir == MOCK_PACKAGE_DIR def test_archs_default(platform, intercepted_build_args, monkeypatch): main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == {Architecture.x86_64, Architecture.i686} + assert options.globals.architectures == {Architecture.x86_64, Architecture.i686} elif platform == "windows": - assert build_options.architectures == {Architecture.AMD64, Architecture.x86} + assert options.globals.architectures == {Architecture.AMD64, Architecture.x86} else: - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} @pytest.mark.parametrize("use_env_var", [False, True]) @@ -96,8 +99,8 @@ def test_archs_argument(platform, intercepted_build_args, monkeypatch, use_env_v else: main() - build_options = intercepted_build_args.args[0] - assert build_options.architectures == {Architecture.ppc64le} + options = intercepted_build_args.args[0] + assert options.globals.architectures == {Architecture.ppc64le} def test_archs_platform_specific(platform, intercepted_build_args, monkeypatch): @@ -107,38 +110,38 @@ def test_archs_platform_specific(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS_MACOS", "x86_64") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == {Architecture.ppc64le} + assert options.globals.architectures == {Architecture.ppc64le} elif platform == "windows": - assert build_options.architectures == {Architecture.x86} + assert options.globals.architectures == {Architecture.x86} elif platform == "macos": - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} def test_archs_platform_native(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS", "native") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform in {"linux", "macos"}: - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} elif platform == "windows": - assert build_options.architectures == {Architecture.AMD64} + assert options.globals.architectures == {Architecture.AMD64} def test_archs_platform_auto64(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS", "auto64") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform in {"linux", "macos"}: - assert build_options.architectures == {Architecture.x86_64} + assert options.globals.architectures == {Architecture.x86_64} elif platform == "windows": - assert build_options.architectures == {Architecture.AMD64} + assert options.globals.architectures == {Architecture.AMD64} def test_archs_platform_auto32(platform, intercepted_build_args, monkeypatch): @@ -152,22 +155,22 @@ def test_archs_platform_auto32(platform, intercepted_build_args, monkeypatch): else: main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == {Architecture.i686} + assert options.globals.architectures == {Architecture.i686} elif platform == "windows": - assert build_options.architectures == {Architecture.x86} + assert options.globals.architectures == {Architecture.x86} def test_archs_platform_all(platform, intercepted_build_args, monkeypatch): monkeypatch.setenv("CIBW_ARCHS", "all") main() - build_options = intercepted_build_args.args[0] + options = intercepted_build_args.args[0] if platform == "linux": - assert build_options.architectures == { + assert options.globals.architectures == { Architecture.x86_64, Architecture.i686, Architecture.aarch64, @@ -175,9 +178,9 @@ def test_archs_platform_all(platform, intercepted_build_args, monkeypatch): Architecture.s390x, } elif platform == "windows": - assert build_options.architectures == {Architecture.x86, Architecture.AMD64} + assert options.globals.architectures == {Architecture.x86, Architecture.AMD64} elif platform == "macos": - assert build_options.architectures == { + assert options.globals.architectures == { Architecture.x86_64, Architecture.arm64, Architecture.universal2, diff --git a/unit_test/main_tests/main_requires_python_test.py b/unit_test/main_tests/main_requires_python_test.py index 6c39b120..0edb0937 100644 --- a/unit_test/main_tests/main_requires_python_test.py +++ b/unit_test/main_tests/main_requires_python_test.py @@ -27,7 +27,8 @@ def test_no_override(platform, monkeypatch, intercepted_build_args): main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector("cp39-win32") assert intercepted_build_selector("cp36-win32") @@ -40,7 +41,8 @@ def test_override_env(platform, monkeypatch, intercepted_build_args): main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") @@ -61,7 +63,8 @@ def test_override_setup_cfg(platform, monkeypatch, intercepted_build_args, fake_ main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") @@ -82,7 +85,8 @@ def test_override_pyproject_toml(platform, monkeypatch, intercepted_build_args, main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") @@ -107,7 +111,8 @@ def test_override_setup_py_simple(platform, monkeypatch, intercepted_build_args, main() - intercepted_build_selector = intercepted_build_args.args[0].build_selector + options = intercepted_build_args.args[0] + intercepted_build_selector = options.globals.build_selector assert intercepted_build_selector.requires_python == SpecifierSet(">=3.7") diff --git a/unit_test/option_prepare_test.py b/unit_test/option_prepare_test.py new file mode 100644 index 00000000..cadab279 --- /dev/null +++ b/unit_test/option_prepare_test.py @@ -0,0 +1,169 @@ +import platform as platform_module +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import cast +from unittest import mock + +import pytest + +from cibuildwheel import linux, util +from cibuildwheel.__main__ import main + +ALL_IDS = {"cp36", "cp37", "cp38", "cp39", "cp310", "pp37"} + + +@pytest.fixture +def mock_build_docker(monkeypatch): + def fail_on_call(*args, **kwargs): + raise RuntimeError("This should never be called") + + def ignore_call(*args, **kwargs): + pass + + @contextmanager + def nullcontext(enter_result=None): + yield enter_result + + def ignore_context_call(*args, **kwargs): + return nullcontext(kwargs) + + monkeypatch.setenv("CIBW_PLATFORM", "linux") + monkeypatch.setattr(platform_module, "machine", lambda: "x86_64") + + monkeypatch.setattr(subprocess, "Popen", fail_on_call) + monkeypatch.setattr(subprocess, "run", ignore_call) + monkeypatch.setattr(util, "download", fail_on_call) + monkeypatch.setattr("cibuildwheel.linux.DockerContainer", ignore_context_call) + + monkeypatch.setattr("cibuildwheel.linux.build_on_docker", mock.Mock(spec=linux.build_on_docker)) + monkeypatch.setattr("cibuildwheel.util.print_new_wheels", ignore_context_call) + + +def test_build_default_launches(mock_build_docker, fake_package_dir, monkeypatch): + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--platform=linux"]) + + main() + + build_on_docker = cast(mock.Mock, linux.build_on_docker) + + assert build_on_docker.call_count == 4 + + # In Python 3.8+, this can be simplified to [0].kwargs + kwargs = build_on_docker.call_args_list[0][1] + assert "quay.io/pypa/manylinux2010_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_x86_64" for x in ALL_IDS} + + kwargs = build_on_docker.call_args_list[1][1] + assert "quay.io/pypa/manylinux2010_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_i686" for x in ALL_IDS} + + kwargs = build_on_docker.call_args_list[2][1] + assert "quay.io/pypa/musllinux_1_1_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == { + f"{x}-musllinux_x86_64" for x in ALL_IDS for x in ALL_IDS if "pp" not in x + } + + kwargs = build_on_docker.call_args_list[3][1] + assert "quay.io/pypa/musllinux_1_1_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-musllinux_i686" for x in ALL_IDS if "pp" not in x} + + +def test_build_with_override_launches(mock_build_docker, monkeypatch, tmp_path): + pkg_dir = tmp_path / "cibw_package" + pkg_dir.mkdir() + + cibw_toml = pkg_dir / "pyproject.toml" + cibw_toml.write_text( + """ +[tool.cibuildwheel] +manylinux-x86_64-image = "manylinux2014" + +# Before Python 3.10, manylinux2010 is the most compatible +[[tool.cibuildwheel.overrides]] +select = "cp3?-*" +manylinux-x86_64-image = "manylinux2010" +manylinux-i686-image = "manylinux2010" + +[[tool.cibuildwheel.overrides]] +select = "cp36-manylinux_x86_64" +before-all = "true" +""" + ) + + monkeypatch.chdir(pkg_dir) + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--platform=linux"]) + + main() + + build_on_docker = cast(mock.Mock, linux.build_on_docker) + + assert build_on_docker.call_count == 6 + + kwargs = build_on_docker.call_args_list[0][1] + assert "quay.io/pypa/manylinux2010_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {"cp36-manylinux_x86_64"} + assert kwargs["options"].build_options("cp36-manylinux_x86_64").before_all == "true" + + kwargs = build_on_docker.call_args_list[1][1] + assert "quay.io/pypa/manylinux2010_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_x86_64" for x in ALL_IDS - {"cp36", "cp310", "pp37"}} + assert kwargs["options"].build_options("cp37-manylinux_x86_64").before_all == "" + + kwargs = build_on_docker.call_args_list[2][1] + assert "quay.io/pypa/manylinux2014_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {"cp310-manylinux_x86_64", "pp37-manylinux_x86_64"} + + kwargs = build_on_docker.call_args_list[3][1] + assert "quay.io/pypa/manylinux2010_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-manylinux_i686" for x in ALL_IDS} + + kwargs = build_on_docker.call_args_list[4][1] + assert "quay.io/pypa/musllinux_1_1_x86_64" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert not kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == { + f"{x}-musllinux_x86_64" for x in ALL_IDS for x in ALL_IDS if "pp" not in x + } + + kwargs = build_on_docker.call_args_list[5][1] + assert "quay.io/pypa/musllinux_1_1_i686" in kwargs["docker"]["docker_image"] + assert kwargs["docker"]["cwd"] == Path("/project") + assert kwargs["docker"]["simulate_32_bit"] + + identifiers = {x.identifier for x in kwargs["platform_configs"]} + assert identifiers == {f"{x}-musllinux_i686" for x in ALL_IDS if "pp" not in x} diff --git a/unit_test/options_test.py b/unit_test/options_test.py new file mode 100644 index 00000000..abaf04c9 --- /dev/null +++ b/unit_test/options_test.py @@ -0,0 +1,68 @@ +import platform as platform_module + +from cibuildwheel.__main__ import get_build_identifiers +from cibuildwheel.environment import parse_environment +from cibuildwheel.options import Options, _get_pinned_docker_images + +from .utils import get_default_command_line_arguments + +PYPROJECT_1 = """ +[tool.cibuildwheel] +build = ["cp38*", "cp37*"] +environment = {FOO="BAR"} + +test-command = "pyproject" + +manylinux-x86_64-image = "manylinux1" + +[tool.cibuildwheel.macos] +test-requires = "else" + +[[tool.cibuildwheel.overrides]] +select = "cp37*" +test-command = "pyproject-override" +manylinux-x86_64-image = "manylinux2014" +""" + + +def test_options_1(tmp_path, monkeypatch): + with tmp_path.joinpath("pyproject.toml").open("w") as f: + f.write(PYPROJECT_1) + + args = get_default_command_line_arguments() + args.package_dir = str(tmp_path) + + monkeypatch.setattr(platform_module, "machine", lambda: "x86_64") + + options = Options(platform="linux", command_line_arguments=args) + + identifiers = get_build_identifiers( + platform="linux", + build_selector=options.globals.build_selector, + architectures=options.globals.architectures, + ) + + override_display = """\ +test_command: 'pyproject' + cp37-manylinux_x86_64: 'pyproject-override'""" + + print(options.summary(identifiers)) + + assert override_display in options.summary(identifiers) + + default_build_options = options.build_options(identifier=None) + + assert default_build_options.environment == parse_environment('FOO="BAR"') + + all_pinned_docker_images = _get_pinned_docker_images() + pinned_x86_64_docker_image = all_pinned_docker_images["x86_64"] + + local = options.build_options("cp38-manylinux_x86_64") + assert local.manylinux_images is not None + assert local.test_command == "pyproject" + assert local.manylinux_images["x86_64"] == pinned_x86_64_docker_image["manylinux1"] + + local = options.build_options("cp37-manylinux_x86_64") + assert local.manylinux_images is not None + assert local.test_command == "pyproject-override" + assert local.manylinux_images["x86_64"] == pinned_x86_64_docker_image["manylinux2014"] diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index d002d209..06ab6e66 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -1,6 +1,8 @@ +from pathlib import Path + import pytest -from cibuildwheel.options import ConfigOptionError, ConfigOptions, _dig_first +from cibuildwheel.options import ConfigOptionError, OptionsReader, _dig_first PYPROJECT_1 = """ [tool.cibuildwheel] @@ -28,39 +30,43 @@ def platform(request): @pytest.mark.parametrize("fname", ["pyproject.toml", "cibuildwheel.toml"]) def test_simple_settings(tmp_path, platform, fname): - with tmp_path.joinpath(fname).open("w") as f: - f.write(PYPROJECT_1) + config_file_path: Path = tmp_path / fname + config_file_path.write_text(PYPROJECT_1) - options = ConfigOptions(tmp_path, f"{{package}}/{fname}", platform=platform) + options_reader = OptionsReader(config_file_path, platform=platform) - assert options("build", env_plat=False, sep=" ") == "cp39*" + assert options_reader.get("build", env_plat=False, sep=" ") == "cp39*" - assert options("test-command") == "pyproject" - assert options("archs", sep=" ") == "auto" + assert options_reader.get("test-command") == "pyproject" + assert options_reader.get("archs", sep=" ") == "auto" assert ( - options("test-requires", sep=" ") + options_reader.get("test-requires", sep=" ") == {"windows": "something", "macos": "else", "linux": "other many"}[platform] ) # Also testing options for support for both lists and tables assert ( - options("environment", table={"item": '{k}="{v}"', "sep": " "}) == 'THING="OTHER" FOO="BAR"' - ) - assert ( - options("environment", sep="x", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("environment", table={"item": '{k}="{v}"', "sep": " "}) == 'THING="OTHER" FOO="BAR"' ) - assert options("test-extras", sep=",") == "one,two" - assert options("test-extras", sep=",", table={"item": '{k}="{v}"', "sep": " "}) == "one,two" + assert ( + options_reader.get("environment", sep="x", table={"item": '{k}="{v}"', "sep": " "}) + == 'THING="OTHER" FOO="BAR"' + ) + assert options_reader.get("test-extras", sep=",") == "one,two" + assert ( + options_reader.get("test-extras", sep=",", table={"item": '{k}="{v}"', "sep": " "}) + == "one,two" + ) - assert options("manylinux-x86_64-image") == "manylinux1" - assert options("manylinux-i686-image") == "manylinux2010" + assert options_reader.get("manylinux-x86_64-image") == "manylinux1" + assert options_reader.get("manylinux-i686-image") == "manylinux2010" with pytest.raises(ConfigOptionError): - options("environment", sep=" ") + options_reader.get("environment", sep=" ") with pytest.raises(ConfigOptionError): - options("test-extras", table={"item": '{k}="{v}"', "sep": " "}) + options_reader.get("test-extras", table={"item": '{k}="{v}"', "sep": " "}) def test_envvar_override(tmp_path, platform, monkeypatch): @@ -70,44 +76,46 @@ def test_envvar_override(tmp_path, platform, monkeypatch): monkeypatch.setenv("CIBW_TEST_REQUIRES", "docs") monkeypatch.setenv("CIBW_TEST_REQUIRES_LINUX", "scod") - with tmp_path.joinpath("pyproject.toml").open("w") as f: - f.write(PYPROJECT_1) + config_file_path: Path = tmp_path / "pyproject.toml" + config_file_path.write_text(PYPROJECT_1) - options = ConfigOptions(tmp_path, platform=platform) + options_reader = OptionsReader(config_file_path, platform=platform) - assert options("archs", sep=" ") == "auto" + assert options_reader.get("archs", sep=" ") == "auto" - assert options("build", sep=" ") == "cp38*" - assert options("manylinux-x86_64-image") == "manylinux2014" - assert options("manylinux-i686-image") == "manylinux2010" + assert options_reader.get("build", sep=" ") == "cp38*" + assert options_reader.get("manylinux-x86_64-image") == "manylinux2014" + assert options_reader.get("manylinux-i686-image") == "manylinux2010" assert ( - options("test-requires", sep=" ") + options_reader.get("test-requires", sep=" ") == {"windows": "docs", "macos": "docs", "linux": "scod"}[platform] ) - assert options("test-command") == "mytest" + assert options_reader.get("test-command") == "mytest" def test_project_global_override_default_platform(tmp_path, platform): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] repair-wheel-command = "repair-project-global" """ ) - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == "repair-project-global" + options_reader = OptionsReader(pyproject_toml, platform=platform) + assert options_reader.get("repair-wheel-command") == "repair-project-global" def test_env_global_override_default_platform(tmp_path, platform, monkeypatch): monkeypatch.setenv("CIBW_REPAIR_WHEEL_COMMAND", "repair-env-global") - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == "repair-env-global" + options_reader = OptionsReader(platform=platform) + assert options_reader.get("repair-wheel-command") == "repair-env-global" def test_env_global_override_project_platform(tmp_path, platform, monkeypatch): monkeypatch.setenv("CIBW_REPAIR_WHEEL_COMMAND", "repair-env-global") - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.linux] repair-wheel-command = "repair-project-linux" @@ -117,12 +125,13 @@ repair-wheel-command = "repair-project-windows" repair-wheel-command = "repair-project-macos" """ ) - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == "repair-env-global" + options_reader = OptionsReader(pyproject_toml, platform=platform) + assert options_reader.get("repair-wheel-command") == "repair-env-global" def test_global_platform_order(tmp_path, platform): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.linux] repair-wheel-command = "repair-project-linux" @@ -134,14 +143,15 @@ repair-wheel-command = "repair-project-macos" repair-wheel-command = "repair-project-global" """ ) - options = ConfigOptions(tmp_path, platform=platform) - assert options("repair-wheel-command") == f"repair-project-{platform}" + options_reader = OptionsReader(pyproject_toml, platform=platform) + assert options_reader.get("repair-wheel-command") == f"repair-project-{platform}" def test_unexpected_key(tmp_path): # Note that platform contents are only checked when running # for that platform. - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] repairs-wheel-command = "repair-project-linux" @@ -149,49 +159,53 @@ repairs-wheel-command = "repair-project-linux" ) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform="linux") + OptionsReader(pyproject_toml, platform="linux") def test_unexpected_table(tmp_path): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.linus] repair-wheel-command = "repair-project-linux" """ ) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform="linux") + OptionsReader(pyproject_toml, platform="linux") def test_unsupported_join(tmp_path): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] build = ["1", "2"] """ ) - options = ConfigOptions(tmp_path, platform="linux") + options_reader = OptionsReader(pyproject_toml, platform="linux") - assert "1, 2" == options("build", sep=", ") + assert "1, 2" == options_reader.get("build", sep=", ") with pytest.raises(ConfigOptionError): - options("build") + options_reader.get("build") def test_disallowed_a(tmp_path): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel.windows] manylinux-x86_64-image = "manylinux1" """ ) disallow = {"windows": {"manylinux-x86_64-image"}} - ConfigOptions(tmp_path, platform="linux", disallow=disallow) + OptionsReader(pyproject_toml, platform="linux", disallow=disallow) with pytest.raises(ConfigOptionError): - ConfigOptions(tmp_path, platform="windows", disallow=disallow) + OptionsReader(pyproject_toml, platform="windows", disallow=disallow) def test_environment_override_empty(tmp_path, monkeypatch): - tmp_path.joinpath("pyproject.toml").write_text( + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text( """ [tool.cibuildwheel] manylinux-i686-image = "manylinux1" @@ -202,15 +216,15 @@ manylinux-x86_64-image = "" monkeypatch.setenv("CIBW_MANYLINUX_I686_IMAGE", "") monkeypatch.setenv("CIBW_MANYLINUX_AARCH64_IMAGE", "manylinux1") - options = ConfigOptions(tmp_path, platform="linux") + options_reader = OptionsReader(pyproject_toml, platform="linux") - assert options("manylinux-x86_64-image") == "" - assert options("manylinux-i686-image") == "" - assert options("manylinux-aarch64-image") == "manylinux1" + assert options_reader.get("manylinux-x86_64-image") == "" + assert options_reader.get("manylinux-i686-image") == "" + assert options_reader.get("manylinux-aarch64-image") == "manylinux1" - assert options("manylinux-x86_64-image", ignore_empty=True) == "manylinux2010" - assert options("manylinux-i686-image", ignore_empty=True) == "manylinux1" - assert options("manylinux-aarch64-image", ignore_empty=True) == "manylinux1" + assert options_reader.get("manylinux-x86_64-image", ignore_empty=True) == "manylinux2010" + assert options_reader.get("manylinux-i686-image", ignore_empty=True) == "manylinux1" + assert options_reader.get("manylinux-aarch64-image", ignore_empty=True) == "manylinux1" @pytest.mark.parametrize("ignore_empty", (True, False)) @@ -246,3 +260,53 @@ def test_dig_first(ignore_empty): (d4, "other"), ignore_empty=ignore_empty, ) + + +PYPROJECT_2 = """ +[tool.cibuildwheel] +build = ["cp38*", "cp37*"] +environment = {FOO="BAR"} + +test-command = "pyproject" + +manylinux-x86_64-image = "manylinux1" + +[tool.cibuildwheel.macos] +test-requires = "else" + +[[tool.cibuildwheel.overrides]] +select = "cp37*" +test-command = "pyproject-override" +manylinux-x86_64-image = "manylinux2014" +""" + + +def test_pyproject_2(tmp_path, platform): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text(PYPROJECT_2) + + options_reader = OptionsReader(config_file_path=pyproject_toml, platform=platform) + assert options_reader.get("test-command") == "pyproject" + + with options_reader.identifier("random"): + assert options_reader.get("test-command") == "pyproject" + + with options_reader.identifier("cp37-something"): + assert options_reader.get("test-command") == "pyproject-override" + + +def test_overrides_not_a_list(tmp_path, platform): + pyproject_toml: Path = tmp_path / "pyproject.toml" + + pyproject_toml.write_text( + """\ +[tool.cibuildwheel] +build = ["cp38*", "cp37*"] +[tool.cibuildwheel.overrides] +select = "cp37*" +test-command = "pyproject-override" +""" + ) + + with pytest.raises(ConfigOptionError): + OptionsReader(config_file_path=pyproject_toml, platform=platform) diff --git a/unit_test/utils.py b/unit_test/utils.py new file mode 100644 index 00000000..0f735916 --- /dev/null +++ b/unit_test/utils.py @@ -0,0 +1,16 @@ +from cibuildwheel.options import CommandLineArguments + + +def get_default_command_line_arguments() -> CommandLineArguments: + defaults = CommandLineArguments() + + defaults.platform = "auto" + defaults.allow_empty = False + defaults.archs = None + defaults.config_file = None + defaults.output_dir = None + defaults.package_dir = "." + defaults.prerelease_pythons = False + defaults.print_build_identifiers = False + + return defaults