Repository: copier-org/copier
Pull request #1991 (2025-02-27 15:06:28)

✨ Add TaskError exception for subprocess management
Introduce the TaskError exception to handle subprocess failures gracefully. 

solves #1990 



tsvikas: > Thanks for your contribution, @tsvikas! 🙏
> 
> I have two minor suggestions (see inline comment).
> 
> IIUC, a failing task will raise the `TaskError` exception and the message will be shown, but the error from the task command will be hidden when running Copier via CLI, as it's not printed anymore but only accessible via the `stdout`/`stderr` attributes of the exception object. I think we should catch this exception in
> 
Actually, the `stdout`/`stderr` will be None, since we did not capture them in `subprocess.run()`, and the output of the process still goes to the screen.

So we only need to output the message itself (`"Task {command!r} returned non-zero exit status {returncode}."`).
I believe that inheriting from `UserMessageError` will take care of that nicely?


tsvikas: I did all of the requested changes, except inheriting from `CalledProcessError` (see my comment above) - waiting for more input for that.
Hopefully i didn't do too much mess in the comments/review here 🙏
tsvikas: Done :)
tsvikas: 
> @copier-org/maintainers What is the correct use of `UserMessageError`? When I check its occurrences, it seems this error is directly raised when it originates from an improper use of Copier that can be fixed by the _user_. One counter-example I've seen is a missing `--unsafe`/`--trust` flag which raises `UnsafeTemplateError` (which isn't a subclass of `UserMessageError`), although this problem can be resolved by a Copier user by adding the flag. What about a task error? Is it an error caused mainly by a template author which can't be fixed by a user? Is subclassing `UserMessageError` appropriate in this case? WDYT?

personally i assumed that `UserMessageError` is for a ny exception that should be reported to the user in a message. 
if it is not the case, i will be happy for input about the way the @copier-org/maintainers prefer to use to report something to the user. is it the `printf_exception` function? something else? i don't want to add here a 2nd/3rd way to do something simple.

tsvikas: Hey @sisp , I saw you pinged the maintainers earlier regarding the `UserMessageError`—do you think it would be helpful to re-ping them, or should we proceed with an alternative approach?
Just want to make sure we align with the project’s conventions before finalizing this. Thanks!


pawamoy: > What is the correct use of UserMessageError?

I would just be guessing so I'll leave that to @yajo :bow: 
sisp: I'd like to wait for @yajo's opinion, as he is the lead maintainer with a long history developing Copier. All of us are working on Copier as time permits. Let's just wait for him to find time to share his thoughts.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/copier-org/copier/pull/1991?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) Report
Attention: Patch coverage is `95.83333%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 95.36%. Comparing base [(`82dd2cb`)](https://app.codecov.io/gh/copier-org/copier/commit/82dd2cbf78c1daaae973d5b1bd1c6f783daebe01?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) to head [(`0ae3b05`)](https://app.codecov.io/gh/copier-org/copier/commit/0ae3b05488ee1b74d7f8c9bd21588f3f99a5163a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).

| [Files with missing lines](https://app.codecov.io/gh/copier-org/copier/pull/1991?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | Patch % | Lines |
|---|---|---|
| [copier/errors.py](https://app.codecov.io/gh/copier-org/copier/pull/1991?src=pr&el=tree&filepath=copier%2Ferrors.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org#diff-Y29waWVyL2Vycm9ycy5weQ==) | 94.11% | [1 Missing :warning: ](https://app.codecov.io/gh/copier-org/copier/pull/1991?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1991      +/-   ##
==========================================
- Coverage   98.13%   95.36%   -2.77%     
==========================================
  Files          54       54              
  Lines        5832     5850      +18     
==========================================
- Hits         5723     5579     -144     
- Misses        109      271     +162     
```

