feat: raise new TaskError exception on task errors

Raising `TaskError` instead of `subprocess.CalledProcessError` is backwards compatible, as `TaskError` is a subclass of `subprocess.CalledProcessError`.
This commit is contained in:
Tsvika Shapira 2025-04-21 16:13:58 +03:00 committed by GitHub
parent 94c5bbdd12
commit a812e14033
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 53 additions and 8 deletions

View File

@ -71,7 +71,7 @@ def _handle_exceptions(method: Callable[[], None]) -> int:
except KeyboardInterrupt:
raise UserMessageError("Execution stopped by user")
except UserMessageError as error:
print(colors.red | "\n".join(error.args), file=sys.stderr)
print(colors.red | error.message, file=sys.stderr)
return 1
except UnsafeTemplateError as error:
print(colors.red | "\n".join(error.args), file=sys.stderr)

View File

@ -2,7 +2,10 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from subprocess import CompletedProcess
from typing import TYPE_CHECKING, Sequence
from .tools import printf_exception
@ -12,6 +15,11 @@ if TYPE_CHECKING: # always false
from .template import Template
from .user_data import AnswersMap, Question
if sys.version_info < (3, 11):
from typing_extensions import Self
else:
from typing import Self
# Errors
class CopierError(Exception):
@ -21,6 +29,12 @@ class CopierError(Exception):
class UserMessageError(CopierError):
"""Exit the program giving a message to the user."""
def __init__(self, message: str):
self.message = message
def __str__(self) -> str:
return self.message
class UnsupportedVersionError(UserMessageError):
"""Copier version does not support template version."""
@ -122,6 +136,35 @@ class MultipleYieldTagsError(CopierError):
"""Multiple yield tags are used in one path name, but it is not allowed."""
class TaskError(subprocess.CalledProcessError, UserMessageError):
"""Exception raised when a task fails."""
def __init__(
self,
command: str | Sequence[str],
returncode: int,
stdout: str | bytes | None,
stderr: str | bytes | None,
):
subprocess.CalledProcessError.__init__(
self, returncode=returncode, cmd=command, output=stdout, stderr=stderr
)
message = f"Task {command!r} returned non-zero exit status {returncode}."
UserMessageError.__init__(self, message)
@classmethod
def from_process(
cls, process: CompletedProcess[str] | CompletedProcess[bytes]
) -> Self:
"""Create a TaskError from a CompletedProcess."""
return cls(
command=process.args,
returncode=process.returncode,
stdout=process.stdout,
stderr=process.stderr,
)
# Warnings
class CopierWarning(Warning):
"""Base class for all other Copier warnings."""

View File

@ -42,6 +42,7 @@ from .errors import (
CopierAnswersInterrupt,
ExtensionNotFoundError,
InteractiveSessionError,
TaskError,
UnsafeTemplateError,
UserMessageError,
YieldTagInFileError,
@ -388,7 +389,9 @@ class Worker:
extra_env = {k[1:].upper(): str(v) for k, v in extra_context.items()}
with local.cwd(working_directory), local.env(**extra_env):
subprocess.run(task_cmd, shell=use_shell, check=True, env=local.env)
process = subprocess.run(task_cmd, shell=use_shell, env=local.env)
if process.returncode:
raise TaskError.from_process(process)
def _render_context(self) -> AnyByStrMutableMapping:
"""Produce render context for Jinja."""

2
poetry.lock generated
View File

@ -1639,4 +1639,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9"
content-hash = "8778772793661c235a48d4ab2de8839426451f751ca66a79387790f5067c2794"
content-hash = "b871a6f90070af08db2af9a9303eb7dde034eef4363d26c67ca8f08be2be29b6"

View File

@ -42,6 +42,7 @@ pyyaml = ">=5.3.1"
questionary = ">=1.8.1"
eval-type-backport = { version = ">=0.1.3,<0.3.0", python = "<3.10" }
platformdirs = ">=4.3.6"
typing-extensions = { version = ">=4.0.0,<5.0.0", python = "<3.11" }
[tool.poetry.group.dev]
optional = true
@ -59,7 +60,6 @@ types-backports = ">=0.1.3"
types-colorama = ">=0.4"
types-pygments = ">=2.17"
types-pyyaml = ">=6.0.4"
typing-extensions = { version = ">=3.10.0.0,<5.0.0", python = "<3.10" }
[tool.poetry.group.docs]
optional = true

View File

@ -1,5 +1,4 @@
from pathlib import Path
from subprocess import CalledProcessError
import pytest
from plumbum import local
@ -10,7 +9,7 @@ import copier
def test_cleanup(tmp_path: Path) -> None:
"""Copier creates dst_path, fails to copy and removes it."""
dst = tmp_path / "new_folder"
with pytest.raises(CalledProcessError):
with pytest.raises(copier.errors.TaskError):
copier.run_copy("./tests/demo_cleanup", dst, quiet=True, unsafe=True)
assert not dst.exists()
@ -18,7 +17,7 @@ def test_cleanup(tmp_path: Path) -> None:
def test_do_not_cleanup(tmp_path: Path) -> None:
"""Copier creates dst_path, fails to copy and keeps it."""
dst = tmp_path / "new_folder"
with pytest.raises(CalledProcessError):
with pytest.raises(copier.errors.TaskError):
copier.run_copy(
"./tests/demo_cleanup", dst, quiet=True, unsafe=True, cleanup_on_error=False
)
@ -29,7 +28,7 @@ def test_no_cleanup_when_folder_existed(tmp_path: Path) -> None:
"""Copier will not delete a folder if it didn't create it."""
preexisting_file = tmp_path / "something"
preexisting_file.touch()
with pytest.raises(CalledProcessError):
with pytest.raises(copier.errors.TaskError):
copier.run_copy(
"./tests/demo_cleanup",
tmp_path,