Repository: copier-org/copier
Pull request #1948: feat(context): expose a `_phase` context variable (fix #1883)
Author: login: noirbizarre
Created: 2025-01-26T19:22:30Z | Merged: 2025-03-02T15:40:57Z

This PR exposes a `_phase` variable which can be one of `prompt`, `tasks`, `mmigrate` and `render`.
The known phases are grouped into the `copier.types.Phase` enum.

This implements #1883 and allows fixing copier-org/copier-templates-extensions#7

Linked issue #1883: Expose the current phase in context
### Actual Situation

Extensions have no way of knowing the current phase (aka. questions, render, tasks or migration).

As a consequence, they have to guess which context variable is available and safe for them to use. 

Ex: [`copier-templates-extensions` was guessing the rendering phase with the presence of `_copier_conf` context var](https://github.com/copier-org/copier-templates-extensions/blob/main/src/copier_templates_extensions/extensions/context.py#L46) but is [broken since 9.4](https://github.com/copier-org/copier-templates-extensions/issues/7) due to its heuristic not working anymore


### Desired Situation

Jinja extension can selectively run during the phase they intend to, and they have a safe way of knowing which phase is currently being executed

### Proposed solution

In addition to an `_operation` context variable which has already been proposed and pending merge (#1733), a `_phase` context variable is also added.
Its value can be one of `questions` (or `prompt`), `render`, `migrate` or `tasks`.
Linked issue comments: user: login: sisp
created_at: 2025-03-02T15:54:38Z
body: Resolved via #1948.
author_association: MEMBER

Diff:
diff --git a/copier/main.py b/copier/main.py
index 360f2b892..55aef37f5 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -65,6 +65,7 @@
     AnyByStrMutableMapping,
     JSONSerializable,
     LazyDict,
+    Phase,
     RelativePath,
     StrOrPath,
 )
@@ -277,10 +278,18 @@ def _external_data(self) -> LazyDict:
         Files will only be parsed lazily on 1st access. This helps avoiding
         circular dependencies when the file name also comes from a variable.
         """
+
+        def _render(path: str) -> str:
+            with Phase.use(Phase.UNDEFINED):
+                return self._render_string(path)
+
+        # Given those values are lazily rendered on 1st access then cached
+        # the phase value is irrelevant and could be misleading.
+        # As a consequence it is explicitely set to "undefined".
         return LazyDict(
             **{
                 name: lambda path=path: load_answersfile_data(
-                    self.dst_path, self._render_string(path)
+                    self.dst_path, _render(path)
                 )
                 for name, path in self.template.external_data.items()
             }
@@ -375,6 +384,7 @@ def _render_context(self) -> AnyByStrMutableMapping:
             _copier_conf=conf,
             _folder_name=self.subproject.local_abspath.name,
             _copier_python=sys.executable,
+            _copier_phase=Phase.current(),
         )
 
     def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]:
@@ -560,7 +570,9 @@ def answers_relpath(self) -> Path:
         """
         path = self.answers_file or self.template.answers_relpath
         template = self.jinja_env.from_string(str(path))
-        return Path(template.render(**self.answers.combined))
+        return Path(
+            template.render(_copier_phase=Phase.current(), **self.answers.combined)
+        )
 
     @cached_property
     def all_exclusions(self) -> Sequence[str]:
@@ -928,7 +940,8 @@ def run_copy(self) -> None:
         """
         self._check_unsafe("copy")
         self._print_message(self.template.message_before_copy)
-        self._ask()
+        with Phase.use(Phase.PROMPT):
+            self._ask()
         was_existing = self.subproject.local_abspath.exists()
         try:
             if not self.quiet:
@@ -937,12 +950,14 @@ def run_copy(self) -> None:
                     f"\nCopying from template version {self.template.version}",
                     file=sys.stderr,
                 )
-            self._render_template()
+            with Phase.use(Phase.RENDER):
+                self._render_template()
             if not self.quiet:
                 # TODO Unify printing tools
                 print("")  # padding space
             if not self.skip_tasks:
-                self._execute_tasks(self.template.tasks)
+                with Phase.use(Phase.TASKS):
+                    self._execute_tasks(self.template.tasks)
         except Exception:
             if not was_existing and self.cleanup_on_error:
                 rmtree(self.subproject.local_abspath)
@@ -1044,9 +1059,10 @@ def _apply_update(self) -> None:  # noqa: C901
             ) as old_worker:
                 old_worker.run_copy()
             # Run pre-migration tasks
-            self._execute_tasks(
-                self.template.migration_tasks("before", self.subproject.template)  # type: ignore[arg-type]
-            )
+            with Phase.use(Phase.MIGRATE):
+                self._execute_tasks(
+                    self.template.migration_tasks("before", self.subproject.template)  # type: ignore[arg-type]
+                )
             # Create a Git tree object from the current (possibly dirty) index
             # and keep the object reference.
             with local.cwd(subproject_top):
@@ -1120,7 +1136,7 @@ def _apply_update(self) -> None:  # noqa: C901
                 self._git_initialize_repo()
                 new_copy_head = git("rev-parse", "HEAD").strip()
             # Extract diff between temporary destination and real destination
-            # with some special handling of newly added files in both the poject
+            # with some special handling of newly added files in both the project
             # and the template.
             with local.cwd(old_copy):
                 # Configure borrowing Git objects from the real destination and
@@ -1265,9 +1281,10 @@ def _apply_update(self) -> None:  # noqa: C901
             _remove_old_files(subproject_top, compared)
 
         # Run post-migration tasks
-        self._execute_tasks(
-            self.template.migration_tasks("after", self.subproject.template)  # type: ignore[arg-type]
-        )
+        with Phase.use(Phase.MIGRATE):
+            self._execute_tasks(
+                self.template.migration_tasks("after", self.subproject.template)  # type: ignore[arg-type]
+            )
 
     def _git_initialize_repo(self) -> None:
         """Initialize a git repository in the current directory."""
diff --git a/copier/types.py b/copier/types.py
index f7ca13761..a8fd98242 100644
--- a/copier/types.py
+++ b/copier/types.py
@@ -1,11 +1,17 @@
 """Complex types, annotations, validators."""
 
+from __future__ import annotations
+
+from contextlib import contextmanager
+from contextvars import ContextVar
+from enum import Enum
 from pathlib import Path
 from typing import (
     Annotated,
     Any,
     Callable,
     Dict,
+    Iterator,
     Literal,
     Mapping,
     MutableMapping,
@@ -75,3 +81,34 @@ def __getitem__(self, key: str) -> Any:
         if key not in self.done:
             self.done[key] = self.pending[key]()
         return self.done[key]
+
+
+class Phase(str, Enum):
+    """The known execution phases."""
+
+    PROMPT = "prompt"
+    TASKS = "tasks"
+    MIGRATE = "migrate"
+    RENDER = "render"
+    UNDEFINED = "undefined"
+
+    def __str__(self) -> str:
+        return str(self.value)
+
+    @classmethod
+    @contextmanager
+    def use(cls, phase: Phase) -> Iterator[None]:
+        """Set the current phase for the duration of a context."""
+        token = _phase.set(phase)
+        try:
+            yield
+        finally:
+            _phase.reset(token)
+
+    @classmethod
+    def current(cls) -> Phase:
+        """Get the current phase."""
+        return _phase.get()
+
+
+_phase: ContextVar[Phase] = ContextVar("phase", default=Phase.UNDEFINED)
diff --git a/copier/user_data.py b/copier/user_data.py
index eec2cfc6d..ea293fe70 100644
--- a/copier/user_data.py
+++ b/copier/user_data.py
@@ -5,13 +5,14 @@
 import json
 import warnings
 from collections import ChainMap
+from collections.abc import Mapping, Sequence
 from dataclasses import field
 from datetime import datetime
 from functools import cached_property
 from hashlib import sha512
 from os import urandom
 from pathlib import Path
-from typing import Any, Callable, Literal, Mapping, Sequence
+from typing import Any, Callable, Literal
 
 import yaml
 from jinja2 import UndefinedError
@@ -33,6 +34,7 @@
     AnyByStrMutableMapping,
     LazyDict,
     MissingType,
+    Phase,
     StrOrPath,
 )
 
@@ -464,7 +466,13 @@ def render_value(
                 else value
             )
         try:
-            return template.render({**self.answers.combined, **(extra_answers or {})})
+            return template.render(
+                {
+                    **self.answers.combined,
+                    **(extra_answers or {}),
+                    "_copier_phase": Phase.current(),
+                }
+            )
         except UndefinedError as error:
             raise UserMessageError(str(error)) from error
 
diff --git a/docs/creating.md b/docs/creating.md
index 174ba0b4b..83e5398c9 100644
--- a/docs/creating.md
+++ b/docs/creating.md
@@ -136,6 +136,16 @@ variable:
 
 The name of the project root directory.
 
+### `_copier_phase`
+
+The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
+
+!!! note
+
+    There is also an additional `"undefined"` phase used when not in any phase.
+    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-specific)
 
 Some rendering contexts provide variables unique to them:
diff --git a/tests/test_answersfile.py b/tests/test_answersfile.py
index 7bf4d7cb5..b5ebaecd5 100644
--- a/tests/test_answersfile.py
+++ b/tests/test_answersfile.py
@@ -250,3 +250,31 @@ def test_external_data_with_umlaut(
     copier.run_copy(str(src), dst, defaults=True, overwrite=True)
     answers = load_answersfile_data(dst, ".copier-answers.yml")
     assert answers["ext_umlaut"] == "äöü"
+
+
+def test_undefined_phase_in_external_data(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    build_file_tree(
+        {
+            (src / "copier.yml"): (
+                """\
+                _external_data:
+                    data: '{{ _copier_phase }}.yml'
+                key: "{{ _external_data.data.key }}"
+                """
+            ),
+            (src / "{{ _copier_conf.answers_file }}.jinja"): (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+        }
+    )
+    git_save(src, tag="v1")
+
+    (dst / "undefined.yml").write_text("key: value")
+
+    copier.run_copy(str(src), dst, defaults=True, overwrite=True)
+    answers = load_answersfile_data(dst, ".copier-answers.yml")
+    assert answers["key"] == "value"
diff --git a/tests/test_answersfile_templating.py b/tests/test_answersfile_templating.py
index 6a951b8df..c2b2c78ba 100644
--- a/tests/test_answersfile_templating.py
+++ b/tests/test_answersfile_templating.py
@@ -118,3 +118,21 @@ def test_answersfile_templating_with_message_before_copy(
     assert answers["module_name"] == "mymodule"
     assert (dst / "result.txt").exists()
     assert (dst / "result.txt").read_text() == "mymodule"
+
+
+def test_answersfile_templating_phase(tmp_path_factory: pytest.TempPathFactory) -> None:
+    """
+    Ensure `_copier_phase` is available while render `answers_relpath`.
+    Not because it is directly useful, but because some extensions might need it.
+    """
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            src / "copier.yml": """\
+                _answers_file: ".copier-answers-{{ _copier_phase }}.yml"
+                """,
+            src / "{{ _copier_conf.answers_file }}.jinja": "",
+        }
+    )
+    copier.run_copy(str(src), dst, overwrite=True, unsafe=True)
+    assert (dst / ".copier-answers-render.yml").exists()
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 8e442b8b1..dc6170929 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -1041,3 +1041,11 @@ def test_templated_choices(tmp_path_factory: pytest.TempPathFactory, spec: str)
     )
     copier.run_copy(str(src), dst, data={"q": "two"})
     assert yaml.safe_load((dst / "q.txt").read_text()) == "two"
+
+
+def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree({src / "{{ _copier_phase }}.jinja": "{{ _copier_phase }}"})
+    copier.run_copy(str(src), dst)
+    assert (dst / "render").exists()
+    assert (dst / "render").read_text() == "render"
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index b77f50d90..61aa4f13c 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -521,3 +521,32 @@ def test_migration_jinja_variables(
             assert f"{variable}={value}" in vars
         else:
             assert f"{variable}=" in vars
+
+
+def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
+    """Test that the Phase variable is properly set."""
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    with local.cwd(src):
+        build_file_tree(
+            {
+                **COPIER_ANSWERS_FILE,
+                "copier.yml": (
+                    """\
+                    _migrations:
+                        - touch {{ _copier_phase }}
+                    """
+                ),
+            }
+        )
+        git_save(tag="v1")
+    with local.cwd(dst):
+        run_copy(src_path=str(src))
+        git_save()
+
+    with local.cwd(src):
+        git("tag", "v2")
+    with local.cwd(dst):
+        run_update(defaults=True, overwrite=True, unsafe=True)
+
+    assert (dst / "migrate").is_file()
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 38be36b50..d4286e1f3 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -164,3 +164,19 @@ def test_os_specific_tasks(
     monkeypatch.setattr("copier.main.OS", os)
     copier.run_copy(str(src), dst, unsafe=True)
     assert (dst / filename).exists()
+
+
+def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            (src / "copier.yml"): (
+                """
+                _tasks:
+                    - touch {{ _copier_phase }}
+                """
+            )
+        }
+    )
+    copier.run_copy(str(src), dst, unsafe=True)
+    assert (dst / "tasks").exists()
diff --git a/tests/test_templated_prompt.py b/tests/test_templated_prompt.py
index d7e745dc0..53dedc14c 100644
--- a/tests/test_templated_prompt.py
+++ b/tests/test_templated_prompt.py
@@ -563,3 +563,22 @@ def test_multiselect_choices_with_templated_default_value(
         "python_version": "3.11",
         "github_runner_python_version": ["3.11"],
     }
+
+
+def test_copier_phase_variable(
+    tmp_path_factory: pytest.TempPathFactory,
+    spawn: Spawn,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            (src / "copier.yml"): """\
+                phase:
+                    type: str
+                    default: "{{ _copier_phase }}"
+            """
+        }
+    )
+    tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
+    expect_prompt(tui, "phase", "str")
+    tui.expect_exact("prompt")


Reviews:
user: login: pawamoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-27T13:52:17Z
user: login: pawamoy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-01-27T15:42:07Z
user: login: sisp
body: Fantastic work again, @noirbizarre! :pray:

Just a minor stylistic request: It seems Python enums should be upper-case[^1][^2]. I suggest following this guideline.

[^1]: https://mail.python.org/pipermail/python-ideas/2016-September/042340.html
[^2]: https://docs.python.org/3/library/enum.html
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-01-27T16:23:12Z
user: login: sisp
body: It seems you're right, there isn't a definitive style rule about enums. :face_with_peeking_eye: Thanks for elaborating on this topic (especially the detailed message before you edited it :wink:)! Well, now it's upper-case. Shall we leave it like this?

Just a final thought: Shall we expose the phase via the `_phase` variable (as it is now) or would it be better to rename/move it? E.g.:

- `_copier_phase` – only `_folder_name`, `now`, and `make_secret` seems to not have the `_copier` prefix where `now` and `make_secret` are both deprecated.
- `_copier_conf.phase` – or should we only have static values under `_copier_conf`?

@noirbizarre @copier-org/maintainers Any opinions?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-01-28T13:23:35Z
user: login: sisp
body: I've thought about this PR some more and also about – what @pawamoy noticed – the phase value being automatically exposed as `_copier_conf.phase` because [most `Worker` dataclass attributes](https://github.com/copier-org/copier/blob/57439e5c18616169ae44635fe56d2f76bb06e416/copier/main.py#L338-L339) are automatically [included in `_copier_conf`](https://github.com/copier-org/copier/blob/57439e5c18616169ae44635fe56d2f76bb06e416/copier/main.py#L354). As

> I would go for a root `_copier_phase` as it is not a configuration, just a runtime state.

makes sense to me, I think the phase value should _not_ be accessible under `_copier_conf` though. This PR also reminds me of a similar situation we've had in #1733 where initially a dataclass attribute was used as well and [I suggested to use a context variable](https://github.com/copier-org/copier/pull/1733#pullrequestreview-2397086710) instead. Now, it feels odd to me to use a dataclass attribute here to manage the phase value because it has no meaning while not rendering, prompting or executing tasks/migrations. How about applying an approach similar to https://github.com/copier-org/copier/pull/1733#pullrequestreview-2397086710 in this PR as well?

Slightly off-topic: I just realized that #1733 exposes a top-level `_operation` Jinja variable; we might want to rename it to `_copier_operation` there for consistency.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-29T11:44:26Z
user: login: sisp
body: Thanks, @noirbizarre!

Sorry for being a little pedantic about the context variable approach. 🫣  But could you remove the default phase value and use a context manager to manage the phase value, such that a particular phase value is only set within an explicitly created context? This way, we reduce the risk of forgetting to set a new phase value and accidentally exposing a previously set one, as [`ContextVar.get()`](https://docs.python.org/3/library/contextvars.html#contextvars.ContextVar.get) raises a `LookupError` when neither a value is set nor a default value is provided.

E.g.:

```python
with use_phase(phase.TASK):
    self._execute_task(self.template.tasks)
```

(I'm open to better context manager names than `use_phase` by the way. 😄)
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-01T10:30:26Z
user: login: sisp
body: Thanks for explaining why no default value for the context variable won't work. You're right, and I think introducing `"undefined"` and using it as a default value is a good idea. I also like the implementation of the context manager as a class method of `Phase`. :+1: Well done! :clap:

IIUC, the phase value when [rendering the answers file path of external data](https://github.com/copier-org/copier/blob/6eaa0d7e0959c81479b214eaf73aa55d14d0364d/copier/main.py#L283) depends on the phase in which that particular external data is _first_ accessed due to lazy access. Would it be better to override the phase value with `"undefined"` instead? The meaning of the phase doesn't seem well defined in this case. WDYT?
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-01T16:03:21Z
user: login: noirbizarre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T17:12:01Z
user: login: sisp
body: Almost done, I think. 🤞 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-01T18:03:12Z
user: login: noirbizarre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T18:43:53Z
user: login: sisp
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-01T19:33:20Z
user: login: sisp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T19:45:01Z
user: login: noirbizarre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T22:53:38Z
user: login: sisp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T10:00:20Z
user: login: sisp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T10:04:28Z
user: login: noirbizarre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-02T15:22:34Z
user: login: sisp
body: Awesome! 🤩 Huge thanks for adding this feature and being incredibly patient with my pedantic review feedback and the many iterations involving code updates and discussions! 🙇 Fantastic work, @noirbizarre! 🏆

Close #1883.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-02T15:38:44Z

Review comments:
diff_hunk: @@ -521,3 +521,32 @@ def test_migration_jinja_variables(
             assert f"{variable}={value}" in vars
         else:
             assert f"{variable}=" in vars
+
+
+def test_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
+    """Test that the j-phase variable is properly set"""
path: tests/test_migrations.py
user: login: pawamoy
body: ```suggestion
    """Test that the phase variable is properly set."""
```
created_at: 2025-01-27T13:52:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,6 +136,13 @@ variable:
 
 The name of the project root directory.
 
+### `_copier_phase`
+
+The current phase, one of `"prompt"`,`"tasks"`, `"migrate"`, `"update"` or `"render"`.
+
+!!! note There is also an additional `"undefined"` phase used when not in any phase,
+mostly for testing purpose.
path: docs/creating.md
user: login: sisp
body: ```suggestion
!!! note

    There is also an additional `"undefined"` phase used when not in any phase, mostly
    for testing purpose.
```
created_at: 2025-03-01T15:20:49Z
author_association: MEMBER
diff_hunk: @@ -1041,3 +1041,10 @@ def test_templated_choices(tmp_path_factory: pytest.TempPathFactory, spec: str)
     )
     copier.run_copy(str(src), dst, data={"q": "two"})
     assert yaml.safe_load((dst / "q.txt").read_text()) == "two"
+
+
+def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree({src / "{{ _copier_phase }}": ""})
+    copier.run_copy(str(src), dst)
+    assert (dst / "render").exists()
path: tests/test_copy.py
user: login: sisp
body: Would this be a stronger test that also checks the phase value exposed when rendering file content?

```suggestion
    build_file_tree({src / "{{ _copier_phase }}.jinja": "{{ _copier_phase }}"})
    copier.run_copy(str(src), dst)
    assert (dst / "render").exists()
    assert (dst / "render").read_text() == "render"
```
created_at: 2025-03-01T15:23:12Z
author_association: MEMBER
diff_hunk: @@ -75,3 +81,35 @@ def __getitem__(self, key: str) -> Any:
         if key not in self.done:
             self.done[key] = self.pending[key]()
         return self.done[key]
+
+
+class Phase(str, Enum):
+    """The known execution phases."""
+
+    PROMPT = "prompt"
+    TASKS = "tasks"
+    MIGRATE = "migrate"
+    RENDER = "render"
+    UPDATE = "update"
path: copier/types.py
user: login: sisp
body: Isn't this redundant with the `_copier_operation` variable from #1733 (although there we're currently planning to expose it only to tasks and when rendering `exclude` items, which is not set in stone if there's a convincing reason to extend its availability)? Could you remind me what the use case for `_copier_phase = "update"` is? Could we potentially remove `"update"` and have the same information with `_copier_operation`?

I think we should make sure that `_copier_operation` and `_copier_phase` are well aligned before merging either of them.
created_at: 2025-03-01T15:34:02Z
author_association: MEMBER
diff_hunk: @@ -927,8 +931,9 @@ def run_copy(self) -> None:
         See [generating a project][generating-a-project].
         """
         self._check_unsafe("copy")
-        self._print_message(self.template.message_before_copy)
-        self._ask()
+        with Phase.use(Phase.PROMPT):
+            self._print_message(self.template.message_before_copy)
+            self._ask()
path: copier/main.py
user: login: sisp
body: Isn't use `_copier_phase = "render"` for the message before copy unexpected to users? With the new `_copier_phase = "undefined"`, wouldn't it be better to use this phase for message rendering? Most other messages are render/printed with in the `"undefined"` phase. So, it actually seems like an oversight.

```suggestion
        self._print_message(self.template.message_before_copy)
        with Phase.use(Phase.PROMPT):
            self._ask()
```
created_at: 2025-03-01T15:38:05Z
author_association: MEMBER
diff_hunk: @@ -1009,8 +1016,9 @@ def run_update(self) -> None:
             print(
                 f"Updating to template version {self.template.version}", file=sys.stderr
             )
-        self._apply_update()
-        self._print_message(self.template.message_after_update)
+        with Phase.use(Phase.UPDATE):
+            self._apply_update()
+            self._print_message(self.template.message_after_update)
path: copier/main.py
user: login: sisp
body: Same as above (for message before copy):

```suggestion
        with Phase.use(Phase.UPDATE):
            self._apply_update()
        self._print_message(self.template.message_after_update)
```

But also see my general question about the `"update"` phase.
created_at: 2025-03-01T15:50:30Z
author_association: MEMBER
diff_hunk: @@ -75,3 +81,35 @@ def __getitem__(self, key: str) -> Any:
         if key not in self.done:
             self.done[key] = self.pending[key]()
         return self.done[key]
+
+
+class Phase(str, Enum):
+    """The known execution phases."""
+
+    PROMPT = "prompt"
+    TASKS = "tasks"
+    MIGRATE = "migrate"
+    RENDER = "render"
+    UPDATE = "update"
path: copier/types.py
user: login: noirbizarre
body: To me it doesn't represent the same thing, the `update` operation is replaying the different existing phases but is also doing some git operations in the `_apply_update` method. So, I hesitated between letting `undefined` as phase for those steps or defining a dedicated phase for that.
I don't have a strong opinion on this, and I am perfectly fine with saying it should stay `undefined` while not in one of the other phases.
I updated the PR to remove it
created_at: 2025-03-01T17:12:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,14 +278,18 @@ def _external_data(self) -> LazyDict:
         Files will only be parsed lazily on 1st access. This helps avoiding
         circular dependencies when the file name also comes from a variable.
         """
-        return LazyDict(
-            **{
-                name: lambda path=path: load_answersfile_data(
-                    self.dst_path, self._render_string(path)
-                )
-                for name, path in self.template.external_data.items()
-            }
-        )
+        # Given those values are layzily rendered on 1st access then cached
+        # the phase value is irrelevant and could be misleading.
+        # As a consequence it is explicitely set to "undefined".
+        with Phase.use(Phase.UNDEFINED):
+            return LazyDict(
+                **{
+                    name: lambda path=path: load_answersfile_data(
+                        self.dst_path, self._render_string(path)
+                    )
+                    for name, path in self.template.external_data.items()
+                }
+            )
path: copier/main.py
user: login: sisp
body: This won't work because the answers file path is rendered lazily. The phase context needs to be overridden inside the lambda. But this isn't possible in Python AFAIK, so we could introduce a closure instead of the lambda which renders the string inside the phase context.

Could you also add a test case please?
created_at: 2025-03-01T18:01:50Z
author_association: MEMBER
diff_hunk: @@ -277,14 +278,18 @@ def _external_data(self) -> LazyDict:
         Files will only be parsed lazily on 1st access. This helps avoiding
         circular dependencies when the file name also comes from a variable.
         """
-        return LazyDict(
-            **{
-                name: lambda path=path: load_answersfile_data(
-                    self.dst_path, self._render_string(path)
-                )
-                for name, path in self.template.external_data.items()
-            }
-        )
+        # Given those values are layzily rendered on 1st access then cached
+        # the phase value is irrelevant and could be misleading.
+        # As a consequence it is explicitely set to "undefined".
+        with Phase.use(Phase.UNDEFINED):
+            return LazyDict(
+                **{
+                    name: lambda path=path: load_answersfile_data(
+                        self.dst_path, self._render_string(path)
+                    )
+                    for name, path in self.template.external_data.items()
+                }
+            )
path: copier/main.py
user: login: noirbizarre
body: Done 👍🏼 
created_at: 2025-03-01T18:43:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,15 +278,21 @@ def _external_data(self) -> LazyDict:
         Files will only be parsed lazily on 1st access. This helps avoiding
         circular dependencies when the file name also comes from a variable.
         """
+        # Given those values are layzily rendered on 1st access then cached
+        # the phase value is irrelevant and could be misleading.
+        # As a consequence it is explicitely set to "undefined".
         return LazyDict(
             **{
-                name: lambda path=path: load_answersfile_data(
-                    self.dst_path, self._render_string(path)
-                )
+                name: lambda path=path: self._lazy_load_answers(path)
                 for name, path in self.template.external_data.items()
             }
         )
 
+    def _lazy_load_answers(self, path: str) -> AnyByStrDict:
+        """Lazy-Load external answers file."""
+        with Phase.use(Phase.UNDEFINED):
+            return load_answersfile_data(self.dst_path, self._render_string(path))
+
path: copier/main.py
user: login: sisp
body: I don't like the semantics of this method because it suggests that lazily loaded answers should always render the path with `_copier_phase = "undefined"` which isn't a general rule. I was thinking about a closure – see my other inline comment.
created_at: 2025-03-01T19:10:56Z
author_association: MEMBER
diff_hunk: @@ -277,15 +278,21 @@ def _external_data(self) -> LazyDict:
         Files will only be parsed lazily on 1st access. This helps avoiding
         circular dependencies when the file name also comes from a variable.
         """
+        # Given those values are layzily rendered on 1st access then cached
+        # the phase value is irrelevant and could be misleading.
+        # As a consequence it is explicitely set to "undefined".
         return LazyDict(
             **{
-                name: lambda path=path: load_answersfile_data(
-                    self.dst_path, self._render_string(path)
-                )
+                name: lambda path=path: self._lazy_load_answers(path)
                 for name, path in self.template.external_data.items()
             }
         )
path: copier/main.py
user: login: sisp
body: I'd use a closure to deal with this special case locally:

```python
def _render(path: str) -> str:
    with Phase.use(Phase.UNDEFINED):
        return self._render_string(path)

# Given those values are layzily rendered on 1st access then cached
# the phase value is irrelevant and could be misleading.
# As a consequence it is explicitely set to "undefined".
return LazyDict(
    **{
        name: lambda path=path: load_answersfile_data(
            self.dst_path, _render(path)
        )
        for name, path in self.template.external_data.items()
    }
)
```
created_at: 2025-03-01T19:28:58Z
author_association: MEMBER
diff_hunk: @@ -277,15 +278,21 @@ def _external_data(self) -> LazyDict:
         Files will only be parsed lazily on 1st access. This helps avoiding
         circular dependencies when the file name also comes from a variable.
         """
+        # Given those values are layzily rendered on 1st access then cached
path: copier/main.py
user: login: sisp
body: ```suggestion
        # Given those values are lazily rendered on 1st access then cached
```

:nerd_face:
created_at: 2025-03-01T19:29:34Z
author_association: MEMBER
diff_hunk: @@ -75,3 +81,34 @@ def __getitem__(self, key: str) -> Any:
         if key not in self.done:
             self.done[key] = self.pending[key]()
         return self.done[key]
+
+
+class Phase(str, Enum):
+    """The known execution phases."""
+
+    PROMPT = "prompt"
+    TASKS = "tasks"
+    MIGRATE = "migrate"
path: copier/types.py
user: login: sisp
body: Admittedly totally subjective, but would

```suggestion
    MIGRATIONS = "migrations"
```

be more consistent with

```python
TASKS = "tasks"
```

without introducing inconsistency with the other members? Or singular names, i.e. `TASK = "task"` and `MIGRATION = "migration"`? :thinking: Just trying to make sure we're consistent because it's not easy to change this later without a breaking change.
created_at: 2025-03-01T19:45:01Z
author_association: MEMBER
diff_hunk: @@ -75,3 +81,34 @@ def __getitem__(self, key: str) -> Any:
         if key not in self.done:
             self.done[key] = self.pending[key]()
         return self.done[key]
+
+
+class Phase(str, Enum):
+    """The known execution phases."""
+
+    PROMPT = "prompt"
+    TASKS = "tasks"
+    MIGRATE = "migrate"
path: copier/types.py
user: login: noirbizarre
body: I don't really have an opinion on the enum members semantic.
They were named after the action being executed in that phase, `prompt`, `migrate` and `render` are actions. `tasks` is short for "execute tasks".
I can rename them whatever suit you.
But for consistency, if you go for `migrations`, aka. the type of object dealt in the phase, I would go for: 
- ~`prompt`~ > `questions`
- `tasks`
- ~`migrate`~ > `migrations`
- `render` > ??? (hard to describe the object there instead of the action)
created_at: 2025-03-01T22:53:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,3 +81,34 @@ def __getitem__(self, key: str) -> Any:
         if key not in self.done:
             self.done[key] = self.pending[key]()
         return self.done[key]
+
+
+class Phase(str, Enum):
+    """The known execution phases."""
+
+    PROMPT = "prompt"
+    TASKS = "tasks"
+    MIGRATE = "migrate"
path: copier/types.py
user: login: sisp
body: Thanks for sharing your thoughts. Let's keep the action semantics.
created_at: 2025-03-02T10:00:20Z
author_association: MEMBER
diff_hunk: @@ -136,6 +136,15 @@ variable:
 
 The name of the project root directory.
 
+### `_copier_phase`
+
+The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
+
+!!! note
+
+    There is also an additional `"undefined"` phase used when not in any phase, mostly
+    for testing purpose.
path: docs/creating.md
user: login: sisp
body: Just a final thought here: I think the `"undefined"` phase isn't mostly used for testing purposes. It's used when rendering, e.g., settings in `copier.yaml` including `exclude` items, `skip_if_exists` items, `subdirectory`. Perhaps we could rephrase this a little?
created_at: 2025-03-02T10:04:27Z
author_association: MEMBER
diff_hunk: @@ -136,6 +136,15 @@ variable:
 
 The name of the project root directory.
 
+### `_copier_phase`
+
+The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
+
+!!! note
+
+    There is also an additional `"undefined"` phase used when not in any phase, mostly
+    for testing purpose.
path: docs/creating.md
user: login: noirbizarre
body: Updated with a more accurate description.
created_at: 2025-03-02T15:22:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-01-26T19:31:09Z
body: ## [Codecov](https://app.codecov.io/gh/copier-org/copier/pull/1948?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 97.93%. Comparing base [(`6eaa0d7`)](https://app.codecov.io/gh/copier-org/copier/commit/6eaa0d7e0959c81479b214eaf73aa55d14d0364d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) to head [(`4cba543`)](https://app.codecov.io/gh/copier-org/copier/commit/4cba543417474a84bf43c5a8ae56314954013019?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).
> Report is 132 commits behind head on master.

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


```diff
@@            Coverage Diff             @@
##           master    #1948      +/-   ##
==========================================
- Coverage   98.06%   97.93%   -0.13%     
==========================================
  Files          53       53              
  Lines        5580     5677      +97     
==========================================
+ Hits         5472     5560      +88     
- Misses        108      117       +9     
```

| [Flag](https://app.codecov.io/gh/copier-org/copier/pull/1948/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/1948/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | `97.93% <100.00%> (-0.13%)` | :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/1948?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>
author_association: NONE
user: login: pawamoy
created_at: 2025-01-27T15:10:11Z
body: Looking super good to me :smile: Thank you for the awesome PRs @noirbizarre!
author_association: CONTRIBUTOR
user: login: noirbizarre
created_at: 2025-01-27T15:16:33Z
body: PR updated with the typo fixes.

You're welcome 🙏🏼 It benefits everybody 👌🏼 
author_association: CONTRIBUTOR
user: login: noirbizarre
created_at: 2025-01-27T21:37:10Z
body: Updated to upper case 👍🏼

I, personally, tend to do the opposite with `StrEnum` and I explicitly chose lower case (and use this convention on all my projects). There is actually no recommendation, the enum PEP itself is all lower case, and `StrEnum` `auto()` values are lower cases too. I think having name==value for `StrEnum` is easier to handle, but I believe it is a personal taste (as highlighted in the thread following Guido's mail)
author_association: CONTRIBUTOR
user: login: pawamoy
created_at: 2025-01-28T13:52:59Z
body: I tend to use lowercase too, except when the enum contains names that are reserved keywords like `class`! No other choice than going uppercase then. As long as we're consistent within Copier's code base, I'm fine with both.

I'm accessing the phase with `parent["_copier_conf"]["_phase"]` in https://github.com/copier-org/copier-templates-extensions/pull/8, it would feel a bit redundant as `_copier_phase`, but again, consistency is more important IMO.
author_association: CONTRIBUTOR
user: login: sisp
created_at: 2025-01-28T14:08:53Z
body: @pawamoy So, would you tend to be in favor of `_copier_phase` rather than `_phase`?
author_association: MEMBER
user: login: pawamoy
created_at: 2025-01-28T14:20:55Z
body: Ah, wait. This PR adds `_phase` to the dict returned by `_render_context`. But this dict already contains `_copier_conf`, which contains the `phase`. So do we really need to add it to the upper dict again?

In the same way, the PR adds `_phase` to the context in `_render_value`, which IIRC is used to render values in copier.yml. I don't remember however if `_copier_conf` is accessible when rendering values in copier.yml. If that's the case, then I'll ask the same question as above: do we need the shortcut `{{ _phase }}` if we already have `{{ _copier_conf.phase }}`? In any case, accessing the phase in copier.yml value is probably useless since we *know* the phase in copier.yml is always "prompt", right? It can't ever be anything else, so we probably don't need to expose it in a more user-friendly way. #1883 only mentions extensions, so I'm not sure what are the other use-cases with `phase` possibly being used in copier.yml, file names, and file contents.

Maybe it's added in both places because the enum value is not serialized to a string? Or because it's easier for testing? Or because you felt that it's not really a "configuration option"? For the former, I'd recommend using a Literal instead of an Enum. Honestly, after having used enums in many places, they tend to give me headaches, and a Literal is much easier, but that's only my own experience.

author_association: CONTRIBUTOR
user: login: pawamoy
created_at: 2025-01-28T14:53:54Z
body: Yes, `_copier_phase` seems good. Do note that we don't strictly need it in `_render_context` though, see next comment. We also don't need it in `render_value`, unless some use-cases are shown where a `_copier_phase` variable whose value is always `"prompt"` would be used in copier.yml values.
author_association: CONTRIBUTOR
user: login: pawamoy
created_at: 2025-01-28T15:28:52Z
body: Here's my final comprehension of the issue (which may be worth adding as documentation or code comments at least).

- During `copier copy`, and prompt rendering, context extensions do not get `_copier_conf`. We can use its absence to decide not to run the context hook.
- During `copier update` however, and prompt rendering, context extensions *do* get `_copier_conf`. We can't use its absence anymore. Instead, we need the phase. It's passed through `parent["_copier_conf"]["phase"]`: we don't strictly need the additional top-level `"_phase"` key. We could still keep it to provide more consistency: a `_phase` / `_copier_phase` top-level key always present. No strong opinions. **I do notice occurrences where `parent["_phase"]` is unset when testing locally**, so if we want this consistency, there are more places this variable must be added.
- Extensions can now also skip running the context hook during tasks and migrations, thanks to the phase.
author_association: CONTRIBUTOR
user: login: noirbizarre
created_at: 2025-01-28T18:19:05Z
body: Oh, I didn't though about all that 😅 
Prefixing by `_copier` makes sense and is consistent with [existing ones](https://copier.readthedocs.io/en/latest/creating/#variables-global).
I would go for a root `_copier_phase` as it is not a configuration, just a runtime state.
I added it in `_render_context` just to say that it is always available. I don't like to presume future evolution as well as community usage, so I've been on the dumb "it's the current state, and it's always available" approach.

Note that I actually use context extensions during prompt on my projects, and actually this PR can maybe help to have a more general approach on this and assume that extensions can run at any phase. (This might later lead to having copier-template-extensions `TemplateExtensionLoader` built-in 🤷🏼‍♂️)

Anyway, PR update with `_copier_phase` naming.
author_association: CONTRIBUTOR
user: login: pawamoy
created_at: 2025-01-29T11:54:59Z
body: I agree with all of @sisp's points :slightly_smiling_face: 

Regarding `_copier_conf`, the code says "backwards compatibility, remove it?":

https://github.com/copier-org/copier/blob/57439e5c18616169ae44635fe56d2f76bb06e416/copier/main.py#L334-L339

Not sure what it means exactly, but it seems like we wanted to stray away from this anyway.
author_association: CONTRIBUTOR
user: login: sisp
created_at: 2025-01-29T12:09:08Z
body: @pawamoy This comment pre-dates my involvement with Copier, but I don't think the (raw) `Worker` dataclass attributes are very useful in the Jinja context; [useful config values are explicitly added](https://github.com/copier-org/copier/blob/57439e5c18616169ae44635fe56d2f76bb06e416/copier/main.py#L342-L344). I'd be in favor of dropping those lines as part of the next major release.
author_association: MEMBER
user: login: pawamoy
created_at: 2025-01-29T13:19:31Z
body: Fine by me. I'd suggest making sure `_copier_phase` is always passed. It's currently not passed from the `answers_relpath` property, which calls `template.render(**self.answers.combined)` (`template` being the answers file name). Looks like it's the only missing location :slightly_smiling_face: 
author_association: CONTRIBUTOR
user: login: noirbizarre
created_at: 2025-01-29T18:27:05Z
body: PR updated with `answers_relpath` handled 👍🏼 

I saw the deprecation noticed, but it is used by all rendering except those for the prompts.

> I'd be in favor of dropping those lines as part of the next major release.

Agreed, but I would go even further (not in this PR to keep it scoped), I would isolate rendering in a single class/module/function/whatever, so future modification of this kind occurs in a single place. It would be easier to maintain and evolve, and easier to test.
author_association: CONTRIBUTOR
user: login: sisp
created_at: 2025-01-30T09:08:12Z
body: While resuming a discussion in #1733, @pawamoy [reminded be about the existence of the `_stage` variable in migrations](https://github.com/copier-org/copier/pull/1733#issuecomment-2621666810) that was introduced by #1510. This variable is limited to render contexts related to migrations, but nevertheless it isn't prefixed with `_copier`. Just to make sure we're consistent, would you mind sharing your thoughts once more about naming the variable `_phase` vs. `_copier_phase`? Honestly, I'm torn between prefixing and not prefixing now. :see_no_evil:
author_association: MEMBER
user: login: noirbizarre
created_at: 2025-01-30T10:00:58Z
body: I think prefixing global variables is consistent with what is already documented: https://copier.readthedocs.io/en/latest/creating/#variables-global
The `_stage` one is one of the scoped variables that currently only exists for migrations: https://copier.readthedocs.io/en/latest/creating/#variables-context-specific 

Having globals prefixed make sense to me (not only in copier but more generally as a sane development anti-collision safety)

It seems part of the current release is being focused on variables and putting some rules on it, so if I follow my reasoning, I would rename the var `_copier_operation` in #1733 as it is a global.

For migration, I think there are 2 choices:
- say they are not global but scoped to migrations and keep them as they are
- say it needs to follow the same rule, refactor migration scoped var into a migration scoped object `_copier_migration` (e.g. `_copier_migration.stage`, `_copier_migration.version_from`...)

Given the 2nd option would be a breaking change, it would force very copier template maintainer to rewrite all migrations and would produce more verbose migrations, I think it would be a lot of work for very few to none benefit except naming rule compliance.
So I would go with the first choice, keep them as they are.
author_association: CONTRIBUTOR
user: login: noirbizarre
created_at: 2025-02-22T01:09:11Z
body: Updated: rebased, solved conflicts and adapted to the latest changes.
That was not easy, as 25bc06d01 introduced a loop:
- `Worker.answers_relpath` uses `Worker.answers.combined` as context
- `Worker.answers.combined` uses `Worker.answers.system`
- `Worker.answers.system` is generated by `Worker._system_render_context()`
- `Worker._system_render_context()` builds `_copier_conf` using `Worker.answers_relpath`

As a consequence, the values used to generate `answers_relpath` are those from the previous `Worker._system_render_context()` call.

I found a fix for this PR and added a comment to keep track of it.

author_association: CONTRIBUTOR
user: login: sisp
created_at: 2025-02-28T16:37:05Z
body: There are some merge conflicts because of #1993 now, which mostly reverted the system render context change because of #1977 that also affected this PR.

> This PR also reminds me of a similar situation we've had in https://github.com/copier-org/copier/pull/1733 where initially a dataclass attribute was used as well and https://github.com/copier-org/copier/pull/1733#pullrequestreview-2397086710 instead. Now, it feels odd to me to use a dataclass attribute here to manage the phase value because it has no meaning while not rendering, prompting or executing tasks/migrations. How about applying an approach similar to https://github.com/copier-org/copier/pull/1733#pullrequestreview-2397086710 in this PR as well?
>
> &mdash; https://github.com/copier-org/copier/pull/1948#pullrequestreview-2580789541

I still think that the `Worker.phase` attribute isn't the right design. Would you mind trying to use a context variable (https://github.com/copier-org/copier/pull/1733#pullrequestreview-2397086710) instead?
author_association: MEMBER
user: login: noirbizarre
created_at: 2025-02-28T23:24:44Z
body: Updated:
- conflicts solved
- uses a `ContextVar` instead of an attribute
author_association: CONTRIBUTOR
user: login: noirbizarre
created_at: 2025-03-01T13:31:54Z
body: Updated with a context manager.
However, it's not possible to work without a default value or ~2/3 of the tests are failing, which is expected:
- methods are unit tested outside the Worker lifecycle so they are out of any known phase and fail with a `LookupError`.
- some properties and methods (especially `answers_relpath` and `_print_message`) are called outside any phase for computation or user feedback and trigger a rendering, so a lookup
- some operations are done in preparation of a phase outside the phase method itself, example, print statement are outside the methods, as the same method can be used differently depending on the phase (ex: `_execute_tasks`)

Not having a default value would mean:
- modifying ~2/3 of the tests
- indenting many statements just to use the context manager to ensure the touched property or the method triggering a render/lookup as side effect is not failing

That would mean having most of the codebase modified just to add a variable, and potential conflict with any pending PR, or any PR merged before this one.

So instead, I added a `Phase.UNDEFINED` as default so tests not relying on it and ran outside the lifecycle don't fail on as an unrelated side effect and the day someone actually hit a case where he needs the `_copier_phase` in a new phase and setting the phase has been forgotten (that's a lot of conditions), it will have an explicit `undefined` as value, it will be easy to identify, and it won't make `copier` failing for him until a fix is released.

WDYT ?
author_association: CONTRIBUTOR
user: login: sisp
created_at: 2025-03-01T17:29:42Z
body: Very nice, thanks!

> IIUC, the phase value when [rendering the answers file path of external data](https://github.com/copier-org/copier/blob/6eaa0d7e0959c81479b214eaf73aa55d14d0364d/copier%2Fmain.py#L283) depends on the phase in which that particular external data is _first_ accessed due to lazy access. Would it be better to override the phase value with `"undefined"` instead? The meaning of the phase doesn't seem well defined in this case. WDYT?

WDYT about this?
author_association: MEMBER
user: login: noirbizarre
created_at: 2025-03-01T17:53:02Z
body: Make sense 👍🏼 
I set it explicitly to `undefined` and added a comment to explain why.
author_association: CONTRIBUTOR