Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions copier/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ class CopierCopySubApp(_Subcommand):
["-w", "--overwrite"],
help="Overwrite files that already exist, without asking.",
)
version_subscription = cli.SwitchAttr(
["-V", "--version-subscription"],
str,
default=None,
help="Prevent copying from a version of the template that does not adhere to "
"the version specification.",
)

def main(self, template_src: str, destination_path: str) -> int:
"""Call [run_copy][copier.run_copy].
Expand Down Expand Up @@ -270,6 +277,7 @@ def inner() -> None:
quiet=self.quiet,
unsafe=self.unsafe,
skip_tasks=self.skip_tasks,
version_subscription=self.version_subscription,
)

return _handle_exceptions(inner)
Expand Down Expand Up @@ -318,6 +326,13 @@ class CopierRecopySubApp(_Subcommand):
default=False,
help="Skip questions that have already been answered",
)
version_subscription = cli.SwitchAttr(
["-V", "--version-subscription"],
str,
default=None,
help="Prevent copying from a version of the template that does not adhere to "
"the version specification.",
)

def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
"""Call [run_recopy][copier.run_recopy].
Expand Down Expand Up @@ -347,6 +362,7 @@ def inner() -> None:
unsafe=self.unsafe,
skip_answered=self.skip_answered,
skip_tasks=self.skip_tasks,
version_subscription=self.version_subscription,
)

return _handle_exceptions(inner)
Expand Down Expand Up @@ -401,6 +417,13 @@ class CopierUpdateSubApp(_Subcommand):
default=False,
help="Skip questions that have already been answered",
)
version_subscription = cli.SwitchAttr(
["-V", "--version-subscription"],
str,
default=None,
help="Prevent updates to any version that does not adhere to the version "
"specification.",
)

