Repository: copier-org/copier
Pull request #1733 (2024-08-13 18:33:36)

feat: Add `_operation` variable
* Makes `exclude` configuration templatable.
* Adds a `_copier_operation` variable to the rendering contexts for `exclude` and `tasks`, representing the current operation - either `copy`~~, `recopy`~~ or `update`.
* ~~Adds the (semi-)undocumented `_copier_python`, `_copier_conf.os` and `_folder_name` variables to the docs in a second commit (since I restructured them anyways)~~ ~~extracted into https://github.com/copier-org/copier/pull/1856, will rebase once it's merged~~ rebased

Notes:
This was proposed here: https://github.com/copier-org/copier/issues/1718#issuecomment-2282643624

I hope the way it's implemented and tested is as intended by @yajo. ~~The tests are quite synthetic,~~ I would expect the usual application to be `_copier_operation (!/=)= "update"`.

Fixes: https://github.com/copier-org/copier/issues/1725
Fixes: https://github.com/copier-org/copier/issues/1625
([mostly](https://github.com/copier-org/copier/pull/1733#issuecomment-2621951039)) Fixes: https://github.com/copier-org/copier/issues/240

Alternative to https://github.com/copier-org/copier/pull/1732 (regarding update exclusions only).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/copier-org/copier/pull/1733?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.67%. Comparing base [(`5ac93ee`)](https://app.codecov.io/gh/copier-org/copier/commit/5ac93ee1993740a2c8e2d7fa8b050f91a25292a2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) to head [(`e0cac30`)](https://app.codecov.io/gh/copier-org/copier/commit/e0cac30691f9edfe9b07fbb509c372b15ba7c248?dropdown=coverage&el=desc&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    #1733      +/-   ##
==========================================
- Coverage   97.61%   96.67%   -0.95%     
==========================================
  Files          49       50       +1     
  Lines        5042     5083      +41     
==========================================
- Hits         4922     4914       -8     
- Misses        120      169      +49     
```

| [Flag](https://app.codecov.io/gh/copier-org/copier/pull/1733/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/1733/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | `96.67% <100.00%> (-0.95%)` | :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/1733?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).

lkubb: @yajo Is there anything I still need to do here? Just making sure you noticed the changes. :)
lkubb: > Sorry for taking so long to give feedback. 🫣 This PR really needs some deep thinking.

@sisp Thank you for your thoughtful feedback and sorry for taking my time to reply as well.

> Adding the attribute `operation` to the `Worker` class seems conceptually wrong to me because we'd make the operation mode part of its state while it also has methods to execute either operation (`run_copy()` and `run_update()`).

Agreed, I wasn't really happy with it myself, also because it meant the forced inclusion of the operation in inappropriate contexts. Part of this design was caused by the tight coupling of the Copier worker with the renderer imho, I wasn't sure how to solve it properly without a major refactoring.

> Thus, it would be possible to set the `operation` attribute to `"update"` and call `run_copy()` (or set the `operation` attribute to `"copy"` and call `run_update()`) which would lead to incorrect behavior. This indicates a bad design to me.

Note that it's only the former situation that leads to incorrect behavior, the latter is the expected path since the `run_update` method replaces the `operation` value itself.

> How about using a [context variable](https://docs.python.org/3/library/contextvars.html) to manage the operation context? This is a simplified example of what I mean:

Thanks a lot for your well thought-out proposal. I agree this design is superior and am currently implementing it. I'm still contemplating a detail since it aggravates a flaw I just noticed in the `exclude` rendering logic by decoupling the context switch implementation from the worker:

Since `match_excludes` is a cached property whose value now depends on the `_operation`, a changed `_operation` is not reflected in its value if an instance has already been used in a different context. This is not an issue in practice currently since a context switch always coincides with the creation of a new worker instance via `replace`, but it's definitely a gotcha in case this changes at some point.

[Note: An analog of this is currently present in `match_skip`, especially if externally mutated instance attributes need to be considered in worker functions].

I might need to address this by `del`eting the property's cached value in the `finally` block of the context manager to force regeneration, which is still a bit smelly, or turning it into a cached function instead (less smelly, but a breaking change).

> So, how about omitting the `_copier_conf.operation` variable in the Jinja context while rendering files, directories, and path names? We'd need to document this behavior of course.

👍 
mschoettle: > Adds an _operation variable to the rendering contexts for exclude and tasks, representing the current operation - either copy, recopy or update.

What this PR adds is exactly what I am missing. 

I recently started using copier for an infrastructure project using compose, env files etc. with various different configuration options. 

There are some things, such as questions/answers and tasks, that I don’t want to ask/run on update. And, other tasks should only run when an update is performed. 

I am wondering what the rationale is behind not making the operation available to questions (conditions), if I read the above correctly. Would it be possible to use it in `when`?
lkubb: @mschoettle 

> There are some things, such as questions/answers and tasks, that I don’t want to ask/run on update. And, other tasks should only run when an update is performed.

For the latter I'd recommend using migrations. They can be configured to run independent of the involved versions.
 
> I am wondering what the rationale is behind not making the operation available to questions (conditions), if I read the above correctly. Would it be possible to use it in `when`?

No, the reason is that `when` == `False` variables are not saved, so you'd lose previously provided answers during an update. If I understood correctly, there's a workaround described in https://github.com/copier-org/copier/issues/1574#issuecomment-2067980359
pawamoy: Do we agree that this PR also fixes the long-standing https://github.com/copier-org/copier/issues/240?
sisp: @pawamoy I think it addresses part of #240, but pre/post copy/update granularity is not addressed. There's #1511, but it seems stale.

@lkubb @copier-org/maintainers A thought that originates from https://github.com/copier-org/copier/pull/1948#pullrequestreview-2578227878 (and subsequent comments): I think we should rename `_operation` to `_copier_operation`.
pawamoy: If I read #240 correctly, pre/post were specific to migrations, and are now handled with a `_stage` variable being `"before"` or `"after"`. Tasks get the same `_stage` variable with value `"task"`.

I'm not sure what pre-copy, post-copy, pre-update, or post-update would mean exactly. IMO this PR solves #240.

Let me also link again to [my comment above](https://github.com/copier-org/copier/pull/1733#discussion_r1922313252) about a third update-diff operation, which I think is super important to have.
sisp: Ah, you're right, @pawamoy. I got confused. :confused: But looking at https://copier.readthedocs.io/en/stable/updating/#how-the-update-works (and thinking about the detailed implementation), I think are pre-/post-migrations are equivalent to pre-/post-update tasks for `copier update`. So the only thing we seem to be missing is a pre-copy task. Right? :thinking:
pawamoy: > So the only thing we seem to be missing is a pre-copy task. Right?

Seems like it, yes! I feel like this deserves a proper entry in the docs, something like "Pre/post hooks", which is how Cookiecutter named them, and will be familiar to most users. This entry would document that you can use migration as pre/post-update hooks (with examples), tasks as post-copy or post-update hooks, and... pre-copy hooks are not supported yet. The docs entry should emphasize that for simple message display, users can set `message_after_copy` and related settings. Let me copy this in a new docs issue.
lkubb: >  I think we should rename `_operation` to `_copier_operation`.

@sisp Done and rebased.

Given the many comments this PR has accumulated over the half year it has been open, I'll try to give a final summary of what it provides and where it fits in the Copier hook system.

<details>
<summary>This PR</summary>

1) Adds templating functionality to `_exclude` items
2) Adds a `_copier_operation` context variable for the `_tasks` and `_exclude` rendering contexts, which indicates whether `copier (re)copy` or `copier update` was invoked by the user.

(1+2) allows to provide dumb boilerplate files which will be excluded during updates
(2) additionally relates to hooks, see `after a copy` below
</details>


If you need your logic to run...

<details>
<summary>before/after any `update`</summary>
Use a `migration` without specifying a version constraint and with appropriate `when` (or even `when: 'true'` to run both before and after). Example:

```yaml
_migrations:
  - this_script_runs_after_all_updates.sh
  - command: this_script_runs_before_all_updates.sh
    when: "{{ _stage == 'before' }}"
  - command: this_script_runs_before_and_after_all_updates.sh
    when: "true"
```
</details>

<details>
<summary>before a `copy`</summary>
Come up with an ugly hack possibly involving a custom extension or provide a patch to core, not officially supported atm
</details>

<details>
<summary>after a `copy`</summary>
Use a `task` with `when: '{{ _copier_operation == "copy" }}'` Example:

```yaml
_tasks:
  - command: this_script_runs_after_copy_operations_only.sh
    when: "{{ _copier_operation == 'copy' }}"
```
</details>

> [!NOTE]
> All of the hook suggestions apply to the **generated project** only, not the two intermediate copies involved in `update` operations. If you need a task to run on these intermediate copies as well, specify a plain `task`. Off the top of my head, I can't come up with any use case for this, it seems fragile.


Is there anything I can do to help carry this PR over the line? It has been stalled for a while now and the release of Copier 9.5.0 broke the ugly hack I was using for not having `_copier_operation`:

```yaml
_tasks:
  # Don't run this when updating (updating copies the template twice).
  # This breaks when recopying.
  - >-
    {%- if
      not _copier_conf.answers.last
      and '.new_copy.' not in _copier_conf.dst_path.name
      and '.old_copy.' not in _copier_conf.dst_path.name -%}
        "{{ _copier_python }}" "{{ "{src}{sep}tasks{sep}initialize.py".format(src=_copier_conf.src_path, sep=_copier_conf.sep) }}" init
    {%- endif -%}
```

Specifically https://github.com/copier-org/copier/pull/1880, which added this line and thereby removed `_copier_conf.answers.last` from the context:

https://github.com/copier-org/copier/blob/e444514e5d714a318cf434bcb7fec13d21dfc93c/copier/main.py#L382


Patch:
diff --git a/copier/main.py b/copier/main.py
index f164ff0..45378fd 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -7,9 +7,10 @@ import platform
 import subprocess
 import sys
 from contextlib import suppress
+from contextvars import ContextVar
 from dataclasses import asdict, field, replace
 from filecmp import dircmp
-from functools import cached_property, partial
+from functools import cached_property, partial, wraps
 from itertools import chain
 from pathlib import Path
 from shutil import rmtree
@@ -65,6 +66,8 @@ from .types import (
     AnyByStrMutableMapping,
     JSONSerializable,
     LazyDict,
+    Operation,
+    ParamSpec,
     Phase,
     RelativePath,
     StrOrPath,
@@ -73,6 +76,29 @@ from .user_data import AnswersMap, Question, load_answersfile_data
 from .vcs import get_git
 
 _T = TypeVar("_T")
+_P = ParamSpec("_P")
+
+_operation: ContextVar[Operation] = ContextVar("_operation")
+
+
+def as_operation(value: Operation) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
+    """Decorator to set the current operation context, if not defined already.
+
+    This value is used to template specific configuration options.
+    """
+
+    def _decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
+        @wraps(func)
+        def _wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
+            token = _operation.set(_operation.get(value))
+            try:
+                return func(*args, **kwargs)
+            finally:
+                _operation.reset(token)
+
+        return _wrapper
+
+    return _decorator
 
 
 @dataclass(config=ConfigDict(extra="forbid"))
@@ -248,7 +274,7 @@ class Worker:
         for method in self._cleanup_hooks:
             method()
 
-    def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
+    def _check_unsafe(self, mode: Operation) -> None:
         """Check whether a template uses unsafe features."""
         if self.unsafe or self.settings.is_trusted(self.template.url):
             return
@@ -327,8 +353,10 @@ class Worker:
         Arguments:
             tasks: The list of tasks to run.
         """
+        operation = _operation.get()
         for i, task in enumerate(tasks):
             extra_context = {f"_{k}": v for k, v in task.extra_vars.items()}
+            extra_context["_copier_operation"] = operation
 
             if not cast_to_bool(self._render_value(task.condition, extra_context)):
                 continue
@@ -358,7 +386,7 @@ class Worker:
                 / Path(self._render_string(str(task.working_directory), extra_context))
             ).absolute()
 
-            extra_env = {k.upper(): str(v) for k, v in task.extra_vars.items()}
+            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)
 
@@ -625,7 +653,14 @@ class Worker:
     @cached_property
     def match_exclude(self) -> Callable[[Path], bool]:
         """Get a callable to match paths against all exclusions."""
-        return self._path_matcher(self.all_exclusions)
+        # Include the current operation in the rendering context.
+        # Note: This method is a cached property, it needs to be regenerated
+        # when reusing an instance in different contexts.
+        extra_context = {"_copier_operation": _operation.get()}
+        return self._path_matcher(
+            self._render_string(exclusion, extra_context=extra_context)
+            for exclusion in self.all_exclusions
+        )
 
     @cached_property
     def match_skip(self) -> Callable[[Path], bool]:
@@ -928,6 +963,7 @@ class Worker:
         return self.template.local_abspath / subdir
 
     # Main operations
+    @as_operation("copy")
     def run_copy(self) -> None:
         """Generate a subproject from zero, ignoring what was in the folder.
 
@@ -938,6 +974,11 @@ class Worker:
 
         See [generating a project][generating-a-project].
         """
+        with suppress(AttributeError):
+            # We might have switched operation context, ensure the cached property
+            # is regenerated to re-render templates.
+            del self.match_exclude
+
         self._check_unsafe("copy")
         self._print_message(self.template.message_before_copy)
         with Phase.use(Phase.PROMPT):
@@ -967,6 +1008,7 @@ class Worker:
             # TODO Unify printing tools
             print("")  # padding space
 
+    @as_operation("copy")
     def run_recopy(self) -> None:
         """Update a subproject, keeping answers but discarding evolution."""
         if self.subproject.template is None:
@@ -977,6 +1019,7 @@ class Worker:
         with replace(self, src_path=self.subproject.template.url) as new_worker:
             new_worker.run_copy()
 
+    @as_operation("update")
     def run_update(self) -> None:
         """Update a subproject that was already generated.
 
@@ -1024,6 +1067,11 @@ class Worker:
             print(
                 f"Updating to template version {self.template.version}", file=sys.stderr
             )
+        with suppress(AttributeError):
+            # We might have switched operation context, ensure the cached property
+            # is regenerated to re-render templates.
+            del self.match_exclude
+
         self._apply_update()
         self._print_message(self.template.message_after_update)
 
diff --git a/copier/types.py b/copier/types.py
index a8fd982..bcfa57f 100644
--- a/copier/types.py
+++ b/copier/types.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import sys
 from contextlib import contextmanager
 from contextvars import ContextVar
 from enum import Enum
@@ -24,6 +25,11 @@ from typing import (
 
 from pydantic import AfterValidator
 
+if sys.version_info >= (3, 10):
+    from typing import ParamSpec as ParamSpec
+else:
+    from typing_extensions import ParamSpec as ParamSpec
+
 # simple types
 StrOrPath = Union[str, Path]
 AnyByStrDict = Dict[str, Any]
@@ -44,6 +50,7 @@ VCSTypes = Literal["git"]
 Env = Mapping[str, str]
 MissingType = NewType("MissingType", object)
 MISSING = MissingType(object())
+Operation = Literal["copy", "update"]
 
 
 # Validators
diff --git a/docs/configuring.md b/docs/configuring.md
index 9d3c5e5..d40d60e 100644
--- a/docs/configuring.md
+++ b/docs/configuring.md
@@ -962,6 +962,21 @@ to know available options.
 
 The CLI option can be passed several times to add several patterns.
 
+Each pattern can be templated using Jinja.
+
+!!! example
+
+    Templating `exclude` patterns using `_copier_operation` allows to have files
+    that are rendered once during `copy`, but are never updated:
+
+    ```yaml
+    _exclude:
+        - "{% if _copier_operation == 'update' -%}src/*_example.py{% endif %}"
+    ```
+
+    The difference with [skip_if_exists][] is that it will never be rendered during
+    an update, no matter if it exitsts or not.
+
 !!! info
 
     When you define this parameter in `copier.yml`, it will **replace** the default
@@ -1421,6 +1436,8 @@ configuring `secret: true` in the [advanced prompt format][advanced-prompt-forma
 exist, but always be present. If they do not exist in a project during an `update`
 operation, they will be recreated.
 
+Each pattern can be templated using Jinja.
+
 !!! example
 
     For example, it can be used if your project generates a password the 1st time and
@@ -1571,6 +1588,9 @@ other items not present.
         - [invoke, end-process, "--full-conf={{ _copier_conf|to_json }}"]
         # Your script can be run by the same Python environment used to run Copier
         - ["{{ _copier_python }}", task.py]
+        # Run a command during the initial copy operation only, excluding updates
+        - command: ["{{ _copier_python }}", task.py]
+          when: "{{ _copier_operation == 'copy' }}"
         # OS-specific task (supported values are "linux", "macos", "windows" and `None`)
         - command: rm {{ name_of_the_project }}/README.md
           when: "{{ _copier_conf.os in  ['linux', 'macos'] }}"
diff --git a/docs/creating.md b/docs/creating.md
index 83e5398..b93129c 100644
--- a/docs/creating.md
+++ b/docs/creating.md
@@ -146,6 +146,16 @@ The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
     You may encounter this phase when rendering outside of those phases,
     when rendering lazily (and the phase notion can be irrelevant) or when testing.
 
+## Variables (context-dependent)
+
+Some variables are only available in select contexts:
+
+### `_copier_operation`
+
+The current operation, either `"copy"` or `"update"`.
+
+Availability: [`exclude`](configuring.md#exclude), [`tasks`](configuring.md#tasks)
+
 ## Variables (context-specific)
 
 Some rendering contexts provide variables unique to them:
diff --git a/docs/generating.md b/docs/generating.md
index 1a4d042..b15bf58 100644
--- a/docs/generating.md
+++ b/docs/generating.md
@@ -18,6 +18,10 @@ Or within Python code:
 copier.run_copy("path/to/project/template", "path/to/destination")
 ```
 
+If `path/to/destination` doesn't exist, Copier will create the directory and populate it
+with the generated files. If `path/to/destination` exists, it must be writable (not
+read-only).
+
 The "template" parameter can be a local path, an URL, or a shortcut URL:
 
 -   GitHub: `gh:namespace/project`
diff --git a/flake.lock b/flake.lock
index 82feceb..5cdb865 100644
--- a/flake.lock
+++ b/flake.lock
@@ -275,12 +275,12 @@
     },
     "nixpkgs_4": {
       "locked": {
-        "lastModified": 1740743217,
-        "narHash": "sha256-brsCRzLqimpyhORma84c3W2xPbIidZlIc3JGIuQVSNI=",
-        "rev": "b27ba4eb322d9d2bf2dc9ada9fd59442f50c8d7c",
-        "revCount": 715026,
+        "lastModified": 1741332913,
+        "narHash": "sha256-ri1e8ZliWS3Jnp9yqpKApHaOo7KBN33W8ECAKA4teAQ=",
+        "rev": "20755fa05115c84be00b04690630cb38f0a203ad",
+        "revCount": 715401,
         "type": "tarball",
-        "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2411.715026%2Brev-b27ba4eb322d9d2bf2dc9ada9fd59442f50c8d7c/019550a2-12de-77e5-ae95-fe5d0e636b30/source.tar.gz"
+        "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2411.715401%2Brev-20755fa05115c84be00b04690630cb38f0a203ad/0195727e-13d5-7381-935b-fec5e4805a71/source.tar.gz"
       },
       "original": {
         "type": "tarball",
@@ -296,11 +296,11 @@
         "treefmt-nix": "treefmt-nix"
       },
       "locked": {
-        "lastModified": 1739883580,
-        "narHash": "sha256-3ydikhrNaWy8j0cqHju/94PcD4GZ9T4Ju4rHh34oz3k=",
+        "lastModified": 1741163974,
+        "narHash": "sha256-QehWX1ik9vqkylkbvdpO5XZKaqf2fsE92iMEyRBW2qI=",
         "owner": "nix-community",
         "repo": "poetry2nix",
-        "rev": "d90f9db68a4bda31c346be16dfd8d3263be4547e",
+        "rev": "98293f0b368f24c48e05aaa2359dcc0de15e976f",
         "type": "github"
       },
       "original": {
diff --git a/poetry.lock b/poetry.lock
index ceac254..c1e81b0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -725,13 +725,13 @@ files = [
 
 [[package]]
 name = "mkdocstrings"
-version = "0.28.2"
+version = "0.28.3"
 description = "Automatic documentation from sources, for MkDocs."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "mkdocstrings-0.28.2-py3-none-any.whl", hash = "sha256:57f79c557e2718d217d6f6a81bf75a0de097f10e922e7e5e00f085c3f0ff6895"},
-    {file = "mkdocstrings-0.28.2.tar.gz", hash = "sha256:9b847266d7a588ea76a8385eaebe1538278b4361c0d1ce48ed005be59f053569"},
+    {file = "mkdocstrings-0.28.3-py3-none-any.whl", hash = "sha256:df5351ffd10477aa3c2ff5cdf17544b936477195436923660274d084a5c1359c"},
+    {file = "mkdocstrings-0.28.3.tar.gz", hash = "sha256:c753516b1b6cee12d00bf9c28255e22c0d71f34c721ca668971fce885d846e0f"},
 ]
 
 [package.dependencies]
@@ -742,30 +742,31 @@ MarkupSafe = ">=1.1"
 mkdocs = ">=1.4"
 mkdocs-autorefs = ">=1.4"
 mkdocs-get-deps = ">=0.2"
-mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
+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)"]
-python = ["mkdocstrings-python (>=0.5.2)"]
+python = ["mkdocstrings-python (>=1.16.2)"]
 python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
 
 [[package]]
 name = "mkdocstrings-python"
-version = "1.12.2"
+version = "1.16.3"
 description = "A Python handler for mkdocstrings."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "mkdocstrings_python-1.12.2-py3-none-any.whl", hash = "sha256:7f7d40d6db3cb1f5d19dbcd80e3efe4d0ba32b073272c0c0de9de2e604eda62a"},
