Skip to content

Commit 4ffb498

Browse files
committed
Add the --version-spec cli argument, that allows you to specify the
pep440 specifier that limit's what version of the template can be used to copy or update.
1 parent 0b714d5 commit 4ffb498

4 files changed

Lines changed: 60 additions & 5 deletions

File tree

copier/_cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ class CopierCopySubApp(_Subcommand):
239239
["-w", "--overwrite"],
240240
help="Overwrite files that already exist, without asking.",
241241
)
242+
version_spec = cli.SwitchAttr(
243+
["-V", "--version-spec"],
244+
str,
245+
default=None,
246+
help="Prevent copying from a version of the template that does not adhere to "
247+
"the version specification.",
248+
)
242249

243250
def main(self, template_src: str, destination_path: str) -> int:
244251
"""Call [run_copy][copier.run_copy].
@@ -270,6 +277,7 @@ def inner() -> None:
270277
quiet=self.quiet,
271278
unsafe=self.unsafe,
272279
skip_tasks=self.skip_tasks,
280+
version_spec=self.version_spec,
273281
)
274282

275283
return _handle_exceptions(inner)
@@ -318,6 +326,13 @@ class CopierRecopySubApp(_Subcommand):
318326
default=False,
319327
help="Skip questions that have already been answered",
320328
)
329+
version_spec = cli.SwitchAttr(
330+
["-V", "--version-spec"],
331+
str,
332+
default=None,
333+
help="Prevent copying from a version of the template that does not adhere to "
334+
"the version specification.",
335+
)
321336

322337
def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
323338
"""Call [run_recopy][copier.run_recopy].
@@ -347,6 +362,7 @@ def inner() -> None:
347362
unsafe=self.unsafe,
348363
skip_answered=self.skip_answered,
349364
skip_tasks=self.skip_tasks,
365+
version_spec=self.version_spec,
350366
)
351367

352368
return _handle_exceptions(inner)
@@ -401,6 +417,13 @@ class CopierUpdateSubApp(_Subcommand):
401417
default=False,
402418
help="Skip questions that have already been answered",
403419
)
420+
version_spec = cli.SwitchAttr(
421+
["-V", "--version-spec"],
422+
str,
423+
default=None,
424+
help="Prevent updates to any version that does not adhere to the version "
425+
"specification.",
426+
)
404427

