Skip to content

Commit bf817c6

Browse files
joerickhenryiiimayeut
authored
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: <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 <[email protected]> * 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 <[email protected]> Co-authored-by: mayeut <[email protected]>
1 parent 384c8d5 commit bf817c6

File tree

12 files changed

+207
-142
lines changed

12 files changed

+207
-142
lines changed

cibuildwheel/__main__.py

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

33
import argparse
4+
import dataclasses
45
import os
56
import shutil
67
import sys
78
import tarfile
89
import textwrap
10+
import traceback
911
import typing
1012
from collections.abc import Iterable, Sequence, Set
1113
from pathlib import Path
@@ -18,6 +20,7 @@
1820
import cibuildwheel.pyodide
1921
import cibuildwheel.util
2022
import cibuildwheel.windows
23+
from cibuildwheel import errors
2124
from cibuildwheel._compat.typing import assert_never
2225
from cibuildwheel.architecture import Architecture, allowed_architectures_check
2326
from cibuildwheel.logger import log
@@ -31,10 +34,38 @@
3134
chdir,
3235
detect_ci_provider,
3336
fix_ansi_codes_for_github_actions,
37+
strtobool,
3438
)
3539

3640

41+
@dataclasses.dataclass
42+
class GlobalOptions:
43+
print_traceback_on_error: bool = True # decides what happens when errors are hit.
44+
45+
3746
def main() -> None:
47+
global_options = GlobalOptions()
48+
try:
49+
main_inner(global_options)
50+
except errors.FatalError as e:
51+
message = e.args[0]
52+
if log.step_active:
53+
log.step_end_with_error(message)
54+
else:
55+
print(f"cibuildwheel: {message}", file=sys.stderr)
56+
57+
if global_options.print_traceback_on_error:
58+
traceback.print_exc(file=sys.stderr)
59+
60+
sys.exit(e.return_code)
61+
62+
63+
def main_inner(global_options: GlobalOptions) -> None:
64+
"""
65+
`main_inner` is the same as `main`, but it raises FatalError exceptions
66+
rather than exiting directly.
67+
"""
68+
3869
parser = argparse.ArgumentParser(
3970
description="Build wheels for all the platforms.",
4071
epilog="""
@@ -132,8 +163,17 @@ def main() -> None:
132163
help="Enable pre-release Python versions if available.",
133164
)
134165

166+
parser.add_argument(
167+
"--debug-traceback",
168+
action="store_true",
169+
default=strtobool(os.environ.get("CIBW_DEBUG_TRACEBACK", "0")),
170+
help="Print a full traceback for all errors",
171+
)
172+
135173
args = CommandLineArguments(**vars(parser.parse_args()))
136174

175+
global_options.print_traceback_on_error = args.debug_traceback
176+
137177
args.package_dir = args.package_dir.resolve()
138178

139179
# This are always relative to the base directory, even in SDist builds
@@ -179,11 +219,8 @@ def _compute_platform_only(only: str) -> PlatformName:
179219
return "windows"
180220
if "pyodide_" in only:
181221
return "pyodide"
182-
print(
183-
f"Invalid --only='{only}', must be a build selector with a known platform",
184-
file=sys.stderr,
185-
)
186-
sys.exit(2)
222+
msg = f"Invalid --only='{only}', must be a build selector with a known platform"
223+
raise errors.ConfigurationError(msg)
187224

188225

189226
def _compute_platform_auto() -> PlatformName:
@@ -194,34 +231,27 @@ def _compute_platform_auto() -> PlatformName:
194231
elif sys.platform == "win32":
195232
return "windows"
196233
else:
197-
print(
234+
msg = (
198235
'cibuildwheel: Unable to detect platform from "sys.platform". cibuildwheel doesn\'t '
199236
"support building wheels for this platform. You might be able to build for a different "
200-
"platform using the --platform argument. Check --help output for more information.",
201-
file=sys.stderr,
237+
"platform using the --platform argument. Check --help output for more information."
202238
)
203-
sys.exit(2)
239+
raise errors.ConfigurationError(msg)
204240

205241

206242
def _compute_platform(args: CommandLineArguments) -> PlatformName:
207243
platform_option_value = args.platform or os.environ.get("CIBW_PLATFORM", "auto")
208244

209245
if args.only and args.platform is not None:
210-
print(
211-
"--platform cannot be specified with --only, it is computed from --only",
212-
file=sys.stderr,
213-
)
214-
sys.exit(2)
246+
msg = "--platform cannot be specified with --only, it is computed from --only"
247+
raise errors.ConfigurationError(msg)
215248
if args.only and args.archs is not None:
216-
print(
217-
"--arch cannot be specified with --only, it is computed from --only",
218-
file=sys.stderr,
219-
)
220-
sys.exit(2)
249+
msg = "--arch cannot be specified with --only, it is computed from --only"
250+
raise errors.ConfigurationError(msg)
221251

222252
if platform_option_value not in PLATFORMS | {"auto"}:
223-
print(f"cibuildwheel: Unsupported platform: {platform_option_value}", file=sys.stderr)
224-
sys.exit(2)
253+
msg = f"Unsupported platform: {platform_option_value}"
254+
raise errors.ConfigurationError(msg)
225255

226256
if args.only:
227257
return _compute_platform_only(args.only)
@@ -268,9 +298,8 @@ def build_in_directory(args: CommandLineArguments) -> None:
268298

269299
if not any(package_dir.joinpath(name).exists() for name in package_files):
270300
names = ", ".join(sorted(package_files, reverse=True))
271-
msg = f"cibuildwheel: Could not find any of {{{names}}} at root of package"
272-
print(msg, file=sys.stderr)
273-
sys.exit(2)
301+
msg = f"Could not find any of {{{names}}} at root of package"
302+
raise errors.ConfigurationError(msg)
274303

275304
platform_module = get_platform_module(platform)
276305
identifiers = get_build_identifiers(
@@ -301,16 +330,14 @@ def build_in_directory(args: CommandLineArguments) -> None:
301330
options.check_for_invalid_configuration(identifiers)
302331
allowed_architectures_check(platform, options.globals.architectures)
303332
except ValueError as err:
304-
print("cibuildwheel:", *err.args, file=sys.stderr)
305-
sys.exit(4)
333+
raise errors.DeprecationError(*err.args) from err
306334

307335
if not identifiers:
308-
print(
309-
f"cibuildwheel: No build identifiers selected: {options.globals.build_selector}",
310-
file=sys.stderr,
311-
)
312-
if not args.allow_empty:
313-
sys.exit(3)
336+
message = f"No build identifiers selected: {options.globals.build_selector}"
337+
if args.allow_empty:
338+
print(f"cibuildwheel: {message}", file=sys.stderr)
339+
else:
340+
raise errors.NothingToDoError(message)
314341

315342
output_dir = options.globals.output_dir
316343

@@ -365,7 +392,9 @@ def print_preamble(platform: str, options: Options, identifiers: Sequence[str])
365392

366393

367394
def get_build_identifiers(
368-
platform_module: PlatformModule, build_selector: BuildSelector, architectures: Set[Architecture]
395+
platform_module: PlatformModule,
396+
build_selector: BuildSelector,
397+
architectures: Set[Architecture],
369398
) -> list[str]:
370399
python_configurations = platform_module.get_python_configurations(build_selector, architectures)
371400
return [config.identifier for config in python_configurations]

cibuildwheel/errors.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Errors that can cause the build to fail. Each subclass of FatalError has
3+
a different return code, by defining them all here, we can ensure that they're
4+
semantically clear and unique.
5+
"""
6+
7+
8+
class FatalError(BaseException):
9+
"""
10+
Raising an error of this type will cause the message to be printed to
11+
stderr and the process to be terminated. Within cibuildwheel, raising this
12+
exception produces a better error message, and optional traceback.
13+
"""
14+
15+
return_code: int = 1
16+
17+
18+
class ConfigurationError(FatalError):
19+
return_code = 2
20+
21+
22+
class NothingToDoError(FatalError):
23+
return_code = 3
24+
25+
26+
class DeprecationError(FatalError):
27+
return_code = 4

cibuildwheel/linux.py

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from packaging.version import Version
1212

13+
from . import errors
1314
from ._compat.typing import assert_never
1415
from .architecture import Architecture
1516
from .logger import log
@@ -147,11 +148,7 @@ def check_all_python_exist(
147148
exist = False
148149
if not exist:
149150
message = "\n".join(messages)
150-
print(
151-
f"cibuildwheel:\n{message}",
152-
file=sys.stderr,
153-
)
154-
sys.exit(1)
151+
raise errors.FatalError(message)
155152

156153

157154
def build_in_container(
@@ -225,28 +222,19 @@ def build_in_container(
225222
# check config python is still on PATH
226223
which_python = container.call(["which", "python"], env=env, capture_output=True).strip()
227224
if PurePosixPath(which_python) != python_bin / "python":
228-
print(
229-
"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.",
230-
file=sys.stderr,
231-
)
232-
sys.exit(1)
225+
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."
226+
raise errors.FatalError(msg)
233227

234228
if use_uv:
235229
which_uv = container.call(["which", "uv"], env=env, capture_output=True).strip()
236230
if not which_uv:
237-
print(
238-
"cibuildwheel: uv not found on PATH. You must use a supported manylinux or musllinux environment with uv.",
239-
file=sys.stderr,
240-
)
241-
sys.exit(1)
231+
msg = "uv not found on PATH. You must use a supported manylinux or musllinux environment with uv."
232+
raise errors.FatalError(msg)
242233
else:
243234
which_pip = container.call(["which", "pip"], env=env, capture_output=True).strip()
244235
if PurePosixPath(which_pip) != python_bin / "pip":
245-
print(
246-
"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.",
247-
file=sys.stderr,
248-
)
249-
sys.exit(1)
236+
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."
237+
raise errors.FatalError(msg)
250238

251239
compatible_wheel = find_compatible_wheel(built_wheels, config.identifier)
252240
if compatible_wheel:
@@ -446,21 +434,18 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
446434
check=True,
447435
stdout=subprocess.DEVNULL,
448436
)
449-
except subprocess.CalledProcessError:
450-
print(
451-
unwrap(
452-
f"""
453-
cibuildwheel: {build_step.container_engine.name} not found. An
454-
OCI exe like Docker or Podman is required to run Linux builds.
455-
If you're building on Travis CI, add `services: [docker]` to
456-
your .travis.yml. If you're building on Circle CI in Linux,
457-
add a `setup_remote_docker` step to your .circleci/config.yml.
458-
If you're building on Cirrus CI, use `docker_builder` task.
459-
"""
460-
),
461-
file=sys.stderr,
437+
except subprocess.CalledProcessError as error:
438+
msg = unwrap(
439+
f"""
440+
cibuildwheel: {build_step.container_engine.name} not found. An
441+
OCI exe like Docker or Podman is required to run Linux builds.
442+
If you're building on Travis CI, add `services: [docker]` to
443+
your .travis.yml. If you're building on Circle CI in Linux,
444+
add a `setup_remote_docker` step to your .circleci/config.yml.
445+
If you're building on Cirrus CI, use `docker_builder` task.
446+
"""
462447
)
463-
sys.exit(2)
448+
raise errors.ConfigurationError(msg) from error
464449

465450
try:
466451
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
483468
)
484469

485470
except subprocess.CalledProcessError as error:
486-
log.step_end_with_error(
487-
f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}"
488-
)
489471
troubleshoot(options, error)
490-
sys.exit(1)
472+
msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}"
473+
raise errors.FatalError(msg) from error
491474

492475

493476
def _matches_prepared_command(error_cmd: Sequence[str], command_template: str) -> bool:
@@ -506,7 +489,6 @@ def troubleshoot(options: Options, error: Exception) -> None:
506489
) # TODO allow matching of overrides too?
507490
):
508491
# the wheel build step or the repair step failed
509-
print("Checking for common errors...")
510492
so_files = list(options.globals.package_dir.glob("**/*.so"))
511493