| [Flag](https://app.codecov.io/gh/copier-org/copier/pull/1991/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/copier-org/copier/pull/1991/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | `95.36% <95.83%> (-2.77%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/copier-org/copier/pull/1991?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index abb833b..ce3be77 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -196,3 +196,32 @@ nix-shell -p cachix --run 'cachix use copier && cachix use devenv'
 
 If you use Nix Flakes, add `--accept-flake-config` to install our binary cache
 automatically.
+
+## How to create a new release
+
+This section is for maintainers. Since we use the
+[conventional commits](https://www.conventionalcommits.org/) standard, the easiest way
+to create a new release is to open Copier repo locally and run:
+
+```shell
+# Make sure you're in the last commit
+git checkout master
+git pull --tags
+
+# Create a new changelog entry and bump the version automatically
+cz bump --retry
+
+# Push it
+git push --tags
+```
+
+Now the tag is released, but GitHub won't display it in the releases page. For that:
+
+1. [Draft a new release](https://github.com/copier-org/copier/releases/new).
+1. Choose the tag you just pushed.
+1. Set the tag also as release title.
+1. Copy the just added changelog entry from [CHANGELOG](./CHANGELOG.md) and paste it as
+   a description.
+1. Enable "Set as the latest release".
+1. Optionally, enable "Create a discussion for this release".
+1. Click on "Publish release".
diff --git a/copier/cli.py b/copier/cli.py
index c3a6380..1cbee34 100644
--- a/copier/cli.py
+++ b/copier/cli.py
@@ -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)
diff --git a/copier/errors.py b/copier/errors.py
index 5c41f0b..f48e617 100644
--- a/copier/errors.py
+++ b/copier/errors.py
@@ -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."""
diff --git a/copier/main.py b/copier/main.py
index ce462f0..749b4e2 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -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."""
@@ -563,6 +566,10 @@ class Worker:
             if not question.get_when():
                 # Omit its answer from the answers file.
                 self.answers.hide(var_name)
+                # Delete last answers to re-compute the answer from the default
+                # value (if it exists).
+                if var_name in self.answers.last:
+                    del self.answers.last[var_name]
                 # Skip immediately to the next question when it has no default
                 # value.
                 if question.default is MISSING:
diff --git a/copier/settings.py b/copier/settings.py
index b1d5d36..30b0fa3 100644
--- a/copier/settings.py
+++ b/copier/settings.py
@@ -13,6 +13,7 @@ from platformdirs import user_config_path
 from pydantic import BaseModel, Field
 
 from .errors import MissingSettingsWarning
+from .tools import OS
 
 ENV_VAR = "COPIER_SETTINGS_PATH"
 
@@ -27,6 +28,10 @@ class Settings(BaseModel):
         default_factory=set, description="List of trusted repositories or prefixes"
     )
 
+    @staticmethod
+    def _default_settings_path() -> Path:
+        return user_config_path("copier", appauthor=False) / "settings.yml"
+
     @classmethod
     def from_file(cls, settings_path: Path | None = None) -> Settings:
         """Load settings from a file."""
@@ -35,7 +40,19 @@ class Settings(BaseModel):
             if env_path:
                 settings_path = Path(env_path)
             else:
-                settings_path = user_config_path("copier") / "settings.yml"
+                settings_path = cls._default_settings_path()
+
+                # NOTE: Remove after a sufficiently long deprecation period.
+                if OS == "windows":
+                    old_settings_path = user_config_path("copier") / "settings.yml"
+                    if old_settings_path.is_file():
+                        warnings.warn(
+                            f"Settings path {old_settings_path} is deprecated. "
+                            f"Please migrate to {settings_path}.",
+                            DeprecationWarning,
+                            stacklevel=2,
+                        )
+                        settings_path = old_settings_path
         if settings_path.is_file():
             data = yaml.safe_load(settings_path.read_bytes())
             return cls.model_validate(data)
diff --git a/docs/settings.md b/docs/settings.md
index d559d2d..66fbaf2 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -10,6 +10,10 @@ standard configuration directory for your platform:
 -   `%USERPROFILE%\AppData\Local\copier` on Windows as defined in
     [Known folders](https://docs.microsoft.com/en-us/windows/win32/shell/known-folders)
 
+!!! note
+
+    Windows only: `%USERPROFILE%\AppData\Local\copier\copier` was the standard configuration directory until v9.6.0. This standard configuration directory is deprecated and will be removed in a future version.
+
 This location can be overridden by setting the `COPIER_SETTINGS_PATH` environment
 variable.
 
diff --git a/flake.lock b/flake.lock
index 9c2175b..81a49c7 100644
--- a/flake.lock
+++ b/flake.lock
@@ -8,11 +8,11 @@
         "nixpkgs": "nixpkgs"
       },
       "locked": {
-        "lastModified": 1737621947,
-        "narHash": "sha256-8HFvG7fvIFbgtaYAY2628Tb89fA55nPm2jSiNs0/Cws=",
+        "lastModified": 1742042642,
+        "narHash": "sha256-D0gP8srrX0qj+wNYNPdtVJsQuFzIng3q43thnHXQ/es=",
         "owner": "cachix",
         "repo": "cachix",
-        "rev": "f65a3cd5e339c223471e64c051434616e18cc4f5",
+        "rev": "a624d3eaf4b1d225f918de8543ed739f2f574203",
         "type": "github"
       },
       "original": {
@@ -31,11 +31,11 @@
         "nixpkgs": "nixpkgs_3"
       },
       "locked": {
-        "lastModified": 1739852725,
-        "narHash": "sha256-OjdnHKQ+eWA8YvPUpl3xxyaNK91c9sMebqXgVdN8Lm4=",
+        "lastModified": 1744902663,
+        "narHash": "sha256-rXtUUxfQ34ukTy2OyHwuypnSgK95FRPGwJf69QnWMrc=",
         "owner": "cachix",
         "repo": "devenv",
-        "rev": "98b99188b1539c0d2a45e0a0a161a7b8e797caac",
+        "rev": "379980fb42ca804f7c3220d978d9da86360a68fa",
         "type": "github"
       },
       "original": {
@@ -135,11 +135,11 @@
         "nixpkgs": ["devenv", "nixpkgs"]
       },
       "locked": {
-        "lastModified": 1737465171,
-        "narHash": "sha256-R10v2hoJRLq8jcL4syVFag7nIGE7m13qO48wRIukWNg=",
+        "lastModified": 1742649964,
+        "narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
         "owner": "cachix",
         "repo": "git-hooks.nix",
-        "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
+        "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
         "type": "github"
       },
       "original": {
@@ -193,11 +193,11 @@
         "pre-commit-hooks": ["devenv"]
       },
       "locked": {
-        "lastModified": 1734114420,
-        "narHash": "sha256-n52PUzub5jZWc8nI/sR7UICOheU8rNA+YZ73YaHeCBg=",
+        "lastModified": 1741798497,
+        "narHash": "sha256-E3j+3MoY8Y96mG1dUIiLFm2tZmNbRvSiyN7CrSKuAVg=",
         "owner": "domenkozar",
         "repo": "nix",
-        "rev": "bde6a1a0d1f2af86caa4d20d23eca019f3d57eee",
+        "rev": "f3f44b2baaf6c4c6e179de8cbb1cc6db031083cd",
         "type": "github"
       },
       "original": {
@@ -275,12 +275,12 @@
     },
     "nixpkgs_4": {
       "locked": {
-        "lastModified": 1741862977,
-        "narHash": "sha256-prZ0M8vE/ghRGGZcflvxCu40ObKaB+ikn74/xQoNrGQ=",
-        "rev": "cdd2ef009676ac92b715ff26630164bb88fec4e0",
-        "revCount": 715614,
+        "lastModified": 1744440957,
+        "narHash": "sha256-FHlSkNqFmPxPJvy+6fNLaNeWnF1lZSgqVCl/eWaJRc4=",
+        "rev": "26d499fc9f1d567283d5d56fcf367edd815dba1d",
+        "revCount": 716947,
         "type": "tarball",
-        "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2411.715614%2Brev-cdd2ef009676ac92b715ff26630164bb88fec4e0/019590d8-bf83-7849-9c87-9e373480fc07/source.tar.gz"
+        "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2411.716947%2Brev-26d499fc9f1d567283d5d56fcf367edd815dba1d/01962e50-af41-7ff9-8765-ebb3d39458ba/source.tar.gz"
       },
       "original": {
         "type": "tarball",
@@ -296,11 +296,11 @@
         "treefmt-nix": "treefmt-nix"
       },
       "locked": {
-        "lastModified": 1741790785,
-        "narHash": "sha256-nzgO/ZCSBzWjbMkYDxG+yl9Z2eGbCgQu06Oku3ir5D4=",
+        "lastModified": 1743690424,
+        "narHash": "sha256-cX98bUuKuihOaRp8dNV1Mq7u6/CQZWTPth2IJPATBXc=",
         "owner": "nix-community",
         "repo": "poetry2nix",
-        "rev": "f842aae3a72ef764e38eaf3e75aa435fb9861bba",
+        "rev": "ce2369db77f45688172384bbeb962bc6c2ea6f94",
         "type": "github"
       },
       "original": {
diff --git a/poetry.lock b/poetry.lock
index bd8ac0f..f7fe4e9 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -523,13 +523,13 @@ testing = ["coverage", "pyyaml"]
 
 [[package]]
 name = "markdown-exec"
-version = "1.10.2"
+version = "1.10.3"
 description = "Utilities to execute code blocks in Markdown files."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "markdown_exec-1.10.2-py3-none-any.whl", hash = "sha256:cc92e8e9a3dbd02648732a7d25ad696b30cbaf28cd7b3de939545331ef5f3ec1"},
-    {file = "markdown_exec-1.10.2.tar.gz", hash = "sha256:1f5e199e60b6afea86e11515f096a53d06d8b32687ab6cd45f12551d3490692f"},
+    {file = "markdown_exec-1.10.3-py3-none-any.whl", hash = "sha256:74bfe5a9063fafab6199847cbef28dd5071a515e8959f326cf16f2ae7a66033b"},
+    {file = "markdown_exec-1.10.3.tar.gz", hash = "sha256:ddd33996526a54dcc33debc464a9d4c00c1acece092cf1843cbb1264bf6800a6"},
 ]
 
 [package.dependencies]
@@ -685,20 +685,20 @@ pyyaml = ">=5.1"
 
 [[package]]
 name = "mkdocs-material"
-version = "9.6.9"
+version = "9.6.12"
 description = "Documentation that simply works"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs_material-9.6.9-py3-none-any.whl", hash = "sha256:6e61b7fb623ce2aa4622056592b155a9eea56ff3487d0835075360be45a4c8d1"},
-    {file = "mkdocs_material-9.6.9.tar.gz", hash = "sha256:a4872139715a1f27b2aa3f3dc31a9794b7bbf36333c0ba4607cf04786c94f89c"},
+    {file = "mkdocs_material-9.6.12-py3-none-any.whl", hash = "sha256:92b4fbdc329e4febc267ca6e2c51e8501fa97b2225c5f4deb4d4e43550f8e61e"},
+    {file = "mkdocs_material-9.6.12.tar.gz", hash = "sha256:add6a6337b29f9ea7912cb1efc661de2c369060b040eb5119855d794ea85b473"},
 ]
 
 [package.dependencies]
 babel = ">=2.10,<3.0"
 backrefs = ">=5.7.post1,<6.0"
 colorama = ">=0.4,<1.0"
