Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor error handling to use exceptions #1719

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
92 changes: 59 additions & 33 deletions cibuildwheel/__main__.py
Expand Up @@ -6,6 +6,7 @@
import sys
import tarfile
import textwrap
import traceback
import typing
from collections.abc import Iterable, Sequence, Set
from pathlib import Path
Expand All @@ -17,6 +18,7 @@
import cibuildwheel.macos
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
Expand All @@ -30,10 +32,35 @@
chdir,
detect_ci_provider,
fix_ansi_codes_for_github_actions,
strtobool,
)

# a global variable that decides what happens when errors are hit.
print_traceback_on_error = True


def main() -> None:
try:
main_inner()
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 print_traceback_on_error:
traceback.print_exc(file=sys.stderr)

sys.exit(e.return_code)


def main_inner() -> 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="""
Expand Down Expand Up @@ -131,8 +158,18 @@
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 print_traceback_on_error # noqa: PLW0603

Check warning on line 170 in cibuildwheel/__main__.py

View workflow job for this annotation

GitHub Actions / Linters (mypy, flake8, etc.)

Using the global statement
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
Expand Down Expand Up @@ -176,11 +213,8 @@
return "macos"
if "win_" in only or "win32" in only:
return "windows"
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:
Expand All @@ -191,34 +225,27 @@
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)
Expand Down Expand Up @@ -260,9 +287,8 @@

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(
Expand Down Expand Up @@ -293,16 +319,14 @@
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

Expand Down Expand Up @@ -357,7 +381,9 @@


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]
Expand Down
27 changes: 27 additions & 0 deletions cibuildwheel/errors.py
joerick marked this conversation as resolved.
Show resolved Hide resolved
@@ -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(Exception):
"""
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
53 changes: 19 additions & 34 deletions cibuildwheel/linux.py
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path, PurePath, PurePosixPath
from typing import OrderedDict, Tuple

from . import errors
from ._compat.typing import assert_never
from .architecture import Architecture
from .logger import log
Expand Down Expand Up @@ -139,11 +140,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(
Expand Down Expand Up @@ -215,19 +212,13 @@ 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)

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:
Expand Down Expand Up @@ -395,21 +386,18 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
check=True,
stdout=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
print(
unwrap(
f"""
cibuildwheel: {options.globals.container_engine} 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: {options.globals.container_engine} 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

python_configurations = get_python_configurations(
options.globals.build_selector, options.globals.architectures
Expand Down Expand Up @@ -446,11 +434,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:
Expand All @@ -469,7 +455,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:
Expand Down
4 changes: 4 additions & 0 deletions cibuildwheel/logger.py
Expand Up @@ -151,6 +151,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
Expand Down
21 changes: 7 additions & 14 deletions cibuildwheel/macos.py
Expand Up @@ -16,6 +16,7 @@

from filelock import FileLock

from . import errors
from ._compat.typing import assert_never
from .architecture import Architecture
from .environment import ParsedEnvironment
Expand Down Expand Up @@ -218,22 +219,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")
Expand Down Expand Up @@ -621,7 +616,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