Skip to content

Commit badf574

Browse files
committed
feat(context): expose a _context_phase context variable (fix copier-org#1883)
1 parent 57439e5 commit badf574

File tree

8 files changed

+101
-3
lines changed

8 files changed

+101
-3
lines changed

Diff for: copier/main.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
MISSING,
6363
AnyByStrDict,
6464
JSONSerializable,
65+
Phase,
6566
RelativePath,
6667
StrOrPath,
6768
)
@@ -206,6 +207,7 @@ class Worker:
206207
unsafe: bool = False
207208
skip_answered: bool = False
208209
skip_tasks: bool = False
210+
phase: Phase = Phase.PROMPT
209211

210212
answers: AnswersMap = field(default_factory=AnswersMap, init=False)
211213
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)
@@ -354,6 +356,7 @@ def _render_context(self) -> Mapping[str, Any]:
354356
_copier_conf=conf,
355357
_folder_name=self.subproject.local_abspath.name,
356358
_copier_python=sys.executable,
359+
_copier_phase=self.phase.value,
357360
)
358361

359362
def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]:
@@ -456,6 +459,7 @@ def _render_allowed(
456459

457460
def _ask(self) -> None: # noqa: C901
458461
"""Ask the questions of the questionnaire and record their answers."""
462+
self.phase = Phase.PROMPT
459463
result = AnswersMap(
460464
user_defaults=self.user_defaults,
461465
init=self.data,
@@ -601,6 +605,7 @@ def match_skip(self) -> Callable[[Path], bool]:
601605

602606
def _render_template(self) -> None:
603607
"""Render the template in the subproject root."""
608+
self.phase = Phase.RENDER
604609
follow_symlinks = not self.template.preserve_symlinks
605610
for src in scantree(str(self.template_copy_root), follow_symlinks):
606611
src_abspath = Path(src.path)
@@ -916,6 +921,7 @@ def run_copy(self) -> None:
916921
# TODO Unify printing tools
917922
print("") # padding space
918923
if not self.skip_tasks:
924+
self.phase = Phase.TASKS
919925
self._execute_tasks(self.template.tasks)
920926
except Exception:
921927
if not was_existing and self.cleanup_on_error:
@@ -1015,6 +1021,7 @@ def _apply_update(self) -> None: # noqa: C901
10151021
) as old_worker:
10161022
old_worker.run_copy()
10171023
# Run pre-migration tasks
1024+
self.phase = Phase.MIGRATE
10181025
self._execute_tasks(
10191026
self.template.migration_tasks("before", self.subproject.template) # type: ignore[arg-type]
10201027
)
@@ -1090,7 +1097,7 @@ def _apply_update(self) -> None: # noqa: C901
10901097
self._git_initialize_repo()
10911098
new_copy_head = git("rev-parse", "HEAD").strip()
10921099
# Extract diff between temporary destination and real destination
1093-
# with some special handling of newly added files in both the poject
1100+
# with some special handling of newly added files in both the project
10941101
# and the template.
10951102
with local.cwd(old_copy):
10961103
# Configure borrowing Git objects from the real destination and

Diff for: copier/types.py

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Complex types, annotations, validators."""
22

3+
from enum import Enum
34
from pathlib import Path
45
from typing import (
56
Annotated,
@@ -58,3 +59,12 @@ def path_is_relative(value: Path) -> Path:
5859

5960
AbsolutePath = Annotated[Path, AfterValidator(path_is_absolute)]
6061
RelativePath = Annotated[Path, AfterValidator(path_is_relative)]
62+
63+
64+
class Phase(str, Enum):
65+
"""The known execution phases."""
66+
67+
PROMPT = "prompt"
68+
TASKS = "tasks"
69+
MIGRATE = "migrate"
70+
RENDER = "render"

Diff for: copier/user_data.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from .errors import InvalidTypeError, UserMessageError
2727
from .tools import cast_to_bool, cast_to_str, force_str_end
28-
from .types import MISSING, AnyByStrDict, MissingType, OptStrOrPath, StrOrPath
28+
from .types import MISSING, AnyByStrDict, MissingType, OptStrOrPath, Phase, StrOrPath
2929

3030

3131
# TODO Remove these two functions as well as DEFAULT_DATA in a future release
@@ -441,7 +441,13 @@ def render_value(
441441
else value
442442
)
443443
try:
444-
return template.render({**self.answers.combined, **(extra_answers or {})})
444+
return template.render(
445+
{
446+
**self.answers.combined,
447+
**(extra_answers or {}),
448+
"_copier_phase": Phase.PROMPT.value,
449+
}
450+
)
445451
except UndefinedError as error:
446452
raise UserMessageError(str(error)) from error
447453

Diff for: docs/creating.md

+4
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ The absolute path of the Python interpreter running Copier.
125125

126126
The name of the project root directory.
127127

128+
### `_copier_phase`
129+
130+
The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
131+
128132
## Variables (context-specific)
129133

130134
Some rendering contexts provide variables unique to them:

Diff for: tests/test_copy.py

+7
Original file line numberDiff line numberDiff line change
@@ -1042,3 +1042,10 @@ def test_templated_choices(tmp_path_factory: pytest.TempPathFactory, spec: str)
10421042
)
10431043
copier.run_copy(str(src), dst, data={"q": "two"})
10441044
assert yaml.safe_load((dst / "q.txt").read_text()) == "two"
1045+
1046+
1047+
def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
1048+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
1049+
build_file_tree({src / "{{ _copier_phase }}": ""})
1050+
copier.run_copy(str(src), dst)
1051+
assert (dst / "render").exists()

Diff for: tests/test_migrations.py

+29
Original file line numberDiff line numberDiff line change
@@ -521,3 +521,32 @@ def test_migration_jinja_variables(
521521
assert f"{variable}={value}" in vars
522522
else:
523523
assert f"{variable}=" in vars
524+
525+
526+
def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
527+
"""Test that the Phase variable is properly set."""
528+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
529+
530+
with local.cwd(src):
531+
build_file_tree(
532+
{
533+
**COPIER_ANSWERS_FILE,
534+
"copier.yml": (
535+
"""\
536+
_migrations:
537+
- touch {{ _copier_phase }}
538+
"""
539+
),
540+
}
541+
)
542+
git_save(tag="v1")
543+
with local.cwd(dst):
544+
run_copy(src_path=str(src))
545+
git_save()
546+
547+
with local.cwd(src):
548+
git("tag", "v2")
549+
with local.cwd(dst):
550+
run_update(defaults=True, overwrite=True, unsafe=True)
551+
552+
assert (dst / "migrate").is_file()

Diff for: tests/test_tasks.py

+16
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,19 @@ def test_os_specific_tasks(
164164
monkeypatch.setattr("copier.main.OS", os)
165165
copier.run_copy(str(src), dst, unsafe=True)
166166
assert (dst / filename).exists()
167+
168+
169+
def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
170+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
171+
build_file_tree(
172+
{
173+
(src / "copier.yml"): (
174+
"""
175+
_tasks:
176+
- touch {{ _copier_phase }}
177+
"""
178+
)
179+
}
180+
)
181+
copier.run_copy(str(src), dst, unsafe=True)
182+
assert (dst / "tasks").exists()

Diff for: tests/test_templated_prompt.py

+19
Original file line numberDiff line numberDiff line change
@@ -563,3 +563,22 @@ def test_multiselect_choices_with_templated_default_value(
563563
"python_version": "3.11",
564564
"github_runner_python_version": ["3.11"],
565565
}
566+
567+
568+
def test_copier_phase_variable(
569+
tmp_path_factory: pytest.TempPathFactory,
570+
spawn: Spawn,
571+
) -> None:
572+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
573+
build_file_tree(
574+
{
575+
(src / "copier.yml"): """\
576+
phase:
577+
type: str
578+
default: "{{ _copier_phase }}"
579+
"""
580+
}
581+
)
582+
tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
583+
expect_prompt(tui, "phase", "str")
584+
tui.expect_exact("prompt")

0 commit comments

Comments
 (0)