-jinja2 = ">=3.0,<4.0"
+jinja2 = ">=3.1,<4.0"
 markdown = ">=3.2,<4.0"
 mkdocs = ">=1.6,<2.0"
 mkdocs-material-extensions = ">=1.3,<2.0"
@@ -725,13 +725,13 @@ files = [
 
 [[package]]
 name = "mkdocstrings"
-version = "0.29.0"
+version = "0.29.1"
 description = "Automatic documentation from sources, for MkDocs."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "mkdocstrings-0.29.0-py3-none-any.whl", hash = "sha256:8ea98358d2006f60befa940fdebbbc88a26b37ecbcded10be726ba359284f73d"},
-    {file = "mkdocstrings-0.29.0.tar.gz", hash = "sha256:3657be1384543ce0ee82112c3e521bbf48e41303aa0c229b9ffcccba057d922e"},
+    {file = "mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6"},
+    {file = "mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42"},
 ]
 
 [package.dependencies]
@@ -743,7 +743,6 @@ mkdocs = ">=1.6"
 mkdocs-autorefs = ">=1.4"
 mkdocstrings-python = {version = ">=1.16.2", optional = true, markers = "extra == \"python\""}
 pymdown-extensions = ">=6.3"
-typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""}
 
 [package.extras]
 crystal = ["mkdocstrings-crystal (>=0.3.4)"]