def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
"""Call [run_update][copier.run_update].
Expand Down Expand Up @@ -432,6 +455,7 @@ def inner() -> None:
unsafe=self.unsafe,
skip_answered=self.skip_answered,
skip_tasks=self.skip_tasks,
version_subscription=self.version_subscription,
)

return _handle_exceptions(inner)
Expand Down
33 changes: 31 additions & 2 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

from jinja2.loaders import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from pathspec import PathSpec, __version__ as pathspec_version
from plumbum import ProcessExecutionError, colors
Expand Down Expand Up @@ -249,6 +250,7 @@ class Worker:
unsafe: bool = False
skip_answered: bool = False
skip_tasks: bool = False
version_subscription: str | None = None

answers: AnswersMap = field(default_factory=AnswersMap, init=False)
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)
Expand Down Expand Up @@ -360,7 +362,12 @@ def _answers_to_remember(self) -> Mapping[str, Any]:
answers: AnyByStrDict = {}
commit = self.template.commit
src = self.template.url
for key, value in (("_commit", commit), ("_src_path", src)):
version_subscription = self.template.version_subscription
for key, value in (
("_commit", commit),
("_src_path", src),
("_version_subscription", version_subscription),
):
if value is not None:
answers[key] = value
# Other data goes next
Expand Down Expand Up @@ -1065,7 +1072,16 @@ def template(self) -> Template:
raise TypeError("Template not found")
url = str(self.subproject.template.url)
ref = self.resolved_vcs_ref
result = Template(url=url, ref=ref, use_prereleases=self.use_prereleases)
version_subscription = (
self.version_subscription
or self.subproject.last_answers.get("_version_subscription", None)
)
result = Template(
url=url,
ref=ref,
use_prereleases=self.use_prereleases,
version_subscription=version_subscription,
)
self._cleanup_hooks.append(result._cleanup)
return result

Expand Down Expand Up @@ -1189,6 +1205,13 @@ def run_update(self) -> None:
# review the diff before committing; so we can safely avoid
# asking for confirmation
raise UserMessageError("Enable overwrite to update a subproject.")
if self.version_subscription and not SpecifierSet(
self.version_subscription
).contains(self.template.version):
raise UserMessageError(
f"Cannot update: new version {self.template.version}, as it does "
f'not adhere to specification "{self.version_subscription}".'
)
self._print_message(self.template.message_before_update)
self._print_template_update_info(self.subproject.template)
with suppress(AttributeError):
Expand Down Expand Up @@ -1544,6 +1567,7 @@ def run_copy(
quiet: bool = False,
unsafe: bool = False,
skip_tasks: bool = False,
version_subscription: SpecifierSet | None = None,
) -> Worker:
"""Copy a template to a destination, from zero."""
with Worker(
Expand Down Expand Up @@ -1572,6 +1596,7 @@ def run_copy(
quiet=quiet,
unsafe=unsafe,
skip_tasks=skip_tasks,
version_subscription=version_subscription,
) as worker:
worker.run_copy()
return worker
Expand All @@ -1596,6 +1621,7 @@ def run_recopy(
unsafe: bool = False,
skip_answered: bool = False,
skip_tasks: bool = False,
version_subscription: SpecifierSet | None = None,
) -> Worker:
"""Update a subproject from its template, discarding subproject evolution."""
with Worker(
Expand Down Expand Up @@ -1624,6 +1650,7 @@ def run_recopy(
unsafe=unsafe,
skip_answered=skip_answered,
skip_tasks=skip_tasks,
version_subscription=version_subscription,
) as worker:
worker.run_recopy()
return worker
Expand All @@ -1650,6 +1677,7 @@ def run_update(
unsafe: bool = False,
skip_answered: bool = False,
skip_tasks: bool = False,
version_subscription: SpecifierSet | None = None,
) -> Worker:
"""Update a subproject, from its template."""
with Worker(
Expand Down Expand Up @@ -1680,6 +1708,7 @@ def run_update(
unsafe=unsafe,
skip_answered=skip_answered,
skip_tasks=skip_tasks,
version_subscription=version_subscription,
) as worker:
worker.run_update()
return worker
Expand Down
3 changes: 2 additions & 1 deletion copier/_subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ def last_answers(self) -> AnyByStrDict:
return {
key: value
for key, value in self._raw_answers.items()
if key in {"_src_path", "_commit"} or not key.startswith("_")
if key in {"_src_path", "_commit", "_version_subscription"}
or not key.startswith("_")
}

@cached_property
Expand Down
11 changes: 10 additions & 1 deletion copier/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import packaging.version
import yaml
from funcy import lflatten
from packaging.specifiers import SpecifierSet
from packaging.version import Version, parse
from plumbum.machines import local
from pydantic.dataclasses import dataclass
Expand Down Expand Up @@ -219,6 +220,7 @@ class Template:
url: str
ref: str | None = None
use_prereleases: bool = False
version_subscription: str | None = None

def _cleanup(self) -> None:
if temp_clone := self._temp_clone():
Expand Down Expand Up @@ -570,7 +572,14 @@ def local_abspath(self) -> Path:
result = Path(
clone(
self.url_expanded,
self.ref or get_latest_tag(self.url_expanded, self.use_prereleases),
self.ref
or get_latest_tag(
self.url_expanded,
self.use_prereleases,
self.version_subscription
and SpecifierSet(self.version_subscription)
or None,
),
)
)
if not result.is_dir():
Expand Down
18 changes: 15 additions & 3 deletions copier/_vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
from warnings import warn

from packaging import version
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from plumbum import TF, ProcessExecutionError, colors, local
from plumbum.machines import LocalCommand

from ._types import OptBool, OptStrOrPath, StrOrPath
from .errors import DirtyLocalWarning, ShallowCloneWarning
from .errors import DirtyLocalWarning, ShallowCloneWarning, UserMessageError

GIT_USER_NAME = "Copier"
GIT_USER_EMAIL = "copier@copier"
Expand Down Expand Up @@ -126,7 +127,9 @@ def get_repo(url: str) -> str | None:
return None


def get_latest_tag(url: str, use_prereleases: OptBool = False) -> str:
def get_latest_tag(
url: str, use_prereleases: OptBool = False, spec: SpecifierSet | None = None
) -> str:
"""Get latest git tag, sorted by PEP 440.