512494
if so_files:

cibuildwheel/logger.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ def error(self, error: BaseException | str) -> None:
152152
c = self.colors
153153
print(f"{c.bright_red}Error{c.end}: {error}\n", file=sys.stderr)
154154

155+
@property
156+
def step_active(self) -> bool:
157+
return self.step_start_time is not None
158+
155159
def _start_fold_group(self, name: str) -> None:
156160
self._end_fold_group()
157161
self.active_fold_group_name = name

cibuildwheel/macos.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from filelock import FileLock
1717
from packaging.version import Version
1818

19+
from . import errors
1920
from ._compat.typing import assert_never
2021
from .architecture import Architecture
2122
from .environment import ParsedEnvironment
@@ -150,14 +151,13 @@ def install_cpython(tmp: Path, version: str, url: str, free_threading: bool) ->
150151
if detect_ci_provider() is None:
151152
# if running locally, we don't want to install CPython with sudo
152153
# let the user know & provide a link to the installer
153-
print(
154+
msg = (
154155
f"Error: CPython {version} is not installed.\n"
155156
"cibuildwheel will not perform system-wide installs when running outside of CI.\n"
156157
f"To build locally, install CPython {version} on this machine, or, disable this version of Python using CIBW_SKIP=cp{version.replace('.', '')}-macosx_*\n"
157-
f"\nDownload link: {url}",
158-
file=sys.stderr,
158+
f"\nDownload link: {url}"
159159
)
160-
raise SystemExit(1)
160+
raise errors.FatalError(msg)
161161
pkg_path = tmp / "Python.pkg"
162162
# download the pkg
163163
download(url, pkg_path)
@@ -279,22 +279,16 @@ def setup_python(
279279
call("pip", "--version", env=env)
280280
which_pip = call("which", "pip", env=env, capture_stdout=True).strip()
281281
if which_pip != str(venv_bin_path / "pip"):
282-
print(
283-
"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.",
284-
file=sys.stderr,
285-
)
286-
sys.exit(1)
282+
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."
283+
raise errors.FatalError(msg)
287284

288285
# check what Python version we're on
289286
call("which", "python", env=env)
290287
call("python", "--version", env=env)
291288
which_python = call("which", "python", env=env, capture_stdout=True).strip()
292289
if which_python != str(venv_bin_path / "python"):
293-
print(
294-
"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.",
295-
file=sys.stderr,
296-
)
297-
sys.exit(1)
290+
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."
291+
raise errors.FatalError(msg)
298292

299293
config_is_arm64 = python_configuration.identifier.endswith("arm64")
300294
config_is_universal2 = python_configuration.identifier.endswith("universal2")
@@ -756,7 +750,5 @@ def build(options: Options, tmp_path: Path) -> None:
756750

757751
log.build_end()
758752
except subprocess.CalledProcessError as error:
759-
log.step_end_with_error(
760-
f"Command {error.cmd} failed with code {error.returncode}. {error.stdout}"
761-
)
762-
sys.exit(1)
753+
msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}"
754+
raise errors.FatalError(msg) from error

0 commit comments

Comments
 (0)