-    {file = "mkdocstrings_python-1.12.2.tar.gz", hash = "sha256:7a1760941c0b52a2cd87b960a9e21112ffe52e7df9d0b9583d04d47ed2e186f3"},
+    {file = "mkdocstrings_python-1.16.3-py3-none-any.whl", hash = "sha256:17b4d7a1add16032dff4b2f54b51d889fbd5d2fa87c166b22fbc3a56d37ec75e"},
+    {file = "mkdocstrings_python-1.16.3.tar.gz", hash = "sha256:d0cdee60399b397e7cbb25075cac4aa7a27b34f75621eb1898dfebc98b88d600"},
 ]
 
 [package.dependencies]
 griffe = ">=0.49"
-mkdocs-autorefs = ">=1.2"
-mkdocstrings = ">=0.26"
+mkdocs-autorefs = ">=1.4"
+mkdocstrings = ">=0.28.3"
+typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
 
 [[package]]
 name = "mypy"


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d54ca85..da6498b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -11,6 +11,7 @@ import yaml
 from plumbum import local
 
 from copier.cli import CopierApp
+from copier.user_data import load_answersfile_data
 
 from .helpers import COPIER_CMD, build_file_tree, git
 
@@ -70,7 +71,7 @@ def test_good_cli_run(template_path: str, tmp_path: Path) -> None:
     assert a_txt.exists()
     assert a_txt.is_file()
     assert a_txt.read_text() == "EXAMPLE_CONTENT"
