mirror of
https://github.com/copier-org/copier.git
synced 2025-05-05 15:32:54 +00:00
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: https://github.com/copier-org/copier/issues/1718#issuecomment-2282643624
This commit is contained in:
parent
9b0f2b6956
commit
55d31e01c2
@ -7,9 +7,10 @@ import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, field, replace
|
||||
from filecmp import dircmp
|
||||
from functools import cached_property, partial
|
||||
from functools import cached_property, partial, wraps
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
@ -65,6 +66,8 @@ from .types import (
|
||||
AnyByStrMutableMapping,
|
||||
JSONSerializable,
|
||||
LazyDict,
|
||||
Operation,
|
||||
ParamSpec,
|
||||
Phase,
|
||||
RelativePath,
|
||||
StrOrPath,
|
||||
@ -73,6 +76,29 @@ from .user_data import AnswersMap, Question, load_answersfile_data
|
||||
from .vcs import get_git
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
_operation: ContextVar[Operation] = ContextVar("_operation")
|
||||
|
||||
|
||||
def as_operation(value: Operation) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
|
||||
"""Decorator to set the current operation context, if not defined already.
|
||||
|
||||
This value is used to template specific configuration options.
|
||||
"""
|
||||
|
||||
def _decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
|
||||
@wraps(func)
|
||||
def _wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
|
||||
token = _operation.set(_operation.get(value))
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
_operation.reset(token)
|
||||
|
||||
return _wrapper
|
||||
|
||||
return _decorator
|
||||
|
||||
|
||||
@dataclass(config=ConfigDict(extra="forbid"))
|
||||
@ -248,7 +274,7 @@ class Worker:
|
||||
for method in self._cleanup_hooks:
|
||||
method()
|
||||
|
||||
def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
|
||||
def _check_unsafe(self, mode: Operation) -> None:
|
||||
"""Check whether a template uses unsafe features."""
|
||||
if self.unsafe or self.settings.is_trusted(self.template.url):
|
||||
return
|
||||
@ -327,8 +353,10 @@ class Worker:
|
||||
Arguments:
|
||||
tasks: The list of tasks to run.
|
||||
"""
|
||||
operation = _operation.get()
|
||||
for i, task in enumerate(tasks):
|
||||
extra_context = {f"_{k}": v for k, v in task.extra_vars.items()}
|
||||
extra_context["_copier_operation"] = operation
|
||||
|
||||
if not cast_to_bool(self._render_value(task.condition, extra_context)):
|
||||
continue
|
||||
@ -358,7 +386,7 @@ class Worker:
|
||||
/ Path(self._render_string(str(task.working_directory), extra_context))
|
||||
).absolute()
|
||||
|
||||
extra_env = {k.upper(): str(v) for k, v in task.extra_vars.items()}
|
||||
extra_env = {k[1:].upper(): str(v) for k, v in extra_context.items()}
|
||||
with local.cwd(working_directory), local.env(**extra_env):
|
||||
subprocess.run(task_cmd, shell=use_shell, check=True, env=local.env)
|
||||
|
||||
@ -625,7 +653,14 @@ class Worker:
|
||||
@cached_property
|
||||
def match_exclude(self) -> Callable[[Path], bool]:
|
||||
"""Get a callable to match paths against all exclusions."""
|
||||
return self._path_matcher(self.all_exclusions)
|
||||
# Include the current operation in the rendering context.
|
||||
# Note: This method is a cached property, it needs to be regenerated
|
||||
# when reusing an instance in different contexts.
|
||||
extra_context = {"_copier_operation": _operation.get()}
|
||||
return self._path_matcher(
|
||||
self._render_string(exclusion, extra_context=extra_context)
|
||||
for exclusion in self.all_exclusions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def match_skip(self) -> Callable[[Path], bool]:
|
||||
@ -928,6 +963,7 @@ class Worker:
|
||||
return self.template.local_abspath / subdir
|
||||
|
||||
# Main operations
|
||||
@as_operation("copy")
|
||||
def run_copy(self) -> None:
|
||||
"""Generate a subproject from zero, ignoring what was in the folder.
|
||||
|
||||
@ -938,6 +974,11 @@ class Worker:
|
||||
|
||||
See [generating a project][generating-a-project].
|
||||
"""
|
||||
with suppress(AttributeError):
|
||||
# We might have switched operation context, ensure the cached property
|
||||
# is regenerated to re-render templates.
|
||||
del self.match_exclude
|
||||
|
||||
self._check_unsafe("copy")
|
||||
self._print_message(self.template.message_before_copy)
|
||||
with Phase.use(Phase.PROMPT):
|
||||
@ -967,6 +1008,7 @@ class Worker:
|
||||
# TODO Unify printing tools
|
||||
print("") # padding space
|
||||
|
||||
@as_operation("copy")
|
||||
def run_recopy(self) -> None:
|
||||
"""Update a subproject, keeping answers but discarding evolution."""
|
||||
if self.subproject.template is None:
|
||||
@ -977,6 +1019,7 @@ class Worker:
|
||||
with replace(self, src_path=self.subproject.template.url) as new_worker:
|
||||
new_worker.run_copy()
|
||||
|
||||
@as_operation("update")
|
||||
def run_update(self) -> None:
|
||||
"""Update a subproject that was already generated.
|
||||
|
||||
@ -1024,6 +1067,11 @@ class Worker:
|
||||
print(
|
||||
f"Updating to template version {self.template.version}", file=sys.stderr
|
||||
)
|
||||
with suppress(AttributeError):
|
||||
# We might have switched operation context, ensure the cached property
|
||||
# is regenerated to re-render templates.
|
||||
del self.match_exclude
|
||||
|
||||
self._apply_update()
|
||||
self._print_message(self.template.message_after_update)
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from enum import Enum
|
||||
@ -24,6 +25,11 @@ from typing import (
|
||||
|
||||
from pydantic import AfterValidator
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import ParamSpec as ParamSpec
|
||||
else:
|
||||
from typing_extensions import ParamSpec as ParamSpec
|
||||
|
||||
# simple types
|
||||
StrOrPath = Union[str, Path]
|
||||
AnyByStrDict = Dict[str, Any]
|
||||
@ -44,6 +50,7 @@ VCSTypes = Literal["git"]
|
||||
Env = Mapping[str, str]
|
||||
MissingType = NewType("MissingType", object)
|
||||
MISSING = MissingType(object())
|
||||
Operation = Literal["copy", "update"]
|
||||
|
||||
|
||||
# Validators
|
||||
|
@ -962,6 +962,21 @@ to know available options.
|
||||
|
||||
The CLI option can be passed several times to add several patterns.
|
||||
|
||||
Each pattern can be templated using Jinja.
|
||||
|
||||
!!! example
|
||||
|
||||
Templating `exclude` patterns using `_copier_operation` allows to have files
|
||||
that are rendered once during `copy`, but are never updated:
|
||||
|
||||
```yaml
|
||||
_exclude:
|
||||
- "{% if _copier_operation == 'update' -%}src/*_example.py{% endif %}"
|
||||
```
|
||||
|
||||
The difference with [skip_if_exists][] is that it will never be rendered during
|
||||
an update, no matter if it exitsts or not.
|
||||
|
||||
!!! info
|
||||
|
||||
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
|
||||
exist, but always be present. If they do not exist in a project during an `update`
|
||||
operation, they will be recreated.
|
||||
|
||||
Each pattern can be templated using Jinja.
|
||||
|
||||
!!! example
|
||||
|
||||
For example, it can be used if your project generates a password the 1st time and
|
||||
@ -1571,6 +1588,9 @@ other items not present.
|
||||
- [invoke, end-process, "--full-conf={{ _copier_conf|to_json }}"]
|
||||
# Your script can be run by the same Python environment used to run Copier
|
||||
- ["{{ _copier_python }}", task.py]
|
||||
# Run a command during the initial copy operation only, excluding updates
|
||||
- command: ["{{ _copier_python }}", task.py]
|
||||
when: "{{ _copier_operation == 'copy' }}"
|
||||
# OS-specific task (supported values are "linux", "macos", "windows" and `None`)
|
||||
- command: rm {{ name_of_the_project }}/README.md
|
||||
when: "{{ _copier_conf.os in ['linux', 'macos'] }}"
|
||||
|
@ -146,6 +146,16 @@ The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
|
||||
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-dependent)
|
||||
|
||||
Some variables are only available in select contexts:
|
||||
|
||||
### `_copier_operation`
|
||||
|
||||
The current operation, either `"copy"` or `"update"`.
|
||||
|
||||
Availability: [`exclude`](configuring.md#exclude), [`tasks`](configuring.md#tasks)
|
||||
|
||||
## Variables (context-specific)
|
||||
|
||||
Some rendering contexts provide variables unique to them:
|
||||
|
90
tests/test_context.py
Normal file
90
tests/test_context.py
Normal file
@ -0,0 +1,90 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from plumbum import local
|
||||
|
||||
import copier
|
||||
|
||||
from .helpers import build_file_tree, git_save
|
||||
|
||||
|
||||
def test_exclude_templating_with_operation(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure it's possible to create one-off boilerplate files that are not
|
||||
managed during updates via `_exclude` using the `_copier_operation` context variable.
|
||||
"""
|
||||
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
||||
|
||||
template = "{% if _copier_operation == 'update' %}copy-only{% endif %}"
|
||||
with local.cwd(src):
|
||||
build_file_tree(
|
||||
{
|
||||
"copier.yml": f'_exclude:\n - "{template}"',
|
||||
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
||||
"copy-only": "foo",
|
||||
"copy-and-update": "foo",
|
||||
}
|
||||
)
|
||||
git_save(tag="1.0.0")
|
||||
build_file_tree(
|
||||
{
|
||||
"copy-only": "bar",
|
||||
"copy-and-update": "bar",
|
||||
}
|
||||
)
|
||||
git_save(tag="2.0.0")
|
||||
copy_only = dst / "copy-only"
|
||||
copy_and_update = dst / "copy-and-update"
|
||||
|
||||
copier.run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="1.0.0")
|
||||
for file in (copy_only, copy_and_update):
|
||||
assert file.exists()
|
||||
assert file.read_text() == "foo"
|
||||
|
||||
with local.cwd(dst):
|
||||
git_save()
|
||||
|
||||
copier.run_update(str(dst), overwrite=True)
|
||||
assert copy_only.read_text() == "foo"
|
||||
assert copy_and_update.read_text() == "bar"
|
||||
|
||||
|
||||
def test_task_templating_with_operation(
|
||||
tmp_path_factory: pytest.TempPathFactory, tmp_path: Path
|
||||
) -> None:
|
||||
"""
|
||||
Ensure that it is possible to define tasks that are only executed when copying.
|
||||
"""
|
||||
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
||||
# Use a file outside the Copier working directories to ensure accurate tracking
|
||||
task_counter = tmp_path / "task_calls.txt"
|
||||
with local.cwd(src):
|
||||
build_file_tree(
|
||||
{
|
||||
"copier.yml": (
|
||||
f"""\
|
||||
_tasks:
|
||||
- command: echo {{{{ _copier_operation }}}} >> {json.dumps(str(task_counter))}
|
||||
when: "{{{{ _copier_operation == 'copy' }}}}"
|
||||
"""
|
||||
),
|
||||
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
||||
}
|
||||
)
|
||||
git_save(tag="1.0.0")
|
||||
|
||||
copier.run_copy(str(src), dst, defaults=True, overwrite=True, unsafe=True)
|
||||
assert task_counter.exists()
|
||||
assert len(task_counter.read_text().splitlines()) == 1
|
||||
|
||||
with local.cwd(dst):
|
||||
git_save()
|
||||
|
||||
copier.run_recopy(dst, defaults=True, overwrite=True, unsafe=True)
|
||||
assert len(task_counter.read_text().splitlines()) == 2
|
||||
|
||||
copier.run_update(dst, defaults=True, overwrite=True, unsafe=True)
|
||||
assert len(task_counter.read_text().splitlines()) == 2
|
Loading…
x
Reference in New Issue
Block a user