Skip to content

Commit 7f19627

Browse files
authored
feat(updating): add VCS ref sentinel :current: for referring to the current template ref
1 parent 8bf99c7 commit 7f19627

File tree

8 files changed

+131
-19
lines changed

8 files changed

+131
-19
lines changed

copier/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from . import _main
1010
from ._deprecation import deprecate_member_as_internal
11+
from ._types import VcsRef as VcsRef
1112

1213
if TYPE_CHECKING:
1314
from ._main import * # noqa: F403

copier/_cli.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@
5959
from plumbum import cli, colors
6060

6161
from ._main import Worker
62-
from ._tools import copier_version
63-
from ._types import AnyByStrDict
62+
from ._tools import copier_version, try_enum
63+
from ._types import AnyByStrDict, VcsRef
6464
from .errors import UnsafeTemplateError, UserMessageError
6565

6666

@@ -134,7 +134,9 @@ def __init__(self, executable: PathLike[str]) -> None:
134134
"Git reference to checkout in `template_src`. "
135135
"If you do not specify it, it will try to checkout the latest git tag, "
136136
"as sorted using the PEP 440 algorithm. If you want to checkout always "
137-
"the latest version, use `--vcs-ref=HEAD`."
137+
"the latest version, use `--vcs-ref=HEAD`. "
138+
"Use the special value `:current:` to refer to the current reference "
139+
"of the template if it already exists."
138140
),
139141
)
140142
pretend = cli.Flag(["-n", "--pretend"], help="Run but do not make any changes")
@@ -220,7 +222,7 @@ def _worker(
220222
skip_if_exists=self.skip,
221223
quiet=self.quiet,
222224
src_path=src_path,
223-
vcs_ref=self.vcs_ref,
225+
vcs_ref=try_enum(VcsRef, self.vcs_ref),
224226
use_prereleases=self.prereleases,
225227
unsafe=self.unsafe,
226228
skip_tasks=self.skip_tasks,

copier/_main.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
Phase,
6161
RelativePath,
6262
StrOrPath,
63+
VcsRef,
6364
)
6465
from ._user_data import AnswersMap, Question, load_answersfile_data
6566
from ._vcs import get_git
@@ -219,7 +220,7 @@ class Worker:
219220
src_path: str | None = None
220221
dst_path: Path = Path()
221222
answers_file: RelativePath | None = None
222-
vcs_ref: str | None = None
223+
vcs_ref: str | VcsRef | None = None
223224
data: AnyByStrDict = field(default_factory=dict)
224225
settings: Settings = field(default_factory=Settings.from_file)
225226
exclude: Sequence[str] = ()
@@ -398,7 +399,7 @@ def _render_context(self) -> AnyByStrMutableMapping:
398399
"src_path": lambda: self.template.local_abspath,
399400
"dst_path": lambda: self.dst_path,
400401
"answers_file": lambda: self.answers_relpath,
401-
"vcs_ref": lambda: self.vcs_ref,
402+
"vcs_ref": lambda: self.resolved_vcs_ref,
402403
"vcs_ref_hash": lambda: self.template.commit_hash,
403404
"data": lambda: self.data,
404405
"settings": lambda: self.settings,
@@ -953,6 +954,19 @@ def _render_value(
953954
except TypeError:
954955
return value
955956

957+
@cached_property
958+
def resolved_vcs_ref(self) -> str | None:
959+
"""Get the resolved VCS reference to use.
960+
961+
This is either `vcs_ref` or the subproject template ref
962+
if `vcs_ref` is `VcsRef.CURRENT`.
963+
"""
964+
if self.vcs_ref is VcsRef.CURRENT:
965+
if self.subproject.template is None:
966+
raise TypeError("Template not found")
967+
return self.subproject.template.ref
968+
return self.vcs_ref
969+
956970
@cached_property
957971
def subproject(self) -> Subproject:
958972
"""Get related subproject."""
@@ -965,15 +979,13 @@ def subproject(self) -> Subproject:
965979

966980
@cached_property
967981
def template(self) -> Template:
968-
"""Get related template."""
969982
url = self.src_path
970983
if not url:
971984
if self.subproject.template is None:
972985
raise TypeError("Template not found")
973986
url = str(self.subproject.template.url)
974-
result = Template(
975-
url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases
976-
)
987+
ref = self.resolved_vcs_ref
988+
result = Template(url=url, ref=ref, use_prereleases=self.use_prereleases)
977989
self._cleanup_hooks.append(result._cleanup)
978990
return result
979991

@@ -1043,6 +1055,15 @@ def run_recopy(self) -> None:
10431055
with replace(self, src_path=self.subproject.template.url) as new_worker:
10441056
new_worker.run_copy()
10451057

1058+
def _print_template_update_info(self, subproject_template: Template) -> None:
1059+
# TODO Unify printing tools
1060+
if not self.quiet:
1061+
if subproject_template.version == self.template.version:
1062+
message = f"Keeping template version {self.template.version}"
1063+
else:
1064+
message = f"Updating to template version {self.template.version}"
1065+
print(message, file=sys.stderr)
1066+
10461067
@as_operation("update")
10471068
def run_update(self) -> None:
10481069
"""Update a subproject that was already generated.
@@ -1086,11 +1107,7 @@ def run_update(self) -> None:
10861107
# asking for confirmation
10871108
raise UserMessageError("Enable overwrite to update a subproject.")
10881109
self._print_message(self.template.message_before_update)
1089-
if not self.quiet:
1090-
# TODO Unify printing tools
1091-
print(
1092-
f"Updating to template version {self.template.version}", file=sys.stderr
1093-
)
1110+
self._print_template_update_info(self.subproject.template)
10941111
with suppress(AttributeError):
10951112
# We might have switched operation context, ensure the cached property
10961113
# is regenerated to re-render templates.
@@ -1202,6 +1219,7 @@ def _apply_update(self) -> None: # noqa: C901
12021219
quiet=True,
12031220
src_path=self.subproject.template.url, # type: ignore[union-attr]
12041221
exclude=exclude_plus_removed,
1222+
vcs_ref=self.resolved_vcs_ref,
12051223
) as new_worker:
12061224
new_worker.run_copy()
12071225
with local.cwd(new_copy):

copier/_tools.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from importlib.metadata import version
1616
from pathlib import Path
1717
from types import TracebackType
18-
from typing import Any, Callable, Literal, TextIO, cast
18+
from typing import Any, Callable, Literal, TextIO, TypeVar, cast
1919

2020
import colorama
2121
from packaging.version import Version
@@ -265,3 +265,18 @@ def scantree(path: str, follow_symlinks: bool) -> Iterator[os.DirEntry[str]]:
265265
yield entry
266266
if entry.is_dir(follow_symlinks=follow_symlinks):
267267
yield from scantree(entry.path, follow_symlinks)
268+
269+
270+
_T = TypeVar("_T")
271+
_E = TypeVar("_E", bound=Enum)
272+
273+
274+
def try_enum(enum_type: type[_E], value: _T) -> _E | _T:
275+
"""Try to convert a value into an enum.
276+
277+
If the value is not a valid enum member, return the original value.
278+
"""
279+
try:
280+
return enum_type(value)
281+
except ValueError:
282+
return value

copier/_types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,10 @@ def current(cls) -> Phase:
125125

126126

127127
_phase: ContextVar[Phase] = ContextVar("phase", default=Phase.UNDEFINED)
128+
129+
130+
class VcsRef(Enum):
131+
CURRENT = ":current:"
132+
"""A special value to indicate that the current ref of the existing
133+
template should be used.
134+
"""

docs/configuring.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,7 +1721,7 @@ the `v2.0.0a1` tag unless this flag is enabled.
17211721

17221722
### `vcs_ref`
17231723

1724-
- Format: `str`
1724+
- Format: `str | VcsRef`
17251725
- CLI flags: `-r`, `--vcs-ref`
17261726
- Default value: N/A (use latest release)
17271727

@@ -1738,8 +1738,10 @@ _commit: v1.0.0
17381738

17391739
Not supported in `copier.yml`.
17401740

1741-
By default, Copier will copy from the last release found in template Git tags, sorted as
1742-
[PEP 440][].
1741+
The special value `VcsRef.CURRENT` is set to indicate that the template version should
1742+
be identical to the version already present. It is set when using `--vcs-ref=:current:`
1743+
in the CLI. By default, Copier will copy from the last release found in template Git
1744+
tags, sorted as [PEP 440][].
17431745

17441746
## Patterns syntax
17451747

docs/updating.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ copier update --defaults --data-file /tmp/data-file.yaml
142142
it is not yet possible to update a multiselect choice using ˋ--dataˋ.
143143
Use ˋ--data-fileˋ instead for now.
144144

145+
If you want to update the answers to all questions, but not the template:
146+
147+
```shell
148+
copier update --vcs-ref=:current:
149+
```
150+
145151
## How the update works
146152

147153
To understand how the updating process works, take a look at this diagram:

tests/test_updatediff.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from copier._cli import CopierApp
1414
from copier._main import Worker, run_copy, run_update
1515
from copier._tools import normalize_git_path
16+
from copier._types import VcsRef
1617
from copier._user_data import load_answersfile_data
1718
from copier.errors import UserMessageError
1819

@@ -25,6 +26,7 @@
2526
build_file_tree,
2627
git,
2728
git_init,
29+
git_save,
2830
)
2931

3032

@@ -1603,6 +1605,65 @@ def test_update_with_skip_answered_and_new_answer(
16031605
assert answers["boolean"] is True
16041606

16051607

1608+
@pytest.mark.parametrize("cli", [True, False])
1609+
def test_update_vcs_ref_current(
1610+
tmp_path_factory: pytest.TempPathFactory,
1611+
cli: bool,
1612+
) -> None:
1613+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
1614+
1615+
with local.cwd(src):
1616+
build_file_tree(
1617+
{
1618+
"copier.yml": "boolean: false",
1619+
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
1620+
}
1621+
)
1622+
git_init("v1")
1623+
git("tag", "v1")
1624+
1625+
if cli:
1626+
CopierApp.run(
1627+
["copier", "copy", str(src), str(dst), "--defaults", "--overwrite"],
1628+
exit=False,
1629+
)
1630+
else:
1631+
run_copy(str(src), dst, defaults=True, overwrite=True)
1632+
answers = load_answersfile_data(dst)
1633+
assert answers["_commit"] == "v1"
1634+
assert answers["boolean"] is False
1635+
1636+
with local.cwd(dst):
1637+
git_init("v1")
1638+
1639+
with local.cwd(src):
1640+
build_file_tree({"README.md": "# Template Update"})
1641+
git_save(message="update template", tag="v2")
1642+
1643+
if cli:
1644+
with local.cwd(dst):
1645+
CopierApp.run(
1646+
[
1647+
"copier",
1648+
"update",
1649+
"--data",
1650+
"boolean=true",
1651+
"--vcs-ref=:current:",
1652+
],
1653+
exit=False,
1654+
)
1655+
else:
1656+
run_update(
1657+
dst, data={"boolean": "true"}, vcs_ref=VcsRef.CURRENT, overwrite=True
1658+
)
1659+
answers = load_answersfile_data(dst)
1660+
assert answers["_commit"] == "v1"
1661+
assert answers["boolean"] is True
1662+
1663+
# assert that the README.md file was not created
1664+
assert not (dst / "README.md").exists()
1665+
1666+
16061667
def test_update_dont_validate_computed_value(
16071668
tmp_path_factory: pytest.TempPathFactory,
16081669
) -> None:

0 commit comments

Comments
 (0)