Skip to content

Commit 55d31e0

Browse files
authored
feat: Add _copier_operation variable (#1733)
* Makes `exclude` configuration templatable. * Adds a `_copier_operation` variable to the rendering contexts for `exclude` and `tasks`, representing the current operation - either `copy`~~, `recopy`~~ or `update`. This was proposed here: #1718 (comment)
1 parent 9b0f2b6 commit 55d31e0

File tree

5 files changed

+179
-4
lines changed

5 files changed

+179
-4
lines changed

copier/main.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
import subprocess
88
import sys
99
from contextlib import suppress
10+
from contextvars import ContextVar
1011
from dataclasses import asdict, field, replace
1112
from filecmp import dircmp
12-
from functools import cached_property, partial
13+
from functools import cached_property, partial, wraps
1314
from itertools import chain
1415
from pathlib import Path
1516
from shutil import rmtree
@@ -65,6 +66,8 @@
6566
AnyByStrMutableMapping,
6667
JSONSerializable,
6768
LazyDict,
69+
Operation,
70+
ParamSpec,
6871
Phase,
6972
RelativePath,
7073
StrOrPath,
@@ -73,6 +76,29 @@
7376
from .vcs import get_git
7477

7578
_T = TypeVar("_T")
79+
_P = ParamSpec("_P")
80+
81+
_operation: ContextVar[Operation] = ContextVar("_operation")
82+
83+
84+
def as_operation(value: Operation) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
85+
"""Decorator to set the current operation context, if not defined already.
86+
87+
This value is used to template specific configuration options.
88+
"""
89+
90+
def _decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
91+
@wraps(func)
92+
def _wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
93+
token = _operation.set(_operation.get(value))
94+
try:
95+
return func(*args, **kwargs)
96+
finally:
97+
_operation.reset(token)
98+
99+
return _wrapper
100+
101+
return _decorator
76102

77103

78104
@dataclass(config=ConfigDict(extra="forbid"))
@@ -248,7 +274,7 @@ def _cleanup(self) -> None:
248274
for method in self._cleanup_hooks:
249275
method()
250276

251-
def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
277+
def _check_unsafe(self, mode: Operation) -> None:
252278
"""Check whether a template uses unsafe features."""
253279
if self.unsafe or self.settings.is_trusted(self.template.url):
254280
return
@@ -327,8 +353,10 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
327353
Arguments:
328354
tasks: The list of tasks to run.
329355
"""
356+
operation = _operation.get()
330357
for i, task in enumerate(tasks):
331358
extra_context = {f"_{k}": v for k, v in task.extra_vars.items()}
359+
extra_context["_copier_operation"] = operation
332360

333361
if not cast_to_bool(self._render_value(task.condition, extra_context)):
334362
continue
@@ -358,7 +386,7 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
358386
/ Path(self._render_string(str(task.working_directory), extra_context))
359387
).absolute()
360388

361-
extra_env = {k.upper(): str(v) for k, v in task.extra_vars.items()}
389+
extra_env = {k[1:].upper(): str(v) for k, v in extra_context.items()}
362390
with local.cwd(working_directory), local.env(**extra_env):
363391
subprocess.run(task_cmd, shell=use_shell, check=True, env=local.env)
364392

@@ -625,7 +653,14 @@ def _pathjoin(
625653
@cached_property
626654
def match_exclude(self) -> Callable[[Path], bool]:
627655
"""Get a callable to match paths against all exclusions."""
628-
return self._path_matcher(self.all_exclusions)
656+
# Include the current operation in the rendering context.
657+
# Note: This method is a cached property, it needs to be regenerated
658+
# when reusing an instance in different contexts.
659+
extra_context = {"_copier_operation": _operation.get()}
660+
return self._path_matcher(
661+
self._render_string(exclusion, extra_context=extra_context)
662+
for exclusion in self.all_exclusions
663+
)
629664

630665
@cached_property
631666
def match_skip(self) -> Callable[[Path], bool]:
@@ -928,6 +963,7 @@ def template_copy_root(self) -> Path:
928963
return self.template.local_abspath / subdir
929964

930965
# Main operations
966+
@as_operation("copy")
931967
def run_copy(self) -> None:
932968
"""Generate a subproject from zero, ignoring what was in the folder.
933969
@@ -938,6 +974,11 @@ def run_copy(self) -> None:
938974
939975
See [generating a project][generating-a-project].
940976
"""
977+
with suppress(AttributeError):
978+
# We might have switched operation context, ensure the cached property
979+
# is regenerated to re-render templates.
980+
del self.match_exclude
981+
941982
self._check_unsafe("copy")
942983
self._print_message(self.template.message_before_copy)
943984
with Phase.use(Phase.PROMPT):
@@ -967,6 +1008,7 @@ def run_copy(self) -> None:
9671008
# TODO Unify printing tools
9681009
print("") # padding space
9691010

1011+
@as_operation("copy")
9701012
def run_recopy(self) -> None:
9711013
"""Update a subproject, keeping answers but discarding evolution."""
9721014
if self.subproject.template is None:
@@ -977,6 +1019,7 @@ def run_recopy(self) -> None:
9771019
with replace(self, src_path=self.subproject.template.url) as new_worker:
9781020
new_worker.run_copy()
9791021

1022+
@as_operation("update")
9801023
def run_update(self) -> None:
9811024
"""Update a subproject that was already generated.
9821025
@@ -1024,6 +1067,11 @@ def run_update(self) -> None:
10241067
print(
10251068
f"Updating to template version {self.template.version}", file=sys.stderr
10261069
)
1070+
with suppress(AttributeError):
1071+
# We might have switched operation context, ensure the cached property
1072+
# is regenerated to re-render templates.
1073+
del self.match_exclude
1074+
10271075
self._apply_update()
10281076
self._print_message(self.template.message_after_update)
10291077

copier/types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import sys
56
from contextlib import contextmanager
67
from contextvars import ContextVar
78
from enum import Enum
@@ -24,6 +25,11 @@
2425

2526
from pydantic import AfterValidator
2627

28+
if sys.version_info >= (3, 10):
29+
from typing import ParamSpec as ParamSpec
30+
else:
31+
from typing_extensions import ParamSpec as ParamSpec
32+
2733
# simple types
2834
StrOrPath = Union[str, Path]
2935
AnyByStrDict = Dict[str, Any]
@@ -44,6 +50,7 @@
4450
Env = Mapping[str, str]
4551
MissingType = NewType("MissingType", object)
4652
MISSING = MissingType(object())
53+
Operation = Literal["copy", "update"]
4754

4855

4956
# Validators

docs/configuring.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,21 @@ to know available options.
962962

963963
The CLI option can be passed several times to add several patterns.
964964

965+
Each pattern can be templated using Jinja.
966+
967+
!!! example
968+
969+
Templating `exclude` patterns using `_copier_operation` allows to have files
970+
that are rendered once during `copy`, but are never updated:
971+
972+
```yaml
973+
_exclude:
974+
- "{% if _copier_operation == 'update' -%}src/*_example.py{% endif %}"
975+
```
976+
977+
The difference with [skip_if_exists][] is that it will never be rendered during
978+
an update, no matter if it exitsts or not.
979+
965980
!!! info
966981

967982
When you define this parameter in `copier.yml`, it will **replace** the default
@@ -1421,6 +1436,8 @@ configuring `secret: true` in the [advanced prompt format][advanced-prompt-forma
14211436
exist, but always be present. If they do not exist in a project during an `update`
14221437
operation, they will be recreated.
14231438

1439+
Each pattern can be templated using Jinja.
1440+
14241441
!!! example
14251442

14261443
For example, it can be used if your project generates a password the 1st time and
@@ -1571,6 +1588,9 @@ other items not present.
15711588
- [invoke, end-process, "--full-conf={{ _copier_conf|to_json }}"]
15721589
# Your script can be run by the same Python environment used to run Copier
15731590
- ["{{ _copier_python }}", task.py]
1591+
# Run a command during the initial copy operation only, excluding updates
1592+
- command: ["{{ _copier_python }}", task.py]
1593+
when: "{{ _copier_operation == 'copy' }}"
15741594
# OS-specific task (supported values are "linux", "macos", "windows" and `None`)
15751595
- command: rm {{ name_of_the_project }}/README.md
15761596
when: "{{ _copier_conf.os in ['linux', 'macos'] }}"

docs/creating.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,16 @@ The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
146146
You may encounter this phase when rendering outside of those phases,
147147
when rendering lazily (and the phase notion can be irrelevant) or when testing.
148148

149+
## Variables (context-dependent)
150+
151+
Some variables are only available in select contexts:
152+
153+
### `_copier_operation`
154+
155+
The current operation, either `"copy"` or `"update"`.
156+
157+
Availability: [`exclude`](configuring.md#exclude), [`tasks`](configuring.md#tasks)
158+
149159
## Variables (context-specific)
150160

151161
Some rendering contexts provide variables unique to them:

tests/test_context.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
from plumbum import local
6+
7+
import copier
8+
9+
from .helpers import build_file_tree, git_save
10+
11+
12+
def test_exclude_templating_with_operation(
13+
tmp_path_factory: pytest.TempPathFactory,
14+
) -> None:
15+
"""
16+
Ensure it's possible to create one-off boilerplate files that are not
17+
managed during updates via `_exclude` using the `_copier_operation` context variable.
18+
"""
19+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
20+
21+
template = "{% if _copier_operation == 'update' %}copy-only{% endif %}"
22+
with local.cwd(src):
23+
build_file_tree(
24+
{
25+
"copier.yml": f'_exclude:\n - "{template}"',
26+
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
27+
"copy-only": "foo",
28+
"copy-and-update": "foo",
29+
}
30+
)
31+
git_save(tag="1.0.0")
32+
build_file_tree(
33+
{
34+
"copy-only": "bar",
35+
"copy-and-update": "bar",
36+
}
37+
)
38+
git_save(tag="2.0.0")
39+
copy_only = dst / "copy-only"
40+
copy_and_update = dst / "copy-and-update"
41+
42+
copier.run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="1.0.0")
43+
for file in (copy_only, copy_and_update):
44+
assert file.exists()
45+
assert file.read_text() == "foo"
46+
47+
with local.cwd(dst):
48+
git_save()
49+
50+
copier.run_update(str(dst), overwrite=True)
51+
assert copy_only.read_text() == "foo"
52+
assert copy_and_update.read_text() == "bar"
53+
54+
55+
def test_task_templating_with_operation(
56+
tmp_path_factory: pytest.TempPathFactory, tmp_path: Path
57+
) -> None:
58+
"""
59+
Ensure that it is possible to define tasks that are only executed when copying.
60+
"""
61+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
62+
# Use a file outside the Copier working directories to ensure accurate tracking
63+
task_counter = tmp_path / "task_calls.txt"
64+
with local.cwd(src):
65+
build_file_tree(
66+
{
67+
"copier.yml": (
68+
f"""\
69+
_tasks:
70+
- command: echo {{{{ _copier_operation }}}} >> {json.dumps(str(task_counter))}
71+
when: "{{{{ _copier_operation == 'copy' }}}}"
72+
"""
73+
),
74+
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
75+
}
76+
)
77+
git_save(tag="1.0.0")
78+
79+
copier.run_copy(str(src), dst, defaults=True, overwrite=True, unsafe=True)
80+
assert task_counter.exists()
81+
assert len(task_counter.read_text().splitlines()) == 1
82+
83+
with local.cwd(dst):
84+
git_save()
85+
86+
copier.run_recopy(dst, defaults=True, overwrite=True, unsafe=True)
87+
assert len(task_counter.read_text().splitlines()) == 2
88+
89+
copier.run_update(dst, defaults=True, overwrite=True, unsafe=True)
90+
assert len(task_counter.read_text().splitlines()) == 2

0 commit comments

Comments
 (0)