Skip to content

Commit 498a5f5

Browse files
committed
feat: add failure_message option for tasks (#2080)
* If a `failure_message` option is provided, show that message: "Task N failed: a nice failure message" * If no `failure_message` option is provided, show the subprocess exception: "Task N failed: Command 'might-fail' returned non-zero exit status 1." Addresses Issue #2080: #2080
1 parent be767f6 commit 498a5f5

File tree

5 files changed

+65
-4
lines changed

5 files changed

+65
-4
lines changed

copier/_main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,14 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
389389
with local.cwd(working_directory), local.env(**extra_env):
390390
process = subprocess.run(task_cmd, shell=use_shell, env=local.env)
391391
if process.returncode:
392-
raise TaskError.from_process(process)
392+
if task.failure_message:
393+
message = self._render_string(task.failure_message, extra_context)
394+
else:
395+
message = f"{task_cmd!r} returned non-zero exit status {process.returncode}"
396+
raise TaskError.from_process(
397+
process,
398+
message=f"Task {i + 1} failed: {message}.",
399+
)
393400

394401
def _render_context(self) -> AnyByStrMutableMapping:
395402
"""Produce render context for Jinja."""

copier/_template.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,17 @@ class Task:
169169
working_directory:
170170
The directory from inside where to execute the task.
171171
If `None`, the project directory will be used.
172+
173+
failure_message:
174+
Provides a message to pritn if the task fails.
175+
If `None`, the subprocess exception message will be used.
172176
"""
173177

174178
cmd: str | Sequence[str]
175179
extra_vars: dict[str, Any] = field(default_factory=dict)
176180
condition: str | bool = True
177181
working_directory: Path = Path()
182+
failure_message: str | None = None
178183

179184

180185
@dataclass
@@ -526,6 +531,7 @@ def tasks(self) -> Sequence[Task]:
526531
extra_vars=extra_vars,
527532
condition=task.get("when", "true"),
528533
working_directory=Path(task.get("working_directory", ".")),
534+
failure_message=task.get("failure_message"),
529535
)
530536
)
531537
else:

copier/errors.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,23 +146,31 @@ def __init__(
146146
returncode: int,
147147
stdout: str | bytes | None,
148148
stderr: str | bytes | None,
149+
message: str | None = None,
149150
):
150151
subprocess.CalledProcessError.__init__(
151152
self, returncode=returncode, cmd=command, output=stdout, stderr=stderr
152153
)
153-
message = f"Task {command!r} returned non-zero exit status {returncode}."
154-
UserMessageError.__init__(self, message)
154+
if not message:
155+
message = subprocess.CalledProcessError.__str__(self)
156+
UserMessageError.__init__(self, message=message)
157+
158+
def __str__(self) -> str:
159+
return self.message
155160

156161
@classmethod
157162
def from_process(
158-
cls, process: CompletedProcess[str] | CompletedProcess[bytes]
163+
cls,
164+
process: CompletedProcess[str] | CompletedProcess[bytes],
165+
message: str | None = None,
159166
) -> Self:
160167
"""Create a TaskError from a CompletedProcess."""
161168
return cls(
162169
command=process.args,
163170
returncode=process.returncode,
164171
stdout=process.stdout,
165172
stderr=process.stderr,
173+
message=message,
166174
)
167175

168176

docs/configuring.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,8 @@ If a `dict` is given it can contain the following items:
15721572
- **when** (optional): Specifies a condition that needs to hold for the task to run.
15731573
- **working_directory** (optional): Specifies the directory in which the command will
15741574
be run. Defaults to the destination directory.
1575+
- **failure_message** (optional): Provides a message to print if the task fails. If
1576+
not provided, the subprocess exception message will be shown.
15751577

15761578
If a `str` or `List[str]` is given as a task it will be treated as `command` with all
15771579
other items not present.
@@ -1599,6 +1601,8 @@ Refer to the example provided below for more information.
15991601
when: "{{ _copier_conf.os in ['linux', 'macos'] }}"
16001602
- command: Remove-Item {{ name_of_the_project }}\\README.md
16011603
when: "{{ _copier_conf.os == 'windows' }}"
1604+
- command: finalize-project
1605+
failure_message: Couldn't finalize {{ name_of_the_project }}
16021606
```
16031607
16041608
Note: the example assumes you use [Invoke](https://www.pyinvoke.org/) as

tests/test_tasks.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44
from typing import Literal
55

6+
from copier.errors import TaskError
67
import pytest
78
import yaml
89

@@ -180,3 +181,38 @@ def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None
180181
)
181182
copier.run_copy(str(src), dst, unsafe=True)
182183
assert (dst / "tasks").exists()
184+
185+
186+
@pytest.mark.parametrize(
187+
"task,failure_message,match,data",
188+
[
189+
("false", "Oh, dear! The task failed", r"Task \d+ failed: Oh, dear! The task failed\.", None),
190+
("ls non-existing-directory", None, r"Task \d+ failed: 'ls non-existing-directory' returned non-zero exit status 2\.", None),
191+
("false", "{{ name }} blew up", r"Task \d+ failed: Wile E. Coyote blew up\.", {"name": "Wile E. Coyote"}),
192+
],
193+
)
194+
def test_task_failure_message(
195+
tmp_path_factory: pytest.TempPathFactory,
196+
task: str,
197+
failure_message: str | None,
198+
match: str,
199+
data: dict[str, str] | None,
200+
) -> None:
201+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
202+
yaml_dict = {
203+
"_tasks": [
204+
{
205+
"command": task,
206+
}
207+
]
208+
}
209+
if failure_message:
210+
yaml_dict["_tasks"][0]["failure_message"] = failure_message
211+
212+
build_file_tree(
213+
{
214+
(src / "copier.yml"): yaml.safe_dump(yaml_dict)
215+
}
216+
)
217+
with pytest.raises(TaskError, match=match):
218+
copier.run_copy(str(src), dst, unsafe=True, data=data)

0 commit comments

Comments
 (0)