diff --git a/copier/errors.py b/copier/errors.py index c957e046a..dea14a988 100644 --- a/copier/errors.py +++ b/copier/errors.py @@ -8,19 +8,24 @@ from .types import PathSeq -class UserMessageError(Exception): +# Errors +class CopierError(Exception): + """Base class for all other Copier errors.""" + + +class UserMessageError(CopierError): """Exit the program giving a message to the user.""" -class UnsupportedVersionError(UserMessageError): +class UnsupportedVersionError(UserMessageError, CopierError): """Copier version does not support template version.""" -class ConfigFileError(ValueError): +class ConfigFileError(ValueError, CopierError): """Parent class defining problems with the config file.""" -class InvalidConfigFileError(ConfigFileError): +class InvalidConfigFileError(ConfigFileError, CopierError): """Indicates that the config file is wrong.""" def __init__(self, conf_path: Path, quiet: bool): @@ -29,7 +34,7 @@ def __init__(self, conf_path: Path, quiet: bool): super().__init__(msg) -class MultipleConfigFilesError(ConfigFileError): +class MultipleConfigFilesError(ConfigFileError, CopierError): """Both copier.yml and copier.yaml found, and that's an error.""" def __init__(self, conf_paths: "PathSeq", quiet: bool): @@ -38,19 +43,32 @@ def __init__(self, conf_paths: "PathSeq", quiet: bool): super().__init__(msg) -class InvalidTypeError(TypeError): +class InvalidTypeError(TypeError, CopierError): """The question type is not among the supported ones.""" -class PathNotAbsoluteError(_PathValueError): +class PathNotAbsoluteError(_PathValueError, CopierError): """The path is not absolute, but it should be.""" code = "path.not_absolute" msg_template = '"{path}" is not an absolute path' -class PathNotRelativeError(_PathValueError): +class PathNotRelativeError(_PathValueError, CopierError): """The path is not relative, but it should be.""" code = "path.not_relative" msg_template = '"{path}" is not a relative path' + + +# Warnings +class CopierWarning(Warning): + """Base class for all other Copier warnings.""" + + +class UnknownCopierVersionWarning(UserWarning, CopierWarning): + """Cannot determine installed Copier version.""" + + +class OldTemplateWarning(UserWarning, CopierWarning): + """Template was designed for an older Copier version.""" diff --git a/copier/template.py b/copier/template.py index 958bf35fb..8e073031b 100644 --- a/copier/template.py +++ b/copier/template.py @@ -2,14 +2,18 @@ from contextlib import suppress from pathlib import Path from typing import List, Mapping, Optional, Sequence, Set, Tuple +from warnings import warn -from packaging import version -from packaging.version import parse +from packaging.version import Version, parse from plumbum.cmd import git from plumbum.machines import local from pydantic.dataclasses import dataclass -from .errors import UnsupportedVersionError +from .errors import ( + OldTemplateWarning, + UnknownCopierVersionWarning, + UnsupportedVersionError, +) from .types import AnyByStrDict, OptStr, StrSeq, VCSTypes from .user_data import load_config_data from .vcs import checkout_latest_tag, clone, get_repo @@ -53,8 +57,13 @@ def filter_config(data: AnyByStrDict) -> Tuple[AnyByStrDict, AnyByStrDict]: return conf_data, questions_data -def verify_minimum_version(version_str: str) -> None: - """Raise an error if the current Copier version is less than the given version.""" +def verify_copier_version(version_str: str) -> None: + """Raise an error if the current Copier version is less than the given version. + + Args: + version_str: + Minimal copier version for the template. + """ # Importing __version__ at the top of the module creates a circular import # ("cannot import name '__version__' from partially initialized module 'copier'"), # so instead we do a lazy import here @@ -62,13 +71,24 @@ def verify_minimum_version(version_str: str) -> None: # Disable check when running copier as editable installation if __version__ == "0.0.0": + warn( + "Cannot check Copier version constraint.", + UnknownCopierVersionWarning, + ) return - - if version.parse(__version__) < version.parse(version_str): + parsed_installed, parsed_min = map(Version, (__version__, version_str)) + if parsed_installed < parsed_min: raise UnsupportedVersionError( f"This template requires Copier version >= {version_str}, " f"while your version of Copier is {__version__}." ) + if parsed_installed.major > parsed_min.major: + warn( + f"This template was designed for Copier {version_str}, " + f"but your version of Copier is {__version__}. " + f"You could find some incompatibilities.", + OldTemplateWarning, + ) @dataclass @@ -120,7 +140,7 @@ def _raw_config(self) -> AnyByStrDict: """ result = load_config_data(self.local_abspath) with suppress(KeyError): - verify_minimum_version(result["_min_copier_version"]) + verify_copier_version(result["_min_copier_version"]) return result @cached_property diff --git a/tests/test_minimum_version.py b/tests/test_minimum_version.py index 7354dff4b..75f3b6176 100644 --- a/tests/test_minimum_version.py +++ b/tests/test_minimum_version.py @@ -1,9 +1,15 @@ +import warnings + import pytest from plumbum import local from plumbum.cmd import git import copier -from copier.errors import UnsupportedVersionError +from copier.errors import ( + OldTemplateWarning, + UnknownCopierVersionWarning, + UnsupportedVersionError, +) from .helpers import build_file_tree @@ -68,4 +74,15 @@ def test_minimum_version_update(template_path, tmp_path, monkeypatch): def test_version_0_0_0_ignored(template_path, tmp_path, monkeypatch): monkeypatch.setattr("copier.__version__", "0.0.0") # assert no error - copier.copy(template_path, tmp_path) + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(UnknownCopierVersionWarning): + copier.run_copy(template_path, tmp_path) + + +def test_version_bigger_major_warning(template_path, tmp_path, monkeypatch): + monkeypatch.setattr("copier.__version__", "11.0.0a0") + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(OldTemplateWarning): + copier.run_copy(template_path, tmp_path)