Args:
Expand All @@ -135,6 +138,9 @@ def get_latest_tag(url: str, use_prereleases: OptBool = False) -> str:
[get_repo][copier.vcs.get_repo].
use_prereleases:
If `False`, skip prerelease git tags.
spec:
An optional pep440 specifier set to filter the available tags
with.

Returns:
The latest git tag, or `HEAD` if no valid tags are found.
Expand All @@ -147,10 +153,16 @@ def get_latest_tag(url: str, use_prereleases: OptBool = False) -> str:
all_tags = (tag for tag in all_tags if valid_version(tag))
if not use_prereleases:
all_tags = (tag for tag in all_tags if not version.parse(tag).is_prerelease)
if spec:
all_tags = spec.filter(all_tags, use_prereleases)
sorted_tags = sorted(all_tags, key=version.parse, reverse=True)
try:
return str(sorted_tags[0])
except IndexError:
except IndexError as e:
if spec:
raise UserMessageError(
f"No git tag found that matches version spec {spec}."
) from e
print(
colors.warn | "No git tags found in template; using HEAD as ref",
file=sys.stderr,
Expand Down
7 changes: 7 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@ def test_copy_help(capsys: pytest.CaptureFixture[str]) -> None:
-T, --skip-tasks Skip template tasks execution
--UNSAFE, --trust Allow templates with unsafe features (Jinja
extensions, migrations, tasks)
-V, --version-subscription VALUE:str
Prevent copying from a version of the
template that does not adhere to the version
specification.
-a, --answers-file VALUE:str Update using this path (relative to
`destination_path`) to find the answers file
-d, --data VARIABLE=VALUE:str Make VARIABLE available as VALUE when
Expand Down Expand Up @@ -458,6 +462,9 @@ def test_update_help(capsys: pytest.CaptureFixture[str]) -> None:
-T, --skip-tasks Skip template tasks execution
--UNSAFE, --trust Allow templates with unsafe features (Jinja
extensions, migrations, tasks)
-V, --version-subscription VALUE:str
Prevent updates to any version that does not
adhere to the version specification.
-a, --answers-file VALUE:str Update using this path (relative to
`destination_path`) to find the answers file
-c, --context-lines VALUE:int Lines of context to use for detecting
Expand Down
24 changes: 20 additions & 4 deletions tests/test_vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,20 @@ def test_invalid_version(tmp_path: Path) -> None:
assert get_latest_tag(str(tmp_path)) == "v2"


@pytest.mark.parametrize("sorter", [iter, reversed])
@pytest.mark.parametrize(
("sorter", "expected_version", "version_subscription"),
[
(iter, "v1.0.1", ""),
(reversed, "v1.0.1", ""),
(iter, "v1", "==1.0.0"),
(iter, "v1.0.1", ">=1.0.0"),
],
)
def test_select_latest_version_tag(
tmp_path_factory: pytest.TempPathFactory,
sorter: Callable[[Sequence[str]], Iterator[str]],
expected_version: str,
version_subscription: str,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
filename = "version.txt"
Expand All @@ -232,12 +242,18 @@ def test_select_latest_version_tag(
git("commit", "-m", version)
git("tag", version)

run_copy(str(src), dst)
args = {
"src_path": str(src),
"dst_path": dst,
}
if version_subscription:
args["version_subscription"] = version_subscription
run_copy(**args)

assert (dst / filename).is_file()
assert (dst / filename).read_text() == "v1.0.1"
assert (dst / filename).read_text() == expected_version
answers = load_answersfile_data(dst)
assert answers["_commit"] == "v1.0.1"
assert answers["_commit"] == expected_version


@pytest.mark.parametrize(
Expand Down