@@ -844,13 +843,13 @@ files = [
 
 [[package]]
 name = "packaging"
-version = "24.2"
+version = "25.0"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
-    {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
+    {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
+    {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
 ]
 
 [[package]]
@@ -976,13 +975,13 @@ poetry-plugin = ["poetry (>=1.0,<3.0)"]
 
 [[package]]
 name = "poetry-dynamic-versioning"
-version = "1.8.0"
+version = "1.8.2"
 description = "Plugin for Poetry to enable dynamic versioning based on VCS tags"
 optional = false
 python-versions = "<4.0,>=3.7"
 files = [
-    {file = "poetry_dynamic_versioning-1.8.0-py3-none-any.whl", hash = "sha256:f1cd8c8b480544b1d6531e84aa0afcb68683c6a9a2a43a1c97f5f7749f9c883c"},
-    {file = "poetry_dynamic_versioning-1.8.0.tar.gz", hash = "sha256:81b8814d3cf9b86a6d1287a09e6351b062057ecda0d0ea698e94c1486043aa5a"},
+    {file = "poetry_dynamic_versioning-1.8.2-py3-none-any.whl", hash = "sha256:3758a0b12228230ce384fbb303948c5e405e1d33c3bbe1ba71144c15f5e7a8de"},
+    {file = "poetry_dynamic_versioning-1.8.2.tar.gz", hash = "sha256:d14de13d426ac28e98f4519aac7f4aa857e7b97ad9d7a4c72293377033065f44"},
 ]
 
 [package.dependencies]
@@ -1224,13 +1223,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
 
 [[package]]
 name = "pytest-cov"
-version = "6.0.0"
+version = "6.1.1"
 description = "Pytest plugin for measuring coverage."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
-    {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
+    {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"},
+    {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"},
 ]
 
 [package.dependencies]
@@ -1508,13 +1507,13 @@ types-docutils = "*"
 
 [[package]]
 name = "types-pyyaml"
-version = "6.0.12.20241230"
+version = "6.0.12.20250326"
 description = "Typing stubs for PyYAML"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6"},
-    {file = "types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c"},
+    {file = "types_pyyaml-6.0.12.20250326-py3-none-any.whl", hash = "sha256:961871cfbdc1ad8ae3cb6ae3f13007262bcfc168adc513119755a6e4d5d7ed65"},
+    {file = "types_pyyaml-6.0.12.20250326.tar.gz", hash = "sha256:5e2d86d8706697803f361ba0b8188eef2999e1c372cd4faee4ebb0844b8a4190"},
 ]
 
 [[package]]
@@ -1640,4 +1639,4 @@ type = ["pytest-mypy"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.9"
-content-hash = "8778772793661c235a48d4ab2de8839426451f751ca66a79387790f5067c2794"
+content-hash = "b871a6f90070af08db2af9a9303eb7dde034eef4363d26c67ca8f08be2be29b6"
diff --git a/pyproject.toml b/pyproject.toml
index c5715a6..58d7eeb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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


Test patch:
diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py
index b08eeed..4cc30ac 100644
--- a/tests/test_cleanup.py
+++ b/tests/test_cleanup.py
@@ -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,
diff --git a/tests/test_recopy.py b/tests/test_recopy.py
index 20bcedb..09dd2a5 100644
--- a/tests/test_recopy.py
+++ b/tests/test_recopy.py
@@ -130,3 +130,50 @@ def test_recopy_dont_validate_computed_value(
     run_recopy(dst, overwrite=True)
     answers = load_answersfile_data(dst)
     assert "computed" not in answers
+
+
+def test_conditional_computed_value(tmp_path_factory: pytest.TempPathFactory) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    build_file_tree(
+        {
+            src / "copier.yml": (
+                """\
+                first:
+                    type: bool
+
+                second:
+                    type: bool
+                    default: "{{ first }}"
+                    when: "{{ first }}"
+                """
+            ),
+            src / "{{ _copier_conf.answers_file }}.jinja": (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+            src / "log.txt.jinja": "{{ first }} {{ second }}",
+        }
+    )
+    git_save(src)
+
+    run_copy(str(src), dst, data={"first": True}, defaults=True)
+    answers = load_answersfile_data(dst)
+    assert answers["first"] is True
+    assert answers["second"] is True
+    assert (dst / "log.txt").read_text() == "True True"
+
+    git_save(dst, "v1")
+
+    run_recopy(dst, data={"first": False}, overwrite=True)
+    answers = load_answersfile_data(dst)
+    assert answers["first"] is False
+    assert "second" not in answers
+    assert (dst / "log.txt").read_text() == "False False"
+
+    git_save(dst, "v2")
+
+    run_recopy(dst, data={"first": True}, defaults=True, overwrite=True)
+    answers = load_answersfile_data(dst)
+    assert answers["first"] is True
+    assert answers["second"] is True
+    assert (dst / "log.txt").read_text() == "True True"
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 68abec7..5ae176d 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import os
+import platform
 import sys
 from pathlib import Path
 
@@ -25,6 +27,18 @@ def test_settings_from_default_location(settings_path: Path) -> None:
     assert settings.defaults == {"foo": "bar"}
 
 
+@pytest.mark.skipif(platform.system() != "Windows", reason="Windows-only test")
+def test_default_windows_settings_path() -> None:
+    settings = Settings()
+    assert settings._default_settings_path() == Path(
+        os.getenv("USERPROFILE", default=""),
+        "AppData",
+        "Local",
+        "copier",
+        "settings.yml",
+    )
+
+
 @pytest.mark.usefixtures("config_path")
 def test_settings_from_default_location_dont_exists() -> None:
     settings = Settings.from_file()
diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 07f42c3..b4f5f0d 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -1869,3 +1869,55 @@ def test_conflict_on_update_with_unicode_in_content(
             zzz🐍
             """
         )
+
+
+def test_conditional_computed_value(tmp_path_factory: pytest.TempPathFactory) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    build_file_tree(
+        {
+            src / "copier.yml": (
+                """\
+                first:
+                    type: bool
+
+                second:
+                    type: bool
+                    default: "{{ first }}"
+                    when: "{{ first }}"
+                """
+            ),
+            src / "{{ _copier_conf.answers_file }}.jinja": (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+            src / "log.txt.jinja": "{{ first }} {{ second }}",
+        }
+    )
+    with local.cwd(src):
+        git_init("v1")
+        git("tag", "v1")
+
+    run_copy(str(src), dst, data={"first": True}, defaults=True)
+    answers = load_answersfile_data(dst)
+    assert answers["first"] is True
+    assert answers["second"] is True
+    assert (dst / "log.txt").read_text() == "True True"
+
+    with local.cwd(dst):
+        git_init("v1")
+
+    run_update(dst, data={"first": False}, overwrite=True)
+    answers = load_answersfile_data(dst)
+    assert answers["first"] is False
+    assert "second" not in answers
+    assert (dst / "log.txt").read_text() == "False False"
+
+    with local.cwd(dst):
+        git("add", ".")
+        git("commit", "-m", "v2")
+
+    run_update(dst, data={"first": True}, defaults=True, overwrite=True)
+    answers = load_answersfile_data(dst)
+    assert answers["first"] is True
+    assert answers["second"] is True
+    assert (dst / "log.txt").read_text() == "True True"