* Refactor error handling to use exceptions cibuildwheel has up until now handled most errors by printing an error message to sys.stderr and calling sys.exit. Others were handled using Logger.error, depending on the context. We also had return codes, but these weren't explicitly defined anywhere. This makes that convention more explicit and codified. Now to halt the program, the correct thing to do is to throw a cibuildwheel.errors.FatalError exception - that is caught in main() and printed before exiting. The existing behaviour was kept - if an error occurs within a build step (probably something to do with the build itself), the Logger.error() method is used. Outside of a build step (e.g. a misconfiguration), the behaviour is still to print 'cibuildwheel: <message>' I also took the opportunity to add a debugging option `--debug-traceback` (and `CIBW_DEBUG_TRACEBACK`), which you can enable to see a full traceback on errors. (I've deactivated the flake8-errmsg lint rule, as it was throwing loads of errors and these error messages aren't generally seen in a traceback context) * add noqa rule * Apply suggestions from code review Co-authored-by: Henry Schreiner <HenrySchreinerIII@gmail.com> * Return to flake8-errmsg conformance * Code review suggestions * Subclass Exception rather than SystemExit * apply error handling to new code and fix merge issues * Apply review suggestion * fix: merge issue * Update cibuildwheel/errors.py --------- Co-authored-by: Henry Schreiner <HenrySchreinerIII@gmail.com> Co-authored-by: mayeut <mayeut@users.noreply.github.com>
423 lines
14 KiB
Python
423 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tarfile
|
|
import textwrap
|
|
import traceback
|
|
import typing
|
|
from collections.abc import Iterable, Sequence, Set
|
|
from pathlib import Path
|
|
from tempfile import mkdtemp
|
|
from typing import Protocol
|
|
|
|
import cibuildwheel
|
|
import cibuildwheel.linux
|
|
import cibuildwheel.macos
|
|
import cibuildwheel.pyodide
|
|
import cibuildwheel.util
|
|
import cibuildwheel.windows
|
|
from cibuildwheel import errors
|
|
from cibuildwheel._compat.typing import assert_never
|
|
from cibuildwheel.architecture import Architecture, allowed_architectures_check
|
|
from cibuildwheel.logger import log
|
|
from cibuildwheel.options import CommandLineArguments, Options, compute_options
|
|
from cibuildwheel.typing import PLATFORMS, GenericPythonConfiguration, PlatformName
|
|
from cibuildwheel.util import (
|
|
CIBW_CACHE_PATH,
|
|
BuildSelector,
|
|
CIProvider,
|
|
Unbuffered,
|
|
chdir,
|
|
detect_ci_provider,
|
|
fix_ansi_codes_for_github_actions,
|
|
strtobool,
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class GlobalOptions:
|
|
print_traceback_on_error: bool = True # decides what happens when errors are hit.
|
|
|
|
|
|
def main() -> None:
|
|
global_options = GlobalOptions()
|
|
try:
|
|
main_inner(global_options)
|
|
except errors.FatalError as e:
|
|
message = e.args[0]
|
|
if log.step_active:
|
|
log.step_end_with_error(message)
|
|
else:
|
|
print(f"cibuildwheel: {message}", file=sys.stderr)
|
|
|
|
if global_options.print_traceback_on_error:
|
|
traceback.print_exc(file=sys.stderr)
|
|
|
|
sys.exit(e.return_code)
|
|
|
|
|
|
def main_inner(global_options: GlobalOptions) -> None:
|
|
"""
|
|
`main_inner` is the same as `main`, but it raises FatalError exceptions
|
|
rather than exiting directly.
|
|
"""
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Build wheels for all the platforms.",
|
|
epilog="""
|
|
Most options are supplied via environment variables or in
|
|
--config-file (pyproject.toml usually). See
|
|
https://github.com/pypa/cibuildwheel#options for info.
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--platform",
|
|
choices=["auto", "linux", "macos", "windows", "pyodide"],
|
|
default=None,
|
|
help="""
|
|
Platform to build for. Use this option to override the
|
|
auto-detected platform. Specifying "macos" or "windows" only works
|
|
on that operating system, but "linux" works on all three, as long
|
|
as Docker/Podman is installed. Default: auto.
|
|
""",
|
|
)
|
|
|
|
arch_list_str = ", ".join(a.name for a in Architecture)
|
|
parser.add_argument(
|
|
"--archs",
|
|
default=None,
|
|
help=f"""
|
|
Comma-separated list of CPU architectures to build for.
|
|
When set to 'auto', builds the architectures natively supported
|
|
on this machine. Set this option to build an architecture
|
|
via emulation, for example, using binfmt_misc and QEMU.
|
|
Default: auto.
|
|
Choices: auto, auto64, auto32, native, all, {arch_list_str}
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--only",
|
|
default=None,
|
|
help="""
|
|
Force a single wheel build when given an identifier. Overrides
|
|
CIBW_BUILD/CIBW_SKIP. --platform and --arch cannot be specified
|
|
if this is given.
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
type=Path,
|
|
default=Path(os.environ.get("CIBW_OUTPUT_DIR", "wheelhouse")),
|
|
help="Destination folder for the wheels. Default: wheelhouse.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--config-file",
|
|
default="",
|
|
help="""
|
|
TOML config file. Default: "", meaning {package}/pyproject.toml, if
|
|
it exists. To refer to a project inside your project, use {package};
|
|
this matters if you build from an SDist.
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"package_dir",
|
|
metavar="PACKAGE",
|
|
default=Path("."),
|
|
type=Path,
|
|
nargs="?",
|
|
help="""
|
|
Path to the package that you want wheels for. Default: the working
|
|
directory. Can be a directory inside the working directory, or an
|
|
sdist. When set to a directory, the working directory is still
|
|
considered the 'project' and is copied into the build container
|
|
on Linux. When set to a tar.gz sdist file, --config-file
|
|
and --output-dir are relative to the current directory, and other
|
|
paths are relative to the expanded SDist directory.
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--print-build-identifiers",
|
|
action="store_true",
|
|
help="Print the build identifiers matched by the current invocation and exit.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--allow-empty",
|
|
action="store_true",
|
|
help="Do not report an error code if the build does not match any wheels.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--prerelease-pythons",
|
|
action="store_true",
|
|
help="Enable pre-release Python versions if available.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--debug-traceback",
|
|
action="store_true",
|
|
default=strtobool(os.environ.get("CIBW_DEBUG_TRACEBACK", "0")),
|
|
help="Print a full traceback for all errors",
|
|
)
|
|
|
|
args = CommandLineArguments(**vars(parser.parse_args()))
|
|
|
|
global_options.print_traceback_on_error = args.debug_traceback
|
|
|
|
args.package_dir = args.package_dir.resolve()
|
|
|
|
# This are always relative to the base directory, even in SDist builds
|
|
args.output_dir = args.output_dir.resolve()
|
|
|
|
# Standard builds if a directory or non-existent path is given
|
|
if not args.package_dir.is_file() and not args.package_dir.name.endswith("tar.gz"):
|
|
build_in_directory(args)
|
|
return
|
|
|
|
# Tarfile builds require extraction and changing the directory
|
|
temp_dir = Path(mkdtemp(prefix="cibw-sdist-")).resolve(strict=True)
|
|
try:
|
|
with tarfile.open(args.package_dir) as tar:
|
|
tar.extractall(path=temp_dir)
|
|
|
|
# The extract directory is now the project dir
|
|
try:
|
|
(project_dir,) = temp_dir.iterdir()
|
|
except ValueError:
|
|
msg = "invalid sdist: didn't contain a single dir"
|
|
raise SystemExit(msg) from None
|
|
|
|
# This is now the new package dir
|
|
args.package_dir = project_dir.resolve()
|
|
|
|
with chdir(project_dir):
|
|
build_in_directory(args)
|
|
finally:
|
|
# avoid https://github.com/python/cpython/issues/86962 by performing
|
|
# cleanup manually
|
|
shutil.rmtree(temp_dir, ignore_errors=sys.platform.startswith("win"))
|
|
if temp_dir.exists():
|
|
log.warning(f"Can't delete temporary folder '{temp_dir}'")
|
|
|
|
|
|
def _compute_platform_only(only: str) -> PlatformName:
|
|
if "linux_" in only:
|
|
return "linux"
|
|
if "macosx_" in only:
|
|
return "macos"
|
|
if "win_" in only or "win32" in only:
|
|
return "windows"
|
|
if "pyodide_" in only:
|
|
return "pyodide"
|
|
msg = f"Invalid --only='{only}', must be a build selector with a known platform"
|
|
raise errors.ConfigurationError(msg)
|
|
|
|
|
|
def _compute_platform_auto() -> PlatformName:
|
|
if sys.platform.startswith("linux"):
|
|
return "linux"
|
|
elif sys.platform == "darwin":
|
|
return "macos"
|
|
elif sys.platform == "win32":
|
|
return "windows"
|
|
else:
|
|
msg = (
|
|
'cibuildwheel: Unable to detect platform from "sys.platform". cibuildwheel doesn\'t '
|
|
"support building wheels for this platform. You might be able to build for a different "
|
|
"platform using the --platform argument. Check --help output for more information."
|
|
)
|
|
raise errors.ConfigurationError(msg)
|
|
|
|
|
|
def _compute_platform(args: CommandLineArguments) -> PlatformName:
|
|
platform_option_value = args.platform or os.environ.get("CIBW_PLATFORM", "auto")
|
|
|
|
if args.only and args.platform is not None:
|
|
msg = "--platform cannot be specified with --only, it is computed from --only"
|
|
raise errors.ConfigurationError(msg)
|
|
if args.only and args.archs is not None:
|
|
msg = "--arch cannot be specified with --only, it is computed from --only"
|
|
raise errors.ConfigurationError(msg)
|
|
|
|
if platform_option_value not in PLATFORMS | {"auto"}:
|
|
msg = f"Unsupported platform: {platform_option_value}"
|
|
raise errors.ConfigurationError(msg)
|
|
|
|
if args.only:
|
|
return _compute_platform_only(args.only)
|
|
elif platform_option_value != "auto":
|
|
return typing.cast(PlatformName, platform_option_value)
|
|
|
|
return _compute_platform_auto()
|
|
|
|
|
|
class PlatformModule(Protocol):
|
|
# note that as per PEP544, the self argument is ignored when the protocol
|
|
# is applied to a module
|
|
def get_python_configurations(
|
|
self, build_selector: BuildSelector, architectures: Set[Architecture]
|
|
) -> Sequence[GenericPythonConfiguration]: ...
|
|
|
|
def build(self, options: Options, tmp_path: Path) -> None: ...
|
|
|
|
|
|
# pylint: disable-next=inconsistent-return-statements
|
|
def get_platform_module(platform: PlatformName) -> PlatformModule:
|
|
if platform == "linux":
|
|
return cibuildwheel.linux
|
|
if platform == "windows":
|
|
return cibuildwheel.windows
|
|
if platform == "macos":
|
|
return cibuildwheel.macos
|
|
if platform == "pyodide":
|
|
return cibuildwheel.pyodide
|
|
assert_never(platform)
|
|
|
|
|
|
def build_in_directory(args: CommandLineArguments) -> None:
|
|
platform: PlatformName = _compute_platform(args)
|
|
if platform == "pyodide" and sys.platform == "win32":
|
|
msg = "cibuildwheel: Building for pyodide is not supported on Windows"
|
|
print(msg, file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
options = compute_options(platform=platform, command_line_arguments=args, env=os.environ)
|
|
|
|
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"Could not find any of {{{names}}} at root of package"
|
|
raise errors.ConfigurationError(msg)
|
|
|
|
platform_module = get_platform_module(platform)
|
|
identifiers = get_build_identifiers(
|
|
platform_module=platform_module,
|
|
build_selector=options.globals.build_selector,
|
|
architectures=options.globals.architectures,
|
|
)
|
|
|
|
if args.print_build_identifiers:
|
|
for identifier in identifiers:
|
|
print(identifier)
|
|
sys.exit(0)
|
|
|
|
# Add CIBUILDWHEEL environment variable
|
|
os.environ["CIBUILDWHEEL"] = "1"
|
|
|
|
# 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[assignment]
|
|
|
|
# create the cache dir before it gets printed & builds performed
|
|
CIBW_CACHE_PATH.mkdir(parents=True, exist_ok=True)
|
|
|
|
print_preamble(platform=platform, options=options, identifiers=identifiers)
|
|
|
|
try:
|
|
options.check_for_invalid_configuration(identifiers)
|
|
allowed_architectures_check(platform, options.globals.architectures)
|
|
except ValueError as err:
|
|
raise errors.DeprecationError(*err.args) from err
|
|
|
|
if not identifiers:
|
|
message = f"No build identifiers selected: {options.globals.build_selector}"
|
|
if args.allow_empty:
|
|
print(f"cibuildwheel: {message}", file=sys.stderr)
|
|
else:
|
|
raise errors.NothingToDoError(message)
|
|
|
|
output_dir = options.globals.output_dir
|
|
|
|
if not output_dir.exists():
|
|
output_dir.mkdir(parents=True)
|
|
|
|
tmp_path = Path(mkdtemp(prefix="cibw-run-")).resolve(strict=True)
|
|
try:
|
|
with cibuildwheel.util.print_new_wheels(
|
|
"\n{n} wheels produced in {m:.0f} minutes:", output_dir
|
|
):
|
|
platform_module.build(options, tmp_path)
|
|
finally:
|
|
# avoid https://github.com/python/cpython/issues/86962 by performing
|
|
# cleanup manually
|
|
shutil.rmtree(tmp_path, ignore_errors=sys.platform.startswith("win"))
|
|
if tmp_path.exists():
|
|
log.warning(f"Can't delete temporary folder '{tmp_path}'")
|
|
|
|
|
|
def print_preamble(platform: str, options: Options, identifiers: Sequence[str]) -> None:
|
|
print(
|
|
textwrap.dedent(
|
|
"""
|
|
_ _ _ _ _ _ _
|
|
___|_| |_ _ _|_| |_| |_ _ _| |_ ___ ___| |
|
|
| _| | . | | | | | . | | | | | -_| -_| |
|
|
|___|_|___|___|_|_|___|_____|_|_|___|___|_|
|
|
"""
|
|
)
|
|
)
|
|
|
|
print(f"cibuildwheel version {cibuildwheel.__version__}\n")
|
|
|
|
print("Build options:")
|
|
print(f" platform: {platform}")
|
|
options_summary = textwrap.indent(options.summary(identifiers), " ")
|
|
if detect_ci_provider() == CIProvider.github_actions:
|
|
options_summary = fix_ansi_codes_for_github_actions(options_summary)
|
|
print(options_summary)
|
|
|
|
print()
|
|
print(f"Cache folder: {CIBW_CACHE_PATH}")
|
|
|
|
warnings = detect_warnings(options=options, identifiers=identifiers)
|
|
if warnings:
|
|
print("\nWarnings:")
|
|
for warning in warnings:
|
|
print(" " + warning)
|
|
|
|
print("\nHere we go!\n")
|
|
|
|
|
|
def get_build_identifiers(
|
|
platform_module: PlatformModule,
|
|
build_selector: BuildSelector,
|
|
architectures: Set[Architecture],
|
|
) -> list[str]:
|
|
python_configurations = platform_module.get_python_configurations(build_selector, architectures)
|
|
return [config.identifier for config in python_configurations]
|
|
|
|
|
|
def detect_warnings(*, options: Options, identifiers: Iterable[str]) -> list[str]:
|
|
warnings = []
|
|
|
|
# warn about deprecated {python} and {pip}
|
|
for option_name in ["test_command", "before_build"]:
|
|
option_values = [getattr(options.build_options(i), option_name) for i in identifiers]
|
|
|
|
if any(o and ("{python}" in o or "{pip}" in o) for o in option_values):
|
|
# Reminder: in an f-string, double braces means literal single brace
|
|
msg = (
|
|
f"{option_name}: '{{python}}' and '{{pip}}' are no longer needed, "
|
|
"and will be removed in a future release. Simply use 'python' or 'pip' instead."
|
|
)
|
|
warnings.append(msg)
|
|
|
|
return warnings
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|