From bf817c6dc8e45273e5072fd747936a268145428e Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Mon, 10 Jun 2024 15:36:20 +0100 Subject: [PATCH] refactor: error handling to use exceptions (#1719) * 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: ' 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 * 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 Co-authored-by: mayeut --- cibuildwheel/__main__.py | 95 +++++++++++++++-------- cibuildwheel/errors.py | 27 +++++++ cibuildwheel/linux.py | 60 +++++--------- cibuildwheel/logger.py | 4 + cibuildwheel/macos.py | 28 +++---- cibuildwheel/options.py | 28 +++---- cibuildwheel/pyodide.py | 28 +++---- cibuildwheel/util.py | 2 +- cibuildwheel/windows.py | 22 ++---- docs/options.md | 29 ++++++- unit_test/main_tests/main_options_test.py | 22 ++++++ unit_test/options_test.py | 4 +- 12 files changed, 207 insertions(+), 142 deletions(-) create mode 100644 cibuildwheel/errors.py diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index ad354d1e6..a409d725b 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -1,11 +1,13 @@ 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 @@ -18,6 +20,7 @@ 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 @@ -31,10 +34,38 @@ 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=""" @@ -132,8 +163,17 @@ def main() -> None: 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 @@ -179,11 +219,8 @@ def _compute_platform_only(only: str) -> PlatformName: return "windows" if "pyodide_" in only: return "pyodide" - print( - f"Invalid --only='{only}', must be a build selector with a known platform", - file=sys.stderr, - ) - sys.exit(2) + msg = f"Invalid --only='{only}', must be a build selector with a known platform" + raise errors.ConfigurationError(msg) def _compute_platform_auto() -> PlatformName: @@ -194,34 +231,27 @@ def _compute_platform_auto() -> PlatformName: elif sys.platform == "win32": return "windows" else: - print( + 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.", - file=sys.stderr, + "platform using the --platform argument. Check --help output for more information." ) - sys.exit(2) + 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: - print( - "--platform cannot be specified with --only, it is computed from --only", - file=sys.stderr, - ) - sys.exit(2) + 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: - print( - "--arch cannot be specified with --only, it is computed from --only", - file=sys.stderr, - ) - sys.exit(2) + msg = "--arch cannot be specified with --only, it is computed from --only" + raise errors.ConfigurationError(msg) if platform_option_value not in PLATFORMS | {"auto"}: - print(f"cibuildwheel: Unsupported platform: {platform_option_value}", file=sys.stderr) - sys.exit(2) + msg = f"Unsupported platform: {platform_option_value}" + raise errors.ConfigurationError(msg) if args.only: return _compute_platform_only(args.only) @@ -268,9 +298,8 @@ def build_in_directory(args: CommandLineArguments) -> None: 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) + 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( @@ -301,16 +330,14 @@ def build_in_directory(args: CommandLineArguments) -> None: 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) + raise errors.DeprecationError(*err.args) from err if not identifiers: - print( - f"cibuildwheel: No build identifiers selected: {options.globals.build_selector}", - file=sys.stderr, - ) - if not args.allow_empty: - sys.exit(3) + 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 @@ -365,7 +392,9 @@ def print_preamble(platform: str, options: Options, identifiers: Sequence[str]) def get_build_identifiers( - platform_module: PlatformModule, build_selector: BuildSelector, architectures: Set[Architecture] + 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] diff --git a/cibuildwheel/errors.py b/cibuildwheel/errors.py new file mode 100644 index 000000000..717260e54 --- /dev/null +++ b/cibuildwheel/errors.py @@ -0,0 +1,27 @@ +""" +Errors that can cause the build to fail. Each subclass of FatalError has +a different return code, by defining them all here, we can ensure that they're +semantically clear and unique. +""" + + +class FatalError(BaseException): + """ + Raising an error of this type will cause the message to be printed to + stderr and the process to be terminated. Within cibuildwheel, raising this + exception produces a better error message, and optional traceback. + """ + + return_code: int = 1 + + +class ConfigurationError(FatalError): + return_code = 2 + + +class NothingToDoError(FatalError): + return_code = 3 + + +class DeprecationError(FatalError): + return_code = 4 diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index 8e885cbce..329120d7b 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -10,6 +10,7 @@ from packaging.version import Version +from . import errors from ._compat.typing import assert_never from .architecture import Architecture from .logger import log @@ -147,11 +148,7 @@ def check_all_python_exist( exist = False if not exist: message = "\n".join(messages) - print( - f"cibuildwheel:\n{message}", - file=sys.stderr, - ) - sys.exit(1) + raise errors.FatalError(message) def build_in_container( @@ -225,28 +222,19 @@ def build_in_container( # check config python is still on PATH which_python = container.call(["which", "python"], env=env, capture_output=True).strip() if PurePosixPath(which_python) != python_bin / "python": - print( - "cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it." + raise errors.FatalError(msg) if use_uv: which_uv = container.call(["which", "uv"], env=env, capture_output=True).strip() if not which_uv: - print( - "cibuildwheel: uv not found on PATH. You must use a supported manylinux or musllinux environment with uv.", - file=sys.stderr, - ) - sys.exit(1) + msg = "uv not found on PATH. You must use a supported manylinux or musllinux environment with uv." + raise errors.FatalError(msg) else: which_pip = container.call(["which", "pip"], env=env, capture_output=True).strip() if PurePosixPath(which_pip) != python_bin / "pip": - print( - "cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it." + raise errors.FatalError(msg) compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) if compatible_wheel: @@ -446,21 +434,18 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001 check=True, stdout=subprocess.DEVNULL, ) - except subprocess.CalledProcessError: - print( - unwrap( - f""" - cibuildwheel: {build_step.container_engine.name} not found. An - OCI exe like Docker or Podman is required to run Linux builds. - If you're building on Travis CI, add `services: [docker]` to - your .travis.yml. If you're building on Circle CI in Linux, - add a `setup_remote_docker` step to your .circleci/config.yml. - If you're building on Cirrus CI, use `docker_builder` task. - """ - ), - file=sys.stderr, + except subprocess.CalledProcessError as error: + msg = unwrap( + f""" + cibuildwheel: {build_step.container_engine.name} not found. An + OCI exe like Docker or Podman is required to run Linux builds. + If you're building on Travis CI, add `services: [docker]` to + your .travis.yml. If you're building on Circle CI in Linux, + add a `setup_remote_docker` step to your .circleci/config.yml. + If you're building on Cirrus CI, use `docker_builder` task. + """ ) - sys.exit(2) + raise errors.ConfigurationError(msg) from error try: ids_to_build = [x.identifier for x in build_step.platform_configs] @@ -483,11 +468,9 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001 ) except subprocess.CalledProcessError as error: - log.step_end_with_error( - f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}" - ) troubleshoot(options, error) - sys.exit(1) + msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" + raise errors.FatalError(msg) from error def _matches_prepared_command(error_cmd: Sequence[str], command_template: str) -> bool: @@ -506,7 +489,6 @@ def troubleshoot(options: Options, error: Exception) -> None: ) # TODO allow matching of overrides too? ): # the wheel build step or the repair step failed - print("Checking for common errors...") so_files = list(options.globals.package_dir.glob("**/*.so")) if so_files: diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index d679f57ae..312e2f559 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -152,6 +152,10 @@ def error(self, error: BaseException | str) -> None: c = self.colors print(f"{c.bright_red}Error{c.end}: {error}\n", file=sys.stderr) + @property + def step_active(self) -> bool: + return self.step_start_time is not None + def _start_fold_group(self, name: str) -> None: self._end_fold_group() self.active_fold_group_name = name diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index 890ca89e9..c07574df2 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -16,6 +16,7 @@ from filelock import FileLock from packaging.version import Version +from . import errors from ._compat.typing import assert_never from .architecture import Architecture from .environment import ParsedEnvironment @@ -150,14 +151,13 @@ def install_cpython(tmp: Path, version: str, url: str, free_threading: bool) -> if detect_ci_provider() is None: # if running locally, we don't want to install CPython with sudo # let the user know & provide a link to the installer - print( + msg = ( f"Error: CPython {version} is not installed.\n" "cibuildwheel will not perform system-wide installs when running outside of CI.\n" f"To build locally, install CPython {version} on this machine, or, disable this version of Python using CIBW_SKIP=cp{version.replace('.', '')}-macosx_*\n" - f"\nDownload link: {url}", - file=sys.stderr, + f"\nDownload link: {url}" ) - raise SystemExit(1) + raise errors.FatalError(msg) pkg_path = tmp / "Python.pkg" # download the pkg download(url, pkg_path) @@ -279,22 +279,16 @@ def setup_python( call("pip", "--version", env=env) which_pip = call("which", "pip", env=env, capture_stdout=True).strip() if which_pip != str(venv_bin_path / "pip"): - print( - "cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it." + raise errors.FatalError(msg) # check what Python version we're on call("which", "python", env=env) call("python", "--version", env=env) which_python = call("which", "python", env=env, capture_stdout=True).strip() if which_python != str(venv_bin_path / "python"): - print( - "cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it." + raise errors.FatalError(msg) config_is_arm64 = python_configuration.identifier.endswith("arm64") config_is_universal2 = python_configuration.identifier.endswith("universal2") @@ -756,7 +750,5 @@ def build(options: Options, tmp_path: Path) -> None: log.build_end() except subprocess.CalledProcessError as error: - log.step_end_with_error( - f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}" - ) - sys.exit(1) + msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" + raise errors.FatalError(msg) from error diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index da2d68d65..a2b1b7a8b 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -8,15 +8,14 @@ import enum import functools import shlex -import sys import textwrap -import traceback from collections.abc import Callable, Generator, Iterable, Iterator, Set from pathlib import Path from typing import Any, Literal, Mapping, Sequence, TypedDict, Union # noqa: TID251 from packaging.specifiers import SpecifierSet +from . import errors from ._compat import tomllib from ._compat.typing import NotRequired, assert_never from .architecture import Architecture @@ -52,6 +51,7 @@ class CommandLineArguments: print_build_identifiers: bool allow_empty: bool prerelease_pythons: bool + debug_traceback: bool @staticmethod def defaults() -> CommandLineArguments: @@ -65,6 +65,7 @@ def defaults() -> CommandLineArguments: package_dir=Path("."), prerelease_pythons=False, print_build_identifiers=False, + debug_traceback=False, ) @@ -597,18 +598,14 @@ def build_options(self, identifier: str | None) -> BuildOptions: try: build_frontend = BuildFrontendConfig.from_config_string(build_frontend_str) except ValueError as e: - print(f"cibuildwheel: {e}", file=sys.stderr) - sys.exit(2) + msg = f"Failed to parse build frontend. {e}" + raise errors.ConfigurationError(msg) from e try: environment = parse_environment(environment_config) - except (EnvironmentParseError, ValueError): - print( - f"cibuildwheel: Malformed environment option {environment_config!r}", - file=sys.stderr, - ) - traceback.print_exc(None, sys.stderr) - sys.exit(2) + except (EnvironmentParseError, ValueError) as e: + msg = f"Malformed environment option {environment_config!r}" + raise errors.ConfigurationError(msg) from e # Pass through environment variables if self.platform == "linux": @@ -680,9 +677,8 @@ def build_options(self, identifier: str | None) -> BuildOptions: try: container_engine = OCIContainerEngineConfig.from_config_string(container_engine_str) except ValueError as e: - msg = f"cibuildwheel: Failed to parse container config. {e}" - print(msg, file=sys.stderr) - sys.exit(2) + msg = f"Failed to parse container config. {e}" + raise errors.ConfigurationError(msg) from e return BuildOptions( globals=self.globals, @@ -862,6 +858,6 @@ def _get_pinned_container_images() -> Mapping[str, Mapping[str, str]]: def deprecated_selectors(name: str, selector: str, *, error: bool = False) -> None: if "p2" in selector or "p35" in selector: msg = f"cibuildwheel 2.x no longer supports Python < 3.6. Please use the 1.x series or update {name}" - print(msg, file=sys.stderr) if error: - sys.exit(4) + raise errors.DeprecationError(msg) + log.warning(msg) diff --git a/cibuildwheel/pyodide.py b/cibuildwheel/pyodide.py index 780e43cee..67488edce 100644 --- a/cibuildwheel/pyodide.py +++ b/cibuildwheel/pyodide.py @@ -2,13 +2,13 @@ import os import shutil -import sys from collections.abc import Sequence, Set from dataclasses import dataclass from pathlib import Path from filelock import FileLock +from . import errors from .architecture import Architecture from .environment import ParsedEnvironment from .logger import log @@ -96,11 +96,8 @@ def get_base_python(identifier: str) -> Path: python_name = f"python{major_minor}" which_python = shutil.which(python_name) if which_python is None: - print( - f"Error: CPython {major_minor} is not installed.", - file=sys.stderr, - ) - raise SystemExit(1) + msg = f"CPython {major_minor} is not installed." + raise errors.FatalError(msg) return Path(which_python) @@ -141,22 +138,16 @@ def setup_python( call("pip", "--version", env=env) which_pip = call("which", "pip", env=env, capture_stdout=True).strip() if which_pip != str(venv_bin_path / "pip"): - print( - "cibuildwheel: pip available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "pip available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it." + raise errors.FatalError(msg) # check what Python version we're on call("which", "python", env=env) call("python", "--version", env=env) which_python = call("which", "python", env=env, capture_stdout=True).strip() if which_python != str(venv_bin_path / "python"): - print( - "cibuildwheel: python available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "python available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it." + raise errors.FatalError(msg) log.step("Installing build tools...") call( @@ -219,8 +210,9 @@ def build(options: Options, tmp_path: Path) -> None: build_frontend = build_options.build_frontend or BuildFrontendConfig("build") if build_frontend.name == "pip": - print("The pyodide platform doesn't support pip frontend", file=sys.stderr) - sys.exit(1) + msg = "The pyodide platform doesn't support pip frontend" + raise errors.FatalError(msg) + log.build_start(config.identifier) identifier_tmp_dir = tmp_path / config.identifier diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index ce6e60a5f..efc0d0a78 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -448,7 +448,7 @@ def from_config_string(config_string: str) -> BuildFrontendConfig: config_dict = parse_key_value_string(config_string, ["name"], ["args"]) name = " ".join(config_dict["name"]) if name not in {"pip", "build", "build[uv]"}: - msg = f"Unrecognised build frontend {name}, only 'pip', 'build', and 'build[uv]' are supported" + msg = f"Unrecognised build frontend {name!r}, only 'pip', 'build', and 'build[uv]' are supported" raise ValueError(msg) name = typing.cast(BuildFrontendName, name) diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 263e8cf9a..010657e6e 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -4,7 +4,6 @@ import platform as platform_module import shutil import subprocess -import sys import textwrap from collections.abc import MutableMapping, Sequence, Set from dataclasses import dataclass @@ -14,6 +13,7 @@ from filelock import FileLock from packaging.version import Version +from . import errors from ._compat.typing import assert_never from .architecture import Architecture from .environment import ParsedEnvironment @@ -290,22 +290,16 @@ def setup_python( call("python", "-c", "\"import struct; print(struct.calcsize('P') * 8)\"", env=env) where_python = call("where", "python", env=env, capture_stdout=True).splitlines()[0].strip() if where_python != str(venv_path / "Scripts" / "python.exe"): - print( - "cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it." + raise errors.FatalError(msg) # check what pip version we're on if not use_uv: assert (venv_path / "Scripts" / "pip.exe").exists() where_pip = call("where", "pip", env=env, capture_stdout=True).splitlines()[0].strip() if where_pip.strip() != str(venv_path / "Scripts" / "pip.exe"): - print( - "cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it.", - file=sys.stderr, - ) - sys.exit(1) + msg = "pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it." + raise errors.FatalError(msg) call("pip", "--version", env=env) @@ -583,7 +577,5 @@ def build(options: Options, tmp_path: Path) -> None: log.build_end() except subprocess.CalledProcessError as error: - log.step_end_with_error( - f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}" - ) - sys.exit(1) + msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" + raise errors.FatalError(msg) from error diff --git a/docs/options.md b/docs/options.md index b322317a6..052172543 100644 --- a/docs/options.md +++ b/docs/options.md @@ -1612,6 +1612,20 @@ Default: Off (0). export CIBW_DEBUG_KEEP_CONTAINER=TRUE ``` +### `CIBW_DEBUG_TRACEBACK` +> Print full traceback when errors occur. + +Print a full traceback for the cibuildwheel process when errors occur. This +option is provided for debugging cibuildwheel. + +This option can also be set using the [command-line option](#command-line) `--debug-traceback`. + +#### Examples + +```shell +export CIBW_DEBUG_TRACEBACK=TRUE +``` + ### `CIBW_BUILD_VERBOSITY` {: #build-verbosity} > Increase/decrease the output of pip wheel @@ -1638,12 +1652,25 @@ Platform-specific environment variables are also available:
``` -## Command line options {: #command-line} +## Command line {: #command-line} + +### Options ```text « subprocess_run("cibuildwheel", "--help") » ``` +### Return codes + +cibuildwheel exits 0 on success, or >0 if an error occurs. + +Specific error codes are defined: + +- 2 means a configuration error +- 3 means no builds are selected (and --allow-empty wasn't passed) +- 4 means you specified an option that has been deprecated. + + ## Placeholders Some options support placeholders, like `{project}`, `{package}` or `{wheel}`, that are substituted by cibuildwheel before they are used. If, for some reason, you need to write the literal name of a placeholder, e.g. literally `{project}` in a command that would ordinarily substitute `{project}`, prefix it with a hash character - `#{project}`. This is only necessary in commands where the specific string between the curly brackets would be substituted - otherwise, strings not modified. diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index 5b85da73b..acd37c1bc 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -344,6 +344,28 @@ def test_before_all(before_all, platform_specific, platform, intercepted_build_a assert build_options.before_all == (before_all or "") +@pytest.mark.parametrize("method", ["unset", "command_line", "env_var"]) +def test_debug_traceback(monkeypatch, method, capfd): + if method == "command_line": + monkeypatch.setattr(sys, "argv", [*sys.argv, "--debug-traceback"]) + elif method == "env_var": + monkeypatch.setenv("CIBW_DEBUG_TRACEBACK", "TRUE") + + # set an option that produces a configuration error + monkeypatch.setenv("CIBW_BUILD_FRONTEND", "invalid_value") + + with pytest.raises(SystemExit) as exit: + main() + assert exit.value.code == 2 + + _, err = capfd.readouterr() + + if method == "unset": + assert "Traceback (most recent call last)" not in err + else: + assert "Traceback (most recent call last)" in err + + def test_defaults(platform, intercepted_build_args): main() diff --git a/unit_test/options_test.py b/unit_test/options_test.py index 1b482c752..5320c06d9 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -7,6 +7,7 @@ import pytest +from cibuildwheel import errors from cibuildwheel.__main__ import get_build_identifiers, get_platform_module from cibuildwheel.bashlex_eval import local_environment_executor from cibuildwheel.environment import parse_environment @@ -127,7 +128,8 @@ def test_passthrough_evil(tmp_path, monkeypatch, env_var_value): xfail_env_parse = pytest.mark.xfail( - raises=SystemExit, reason="until we can figure out the right way to quote these values" + raises=errors.ConfigurationError, + reason="until we can figure out the right way to quote these values", )