-    answers = yaml.safe_load((tmp_path / "altered-answers.yml").read_text())
+    answers = load_answersfile_data(tmp_path, "altered-answers.yml")
     assert answers["_src_path"] == template_path
 
 
@@ -126,7 +127,7 @@ def test_cli_data_parsed_by_question_type(
         exit=False,
     )
     assert run_result[1] == 0
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_src_path"] == str(src)
     assert answers["question"] == expected
 
@@ -165,7 +166,7 @@ def test_data_file_parsed_by_question_type(
         exit=False,
     )
     assert run_result[1] == 0
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_src_path"] == str(src)
     assert answers["question"] == "answer"
 
@@ -216,7 +217,7 @@ def test_data_cli_takes_precedence_over_data_file(
         exit=False,
     )
     assert run_result[1] == 0
-    actual_answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    actual_answers = load_answersfile_data(dst)
     expected_answers = {
         "_src_path": str(src),
         "question": answer_override,
@@ -293,10 +294,8 @@ def test_read_utf_data_file(
         "text1": "\u3053\u3093\u306b\u3061\u306f",
         "text2": "\U0001f60e",
     }
-    with Path(dst / ".copier-answers.yml").open(encoding="utf-8") as f:
-        content = f.read()
-        actual_answers = yaml.safe_load(content)
-    assert actual_answers == expected_answers
+    answers = load_answersfile_data(dst)
+    assert answers == expected_answers
 
 
 @pytest.mark.parametrize(
@@ -338,9 +337,7 @@ def test_good_cli_run_dot_config(
     assert a_txt.exists()
     assert a_txt.is_file()
     assert a_txt.read_text() == "EXAMPLE_CONTENT"
-    answers = yaml.safe_load(
-        (tmp_path / config_folder / "altered-answers.yml").read_text()
-    )
+    answers = load_answersfile_data(tmp_path / config_folder, "altered-answers.yml")
     assert answers["_src_path"] == src
 
     with local.cwd(str(tmp_path)):
@@ -410,7 +407,7 @@ def test_skip_filenotexists(template_path: str, tmp_path: Path) -> None:
     assert a_txt.exists()
     assert a_txt.is_file()
     assert a_txt.read_text() == "EXAMPLE_CONTENT"
-    answers = yaml.safe_load((tmp_path / "altered-answers.yml").read_text())
+    answers = load_answersfile_data(tmp_path, "altered-answers.yml")
     assert answers["_src_path"] == str(template_path)
 
 
@@ -435,5 +432,5 @@ def test_skip_fileexists(template_path: str, tmp_path: Path) -> None:
     assert a_txt.exists()
     assert a_txt.is_file()
     assert a_txt.read_text() == "PREVIOUS_CONTENT"
-    answers = yaml.safe_load((tmp_path / "altered-answers.yml").read_text())
+    answers = load_answersfile_data(tmp_path, "altered-answers.yml")
     assert answers["_src_path"] == str(template_path)
diff --git a/tests/test_complex_questions.py b/tests/test_complex_questions.py
index 15f3ac7..e53f7ee 100644
--- a/tests/test_complex_questions.py
+++ b/tests/test_complex_questions.py
@@ -12,6 +12,7 @@ from pexpect.popen_spawn import PopenSpawn
 from plumbum import local
 
 from copier import run_copy, run_update
+from copier.user_data import load_answersfile_data
 
 from .helpers import (
     BRACKET_ENVOPS,
@@ -570,11 +571,11 @@ def test_multi_template_answers(tmp_path_factory: pytest.TempPathFactory) -> Non
         git("add", "-A")
         git("commit", "-m2")
         # Check contents
-        answers1 = yaml.safe_load(Path(".copier-answers.yml").read_text())
+        answers1 = load_answersfile_data(".")
         assert answers1["_src_path"] == str(tpl1)
         assert answers1["q1"] == "a1"
         assert "q2" not in answers1
-        answers2 = yaml.safe_load(Path("two.yml").read_text())
+        answers2 = load_answersfile_data(".", "two.yml")
         assert answers2["_src_path"] == str(tpl2)
         assert answers2["q2"] == "a2"
         assert "q1" not in answers2
@@ -610,7 +611,7 @@ def test_omit_answer_for_skipped_question(
         }
     )
     run_copy(str(src), dst, defaults=True, data={"disabled": "hello"})
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {"_src_path": str(src)}
     context = yaml.safe_load((dst / "context.yml").read_text())
     assert context == {"disabled": "hello", "disabled_with_default": "hello"}
diff --git a/tests/test_context.py b/tests/test_context.py
new file mode 100644
index 0000000..9344ff4
--- /dev/null
+++ b/tests/test_context.py
@@ -0,0 +1,90 @@
+import json
+from pathlib import Path
+
+import pytest
+from plumbum import local
+
+import copier
+
+from .helpers import build_file_tree, git_save
+
+
+def test_exclude_templating_with_operation(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    """
+    Ensure it's possible to create one-off boilerplate files that are not
+    managed during updates via `_exclude` using the `_copier_operation` context variable.
+    """
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    template = "{% if _copier_operation == 'update' %}copy-only{% endif %}"
+    with local.cwd(src):
+        build_file_tree(
+            {
+                "copier.yml": f'_exclude:\n - "{template}"',
+                "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
+                "copy-only": "foo",
+                "copy-and-update": "foo",
+            }
+        )
+        git_save(tag="1.0.0")
+        build_file_tree(
+            {
+                "copy-only": "bar",
+                "copy-and-update": "bar",
+            }
+        )
+        git_save(tag="2.0.0")
+    copy_only = dst / "copy-only"
+    copy_and_update = dst / "copy-and-update"
+
+    copier.run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="1.0.0")
+    for file in (copy_only, copy_and_update):
+        assert file.exists()
+        assert file.read_text() == "foo"
+
+    with local.cwd(dst):
+        git_save()
+
+    copier.run_update(str(dst), overwrite=True)
+    assert copy_only.read_text() == "foo"
+    assert copy_and_update.read_text() == "bar"
+
+
+def test_task_templating_with_operation(
+    tmp_path_factory: pytest.TempPathFactory, tmp_path: Path
+) -> None:
+    """
+    Ensure that it is possible to define tasks that are only executed when copying.
+    """
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    # Use a file outside the Copier working directories to ensure accurate tracking
+    task_counter = tmp_path / "task_calls.txt"
+    with local.cwd(src):
+        build_file_tree(
+            {
+                "copier.yml": (
+                    f"""\
+                    _tasks:
+                        -   command: echo {{{{ _copier_operation }}}} >> {json.dumps(str(task_counter))}
+                            when: "{{{{ _copier_operation == 'copy' }}}}"
+                    """
+                ),
+                "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
+            }
+        )
+        git_save(tag="1.0.0")
+
+    copier.run_copy(str(src), dst, defaults=True, overwrite=True, unsafe=True)
+    assert task_counter.exists()
+    assert len(task_counter.read_text().splitlines()) == 1
+
+    with local.cwd(dst):
+        git_save()
+
+    copier.run_recopy(dst, defaults=True, overwrite=True, unsafe=True)
+    assert len(task_counter.read_text().splitlines()) == 2
+
+    copier.run_update(dst, defaults=True, overwrite=True, unsafe=True)
+    assert len(task_counter.read_text().splitlines()) == 2
diff --git a/tests/test_legacy_migration.py b/tests/test_legacy_migration.py
index 1e4742c..0b49f08 100644
--- a/tests/test_legacy_migration.py
+++ b/tests/test_legacy_migration.py
@@ -4,11 +4,11 @@ from pathlib import Path
 from shutil import copytree
 
 import pytest
-import yaml
 from plumbum import local
 
 from copier import run_copy, run_update
 from copier.errors import UserMessageError
+from copier.user_data import load_answersfile_data
 
 from .helpers import BRACKET_ENVOPS_JSON, PROJECT_TEMPLATE, build_file_tree, git
 
@@ -58,7 +58,7 @@ def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None:
     assert not (dst / "tasks.py").exists()
     assert not list(dst.glob("*-before.txt"))
     assert not list(dst.glob("*-after.txt"))
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {"_commit": "v1.0.0", "_src_path": str(src)}
     # Save changes in downstream repo
     with local.cwd(dst):
@@ -88,7 +88,7 @@ def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None:
     assert (dst / "v1.0.0-v2-v2.0-after.json").is_file()
     assert (dst / "PEP440-1.0.0-2-2.0-before.json").is_file()
     assert (dst / "PEP440-1.0.0-2-2.0-after.json").is_file()
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {"_commit": "v2.0", "_src_path": str(src)}
 
 
@@ -216,7 +216,7 @@ def test_prereleases(tmp_path_factory: pytest.TempPathFactory) -> None:
         git("tag", "v2.0.0.alpha1")
     # Copying with use_prereleases=False copies v1
     run_copy(src_path=str(src), dst_path=dst, defaults=True, overwrite=True)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v1.0.0"
     assert (dst / "version.txt").read_text() == "v1.0.0"
     assert not (dst / "v1.9").exists()
@@ -247,7 +247,7 @@ def test_prereleases(tmp_path_factory: pytest.TempPathFactory) -> None:
             use_prereleases=True,
             unsafe=True,
         )
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v2.0.0.alpha1"
     assert (dst / "version.txt").read_text() == "v2.0.0.alpha1"
     assert (dst / "v1.9").exists()
@@ -281,7 +281,7 @@ def test_pretend_mode(tmp_path_factory: pytest.TempPathFactory) -> None:
         git("tag", "v1")
 
     run_copy(str(src), dst)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v1"
 
     with local.cwd(dst):
@@ -313,7 +313,7 @@ def test_pretend_mode(tmp_path_factory: pytest.TempPathFactory) -> None:
 
     with pytest.deprecated_call():
         run_update(dst_path=dst, overwrite=True, pretend=True, unsafe=True)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v1"
     assert not (dst / "v2-before.txt").exists()
     assert not (dst / "v2-after.txt").exists()
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 61aa4f1..1dad974 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -1,11 +1,11 @@
 from pathlib import Path
 
 import pytest
-import yaml
 from plumbum import local
 
 from copier import run_copy, run_update
 from copier.errors import UnsafeTemplateError, UserMessageError
+from copier.user_data import load_answersfile_data
 
 from .helpers import (
     COPIER_ANSWERS_FILE,
@@ -160,7 +160,7 @@ def test_prerelease_version_migration(tmp_path_factory: pytest.TempPathFactory)
         # No pre-releases. Should update to v1.9
         run_update(defaults=True, overwrite=True, unsafe=True)
 
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v1.9"
     assert (dst / "v1.9").exists()
     assert all(not (dst / version).exists() for version in versions)
@@ -170,7 +170,7 @@ def test_prerelease_version_migration(tmp_path_factory: pytest.TempPathFactory)
         # With pre-releases. Should update to v2.a2
         run_update(defaults=True, overwrite=True, unsafe=True, use_prereleases=True)
 
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v2.a2"
     assert (dst / "v1.9").exists()
     assert all((dst / version).exists() for version in versions)
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
index 1034e55..492f806 100644
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -11,6 +11,7 @@ from pexpect.popen_spawn import PopenSpawn
 from plumbum import local
 
 from copier.types import StrOrPath
+from copier.user_data import load_answersfile_data
 
 from .helpers import (
     BRACKET_ENVOPS,
@@ -130,10 +131,10 @@ def test_copy_default_advertised(
         tui.expect_exact(f"Bowser hates {name}")
         tui.sendline()
         tui.expect_exact(pexpect.EOF)
-        assert "_commit: v1" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v1"
         # Update subproject
         git_save()
-        assert "_commit: v1" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v1"
         tui = spawn(COPIER_PATH + ("update",), timeout=30)
         # Check what was captured
         expect_prompt(tui, "in_love", "bool")
@@ -149,7 +150,7 @@ def test_copy_default_advertised(
         tui.expect_exact(f"Bowser hates {name}")
         tui.sendline()
         tui.expect_exact(pexpect.EOF)
-        assert "_commit: v2" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v2"
 
 
 @pytest.mark.parametrize(
@@ -198,7 +199,7 @@ def test_update_skip_answered(
         tui.expect_exact(f"Bowser hates {name}")
         tui.sendline()
         tui.expect_exact(pexpect.EOF)
-        assert "_commit: v1" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v1"
         # Update subproject
         git_save()
         tui = spawn(
@@ -214,7 +215,7 @@ def test_update_skip_answered(
         tui.expect_exact("******")
         tui.sendline()
         tui.expect_exact(pexpect.EOF)
-        assert "_commit: v2" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v2"
 
 
 @pytest.mark.parametrize(
@@ -261,7 +262,7 @@ def test_update_with_new_field_in_new_version_skip_answered(
         tui.expect_exact(f"Bowser hates {name}")
         tui.sendline()
         tui.expect_exact(pexpect.EOF)
-        assert "_commit: v1" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v1"
         # Update subproject
         git_save()
         tui = spawn(
@@ -280,7 +281,7 @@ def test_update_with_new_field_in_new_version_skip_answered(
         tui.expect_exact("Luigi")
         tui.sendline()
         tui.expect_exact(pexpect.EOF)
-        assert "_commit: v2" in Path(".copier-answers.yml").read_text()
+        assert load_answersfile_data(".").get("_commit") == "v2"
 
 
 @pytest.mark.parametrize(
@@ -363,7 +364,7 @@ def test_when(
         expect_prompt(tui, "question_2", "yaml")
         tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {
         "_src_path": str(src),
         "question_1": question_1,
@@ -402,7 +403,7 @@ def test_placeholder(tmp_path_factory: pytest.TempPathFactory, spawn: Spawn) ->
     tui.expect_exact("Write something like answer 1, but better")
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {
         "_src_path": str(src),
         "question_1": "answer 1",
@@ -454,7 +455,7 @@ def test_multiline(
     expect_prompt(tui, "question_5", type_)
     tui.sendline('"answer 5"')
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     if type_ == "str":
         assert answers == {
             "_src_path": str(src),
@@ -518,7 +519,7 @@ def test_update_choice(
     expect_prompt(tui, "pick_one", "float")
     tui.sendline(Keyboard.Up)
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["pick_one"] == 2.0
     with local.cwd(dst):
         git("init")
@@ -536,7 +537,7 @@ def test_update_choice(
     expect_prompt(tui, "pick_one", "float")
     tui.sendline(Keyboard.Down)
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["pick_one"] == 3.0
 
 
@@ -595,7 +596,7 @@ def test_multiline_defaults(
     )
     tui.send(Keyboard.Alt + Keyboard.Enter)
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert (
         answers["yaml_single"]
         == answers["yaml_multi"]
@@ -666,7 +667,7 @@ def test_var_name_value_allowed(
     tui.send(Keyboard.Alt + Keyboard.Enter)
     tui.expect_exact(pexpect.EOF)
     # Check answers file
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["value"] == "string"
 
 
@@ -710,7 +711,7 @@ def test_required_text_question(
         assert not (dst / ".copier-answers.yml").exists()
     else:
         tui.expect_exact(pexpect.EOF)
-        answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+        answers = load_answersfile_data(dst)
         assert answers == {
             "_src_path": str(src),
             "question": expected_answer,
@@ -740,7 +741,7 @@ def test_required_bool_question(
     tui.expect_exact("(y/N)")
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {
         "_src_path": str(src),
         "question": False,
@@ -786,7 +787,7 @@ def test_required_choice_question(
     expect_prompt(tui, "question", type_name)
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers == {
         "_src_path": str(src),
         "question": expected_answer,
@@ -864,7 +865,7 @@ def test_multiselect_choices_question_single_answer(
     tui.send(" ")  # select 1
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["question"] == values[:1]
 
 
@@ -884,7 +885,7 @@ def test_multiselect_choices_question_multiple_answers(
     tui.send(" ")  # select 1
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["question"] == values[:2]
 
 
@@ -904,7 +905,7 @@ def test_multiselect_choices_question_with_default(
     tui.send(" ")  # toggle first
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["question"] == values[1:]
 
 
@@ -933,7 +934,7 @@ def test_update_multiselect_choices(
     tui.send(" ")  # toggle first
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["question"] == values[1:]
 
     with local.cwd(dst):
@@ -947,7 +948,7 @@ def test_update_multiselect_choices(
     tui.send(" ")  # toggle first
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["question"] == values
 
 
@@ -969,7 +970,7 @@ def test_multiselect_choices_validator(
     tui.send(" ")  # select "one"
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["question"] == ["one"]
 
 
diff --git a/tests/test_recopy.py b/tests/test_recopy.py
index f7460c8..20bcedb 100644
--- a/tests/test_recopy.py
+++ b/tests/test_recopy.py
@@ -2,11 +2,11 @@ from pathlib import Path
 from textwrap import dedent
 
 import pytest
-import yaml
 from plumbum import local
 
 from copier import run_copy, run_recopy
 from copier.cli import CopierApp
+from copier.user_data import load_answersfile_data
 from copier.vcs import get_git
 
 from .helpers import build_file_tree, git_save
@@ -92,12 +92,11 @@ def test_recopy_with_skip_answered_and_new_answer(
     # First copy
     run_copy(str(src), dst, defaults=True, overwrite=True)
     git_save(dst)
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert answers["boolean"] is False
     # Recopy with different answer and `skip_answered=True`
     run_recopy(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert answers["boolean"] is True
 
 
@@ -125,10 +124,9 @@ def test_recopy_dont_validate_computed_value(
     # First copy
     run_copy(str(src), dst, defaults=True, overwrite=True)
     git_save(dst)
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert "computed" not in answers
     # Recopy
     run_recopy(dst, overwrite=True)
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert "computed" not in answers
diff --git a/tests/test_subdirectory.py b/tests/test_subdirectory.py
index 5a3393b..8f63dd2 100644
--- a/tests/test_subdirectory.py
+++ b/tests/test_subdirectory.py
@@ -3,10 +3,10 @@ from textwrap import dedent
 from typing import Literal
 
 import pytest
-import yaml
 from plumbum import local
 
 import copier
+from copier.user_data import load_answersfile_data
 
 from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree, git, git_init
 
@@ -172,9 +172,7 @@ def test_update_subdirectory_from_root_path(
             overwrite=True,
             answers_file=".custom.copier-answers.yaml",
         )
-    answers = yaml.safe_load(
-        (dst / "subfolder" / ".custom.copier-answers.yaml").read_bytes()
-    )
+    answers = load_answersfile_data(dst / "subfolder", ".custom.copier-answers.yaml")
     assert answers["_commit"] == "2"
     assert (dst / "subfolder" / "file1").read_text() == "version 2\nhello\na1\nbye\n"
 
@@ -229,7 +227,7 @@ def test_new_version_uses_subdirectory(
     # Generate the project a first time, assert the README exists
     copier.run_copy(str(src), dst, defaults=True, overwrite=True)
     assert (dst / "README.md").exists()
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     # Start versioning the generated project
     with local.cwd(dst):
@@ -262,7 +260,7 @@ def test_new_version_uses_subdirectory(
 
     # Finally, update the generated project
     copier.run_update(dst_path=dst, defaults=True, overwrite=True, conflict=conflict)
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
 
     # Assert that the README still exists, and the conflicts were handled
     # correctly.
diff --git a/tests/test_templated_prompt.py b/tests/test_templated_prompt.py
index b005a6b..d28d700 100644
--- a/tests/test_templated_prompt.py
+++ b/tests/test_templated_prompt.py
@@ -13,6 +13,7 @@ from plumbum import local
 from copier import Worker
 from copier.errors import InvalidTypeError
 from copier.types import AnyByStrDict
+from copier.user_data import load_answersfile_data
 
 from .helpers import (
     BRACKET_ENVOPS,
@@ -181,7 +182,7 @@ def test_templated_prompt(
             tui.expect_exact(output)
     tui.sendline()
     tui.expect_exact(pexpect.EOF)
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers[question_name] == expected_value
 
 
@@ -476,10 +477,7 @@ def test_templated_prompt_update_previous_answer_disabled(
     tui.sendline(Keyboard.Down)  # select "Cloud Formation"
     tui.expect_exact(pexpect.EOF)
 
-    answers_file = dst / ".copier-answers.yml"
-
-    assert answers_file.exists()
-    assert yaml.safe_load(answers_file.read_text()) == {
+    assert load_answersfile_data(dst) == {
         "_src_path": str(src),
         "_commit": "v1",
         "cloud": "AWS",
@@ -496,8 +494,7 @@ def test_templated_prompt_update_previous_answer_disabled(
     tui.sendline()  # select "Terraform" (first supported)
     tui.expect_exact(pexpect.EOF)
 
-    assert answers_file.exists()
-    assert yaml.safe_load(answers_file.read_text()) == {
+    assert load_answersfile_data(dst) == {
         "_src_path": str(src),
         "_commit": "v1",
         "cloud": "Azure",
@@ -558,9 +555,7 @@ def test_multiselect_choices_with_templated_default_value(
     tui.sendline()  # select "[3.11]" (default value)
     tui.expect_exact(pexpect.EOF)
 
-    answers_file = dst / ".copier-answers.yml"
-    assert answers_file.exists()
-    assert yaml.safe_load(answers_file.read_text()) == {
+    assert load_answersfile_data(dst) == {
         "_src_path": str(src),
         "python_version": "3.11",
         "github_runner_python_version": ["3.11"],
diff --git a/tests/test_unsafe.py b/tests/test_unsafe.py
index 888c0f9..0f85a2e 100644
--- a/tests/test_unsafe.py
+++ b/tests/test_unsafe.py
@@ -12,6 +12,7 @@ from copier.cli import CopierApp
 from copier.errors import UnsafeTemplateError
 from copier.main import run_copy, run_update
 from copier.types import AnyByStrDict
+from copier.user_data import load_answersfile_data
 
 from .helpers import build_file_tree, git
 
@@ -329,7 +330,7 @@ def test_update(
         git("tag", "v1")
 
     run_copy(str(src), dst, unsafe=True)
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     with local.cwd(dst):
         git("init")
@@ -382,7 +383,7 @@ def test_update_cli(
         exit=False,
     )
     assert retcode == 0
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
     capsys.readouterr()
 
     with local.cwd(dst):
diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 04fbedc..35a7a45 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -8,13 +8,13 @@ from typing import Literal
 
 import pexpect
 import pytest
-import yaml
 from plumbum import local
 
 from copier.cli import CopierApp
 from copier.errors import UserMessageError
 from copier.main import Worker, run_copy, run_update
 from copier.tools import normalize_git_path
+from copier.user_data import load_answersfile_data
 
 from .helpers import (
     BRACKET_ENVOPS_JSON,
@@ -154,7 +154,6 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None:
     # Generate repo bundle
     target = dst / "target"
     readme = target / "README.txt"
-    answers = target / ".copier-answers.yml"
     commit = git["commit", "--all"]
     # Run copier 1st time, with specific tag
     CopierApp.run(
@@ -170,15 +169,12 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None:
         exit=False,
     )
     # Check it's copied OK
-    assert answers.read_text() == dedent(
-        f"""\
-        # Changes here will be overwritten by Copier
-        _commit: v0.0.1
-        _src_path: {bundle}
-        author_name: Guybrush
-        project_name: to become a pirate\n
-        """
-    )
+    assert load_answersfile_data(target) == {
+        "_commit": "v0.0.1",
+        "_src_path": str(bundle),
+        "author_name": "Guybrush",
+        "project_name": "to become a pirate",
+    }
     assert readme.read_text() == dedent(
         """
         Let me introduce myself.
@@ -209,15 +205,12 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None:
         commit("-m", "I prefer grog")
         # Update target to latest tag and check it's updated in answers file
         CopierApp.run(["copier", "update", "--defaults", "--UNSAFE"], exit=False)
-        assert answers.read_text() == dedent(
-            f"""\
-            # Changes here will be overwritten by Copier
-            _commit: v0.0.2
-            _src_path: {bundle}
-            author_name: Guybrush
-            project_name: to become a pirate\n
-            """
-        )
+        assert load_answersfile_data(target) == {
+            "_commit": "v0.0.2",
+            "_src_path": str(bundle),
+            "author_name": "Guybrush",
+            "project_name": "to become a pirate",
+        }
         # Check migrations were executed properly
         assert not (target / "before-v0.0.1").is_file()
         assert not (target / "after-v0.0.1").is_file()
@@ -241,15 +234,12 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None:
         assert not (target / "before-v1.0.0").is_file()
         assert not (target / "after-v1.0.0").is_file()
         # Check it's updated OK
-        assert answers.read_text() == dedent(
-            f"""\
-            # Changes here will be overwritten by Copier
-            _commit: {last_commit}
-            _src_path: {bundle}
-            author_name: Guybrush
-            project_name: to become a pirate\n
-            """
-        )
+        assert load_answersfile_data(target) == {
+            "_commit": last_commit,
+            "_src_path": str(bundle),
+            "author_name": "Guybrush",
+            "project_name": "to become a pirate",
+        }
         assert readme.read_text() == dedent(
             """
             Let me introduce myself.
@@ -510,9 +500,8 @@ def test_update_from_tagged_to_head(tmp_path_factory: pytest.TempPathFactory) ->
     # Copy it without specifying version
     run_copy(src_path=str(src), dst_path=dst)
     example = dst / "example"
-    answers_file = dst / ".copier-answers.yml"
     assert example.read_text() == "1"
-    assert yaml.safe_load(answers_file.read_text())["_commit"] == "v1"
+    assert load_answersfile_data(dst)["_commit"] == "v1"
     # Build repo on copy
     with local.cwd(dst):
         git("init")
@@ -521,7 +510,7 @@ def test_update_from_tagged_to_head(tmp_path_factory: pytest.TempPathFactory) ->
     # Update project, it must let us do it
     run_update(dst, vcs_ref="HEAD", defaults=True, overwrite=True)
     assert example.read_text() == "2"
-    assert yaml.safe_load(answers_file.read_text())["_commit"] == f"v1-1-g{sha}"
+    assert load_answersfile_data(dst)["_commit"] == f"v1-1-g{sha}"
 
 
 def test_skip_update(tmp_path_factory: pytest.TempPathFactory) -> None:
@@ -540,8 +529,7 @@ def test_skip_update(tmp_path_factory: pytest.TempPathFactory) -> None:
         git("tag", "1.0.0")
     run_copy(str(src), dst, defaults=True, overwrite=True)
     skip_me = dst / "skip_me"
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert skip_me.read_text() == "1"
     assert answers["_commit"] == "1.0.0"
     skip_me.write_text("2")
@@ -554,7 +542,7 @@ def test_skip_update(tmp_path_factory: pytest.TempPathFactory) -> None:
         git("commit", "-am2")
         git("tag", "2.0.0")
     run_update(dst, defaults=True, overwrite=True)
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert skip_me.read_text() == "2"
     assert answers["_commit"] == "2.0.0"
     assert not (dst / "skip_me.rej").exists()
@@ -629,8 +617,7 @@ def test_skip_update_deleted(
         git("tag", "1.0.0")
     run_copy(str(src), dst, defaults=True, overwrite=True)
     skip_me = dst / file_name
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert skip_me.read_text() == "1"
     assert answers["_commit"] == "1.0.0"
     skip_me.unlink()
@@ -728,8 +715,7 @@ def test_update_deleted_path(
     run_copy(str(src), dst, defaults=True, overwrite=True)
     updated_file = dst / file_name
     dont_wildmatch = dst / "dont_wildmatch"
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert dont_wildmatch.read_text() == "bar"
     assert updated_file.read_text() == "foo"
     assert answers["_commit"] == "1.0.0"
@@ -779,9 +765,7 @@ def test_overwrite_answers_file_always(
         # When updating, the only thing to overwrite is the copier answers file,
         # which shouldn't ask, so also this shouldn't hang with overwrite=False
         run_update(defaults=True, overwrite=True, answers_file=answers_file)
-    answers = yaml.safe_load(
-        (dst / (answers_file or ".copier-answers.yml")).read_bytes()
-    )
+    answers = load_answersfile_data(dst, answers_file or ".copier-answers.yml")
     assert answers["question_1"] is True
     assert answers["_commit"] == "2"
     assert (dst / "answer_1").read_text() == "True"
@@ -1221,7 +1205,7 @@ def test_conflicted_files_are_marked_unmerged(
     # Generate the project a first time, assert the file exists
     run_copy(str(src), dst, defaults=True, overwrite=True)
     assert (dst / filename).exists()
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     # Start versioning the generated project
     with local.cwd(dst):
@@ -1243,7 +1227,7 @@ def test_conflicted_files_are_marked_unmerged(
 
     # Finally, update the generated project
     run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline")
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
 
     # Assert that the file still exists, has inline conflict markers,
     # and is reported as "unmerged" by Git.
@@ -1295,7 +1279,7 @@ def test_3way_merged_files_without_conflicts_are_not_marked_unmerged(
     # Generate the project a first time, assert the file exists
     run_copy(str(src), dst, defaults=True, overwrite=True)
     assert (dst / filename).exists()
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     # Start versioning the generated project
     with local.cwd(dst):
@@ -1317,7 +1301,7 @@ def test_3way_merged_files_without_conflicts_are_not_marked_unmerged(
 
     # Finally, update the generated project
     run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline")
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
 
     # Assert that the file still exists, does not have inline conflict markers,
     # and is not reported as "unmerged" by Git.
@@ -1352,7 +1336,7 @@ def test_update_with_new_file_in_template_and_project(
         git("tag", "v1")
 
     run_copy(str(src), dst, defaults=True, overwrite=True)
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     with local.cwd(dst):
         git_init("v1")
@@ -1390,7 +1374,7 @@ def test_update_with_new_file_in_template_and_project(
         git("tag", "v2")
 
     run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline")
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
     assert (dst / ".gitlab-ci.yml").read_text() == dedent(
         """\
         tests:
@@ -1446,7 +1430,7 @@ def test_update_with_new_file_in_template_and_project_via_migration(
         git("tag", "v1")
 
     run_copy(str(src), dst, defaults=True, overwrite=True)
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
     assert (dst / ".gitlab-ci.yml").exists()
 
     with local.cwd(dst):
@@ -1514,7 +1498,7 @@ def test_update_with_new_file_in_template_and_project_via_migration(
     run_update(
         dst_path=dst, defaults=True, overwrite=True, conflict="inline", unsafe=True
     )
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
     assert (dst / ".gitlab-ci.yml").read_text() == dedent(
         """\
         <<<<<<< before updating
@@ -1570,7 +1554,7 @@ def test_update_with_separate_git_directory(
         git("tag", "v1")
 
     run_copy(str(src), dst, overwrite=True)
-    assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     with local.cwd(dst):
         git("init", "--separate-git-dir", dst_git_dir)
@@ -1589,7 +1573,7 @@ def test_update_with_separate_git_directory(
         git("tag", "v2")
 
     run_update(dst, overwrite=True)
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
 
 
 def test_update_with_skip_answered_and_new_answer(
@@ -1608,15 +1592,14 @@ def test_update_with_skip_answered_and_new_answer(
         git("tag", "v1")
 
     run_copy(str(src), dst, defaults=True, overwrite=True)
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert answers["boolean"] is False
 
     with local.cwd(dst):
         git_init("v1")
 
     run_update(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert answers["boolean"] is True
 
 
@@ -1646,8 +1629,7 @@ def test_update_dont_validate_computed_value(
         git("tag", "v1")
 
     run_copy(str(src), dst, overwrite=True)
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert "computed" not in answers
 
     with local.cwd(dst):
@@ -1656,7 +1638,7 @@ def test_update_dont_validate_computed_value(
         git("commit", "-m1")
 
     run_update(dst, overwrite=True)
-    answers = yaml.safe_load(answers_file.read_text())
+    answers = load_answersfile_data(dst)
     assert "computed" not in answers
 
 
@@ -1717,7 +1699,6 @@ def test_gitignore_file_unignored(
 ) -> None:
     env_file = ".env"
     gitignore_file = ".gitignore"
-    answers_file = ".copier-answers.yml"
 
     # Template in v1 has a file with a single line;
     # in v2 it changes that line.
@@ -1743,7 +1724,7 @@ def test_gitignore_file_unignored(
     run_copy(str(src), dst)
     for f in (env_file, gitignore_file):
         assert (dst / f).exists()
-    assert "_commit: v1" in (dst / answers_file).read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v1"
 
     # Start versioning the generated project
     with local.cwd(dst):
@@ -1757,7 +1738,7 @@ def test_gitignore_file_unignored(
 
     # Update the generated project
     run_update(dst_path=dst, overwrite=True)
-    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v2"
 
     # Commit project changes.
     with local.cwd(dst):
@@ -1773,7 +1754,7 @@ def test_gitignore_file_unignored(
     # This would fail if `git add` was called without the `--force` flag;
     # Otherwise, it should succeed.
     run_update(dst_path=dst, overwrite=True)
-    assert "_commit: v3" in (dst / ".copier-answers.yml").read_text()
+    assert load_answersfile_data(dst).get("_commit") == "v3"
 
 
 def test_update_with_answers_with_umlaut(
@@ -1797,13 +1778,12 @@ def test_update_with_answers_with_umlaut(
         git("tag", "v1")
 
     run_copy(str(src), dst, data={"umlaut": "äöü"}, overwrite=True)
-    answers_file = dst / ".copier-answers.yml"
-    answers = yaml.safe_load(answers_file.read_text("utf-8"))
+    answers = load_answersfile_data(dst)
     assert answers["umlaut"] == "äöü"
 
     with local.cwd(dst):
         git_init("v1")
 
     run_update(dst, skip_answered=True, overwrite=True)
-    answers = yaml.safe_load(answers_file.read_text("utf-8"))
+    answers = load_answersfile_data(dst)
     assert answers["umlaut"] == "äöü"
diff --git a/tests/test_vcs.py b/tests/test_vcs.py
index 5eba8d8..eeea3a3 100644
--- a/tests/test_vcs.py
+++ b/tests/test_vcs.py
@@ -4,12 +4,12 @@ from pathlib import Path
 from typing import Callable, Iterator, Sequence
 
 import pytest
-import yaml
 from packaging.version import Version
 from plumbum import local
 
 from copier import Worker, run_copy, run_update
 from copier.errors import ShallowCloneWarning
+from copier.user_data import load_answersfile_data
 from copier.vcs import checkout_latest_tag, clone, get_git_version, get_repo
 
 from .helpers import git
@@ -206,5 +206,5 @@ def test_select_latest_version_tag(
 
     assert (dst / filename).is_file()
     assert (dst / filename).read_text() == "v1.0.1"
-    answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
+    answers = load_answersfile_data(dst)
     assert answers["_commit"] == "v1.0.1"