405428
def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
406429
"""Call [run_update][copier.run_update].
@@ -432,6 +455,7 @@ def inner() -> None:
432455
unsafe=self.unsafe,
433456
skip_answered=self.skip_answered,
434457
skip_tasks=self.skip_tasks,
458+
version_spec=self.version_spec,
435459
)
436460

437461
return _handle_exceptions(inner)

copier/_main.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from filecmp import dircmp
1616
from functools import cached_property, partial, wraps
1717
from itertools import chain
18+
from packaging.specifiers import SpecifierSet
1819
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
1920
from shutil import rmtree
2021
from tempfile import TemporaryDirectory
@@ -249,6 +250,7 @@ class Worker:
249250
unsafe: bool = False
250251
skip_answered: bool = False
251252
skip_tasks: bool = False
253+
version_spec: str | None = None
252254

253255
answers: AnswersMap = field(default_factory=AnswersMap, init=False)
254256
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)
@@ -1065,7 +1067,12 @@ def template(self) -> Template:
10651067
raise TypeError("Template not found")
10661068
url = str(self.subproject.template.url)
10671069
ref = self.resolved_vcs_ref
1068-
result = Template(url=url, ref=ref, use_prereleases=self.use_prereleases)
1070+
result = Template(
1071+
url=url,
1072+
ref=ref,
1073+
use_prereleases=self.use_prereleases,
1074+
version_spec=self.version_spec
1075+
)
10691076
self._cleanup_hooks.append(result._cleanup)
10701077
return result
10711078

@@ -1195,6 +1202,12 @@ def run_update(self) -> None:
11951202
# We might have switched operation context, ensure the cached property
11961203
# is regenerated to re-render templates.
11971204
del self.match_exclude
1205+
if self.version_spec:
1206+
if not SpecifierSet(self.version_spec).contains(self.template.version):
1207+
raise UserMessageError(
1208+
f"Cannot update: new version {self.template.version}, as it does "
1209+
f"not adhere to specification \"{self.version_spec}\"."
1210+
)
11981211

11991212
self._apply_update()
12001213
self._print_message(self.template.message_after_update)
@@ -1544,6 +1557,7 @@ def run_copy(
15441557
quiet: bool = False,
15451558
unsafe: bool = False,
15461559
skip_tasks: bool = False,
1560+
version_spec: SpecifierSet | None = None,
15471561
) -> Worker:
15481562
"""Copy a template to a destination, from zero."""
15491563
with Worker(
@@ -1572,6 +1586,7 @@ def run_copy(
15721586
quiet=quiet,
15731587
unsafe=unsafe,
15741588
skip_tasks=skip_tasks,
1589+
version_spec=version_spec,
15751590
) as worker:
15761591
worker.run_copy()
15771592
return worker
@@ -1596,6 +1611,7 @@ def run_recopy(
15961611
unsafe: bool = False,
15971612
skip_answered: bool = False,
15981613
skip_tasks: bool = False,
1614+
version_spec: SpecifierSet | None = None,
15991615
) -> Worker:
16001616
"""Update a subproject from its template, discarding subproject evolution."""
16011617
with Worker(
@@ -1624,6 +1640,7 @@ def run_recopy(
16241640
unsafe=unsafe,
16251641
skip_answered=skip_answered,
16261642
skip_tasks=skip_tasks,
1643+
version_spec=version_spec,
16271644
) as worker:
16281645
worker.run_recopy()
16291646
return worker
@@ -1650,6 +1667,7 @@ def run_update(
16501667
unsafe: bool = False,
16511668
skip_answered: bool = False,
16521669
skip_tasks: bool = False,
1670+
version_spec: SpecifierSet | None = None,
16531671
) -> Worker:
16541672
"""Update a subproject, from its template."""
16551673
with Worker(
@@ -1680,6 +1698,7 @@ def run_update(
16801698
unsafe=unsafe,
16811699
skip_answered=skip_answered,
16821700
skip_tasks=skip_tasks,
1701+
version_spec=version_spec,
16831702
) as worker:
16841703
worker.run_update()
16851704
return worker

copier/_template.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import yaml
2020
from funcy import lflatten
2121
from packaging.version import Version, parse
22+
from packaging.specifiers import SpecifierSet
2223
from plumbum.machines import local
2324
from pydantic.dataclasses import dataclass
2425

@@ -219,6 +220,7 @@ class Template:
219220
url: str
220221
ref: str | None = None
221222
use_prereleases: bool = False
223+
version_spec: str | None = None
222224

223225
def _cleanup(self) -> None:
224226
if temp_clone := self._temp_clone():
@@ -566,11 +568,16 @@ def local_abspath(self) -> Path:
566568
Dirty changes for local VCS-tracked templates will be copied.
567569
"""
568570
result = Path(self.url)
571+
print(result)
569572
if self.vcs == "git":
570573
result = Path(
571574
clone(
572575
self.url_expanded,
573-
self.ref or get_latest_tag(self.url_expanded, self.use_prereleases),
576+
self.ref or get_latest_tag(
577+
self.url_expanded,
578+
self.use_prereleases,
579+
self.version_spec and SpecifierSet(self.version_spec) or None,
580+
),
574581
)
575582
)
576583
if not result.is_dir():

copier/_vcs.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
from warnings import warn
1111

1212
from packaging import version
13+
from packaging.specifiers import SpecifierSet
1314
from packaging.version import InvalidVersion, Version
1415
from plumbum import TF, ProcessExecutionError, colors, local
1516
from plumbum.machines import LocalCommand
1617

1718
from ._types import OptBool, OptStrOrPath, StrOrPath
18-
from .errors import DirtyLocalWarning, ShallowCloneWarning
19+
from .errors import DirtyLocalWarning, ShallowCloneWarning, UserMessageError
1920

2021
GIT_USER_NAME = "Copier"
2122
GIT_USER_EMAIL = "copier@copier"
@@ -126,7 +127,7 @@ def get_repo(url: str) -> str | None:
126127
return None
127128

128129

129-
def get_latest_tag(url: str, use_prereleases: OptBool = False) -> str:
130+
def get_latest_tag(url: str, use_prereleases: OptBool = False, spec: SpecifierSet | None = None) -> str:
130131
"""Get latest git tag, sorted by PEP 440.
131132
132133
Args:
@@ -147,10 +148,14 @@ def get_latest_tag(url: str, use_prereleases: OptBool = False) -> str:
147148
all_tags = (tag for tag in all_tags if valid_version(tag))
148149
if not use_prereleases:
149150
all_tags = (tag for tag in all_tags if not version.parse(tag).is_prerelease)
151+
if spec:
152+
all_tags = spec.filter(all_tags, use_prereleases)
150153
sorted_tags = sorted(all_tags, key=version.parse, reverse=True)
151154
try:
152155
return str(sorted_tags[0])
153-
except IndexError:
156+
except IndexError as e:
157+
if spec:
158+
raise UserMessageError(f"No git tag found that matches version spec {spec}.") from e
154159
print(
155160
colors.warn | "No git tags found in template; using HEAD as ref",
156161
file=sys.stderr,

0 commit comments

Comments
 (0)