diff --git a/copier/_jinja_ext.py b/copier/_jinja_ext.py index f1b17a315..100fb2db1 100644 --- a/copier/_jinja_ext.py +++ b/copier/_jinja_ext.py @@ -122,4 +122,57 @@ def _yield_support( return res +class IgnoreExtension(Extension): + r"""Jinja2 extension for the `ignore` tag. + + Wraps a block of *developer-owned* template content that should be rendered + on initial generation (`copier copy`) but omitted from the renders Copier + produces internally during `copier update`. Because the block is absent from + both the old and new template renders that feed the 3-way merge, template + changes inside it never reach the diff, so the developer's own version of the + region in the generated project is left untouched on every update. + + Unlike literal marker comments, nothing about this tag survives into the + rendered file, so it is language-agnostic and never leaks Copier syntax into + generated projects. + + The tag compiles to the equivalent of:: + + {% if _copier_operation != 'update' %}...{% endif %} + + so it relies solely on the `_copier_operation` render context variable and + needs no runtime support hook. + + !!! example + + ```pycon + >>> from jinja2.sandbox import SandboxedEnvironment + >>> from copier._jinja_ext import IgnoreExtension + >>> env = SandboxedEnvironment(extensions=[IgnoreExtension]) + >>> template = env.from_string( + ... "keep\n{% ignore %}scaffold{% endignore %}\nkeep" + ... ) + >>> template.render({"_copier_operation": "copy"}) + 'keep\nscaffold\nkeep' + >>> template.render({"_copier_operation": "update"}) + 'keep\n\nkeep' + ``` + """ + + tags = {"ignore"} + + def parse(self, parser: Parser) -> nodes.Node: + """Parse the `ignore` tag into a conditional on the current operation.""" + lineno = next(parser.stream).lineno + body = parser.parse_statements(("name:endignore",), drop_needle=True) + # Render the body for every operation except `update`; during an update + # Copier omits it so the region stays developer-owned. + test = nodes.Compare( + nodes.Name("_copier_operation", "load", lineno=lineno), + [nodes.Operand("ne", nodes.Const("update", lineno=lineno))], + lineno=lineno, + ) + return nodes.If(test, body, [], [], lineno=lineno) + + class UnsetError(UndefinedError): ... diff --git a/copier/_main.py b/copier/_main.py index a584dba48..a72c28453 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -45,7 +45,7 @@ from questionary import confirm, unsafe_prompt from ._deprecation import deprecate_answers_file_template_path -from ._jinja_ext import YieldExtension, get_yield_context +from ._jinja_ext import IgnoreExtension, YieldExtension, get_yield_context from ._settings import Settings, SettingsModel, is_trusted_repository from ._subproject import Subproject from ._template import Task, Template @@ -461,7 +461,7 @@ def _render_context(self) -> AnyByStrMutableMapping: "os": lambda: OS, } ) - return dict( + context = dict( **self.answers.combined, _copier_answers=self._answers_to_remember(), _copier_conf=conf, @@ -469,6 +469,12 @@ def _render_context(self) -> AnyByStrMutableMapping: _copier_python=sys.executable, _copier_phase=Phase.current(), ) + # ``_copier_operation`` is only defined within a copy/update/recopy run. + # Outside of one (e.g. ``check_update`` or other bare-``Worker`` use), + # leave it unset so rendering doesn't require an active operation. + if (operation := _operation.get(None)) is not None: + context["_copier_operation"] = operation + return context def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]: """Produce a function that matches against specified patterns.""" @@ -707,6 +713,7 @@ def jinja_env(self) -> SandboxedEnvironment: default_extensions = [ "jinja2_ansible_filters.AnsibleCoreFiltersExtension", YieldExtension, + IgnoreExtension, ] extensions = default_extensions + list(self.template.jinja_extensions) envops = dict(self.template.envops) @@ -852,7 +859,7 @@ def _render_file( # noqa: C901 new_content = src_abspath.read_bytes() else: new_content = tpl.render( - **self._render_context(), **(extra_context or {}) + {**self._render_context(), **(extra_context or {})} ).encode() if get_yield_context(self.jinja_env).yield_name: raise YieldTagInFileError( @@ -1175,7 +1182,7 @@ def _render_string( Additional variables to use for rendering the template. """ tpl = self.jinja_env.from_string(string) - return tpl.render(**self._render_context(), **(extra_context or {})) + return tpl.render({**self._render_context(), **(extra_context or {})}) def _render_value( self, value: _T, extra_context: AnyByStrDict | None = None diff --git a/docs/updating.md b/docs/updating.md index c4613b57d..136bf3f06 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -255,6 +255,85 @@ git clean -d -i # remove untracked files and folders If you want fine-grained control to restore files selectively, read the output of the `git status` command attentively. It shows all the commands you may need as hints! +## Developer-owned regions with `{% ignore %}` + +Templates often ship files with dummy content or `TODO` markers that you are meant to +replace right after generating a project (e.g. a stub function body, a placeholder +config value). By default, if the template later changes that placeholder, the +[update](#how-the-update-works) 3-way merge treats it like any other template-owned +content, so the template's new placeholder can overwrite your implementation or raise a +needless merge conflict. + +Wrap such a region in the `{% ignore %}` / `{% endignore %}` tag to make it +**developer-owned**. The block is rendered on `copier copy` (so you get the initial +scaffolding), but it is _omitted_ from the renders Copier produces internally during +`copier update`. Because the region is absent from both sides of the update's 3-way +merge, template changes inside it never reach the diff, and your version of the region +in the generated project is left untouched. + +```jinja title="app.py.jinja" +HEADER = "generated by {{ project_name }}" + + +{% ignore -%} +def greeting() -> str: + # TODO: implement your greeting + return "dummy greeting" +{%- endignore %} + + +FOOTER = "generated by {{ project_name }}" +``` + +This renders on the initial `copier copy` as: + +```python title="app.py" +HEADER = "generated by Foo" + + +def greeting() -> str: + # TODO: implement your greeting + return "dummy greeting" + + +FOOTER = "generated by Foo" +``` + +You then replace the body with your real implementation. On the next `copier update`, +your `greeting()` is kept even if the template changed the placeholder, while `HEADER` +and `FOOTER` still receive template updates. + +!!! tip "Nothing leaks into the rendered file" + + Unlike a comment-based marker, the `{% ignore %}` tag is pure Jinja — it is stripped + during rendering, so no Copier-specific syntax ends up in your generated project. It + is therefore **language-agnostic**: it works in `.js`, `.go`, `.c`, `.rs` … or any + other text file, since it never has to be a valid comment in the target language. + +!!! important "Leave stable context around the block" + + The `-%}` and `{%-` [whitespace-control][whitespace] markers strip the tag's own + lines so the output stays clean. Keep at least one unchanging line — such as the + blank lines above — directly around the block, and avoid editing the lines + immediately adjacent to it in later template versions. The update re-applies your + region as a diff against the surrounding lines; if the template changes a line right + next to the block, the merge falls back to a normal Copier + [conflict](#recover-from-a-broken-update) that you resolve by hand (your content is + never silently lost). Embedding the block among stable code, rather than at the very + top or bottom of a tiny file, gives the merge the anchors it needs. + +The tag relies on the `_copier_operation` render context variable, which is `"copy"` +during generation and `"update"` during an update. If you prefer to be explicit, or need +a condition the tag can't express, you can write the equivalent directly: + +```jinja +{% if _copier_operation != "update" %} +...developer-owned content... +{% endif %} +``` + +[whitespace]: https://jinja.palletsprojects.com/en/stable/templates/#whitespace-control + ## Checking for updates Copier provides a subcommand `copier check-update` that can be used to check if there diff --git a/tests/test_ignore.py b/tests/test_ignore.py new file mode 100644 index 000000000..65129e073 --- /dev/null +++ b/tests/test_ignore.py @@ -0,0 +1,178 @@ +"""Tests for the ``{% ignore %}`` tag that keeps developer-owned regions (#2184). + +The tag renders its body on ``copier copy`` but is omitted from the renders +Copier produces internally during ``copier update``. Because the region is +absent from both the old and new template renders that feed the 3-way merge, +template changes inside it never reach the diff and the developer's own version +of the region survives, with no Copier syntax leaking into the rendered file. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from jinja2.sandbox import SandboxedEnvironment +from plumbum import local + +from copier._jinja_ext import IgnoreExtension +from copier._main import run_copy, run_update + +from .helpers import build_file_tree, git + +# Unit tests for the extension in isolation. + + +@pytest.fixture +def env() -> SandboxedEnvironment: + return SandboxedEnvironment(extensions=[IgnoreExtension]) + + +def test_ignore_renders_body_outside_update(env: SandboxedEnvironment) -> None: + template = env.from_string("a\n{% ignore %}b{% endignore %}\nc") + assert template.render({"_copier_operation": "copy"}) == "a\nb\nc" + + +def test_ignore_omits_body_on_update(env: SandboxedEnvironment) -> None: + template = env.from_string("a\n{% ignore %}b{% endignore %}\nc") + assert template.render({"_copier_operation": "update"}) == "a\n\nc" + + +def test_ignore_trim_markers_clean_output(env: SandboxedEnvironment) -> None: + """The idiomatic ``-%}``/``{%-`` markers strip the tag lines entirely.""" + template = env.from_string("a\n{% ignore -%}\nb\n{%- endignore %}\nc") + assert template.render({"_copier_operation": "copy"}) == "a\nb\nc" + assert template.render({"_copier_operation": "update"}) == "a\n\nc" + + +# End-to-end tests exercising the full copy + update flow. +# These also confirm the extension is registered by default (they use the tag +# with no ``_jinja_extensions`` configured). + + +def _commit_template(src: Path, body: str, tag: str) -> None: + build_file_tree( + { + src / "{{ _copier_conf.answers_file }}.jinja": ( + "{{ _copier_answers|to_nice_yaml }}\n" + ), + src / "app.py.jinja": body, + }, + dedent=False, + ) + with local.cwd(src): + git("init") if not (src / ".git").exists() else None + git("add", "-A") + git("commit", "-m", tag) + git("tag", tag) + + +_V1 = """\ +import os +CONFIG = "v1" + + +def stable_helper(): + return 42 + + +{% ignore -%} +def user_code(): + return "dummy v1" +{%- endignore %} + + +def another_stable(): + return "keep" +""" + +_V2 = ( + _V1.replace('CONFIG = "v1"', 'CONFIG = "v2"') + .replace('return "dummy v1"', 'return "dummy v2"') + .replace('return "keep"', 'return "kept in v2"') +) + + +def test_copy_renders_block_without_leaking_syntax( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Initial generation renders the block; no marker/tag survives.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + _commit_template(src, _V1, "v1") + + run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="v1") + + rendered = (dst / "app.py").read_text(encoding="utf-8") + assert 'return "dummy v1"' in rendered # scaffolding is present after copy + assert "ignore" not in rendered # no Copier syntax leaks into the file + assert "{%" not in rendered and "%}" not in rendered + + +def test_update_preserves_developer_region( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Developer content in an ``{% ignore %}`` block survives an update. + + The template changes the surrounding code *and* the placeholder inside the + block; only the surroundings update while the developer's implementation is + kept, with no merge conflict. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + _commit_template(src, _V1, "v1") + + run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="v1") + with local.cwd(dst): + git("init") + git("add", "-A") + git("commit", "-m", "generated") + + # Developer replaces the placeholder body with a real implementation. + app = dst / "app.py" + app.write_text( + app.read_text(encoding="utf-8").replace( + 'return "dummy v1"', 'return "REAL IMPLEMENTATION"' + ), + encoding="utf-8", + ) + with local.cwd(dst): + git("commit", "-am", "customize") + + _commit_template(src, _V2, "v2") + run_update(dst, defaults=True, overwrite=True, vcs_ref="v2") + + result = app.read_text(encoding="utf-8") + assert "<<<<<<<" not in result # clean merge + assert 'return "REAL IMPLEMENTATION"' in result # developer content kept + assert 'return "dummy v2"' not in result # template placeholder ignored + assert 'CONFIG = "v2"' in result # surrounding code updated + assert 'return "kept in v2"' in result + assert "ignore" not in result # still no leaked syntax + + +def test_update_without_customization_takes_template_default( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """If the developer never touched the block, they keep the v1 scaffolding. + + Because the block is omitted from the update renders, the template's newer + placeholder does not overwrite the region -- the region is developer-owned + from the very first ``copy``. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + _commit_template(src, _V1, "v1") + + run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="v1") + with local.cwd(dst): + git("init") + git("add", "-A") + git("commit", "-m", "generated") + + _commit_template(src, _V2, "v2") + run_update(dst, defaults=True, overwrite=True, vcs_ref="v2") + + result = (dst / "app.py").read_text(encoding="utf-8") + assert "<<<<<<<" not in result + # The region keeps the originally generated content, not the v2 placeholder. + assert 'return "dummy v1"' in result + assert 'return "dummy v2"' not in result + assert 'CONFIG = "v2"' in result # surroundings still update