diff --git a/docs/changelog.d/+backend-failed-pickle.bugfix.rst b/docs/changelog.d/+backend-failed-pickle.bugfix.rst new file mode 100644 index 0000000..4d80625 --- /dev/null +++ b/docs/changelog.d/+backend-failed-pickle.bugfix.rst @@ -0,0 +1 @@ +Preserve ``BackendFailed`` details when copying or pickling an exception, including when a build runs in a process pool. diff --git a/src/pyproject_api/_frontend.py b/src/pyproject_api/_frontend.py index 3cd4d47..7fafbea 100644 --- a/src/pyproject_api/_frontend.py +++ b/src/pyproject_api/_frontend.py @@ -152,6 +152,10 @@ def __init__(self, result: dict[str, Any], out: str, err: str) -> None: #: the string representation of the exception thrown self.exc_msg: str = result.get("exc_msg", "missing Exception message") + def __reduce__(self) -> tuple[type[BackendFailed], tuple[dict[str, Any], str, str], dict[str, Any]]: + result = {"code": self.code, "exc_type": self.exc_type, "exc_msg": self.exc_msg} + return self.__class__, (result, self.out, self.err), self.__dict__ + def __str__(self) -> str: return ( f"packaging backend failed{'' if self.code is None else f' (code={self.code})'}, " diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 8410d90..5ddbcb3 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,11 +1,14 @@ from __future__ import annotations +import pickle # ruff:ignore[suspicious-pickle-import] import re import subprocess # ruff:ignore[suspicious-subprocess-import] import sys +from concurrent.futures import ProcessPoolExecutor +from multiprocessing import get_context from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING, Final, Literal +from typing import TYPE_CHECKING, Any, Final, Literal import pytest from packaging.requirements import Requirement @@ -721,3 +724,46 @@ def test_backend_failed_str_repr() -> None: exc = BackendFailed({"code": 3, "exc_type": "Error", "exc_msg": "msg"}, "out\n", "err\n") assert str(exc) == "packaging backend failed (code=3), with Error: msg\nerr\nout" + + +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) +@pytest.mark.parametrize("result", [{}, {"code": None}, {"code": 1, "exc_type": "TypeError", "exc_msg": "bad"}]) +def test_backend_failed_pickle(protocol: int, result: dict[str, Any]) -> None: + exc = BackendFailed(result, "backend stdout", "backend stderr") + exc.add_note("building a wheel") + restored = pickle.loads( # ruff:ignore[suspicious-pickle-usage] + pickle.dumps(exc, protocol=protocol) + ) + + assert type(restored) is BackendFailed + assert restored.args == exc.args == () + assert restored.__dict__ == exc.__dict__ + assert str(restored) == str(exc) + assert repr(restored) == repr(exc) + + +def _build_wheel_in_process(root: Path) -> str: + frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(root)[:-1]) + return str(frontend.build_wheel(root / "dist").wheel) + + +def test_backend_failed_process_pool(local_builder: Callable[[str], Path]) -> None: + root = local_builder(""" + import sys + + def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + print("backend stdout") + print("backend stderr", file=sys.stderr) + raise RuntimeError("build failed") + """) + with ProcessPoolExecutor(max_workers=1, mp_context=get_context("spawn")) as executor: + future = executor.submit(_build_wheel_in_process, root) + with pytest.raises(BackendFailed) as context: + future.result(timeout=30) + exc = context.value + assert exc.args == () + assert exc.code == 1 + assert exc.exc_type == "RuntimeError" + assert exc.exc_msg == "build failed" + assert "backend stdout" in exc.out + assert "backend stderr" in exc.err