diff --git a/copier/_main.py b/copier/_main.py index f9d367639..c52f7ebac 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -246,6 +246,7 @@ class Worker: answers: AnswersMap = field(default_factory=AnswersMap, init=False) _cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False) + _update_stage: str = field(default="current", init=False) def __enter__(self) -> Worker: """Allow using worker as a context manager.""" @@ -363,6 +364,7 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None: for i, task in enumerate(tasks): extra_context = {f"_{k}": v for k, v in task.extra_vars.items()} extra_context["_copier_operation"] = operation + extra_context["_update_stage"] = self._update_stage if not cast_to_bool(self._render_value(task.condition, extra_context)): continue @@ -1062,6 +1064,8 @@ def run_copy(self) -> None: # TODO Unify printing tools print("") # padding space if not self.skip_tasks: + with Phase.use(Phase.POSTRENDER): + self._execute_tasks(self.template.postrender_tasks) with Phase.use(Phase.TASKS): self._execute_tasks(self.template.tasks) except Exception: @@ -1179,6 +1183,7 @@ def _apply_update(self) -> None: # noqa: C901 # https://github.com/orgs/copier-org/discussions/2345 exclude=[*self.template.exclude, *self.exclude], ) as old_worker: + old_worker._update_stage = "previous" old_worker.run_copy() # Run pre-migration tasks with Phase.use(Phase.MIGRATE): @@ -1236,6 +1241,7 @@ def _apply_update(self) -> None: # noqa: C901 # TODO quiet=True, ) as current_worker: + current_worker._update_stage = "current" current_worker.run_copy() self.answers = current_worker.answers self.answers.external = self._external_data() @@ -1254,6 +1260,7 @@ def _apply_update(self) -> None: # noqa: C901 exclude=exclude_plus_removed, vcs_ref=self.resolved_vcs_ref, ) as new_worker: + new_worker._update_stage = "new" new_worker.run_copy() with local.cwd(new_copy): self._git_initialize_repo() diff --git a/copier/_template.py b/copier/_template.py index ce6313134..a540ededf 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -534,6 +534,32 @@ def tasks(self) -> Sequence[Task]: tasks.append(Task(cmd=task, extra_vars=extra_vars)) return tasks + @cached_property + def postrender_tasks(self) -> Sequence[Task]: + """Get postrender tasks defined in the template. + + These run after template rendering is complete but before regular + tasks execute. During updates, they run on both old_copy and new_copy + to ensure consistent transformations before diff calculation. + + See [postrender_tasks][]. + """ + extra_vars = {"stage": "postrender"} + tasks = [] + for task in self.config_data.get("postrender_tasks", []): + if isinstance(task, dict): + tasks.append( + Task( + cmd=task["command"], + extra_vars=extra_vars, + condition=task.get("when", "true"), + working_directory=Path(task.get("working_directory", ".")), + ) + ) + else: + tasks.append(Task(cmd=task, extra_vars=extra_vars)) + return tasks + @cached_property def templates_suffix(self) -> str: """Get the suffix defined for templates. diff --git a/copier/_types.py b/copier/_types.py index 0b5a1abec..bcf04242f 100644 --- a/copier/_types.py +++ b/copier/_types.py @@ -108,9 +108,10 @@ class Phase(str, Enum): """The known execution phases.""" PROMPT = "prompt" + RENDER = "render" + POSTRENDER = "postrender" TASKS = "tasks" MIGRATE = "migrate" - RENDER = "render" UNDEFINED = "undefined" def __str__(self) -> str: diff --git a/docs/configuring.md b/docs/configuring.md index 7f0b10f77..8cbfd0d0c 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -1647,6 +1647,15 @@ Commands to execute after generating or updating a project from your template. They run ordered, and with the `$STAGE=task` variable in their environment. Each task runs in its own subprocess. +**Available variables:** + +- All template variables (from questions and defaults) +- `_copier_phase` = "tasks" +- `_copier_operation` = "copy" or "update" +- `_update_stage` = "current" during copy; "previous", "current", or "new" during + update (see [postrender_tasks][] for details) +- `_stage` = "task" (available as `STAGE` environment variable) + If a `dict` is given it can contain the following items: - **command**: The task command to run. @@ -1686,6 +1695,109 @@ Refer to the example provided below for more information. your task manager. But it's just an example. The point is that we're showing how to build and call commands. +### `postrender_tasks` + +!!! info Available since Copier 9.11.0 + +- Format: `List[str|List[str]|dict]` +- CLI flags: N/A +- Default value: `[]` + +Postrender tasks are commands that run after template rendering is complete but before +regular tasks execute. + +During copy operations, postrender tasks execute once after all files are rendered. + +During update operations, postrender tasks execute three times: + +1. After rendering the old template version (into temporary directory) +2. After rendering the new template version (into temporary directory) +3. After rendering to the actual destination + +This ensures that any transformations are applied consistently before diff calculation, +maintaining correct 3-way merge semantics. + +**Use cases:** + +- Package refactoring (e.g., Java package renaming) +- Path normalization +- File transformations that should be consistent across template versions + +If a `dict` is given it can contain the following items: + +- **command**: The task command to run. +- **when** (optional): Specifies a condition that needs to hold for the task to run. +- **working_directory** (optional): Specifies the directory in which the command will + be run. Defaults to the destination directory. + +If a `str` or `List[str]` is given as a postrender task it will be treated as `command` +with all other items not present. + +**Available variables:** + +- All template variables (from questions and defaults) +- `_copier_phase` = "postrender" +- `_copier_operation` = "copy" or "update" +- `_update_stage` = "current" during copy; "previous", "current", or "new" during + update +- `_stage` = "postrender" (available as `STAGE` environment variable) + +**Execution:** + +- Postrender tasks respect the [`skip_tasks`][skip_tasks] flag +- Task failures will abort the copy/update operation +- Tasks run in the destination directory by default + +!!! warning "Postrender tasks must be idempotent" + + During updates, postrender tasks run three times in different contexts: + + - **`previous` stage**: Renders old template into clean temp directory + - **`new` stage**: Renders new template into clean temp directory + - **`current` stage**: Renders to actual destination (which already has previous postrender results!) + + This means simple commands like `mv src/example src/{{ package }}` will fail during + updates because the destination directory already exists. Use the `_update_stage` variable to + apply different logic based on stage, or write fully idempotent tasks. + +!!! example "Simple idempotent task" + + ```yaml title="copier.yml" + _postrender_tasks: + - "echo 'Postrender complete'" + - ["{{ _copier_python }}", "refactor.py", "example", "{{ package }}"] + ``` + +!!! example "Directory renaming with `_update_stage`" + + When renaming directories, use conditional logic based on `_update_stage`: + + ```yaml title="copier.yml" + package: + type: str + help: Package name + + _postrender_tasks: + # Temp directories: always clean, simple rename works + - command: "[ -d src/example ] && mv src/example src/{{ package }} || true" + when: "{{ _update_stage in ['previous', 'new'] }}" + + # Destination: must handle existing renamed directory from previous render + - command: | + if [ -d src/example ]; then + if [ ! -d "src/{{ package }}" ]; then + # Initial copy: simple rename + mv src/example "src/{{ package }}" + else + # Update: merge new template files into existing directory + mkdir -p "src/{{ package }}" + cp -R src/example/* "src/{{ package }}/" + rm -rf src/example + fi + fi + when: "{{ _update_stage == 'current' }}" + ``` + ### `templates_suffix` - Format: `str` diff --git a/docs/creating.md b/docs/creating.md index e16c73924..d20471917 100644 --- a/docs/creating.md +++ b/docs/creating.md @@ -151,7 +151,8 @@ The name of the project root directory. ### `_copier_phase` -The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`. +The current phase, one of `"prompt"`, `"render"`, `"postrender"`, `"tasks"`, or +`"migrate"`. !!! note @@ -167,7 +168,33 @@ Some variables are only available in select contexts: The current operation, either `"copy"` or `"update"`. -Availability: [`exclude`](configuring.md#exclude), [`tasks`](configuring.md#tasks) +Availability: [`exclude`](configuring.md#exclude), +[`postrender_tasks`](configuring.md#postrender_tasks), [`tasks`](configuring.md#tasks) + +### `_update_stage` + +The current update stage, indicating which rendering context is active. + +**Values:** + +- `"current"` - Rendering to the actual destination (always during copy operations) +- `"previous"` - Rendering the old template version to temporary directory (update + only) +- `"new"` - Rendering the new template version to temporary directory (update only) + +Available as `UPDATE_STAGE` environment variable. + +Availability: [`postrender_tasks`](configuring.md#postrender_tasks), +[`tasks`](configuring.md#tasks) + +!!! example + + ```yaml title="copier.yml" + _postrender_tasks: + # Run expensive operation only on actual destination + - command: "python refactor.py" + when: "{{ _update_stage == 'current' }}" + ``` ## Variables (context-specific) diff --git a/tests/test_postrender.py b/tests/test_postrender.py new file mode 100644 index 000000000..6b07daa3d --- /dev/null +++ b/tests/test_postrender.py @@ -0,0 +1,353 @@ +"""Tests for postrender tasks.""" + +import platform +from pathlib import Path + +import pytest + +import copier + +from .helpers import build_file_tree, git_save + + +@pytest.fixture +def template_with_postrender(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Create a template with postrender tasks.""" + template_path = tmp_path_factory.mktemp("template") + build_file_tree( + { + template_path / "copier.yml": """\ + package: + type: str + default: boilerplate + + _postrender_tasks: + - "echo 'Postrender task executed' > postrender.log" + - command: "echo '{{ package }}' > package.txt" + """, + template_path / "README.md.jinja": "# Project {{ package }}", + template_path / "src" / "main.txt.jinja": "package: {{ package }}", + } + ) + return template_path + + +def test_postrender_tasks_execute_on_copy( + template_with_postrender: Path, tmp_path: Path +) -> None: + """Test that postrender tasks execute during initial copy.""" + copier.run_copy( + str(template_with_postrender), + tmp_path, + data={"package": "myproject"}, + defaults=True, + unsafe=True, + ) + + # Verify postrender tasks executed + assert (tmp_path / "postrender.log").exists() + log_content = (tmp_path / "postrender.log").read_text().strip() + assert "Postrender task executed" in log_content + + # Verify templated postrender task + assert (tmp_path / "package.txt").exists() + assert (tmp_path / "package.txt").read_text().strip() == "myproject" + + # Verify regular template rendering also worked + assert (tmp_path / "README.md").read_text() == "# Project myproject" + + +def test_postrender_tasks_execute_before_regular_tasks( + tmp_path_factory: pytest.TempPathFactory, tmp_path: Path +) -> None: + """Test that postrender tasks execute before regular tasks.""" + template_path = tmp_path_factory.mktemp("template") + build_file_tree( + { + template_path / "copier.yml": """\ + _postrender_tasks: + - "echo 'postrender' >> execution_order.log" + + _tasks: + - "echo 'task' >> execution_order.log" + """, + template_path / "README.md": "# Test", + } + ) + copier.run_copy(str(template_path), tmp_path, unsafe=True) + + # Verify execution order + log = (tmp_path / "execution_order.log").read_text() + assert log == "postrender\ntask\n" + + +def test_postrender_task_features( + tmp_path_factory: pytest.TempPathFactory, tmp_path: Path +) -> None: + """Test postrender task features: working_directory, when clause, and _copier_phase.""" + template_path = tmp_path_factory.mktemp("template") + build_file_tree( + { + template_path / "copier.yml": """\ +enable_feature: + type: bool + default: false + +_postrender_tasks: + # Test working_directory + - command: "pwd > cwd.txt" + - command: "pwd > subdir_cwd.txt" + working_directory: ./subdir + # Test when clause + - command: "echo 'feature enabled' > feature.txt" + when: "{{ enable_feature }}" + # Test _copier_phase variable + - command: "echo '{{ _copier_phase }}' > phase.txt" +""", + template_path / "README.md": "# Test", + template_path / "subdir" / ".gitkeep": "", + } + ) + + # Test with feature disabled + copier.run_copy( + str(template_path), + tmp_path, + data={"enable_feature": False}, + unsafe=True, + ) + + # Verify working_directory + assert (tmp_path / "cwd.txt").exists() + assert (tmp_path / "subdir" / "subdir_cwd.txt").exists() + + # Verify when clause - feature should be disabled + assert not (tmp_path / "feature.txt").exists() + + # Verify _copier_phase + assert (tmp_path / "phase.txt").read_text().strip() == "postrender" + + # Test with feature enabled + copier.run_copy( + str(template_path), + tmp_path, + data={"enable_feature": True}, + unsafe=True, + overwrite=True, + ) + + # Verify when clause - feature should now be enabled + assert (tmp_path / "feature.txt").exists() + assert (tmp_path / "feature.txt").read_text().strip() == "feature enabled" + + +@pytest.mark.skipif( + platform.system() == "Windows", + reason="Uses Unix shell syntax", +) +def test_postrender_on_update_with_git( + tmp_path_factory: pytest.TempPathFactory, tmp_path: Path +) -> None: + """Test postrender directory renaming with mixed file types and updates. + + This test verifies: + - Directory renaming via postrender (src/example/ -> src/{{ package }}/) + - Mix of templated (.jinja) and non-templated files + - New files added in template v2 end up in renamed directory + - User files in renamed directory are preserved during update + - Template changes are merged with user changes + """ + template_path = tmp_path_factory.mktemp("template") + + # INITIAL TEMPLATE (v1) + build_file_tree( + { + template_path / "copier.yml": """\ + _version: "1.0.0" + + package: + type: str + default: boilerplate + + _postrender_tasks: + # Temp directories (old_copy, new_copy): simple rename + - command: "[ -d src/example ] && mv src/example src/{{ package }} || true" + when: "{{ _update_stage in ['previous', 'new'] }}" + + # Destination: handle both initial copy and updates + - command: | + if [ -d src/example ]; then + if [ ! -d "src/{{ package }}" ]; then + # Initial copy: simple rename + mv src/example "src/{{ package }}" + else + # Update: merge new template files into existing directory + mkdir -p "src/{{ package }}" + cp -R src/example/* "src/{{ package }}/" + rm -rf src/example + fi + fi + when: "{{ _update_stage == 'current' }}" + """, + template_path / "README.md.jinja": "# {{ package }} Project", + template_path + / ".copier-answers.yml.jinja": "{{ _copier_answers|to_nice_yaml }}", + template_path / "src" / "example" / "plain.txt": "non-templated content", + template_path + / "src" + / "example" + / "file1.txt.jinja": "one {{ package }} one", + template_path + / "src" + / "example" + / "file2.txt.jinja": "two {{ package }} two", + } + ) + git_save(template_path, "v1.0.0", tag="1.0.0") + + # INITIAL COPY + copier.run_copy( + str(template_path), + tmp_path, + data={"package": "myproject"}, + vcs_ref="1.0.0", + unsafe=True, + ) + git_save(tmp_path, "Initial commit") + + # Verify initial copy: directory should be renamed + assert not (tmp_path / "src" / "example").exists() + assert (tmp_path / "src" / "myproject").exists() + + # Non-templated file should be copied as-is + plain_txt = tmp_path / "src" / "myproject" / "plain.txt" + assert plain_txt.exists() + assert plain_txt.read_text() == "non-templated content" + + # Templated files should have rendered content + file1 = tmp_path / "src" / "myproject" / "file1.txt" + assert file1.exists() + assert file1.read_text() == "one myproject one" + + file2 = tmp_path / "src" / "myproject" / "file2.txt" + assert file2.exists() + assert file2.read_text() == "two myproject two" + + # USER MODIFICATIONS + build_file_tree( + { + tmp_path / "src" / "myproject" / "user.txt": "user custom content", + } + ) + # User modifies an existing template file + file1.write_text("one myproject one\nuser added line") + git_save(tmp_path, "User modifications") + + # UPDATE TEMPLATE (v2) + build_file_tree( + { + template_path + / "src" + / "example" + / "file3.txt.jinja": "three {{ package }} three", + template_path + / "src" + / "example" + / "file1.txt.jinja": "one {{ package }} one\ntemplate added line", + template_path / "README.md.jinja": "# {{ package }} Project v2", + } + ) + git_save(template_path, "v2.0.0", tag="2.0.0") + + # RUN UPDATE + copier.run_update( + dst_path=tmp_path, + vcs_ref="2.0.0", + defaults=True, + unsafe=True, + overwrite=True, + ) + + # VERIFY UPDATE RESULTS + + # 1. Directory should still be renamed (not nested) + assert not (tmp_path / "src" / "example").exists() + assert (tmp_path / "src" / "myproject").exists() + assert not (tmp_path / "src" / "myproject" / "example").exists() + + # 2. New template file should be in renamed directory + file3 = tmp_path / "src" / "myproject" / "file3.txt" + assert file3.exists() + assert file3.read_text() == "three myproject three" + + # 3. User's custom file should be preserved + user_file = tmp_path / "src" / "myproject" / "user.txt" + assert user_file.exists() + assert user_file.read_text() == "user custom content" + + # 4. Modified files should be merged (3-way merge) + file1_updated = file1.read_text() + assert "one myproject one" in file1_updated + assert "template added line" in file1_updated + assert "user added line" in file1_updated + + # 5. Non-templated file should still exist unchanged + assert plain_txt.exists() + assert plain_txt.read_text() == "non-templated content" + + # 6. Other templated files should still exist + assert file2.exists() + + # 7. Root files should be updated + assert (tmp_path / "README.md").read_text() == "# myproject Project v2" + + +def test_postrender_with_skip_tasks_flag( + template_with_postrender: Path, tmp_path: Path +) -> None: + """Test that skip_tasks flag also skips postrender tasks.""" + copier.run_copy( + str(template_with_postrender), + tmp_path, + data={"package": "myproject"}, + skip_tasks=True, + unsafe=True, + ) + + # Verify postrender tasks were skipped + assert not (tmp_path / "postrender.log").exists() + assert not (tmp_path / "package.txt").exists() + + # Verify template rendering still worked + assert (tmp_path / "README.md").exists() + + +@pytest.mark.skipif( + platform.system() == "Windows", + reason="Uses Unix shell syntax", +) +def test_postrender_conditional_on_update_stage( + tmp_path_factory: pytest.TempPathFactory, tmp_path: Path +) -> None: + """Test using _update_stage in when conditions.""" + template_path = tmp_path_factory.mktemp("template") + + (template_path / "copier.yml").write_text( + """\ +_postrender_tasks: + - command: "echo 'expensive' > expensive.txt" + when: "{{ _update_stage == 'current' }}" + - command: "echo '{{ _update_stage }}' > all_stages.txt" +""" + ) + (template_path / "README.md").write_text("# Test") + + copier.run_copy(str(template_path), tmp_path, unsafe=True) + + # Verify conditional task only ran on "current" + assert (tmp_path / "expensive.txt").exists() + assert (tmp_path / "expensive.txt").read_text().strip() == "expensive" + + # Verify unconditional task also ran + assert (tmp_path / "all_stages.txt").exists() + assert (tmp_path / "all_stages.txt").read_text().strip() == "current"