From 645cb2a809c4ed0fda6ff134969b0c14da7f40ee Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 20 Nov 2023 15:18:10 +0100 Subject: [PATCH 1/9] Test epoch and post-release segments --- tests/tasks/test_update_deps.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tasks/test_update_deps.py b/tests/tasks/test_update_deps.py index 760a0e58..793ad280 100644 --- a/tests/tasks/test_update_deps.py +++ b/tests/tasks/test_update_deps.py @@ -84,9 +84,9 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None context = MockContext( run={ **{ - re.compile(r".*invoke$"): "invoke (1.7.1)\n", + re.compile(r".*invoke$"): "invoke (1.7.1.post1)\n", re.compile(r".*tomlkit$"): "tomlkit (1.0.0)", - re.compile(r".*mike$"): "mike (1.1.1)", + re.compile(r".*mike$"): "mike (1!1.1.1)", re.compile(r".*pytest$"): "pytest (7.1.0)", re.compile(r".*pytest-cov$"): "pytest-cov (3.1.5)", re.compile(r".*pre-commit$"): "pre-commit (2.21.5)", @@ -131,7 +131,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None "pytest-cov ~=3.1,!=3.1", ] dev = [ - "mike >={original_dependencies['mike']},<3", + "mike >=1!{original_dependencies['mike']},<3", "pre-commit~=2.21", # "pylint ~={original_dependencies['pylint']},!=2.14.*", "test[testing]", From 1aaa12c55f04621314b3f81f88903628659ef1dd Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 20 Nov 2023 16:03:21 +0100 Subject: [PATCH 2/9] TEMP: Support full python version format Implementing digestion of packaging.version.Version instances in SemanticVersion to be used when updating version specifiers, but still have the usability of the special SemanticVersion methods. --- ci_cd/tasks/update_deps.py | 18 ++++++++++-- ci_cd/utils/versions.py | 56 +++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/ci_cd/tasks/update_deps.py b/ci_cd/tasks/update_deps.py index 31440d04..c5766f1f 100644 --- a/ci_cd/tasks/update_deps.py +++ b/ci_cd/tasks/update_deps.py @@ -15,6 +15,7 @@ from invoke import task from packaging.markers import default_environment from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import VERSION_PATTERN from tomlkit.exceptions import TOMLKitError from ci_cd.exceptions import InputError, UnableToResolve @@ -44,6 +45,18 @@ LOGGER = logging.getLogger(__name__) +VALID_PACKAGE_NAME_PATTERN = r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$" +""" +Pattern to validate package names. + +This is a valid non-normalized name, i.e., it can contain capital letters and +underscores, periods, and multiples of these, including minus characters. + +See PEP 508 for more information, as well as the packaging documentation: +https://packaging.python.org/en/latest/specifications/name-normalization/ +""" + + def _format_and_update_dependency( requirement: Requirement, raw_dependency_line: str, pyproject_path: Path ) -> None: @@ -268,9 +281,8 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s hide=True, ) package_latest_version_line = out.stdout.split(sep="\n", maxsplit=1)[0] - match = re.match( - r"(?P[a-zA-Z0-9-_]+) \((?P[0-9]+(?:\.[0-9]+){0,2})\)", - package_latest_version_line, + match = re.search( + r"(?P\S+) \((?P\S+)\)", package_latest_version_line ) if match is None: msg = ( diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index ed67ee8c..11be3e1e 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -9,6 +9,7 @@ from packaging.markers import Marker, default_environment from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet +from packaging.version import VERSION_PATTERN, Version from ci_cd.exceptions import InputError, InputParserError, UnableToResolve @@ -79,17 +80,21 @@ class SemanticVersion(str): r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" ) + _PYTHON_VERSION: "Optional[Version]" = None + @no_type_check def __new__( - cls, version: "Optional[str]" = None, **kwargs: "Union[str, int]" + cls, + version: "Optional[Union[str, Version]]" = None, + **kwargs: "Union[str, int]", ) -> "SemanticVersion": return super().__new__( - cls, version if version else cls._build_version(**kwargs) + cls, str(version) if version else cls._build_version(**kwargs) ) def __init__( self, - version: "Optional[str]" = None, + version: "Optional[Union[str, Version]]" = None, *, major: "Union[str, int]" = "", minor: "Optional[Union[str, int]]" = None, @@ -103,6 +108,10 @@ def __init__( "version cannot be specified along with other parameters" ) + if isinstance(version, Version): + self._PYTHON_VERSION = version + version = ".".join(version.release) + match = re.match(self._REGEX, version) if match is None: raise ValueError( @@ -728,7 +737,39 @@ def update_specifier_set( # pylint: disable=too-many-statements """Update the specifier set to include the latest version.""" logger = logging.getLogger(__name__) - latest_version = SemanticVersion(latest_version) + epoch = 0 + + if isinstance(latest_version, str): + match = re.search(rf"(?ix){VERSION_PATTERN}", latest_version) + if match is None: + try: + latest_version = SemanticVersion(latest_version) + except ValueError as exc: + raise UnableToResolve( + "Invalid version string given for latest version: " + f"{latest_version!r}" + ) from exc + else: + # Only use `release` part, but store `epoch` if given + epoch = match.group("epoch") + if epoch: + epoch = int(epoch) + + release = match.group("release") + if not release or not isinstance(release, str): + raise UnableToResolve( + "Invalid version string given for latest version: " + f"{latest_version!r}" + ) + + latest_version = SemanticVersion(release) + + if not isinstance(latest_version, SemanticVersion): + raise TypeError( + "latest_version must be a SemanticVersion or a string, got: type " + f"{type(latest_version)}" + ) + new_specifier_set = set(current_specifier_set) updated_specifiers = [] split_latest_version = latest_version.split(".") @@ -768,6 +809,10 @@ def update_specifier_set( # pylint: disable=too-many-statements updated_version = ".".join( split_latest_version[: len(split_specifier_version)] ) + + if epoch > 0: + updated_version = f"{epoch}!{updated_version}" + updated_specifiers.append(f"{specifier.operator}{updated_version}") new_specifier_set.remove(specifier) break @@ -793,6 +838,9 @@ def update_specifier_set( # pylint: disable=too-many-statements f"{len(split_specifier_version)}" ) + if epoch > 0: + updated_version = f"{epoch}!{updated_version}" + updated_specifiers.append(f"{specifier.operator}{updated_version}") new_specifier_set.remove(specifier) break From ad1104725069b4eda6b05493ff679026aaa6aead Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 21 Nov 2023 09:43:28 +0100 Subject: [PATCH 3/9] Parse latest_version into packaging.version.Version Implement support for packaging.version.Version in SemanticVersion, making it able to parse its current internals as a packaging.version.Version, as well as being able to digest one. Add epochs to versions if necessary. --- ci_cd/tasks/update_deps.py | 11 ++- ci_cd/utils/versions.py | 147 ++++++++++++++++++-------------- tests/tasks/test_update_deps.py | 10 +-- 3 files changed, 95 insertions(+), 73 deletions(-) diff --git a/ci_cd/tasks/update_deps.py b/ci_cd/tasks/update_deps.py index c5766f1f..2d8d8d43 100644 --- a/ci_cd/tasks/update_deps.py +++ b/ci_cd/tasks/update_deps.py @@ -15,7 +15,7 @@ from invoke import task from packaging.markers import default_environment from packaging.requirements import InvalidRequirement, Requirement -from packaging.version import VERSION_PATTERN +from packaging.version import Version from tomlkit.exceptions import TOMLKitError from ci_cd.exceptions import InputError, UnableToResolve @@ -297,7 +297,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s error = True continue - latest_version: str = match.group("version") + latest_version = Version(match.group("version")) # Here used to be a sanity check to ensure that the package name parsed from # pyproject.toml matches the name returned from 'pip index versions'. @@ -311,7 +311,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s # Check whether pyproject.toml already uses the latest version # This is expected if the latest version equals a specifier with any of the # operators: ==, >=, or ~=. - split_latest_version = latest_version.split(".") + split_latest_version = latest_version.base_version.split(".") _continue = False for specifier in parsed_requirement.specifier: if specifier.operator in ["==", ">=", "~="]: @@ -377,7 +377,10 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s current_version = specifier.version.split(".") break else: - current_version = "0.0.0".split(".") + if latest_version.epoch != 0: + current_version = "0.0.0".split(".") + else: + current_version = f"{latest_version.epoch}!0.0.0".split(".") if ignore_version( current=current_version, diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index 11be3e1e..f19c2a4f 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -9,7 +9,7 @@ from packaging.markers import Marker, default_environment from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet -from packaging.version import VERSION_PATTERN, Version +from packaging.version import Version from ci_cd.exceptions import InputError, InputParserError, UnableToResolve @@ -73,15 +73,13 @@ class SemanticVersion(str): """ - _REGEX = ( + _regex = ( r"^(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?(?:\.(?P0|[1-9]\d*))?" r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" ) - _PYTHON_VERSION: "Optional[Version]" = None - @no_type_check def __new__( cls, @@ -102,6 +100,8 @@ def __init__( pre_release: "Optional[str]" = None, build: "Optional[str]" = None, ) -> None: + self._python_version: "Optional[Version]" = None + if version is not None: if major or minor or patch or pre_release or build: raise ValueError( @@ -109,10 +109,10 @@ def __init__( ) if isinstance(version, Version): - self._PYTHON_VERSION = version - version = ".".join(version.release) + self._python_version = version + version = ".".join(str(_) for _ in version.release) - match = re.match(self._REGEX, version) + match = re.match(self._regex, version) if match is None: raise ValueError( f"version ({version}) cannot be parsed as a semantic version " @@ -179,7 +179,7 @@ def patch(self) -> int: return self._patch @property - def pre_release(self) -> "Union[None, str]": + def pre_release(self) -> "Union[str, None]": """The pre-release part of the version This is the part supplied after a minus (`-`), but before a plus (`+`). @@ -187,13 +187,54 @@ def pre_release(self) -> "Union[None, str]": return self._pre_release @property - def build(self) -> "Union[None, str]": + def build(self) -> "Union[str, None]": """The build metadata part of the version. This is the part supplied at the end of the version, after a plus (`+`). """ return self._build + @property + def python_version(self) -> "Union[Version, None]": + """The Python version as defined by `packaging.version.Version`.""" + return self._python_version + + def as_python_version(self) -> Version: + """Return the Python version as defined by `packaging.version.Version`.""" + if self.python_version: + # If the SemanticVersion was generated from a Version, return the original + # epoch (and the rest, if the release equals the current shortened version) + + # epoch + redone_version = ( + f"{self.python_version.epoch}!" + if self.python_version.epoch != 0 + else "" + ) + + # release + redone_version += self.shortened() + + if self.shortened() == ".".join( + str(_) for _ in self.python_version.release + ): + # pre, post, dev, local + if self.python_version.pre: + redone_version += "".join(self.python_version.pre) + + if self.python_version.post: + redone_version += f".post{self.python_version.post}" + + if self.python_version.dev: + redone_version += f".dev{self.python_version.dev}" + + if self.python_version.local: + redone_version += f"+{self.python_version.local}" + + return Version(redone_version) + + return Version(self.shortened()) + def __str__(self) -> str: """Return the full version.""" return ( @@ -332,22 +373,15 @@ def previous_version( if version_part == "major": prev_version = f"{self.major - 1}.{max_filler}.{max_filler}" - elif version_part == "minor": + elif version_part == "minor" or self.patch == 0: prev_version = ( - f"{self.major -1 }.{max_filler}.{max_filler}" + f"{self.major - 1}.{max_filler}.{max_filler}" if self.minor == 0 else f"{self.major}.{self.minor - 1}.{max_filler}" ) else: - if self.patch == 0: - prev_version = ( - f"{self.major - 1}.{max_filler}.{max_filler}" - if self.minor == 0 - else f"{self.major}.{self.minor - 1}.{max_filler}" - ) - else: - prev_version = f"{self.major}.{self.minor}.{self.patch - 1}" + prev_version = f"{self.major}.{self.minor}.{self.patch - 1}" return self.__class__(prev_version) @@ -731,48 +765,22 @@ def regenerate_requirement( return updated_dependency -def update_specifier_set( # pylint: disable=too-many-statements - latest_version: "Union[SemanticVersion, str]", current_specifier_set: SpecifierSet +def update_specifier_set( # pylint: disable=too-many-statements,too-many-branches + latest_version: "Union[SemanticVersion, Version, str]", + current_specifier_set: SpecifierSet, ) -> SpecifierSet: """Update the specifier set to include the latest version.""" logger = logging.getLogger(__name__) - epoch = 0 - - if isinstance(latest_version, str): - match = re.search(rf"(?ix){VERSION_PATTERN}", latest_version) - if match is None: - try: - latest_version = SemanticVersion(latest_version) - except ValueError as exc: - raise UnableToResolve( - "Invalid version string given for latest version: " - f"{latest_version!r}" - ) from exc - else: - # Only use `release` part, but store `epoch` if given - epoch = match.group("epoch") - if epoch: - epoch = int(epoch) - - release = match.group("release") - if not release or not isinstance(release, str): - raise UnableToResolve( - "Invalid version string given for latest version: " - f"{latest_version!r}" - ) - - latest_version = SemanticVersion(release) - - if not isinstance(latest_version, SemanticVersion): - raise TypeError( - "latest_version must be a SemanticVersion or a string, got: type " - f"{type(latest_version)}" - ) + latest_version = SemanticVersion(latest_version) new_specifier_set = set(current_specifier_set) updated_specifiers = [] - split_latest_version = latest_version.split(".") + split_latest_version = ( + latest_version.as_python_version().base_version.split(".") + if latest_version.python_version + else latest_version.split(".") + ) logger.debug( "Received latest version: %s and current specifier set: %s", @@ -810,9 +818,6 @@ def update_specifier_set( # pylint: disable=too-many-statements split_latest_version[: len(split_specifier_version)] ) - if epoch > 0: - updated_version = f"{epoch}!{updated_version}" - updated_specifiers.append(f"{specifier.operator}{updated_version}") new_specifier_set.remove(specifier) break @@ -822,25 +827,31 @@ def update_specifier_set( # pylint: disable=too-many-statements # version up from the latest version split_specifier_version = specifier.version.split(".") + updated_version = "" + + # Add epoch if present + if ( + latest_version.python_version + and latest_version.as_python_version().epoch != 0 + ): + updated_version += f"{latest_version.as_python_version().epoch}!" + # Up only the last version segment of the latest version according to # what version segments are defined in the specifier version. if len(split_specifier_version) == 1: - updated_version = str(latest_version.next_version("major").major) + updated_version += str(latest_version.next_version("major").major) elif len(split_specifier_version) == 2: - updated_version = ".".join( + updated_version += ".".join( latest_version.next_version("minor").split(".")[:2] ) elif len(split_specifier_version) == 3: - updated_version = latest_version.next_version("patch") + updated_version += latest_version.next_version("patch") else: raise UnableToResolve( "Invalid/unable to handle number of version parts: " f"{len(split_specifier_version)}" ) - if epoch > 0: - updated_version = f"{epoch}!{updated_version}" - updated_specifiers.append(f"{specifier.operator}{updated_version}") new_specifier_set.remove(specifier) break @@ -851,6 +862,14 @@ def update_specifier_set( # pylint: disable=too-many-statements # the minimum version current_version = SemanticVersion(specifier.version) + # Add epoch if present + epoch = "" + if ( + latest_version.python_version + and latest_version.as_python_version().epoch != 0 + ): + epoch += f"{latest_version.as_python_version().epoch}!" + if latest_version.major > current_version.major: # Expand and change ~= to >= and < operators @@ -859,7 +878,7 @@ def update_specifier_set( # pylint: disable=too-many-statements # < next major version up from latest_version updated_specifiers.append( - f"<{str(latest_version.next_version('major').major)}" + f"<{epoch}{latest_version.next_version('major').major}" ) else: # Keep the ~= operator, but update to include the latest version as diff --git a/tests/tasks/test_update_deps.py b/tests/tasks/test_update_deps.py index 793ad280..557ee141 100644 --- a/tests/tasks/test_update_deps.py +++ b/tests/tasks/test_update_deps.py @@ -21,7 +21,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None original_dependencies = { "invoke": "1.7", "tomlkit": "0.11.4", - "mike": "1.1", + "mike": "1!1.1", "pytest": "7.1", "pytest-cov": "3.0", "pre-commit": "2.20", @@ -42,14 +42,14 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None [project.optional-dependencies] docs = [ - "mike >={original_dependencies['mike']},<3", + "mike >={original_dependencies['mike']},<1!3", ] testing = [ "pytest ~={original_dependencies['pytest']}", "pytest-cov ~={original_dependencies['pytest-cov']},!=3.1", ] dev = [ - "mike >={original_dependencies['mike']},<3", + "mike >={original_dependencies['mike']},<1!3", "pre-commit~={original_dependencies['pre-commit']}", # "pylint ~={original_dependencies['pylint']},!=2.14.*", "test[testing]", @@ -124,14 +124,14 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None [project.optional-dependencies] docs = [ - "mike >={original_dependencies['mike']},<3", + "mike >={original_dependencies['mike']},<1!3", ] testing = [ "pytest ~={original_dependencies['pytest']}", "pytest-cov ~=3.1,!=3.1", ] dev = [ - "mike >=1!{original_dependencies['mike']},<3", + "mike >={original_dependencies['mike']},<1!3", "pre-commit~=2.21", # "pylint ~={original_dependencies['pylint']},!=2.14.*", "test[testing]", From 208b895cd7d5bc788240ed7af1db30e411f30699 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 21 Nov 2023 10:36:39 +0100 Subject: [PATCH 4/9] Fix SemanticVersion initialization --- ci_cd/utils/versions.py | 25 ++++++++++++++++++++----- pyproject.toml | 7 ++----- tests/tasks/test_update_deps.py | 3 +++ tests/utils/test_versions.py | 24 +++++++++++++----------- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index f19c2a4f..56e51888 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -9,7 +9,7 @@ from packaging.markers import Marker, default_environment from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet -from packaging.version import Version +from packaging.version import InvalidVersion, Version from ci_cd.exceptions import InputError, InputParserError, UnableToResolve @@ -114,10 +114,25 @@ def __init__( match = re.match(self._regex, version) if match is None: - raise ValueError( - f"version ({version}) cannot be parsed as a semantic version " - "according to the SemVer.org regular expression" + # Try to parse it as a Python version and try again + try: + _python_version = Version(version) + except InvalidVersion as exc: + raise ValueError( + f"version ({version}) cannot be parsed as a semantic version " + "according to the SemVer.org regular expression" + ) from exc + + self._python_version = _python_version + match = re.match( + self._regex, ".".join(str(_) for _ in _python_version.release) ) + if match is None: + raise ValueError( + f"version ({version}) cannot be parsed as a semantic version " + "according to the SemVer.org regular expression" + ) + major, minor, patch, pre_release, build = match.groups() self._major = int(major) @@ -257,7 +272,7 @@ def _validate_other_type(self, other: "Any") -> "SemanticVersion": if isinstance(other, self.__class__): return other - if isinstance(other, str): + if isinstance(other, (Version, str)): try: return self.__class__(other) except (TypeError, ValueError) as exc: diff --git a/pyproject.toml b/pyproject.toml index dfdd0e66..a8e3b69a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,8 +83,5 @@ max-returns = 10 [tool.pytest.ini_options] minversion = "7.0" -filterwarnings = [ - "ignore:.*imp module.*:DeprecationWarning", - # Remove when invoke updates to `inspect.signature()` or similar: - "ignore:.*inspect.getargspec().*:DeprecationWarning", -] +addopts = ["-rs", "--cov=ci_cd", "--cov-report=term-missing"] +filterwarnings = ["error"] diff --git a/tests/tasks/test_update_deps.py b/tests/tasks/test_update_deps.py index 557ee141..e547c721 100644 --- a/tests/tasks/test_update_deps.py +++ b/tests/tasks/test_update_deps.py @@ -47,6 +47,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None testing = [ "pytest ~={original_dependencies['pytest']}", "pytest-cov ~={original_dependencies['pytest-cov']},!=3.1", + "test-name <=1!3,!=2.0.1", ] dev = [ "mike >={original_dependencies['mike']},<1!3", @@ -95,6 +96,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None re.compile(r".*A.B-C_D$"): "A.B-C_D (1.2.3)", re.compile(r".*aa$"): "aa (1.2.3)", re.compile(r".*name$"): "name (1.2.3)", + re.compile(r".*name$"): "test-name (1!2.3)", }, **{re.compile(rf".*name{i}$"): f"name{i} (3.2.1)" for i in range(1, 12)}, } @@ -129,6 +131,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None testing = [ "pytest ~={original_dependencies['pytest']}", "pytest-cov ~=3.1,!=3.1", + "test-name <=1!3,!=2.0.1", ] dev = [ "mike >={original_dependencies['mike']},<1!3", diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py index f608ea97..dea8dff6 100644 --- a/tests/utils/test_versions.py +++ b/tests/utils/test_versions.py @@ -86,17 +86,19 @@ def test_semanticversion() -> None: "1.0.0-rc.1+exp.sha.5114f85", ), ] - assert all( - SemanticVersion(**input_[0]) == input_[1] - if isinstance(input_, tuple) - else isinstance(SemanticVersion(input_), SemanticVersion) - for input_ in valid_inputs - ) - assert all( - isinstance(SemanticVersion(version=input_), SemanticVersion) - for input_ in valid_inputs - if isinstance(input_, str) - ) + for input_ in valid_inputs: + value = ( + SemanticVersion(**input_[0]) == input_[1] + if isinstance(input_, tuple) + else isinstance(SemanticVersion(input_), SemanticVersion) + ) + assert value, f"Failed for input: {input_}. SemanticVersion: {value}" + + for input_ in valid_inputs: + if isinstance(input_, str): + assert isinstance( + SemanticVersion(version=input_), SemanticVersion + ), f"Failed for input: {input_}" def test_semanticversion_invalid() -> None: From 6671dfb5af430d44a52dff6f37a4683752357d6e Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 21 Nov 2023 11:05:06 +0100 Subject: [PATCH 5/9] Test python_version parts of SemanticVersion --- ci_cd/utils/versions.py | 20 ++++++++------- tests/utils/test_versions.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index 56e51888..a96e6ff4 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -123,6 +123,7 @@ def __init__( "according to the SemVer.org regular expression" ) from exc + # Success. Now let's redo the SemVer.org regular expression match self._python_version = _python_version match = re.match( self._regex, ".".join(str(_) for _ in _python_version.release) @@ -218,7 +219,8 @@ def as_python_version(self) -> Version: """Return the Python version as defined by `packaging.version.Version`.""" if self.python_version: # If the SemanticVersion was generated from a Version, return the original - # epoch (and the rest, if the release equals the current shortened version) + # epoch (and the rest, if the release equals the current version). + # Otherwise, return it as a "base_version". # epoch redone_version = ( @@ -230,20 +232,20 @@ def as_python_version(self) -> Version: # release redone_version += self.shortened() - if self.shortened() == ".".join( - str(_) for _ in self.python_version.release - ): + if (self.major, self.minor, self.patch)[ + : len(self.python_version.release) + ] == self.python_version.release: # pre, post, dev, local - if self.python_version.pre: - redone_version += "".join(self.python_version.pre) + if self.python_version.pre is not None: + redone_version += "".join(str(_) for _ in self.python_version.pre) - if self.python_version.post: + if self.python_version.post is not None: redone_version += f".post{self.python_version.post}" - if self.python_version.dev: + if self.python_version.dev is not None: redone_version += f".dev{self.python_version.dev}" - if self.python_version.local: + if self.python_version.local is not None: redone_version += f"+{self.python_version.local}" return Version(redone_version) diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py index dea8dff6..727bf2d9 100644 --- a/tests/utils/test_versions.py +++ b/tests/utils/test_versions.py @@ -201,6 +201,53 @@ def test_semanticversion_next_version_invalid() -> None: SemanticVersion("1.0.0").next_version(version_part) +@pytest.mark.parametrize( + "version", + [ + "1.dev0", + "1.0.dev456", + "1.0a1", + "1.0a2.dev456", + "1.0a12.dev456", + "1.0a12", + "1.0b1.dev456", + "1.0b2", + "1.0b2.post345.dev456", + "1.0b2.post345", + "1.0rc1.dev456", + "1.0rc1", + "1.0.post456.dev34", + "1.0.post456", + "1.1.dev1", + ], +) +def test_semanticversion_python_version(version: str) -> None: + """Test the python_version method of SemanticVersion class. + This includes checking parsing PEP 440 valid versions. + """ + from packaging.version import Version + + from ci_cd.utils.versions import SemanticVersion + + for version_ in (version, Version(version)): + semver = SemanticVersion(version_) + assert semver + assert ( + semver.python_version == Version(version_) + if isinstance(version_, str) + else version_ + ), f"Failed for version: {version_}, type: {type(version_)}" + assert ( + semver.as_python_version() == Version(version_) + if isinstance(version_, str) + else version_ + ), ( + f"Failed for version: {version_}, type: {type(version_)}, " + f"as_python_version: {semver.as_python_version()}, Version(): " + f"{Version(version_) if isinstance(version_, str) else version_}" + ) + + def _parametrize_ignore_version() -> ( "dict[str, tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]]" ): From 0d7571679499a1d536419c8f2558127558f357d0 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 21 Nov 2023 11:18:00 +0100 Subject: [PATCH 6/9] Add tests for SemanticVersion.python_version with local --- ci_cd/utils/versions.py | 7 +++++ tests/utils/test_versions.py | 50 ++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index a96e6ff4..0727dcfa 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -129,6 +129,9 @@ def __init__( self._regex, ".".join(str(_) for _ in _python_version.release) ) if match is None: + # This should not really be possible at this point, as the + # Version.releasethis is a guaranteed match. + # But we keep it here for sanity's sake. raise ValueError( f"version ({version}) cannot be parsed as a semantic version " "according to the SemVer.org regular expression" @@ -254,6 +257,8 @@ def as_python_version(self) -> Version: def __str__(self) -> str: """Return the full version.""" + if self.python_version: + return str(self.as_python_version()) return ( f"{self.major}.{self.minor}.{self.patch}" f"{f'-{self.pre_release}' if self.pre_release else ''}" @@ -262,6 +267,8 @@ def __str__(self) -> str: def __repr__(self) -> str: """Return the string representation of the object.""" + if self.python_version: + return repr(str(self.as_python_version())) return repr(self.__str__()) def _validate_other_type(self, other: "Any") -> "SemanticVersion": diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py index 727bf2d9..1f25ca01 100644 --- a/tests/utils/test_versions.py +++ b/tests/utils/test_versions.py @@ -216,15 +216,21 @@ def test_semanticversion_next_version_invalid() -> None: "1.0b2.post345", "1.0rc1.dev456", "1.0rc1", + "1.0+abc.5", + "1.0+abc.7", + "1.0+5", "1.0.post456.dev34", "1.0.post456", "1.1.dev1", + "1.1.dev1+abc.7", ], ) def test_semanticversion_python_version(version: str) -> None: """Test the python_version method of SemanticVersion class. This includes checking parsing PEP 440 valid versions. """ + import re + from packaging.version import Version from ci_cd.utils.versions import SemanticVersion @@ -232,20 +238,36 @@ def test_semanticversion_python_version(version: str) -> None: for version_ in (version, Version(version)): semver = SemanticVersion(version_) assert semver - assert ( - semver.python_version == Version(version_) - if isinstance(version_, str) - else version_ - ), f"Failed for version: {version_}, type: {type(version_)}" - assert ( - semver.as_python_version() == Version(version_) - if isinstance(version_, str) - else version_ - ), ( - f"Failed for version: {version_}, type: {type(version_)}, " - f"as_python_version: {semver.as_python_version()}, Version(): " - f"{Version(version_) if isinstance(version_, str) else version_}" - ) + + if isinstance(version_, Version) or ( + isinstance(version_, str) + and re.match( + SemanticVersion._regex, version_ # pylint: disable=protected-access + ) + is None + ): + assert ( + semver.python_version == Version(version_) + if isinstance(version_, str) + else version_ + ), f"Failed for version: {version_}, type: {type(version_)}" + + assert ( + semver.as_python_version() == Version(version_) + if isinstance(version_, str) + else version_ + ), ( + f"Failed for version: {version_}, type: {type(version_)}, " + f"as_python_version: {semver.as_python_version()}, Version(): " + f"{Version(version_) if isinstance(version_, str) else version_}" + ) + else: + # The version is parsed as a regular semantic version, where the 'local' + # part of Version is parsed as a 'build' part for SemanticVersion. + assert semver.python_version is None + assert semver.as_python_version() == Version( + version_.split("+", maxsplit=1)[0] + ) def _parametrize_ignore_version() -> ( From 26ada3abe10d5e7c6deae9cb9c843479e1300dcf Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 21 Nov 2023 11:32:33 +0100 Subject: [PATCH 7/9] Update and test repr(SemanticVersion) --- ci_cd/utils/versions.py | 15 +++++---- tests/utils/test_versions.py | 64 ++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index 0727dcfa..5e32f3a5 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -218,7 +218,7 @@ def python_version(self) -> "Union[Version, None]": """The Python version as defined by `packaging.version.Version`.""" return self._python_version - def as_python_version(self) -> Version: + def as_python_version(self, shortened: bool = True) -> Version: """Return the Python version as defined by `packaging.version.Version`.""" if self.python_version: # If the SemanticVersion was generated from a Version, return the original @@ -233,7 +233,12 @@ def as_python_version(self) -> Version: ) # release - redone_version += self.shortened() + if shortened: + redone_version += self.shortened() + else: + redone_version += ".".join( + str(_) for _ in (self.major, self.minor, self.patch) + ) if (self.major, self.minor, self.patch)[ : len(self.python_version.release) @@ -258,7 +263,7 @@ def as_python_version(self) -> Version: def __str__(self) -> str: """Return the full version.""" if self.python_version: - return str(self.as_python_version()) + return str(self.as_python_version(shortened=False)) return ( f"{self.major}.{self.minor}.{self.patch}" f"{f'-{self.pre_release}' if self.pre_release else ''}" @@ -267,9 +272,7 @@ def __str__(self) -> str: def __repr__(self) -> str: """Return the string representation of the object.""" - if self.python_version: - return repr(str(self.as_python_version())) - return repr(self.__str__()) + return f"{self.__class__.__name__}({self.__str__()!r})" def _validate_other_type(self, other: "Any") -> "SemanticVersion": """Initial check/validation of `other` before rich comparisons.""" diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py index 1f25ca01..195f3bb1 100644 --- a/tests/utils/test_versions.py +++ b/tests/utils/test_versions.py @@ -7,6 +7,8 @@ import pytest if TYPE_CHECKING: + from typing import Optional + from ci_cd.utils.versions import ( IgnoreEntry, IgnoreRules, @@ -202,32 +204,42 @@ def test_semanticversion_next_version_invalid() -> None: @pytest.mark.parametrize( - "version", + ("version", "expected_repr_version"), [ - "1.dev0", - "1.0.dev456", - "1.0a1", - "1.0a2.dev456", - "1.0a12.dev456", - "1.0a12", - "1.0b1.dev456", - "1.0b2", - "1.0b2.post345.dev456", - "1.0b2.post345", - "1.0rc1.dev456", - "1.0rc1", - "1.0+abc.5", - "1.0+abc.7", - "1.0+5", - "1.0.post456.dev34", - "1.0.post456", - "1.1.dev1", - "1.1.dev1+abc.7", + ("1.dev0", None), + ("1.0.dev456", None), + ("1.0a1", None), + ("1.0a2.dev456", None), + ("1.0a12.dev456", None), + ("1.0a12", None), + ("1.0b1.dev456", None), + ("1.0b2", None), + ("1.0b2.post345.dev456", None), + ("1.0b2.post345", None), + ("1.0rc1.dev456", None), + ("1.0rc1", None), + ("1.0+abc.5", "1.0.0+abc.5"), + ("1.0+abc.7", "1.0.0+abc.7"), + ("1.0+5", "1.0.0+5"), + ("1.0.post456.dev34", None), + ("1.0.post456", None), + ("1.1.dev1", None), + ("1.1.dev1+abc.7", None), ], ) -def test_semanticversion_python_version(version: str) -> None: +def test_semanticversion_python_version( + version: str, expected_repr_version: "Optional[str]" +) -> None: """Test the python_version method of SemanticVersion class. This includes checking parsing PEP 440 valid versions. + + Parameters: + version: The version to be parsed. + expected_repr_version: The expected version to be returned by the + `as_python_version` method. + This is only given in the case where the test version is not parsed through + a Version when initializing the SemanticVersion. + """ import re @@ -261,6 +273,11 @@ def test_semanticversion_python_version(version: str) -> None: f"as_python_version: {semver.as_python_version()}, Version(): " f"{Version(version_) if isinstance(version_, str) else version_}" ) + + assert ( + repr(semver) + == f"SemanticVersion({str(semver.as_python_version(shortened=False))!r})" + ) else: # The version is parsed as a regular semantic version, where the 'local' # part of Version is parsed as a 'build' part for SemanticVersion. @@ -269,6 +286,11 @@ def test_semanticversion_python_version(version: str) -> None: version_.split("+", maxsplit=1)[0] ) + assert ( + repr(semver) + == f"SemanticVersion({expected_repr_version or str(version_)!r})" + ) + def _parametrize_ignore_version() -> ( "dict[str, tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]]" From 11b6cd62a3a6ca5135986e3548193fb5ef512a3e Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 21 Nov 2023 13:15:52 +0100 Subject: [PATCH 8/9] Fix parsing ignore rules with epochs and more --- ci_cd/utils/versions.py | 62 ++++++++++++++++++++++----------- tests/tasks/test_update_deps.py | 26 ++++++++++++-- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index 5e32f3a5..839789c5 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -128,7 +128,7 @@ def __init__( match = re.match( self._regex, ".".join(str(_) for _ in _python_version.release) ) - if match is None: + if match is None: # pragma: no cover # This should not really be possible at this point, as the # Version.releasethis is a guaranteed match. # But we keep it here for sanity's sake. @@ -368,7 +368,7 @@ def next_version(self, version_part: str) -> "SemanticVersion": return self.__class__(next_version) def previous_version( - self, version_part: str, max_filler: "Optional[Union[str, int]]" = 99 + self, version_part: str, max_filler: "Optional[Union[str, int]]" = None ) -> "SemanticVersion": """Return the previous version for the specified version part. @@ -516,8 +516,7 @@ def parse_ignore_rules( if "versions" in rules: for versions_entry in rules["versions"]: match = re.match( - r"^(?P>|<|<=|>=|==|!=|~=)\s*" - r"(?P[0-9]+(?:\.[0-9]+){0,2})$", + r"^(?P>|<|<=|>=|==|!=|~=)\s*(?P\S+)$", versions_entry, ) if match is None: @@ -668,28 +667,25 @@ def _ignore_semver_rules( f"'patch' (you gave {semver_rules['version-update']!r})." ) - if "major" in semver_rules["version-update"]: - if latest[0] != current[0]: - return True - - elif "minor" in semver_rules["version-update"]: - if ( - len(latest) >= 2 + if ( # pylint: disable=too-many-boolean-expressions + ("major" in semver_rules["version-update"] and latest[0] != current[0]) + or ( + "minor" in semver_rules["version-update"] + and len(latest) >= 2 and len(current) >= 2 and latest[1] > current[1] and latest[0] == current[0] - ): - return True - - elif "patch" in semver_rules["version-update"]: - if ( - len(latest) >= 3 + ) + or ( + "patch" in semver_rules["version-update"] + and len(latest) >= 3 and len(current) >= 3 and latest[2] > current[2] and latest[0] == current[0] and latest[1] == current[1] - ): - return True + ) + ): + return True return False @@ -804,10 +800,11 @@ def update_specifier_set( # pylint: disable=too-many-statements,too-many-branch new_specifier_set = set(current_specifier_set) updated_specifiers = [] split_latest_version = ( - latest_version.as_python_version().base_version.split(".") + latest_version.as_python_version(shortened=False).base_version.split(".") if latest_version.python_version else latest_version.split(".") ) + current_version_epochs = {Version(_.version).epoch for _ in current_specifier_set} logger.debug( "Received latest version: %s and current specifier set: %s", @@ -833,8 +830,31 @@ def update_specifier_set( # pylint: disable=too-many-statements,too-many-branch # does not need updating. To communicate this, make updated_specifiers # non-empty, but include only an empty string. updated_specifiers.append("") + + elif ( + latest_version.python_version + and latest_version.as_python_version().epoch not in current_version_epochs + ): + # The latest version is *not* included in the specifier set. + # And the latest version is NOT in the same epoch as the current version range. + + # Sanity check that the latest version's epoch is larger than the largest + # epoch in the specifier set. + if current_version_epochs and latest_version.as_python_version().epoch < max( + current_version_epochs + ): + raise UnableToResolve( + "The latest version's epoch is smaller than the largest epoch in " + "the specifier set." + ) + + # Simply add the latest version as a specifier. + updated_specifiers.append(f"=={latest_version}") + else: # The latest version is *not* included in the specifier set. + # But we're in the right epoch. + # Expect the latest version to be greater than the current version range. for specifier in current_specifier_set: # Simply expand the range if the version range is capped through a specifier @@ -911,6 +931,8 @@ def update_specifier_set( # pylint: disable=too-many-statements,too-many-branch # Keep the ~= operator, but update to include the latest version as # the minimum version split_specifier_version = specifier.version.split(".") + print(f"splitted version: {split_specifier_version}") + print(f"splitted latest version: {split_latest_version}") updated_version = ".".join( split_latest_version[: len(split_specifier_version)] ) diff --git a/tests/tasks/test_update_deps.py b/tests/tasks/test_update_deps.py index e547c721..f9efb982 100644 --- a/tests/tasks/test_update_deps.py +++ b/tests/tasks/test_update_deps.py @@ -47,7 +47,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None testing = [ "pytest ~={original_dependencies['pytest']}", "pytest-cov ~={original_dependencies['pytest-cov']},!=3.1", - "test-name <=1!3,!=2.0.1", + "test-pkg <=1!3,!=1!2.0.1", ] dev = [ "mike >={original_dependencies['mike']},<1!3", @@ -56,6 +56,14 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None "test[testing]", ] +# Test epochs +epoch = [ + "epoch>=2!1.2,<2!2", + "epoch1~=2023.1.1", + "epoch2~=1!1.0", + "epoch3~=1!1.0.1", +] + # List from https://peps.python.org/pep-0508/#complete-grammar pep_508 = [ "A", @@ -96,7 +104,11 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None re.compile(r".*A.B-C_D$"): "A.B-C_D (1.2.3)", re.compile(r".*aa$"): "aa (1.2.3)", re.compile(r".*name$"): "name (1.2.3)", - re.compile(r".*name$"): "test-name (1!2.3)", + re.compile(r".*test-pkg$"): "test-pkg (1!2.3)", + re.compile(r".*epoch$"): "epoch (2!2.0.4.post1)", + re.compile(r".*epoch1$"): "epoch1 (1!1.0.0)", + re.compile(r".*epoch2$"): "epoch2 (1!2.1.0)", + re.compile(r".*epoch3$"): "epoch3 (1!1.1.0.post1)", }, **{re.compile(rf".*name{i}$"): f"name{i} (3.2.1)" for i in range(1, 12)}, } @@ -131,7 +143,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None testing = [ "pytest ~={original_dependencies['pytest']}", "pytest-cov ~=3.1,!=3.1", - "test-name <=1!3,!=2.0.1", + "test-pkg <=1!3,!=1!2.0.1", ] dev = [ "mike >={original_dependencies['mike']},<1!3", @@ -140,6 +152,14 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None "test[testing]", ] +# Test epochs +epoch = [ + "epoch>=2!1.2,<2!3", + "epoch1~=2023.1.1,==1!1.0.0", + "epoch2>=1!1.0.0,<1!3", + "epoch3~=1!1.1.0", +] + # List from https://peps.python.org/pep-0508/#complete-grammar pep_508 = [ "A", From 1609b649e7da3686d8de8d9b559c65dc3a7a7e08 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 14:20:36 +0100 Subject: [PATCH 9/9] Updates according to code review Co-authored-by: Daniel Marchand --- ci_cd/tasks/update_deps.py | 22 +++++++++-- ci_cd/utils/versions.py | 74 +++++++++++++++++++----------------- tests/utils/test_versions.py | 3 +- 3 files changed, 60 insertions(+), 39 deletions(-) diff --git a/ci_cd/tasks/update_deps.py b/ci_cd/tasks/update_deps.py index 584be00b..c5917b8b 100644 --- a/ci_cd/tasks/update_deps.py +++ b/ci_cd/tasks/update_deps.py @@ -15,7 +15,7 @@ from invoke import task from packaging.markers import default_environment from packaging.requirements import InvalidRequirement, Requirement -from packaging.version import Version +from packaging.version import InvalidVersion, Version from tomlkit.exceptions import TOMLKitError from ci_cd.exceptions import InputError, UnableToResolve @@ -281,7 +281,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s hide=True, ) package_latest_version_line = out.stdout.split(sep="\n", maxsplit=1)[0] - match = re.search( + match = re.match( r"(?P\S+) \((?P\S+)\)", package_latest_version_line ) if match is None: @@ -297,7 +297,21 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s error = True continue - latest_version = Version(match.group("version")) + try: + latest_version = Version(match.group("version")) + except InvalidVersion as exc: + msg = ( + f"Could not parse version {match.group('version')!r} from 'pip index " + f"versions' output for line:\n {package_latest_version_line}.\n" + f"Exception: {exc}" + ) + LOGGER.error(msg) + if fail_fast: + sys.exit(f"{Emoji.CROSS_MARK.value} {error_msg(msg)}") + print(error_msg(msg), file=sys.stderr, flush=True) + error = True + continue + LOGGER.debug("Retrieved latest version: %r", latest_version) # Here used to be a sanity check to ensure that the package name parsed from # pyproject.toml matches the name returned from 'pip index versions'. @@ -377,7 +391,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s current_version = specifier.version.split(".") break else: - if latest_version.epoch != 0: + if latest_version.epoch == 0: current_version = "0.0.0".split(".") else: current_version = f"{latest_version.epoch}!0.0.0".split(".") diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index 62363996..fce3e8c2 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -74,12 +74,15 @@ class SemanticVersion(str): """ - _regex = ( + _semver_regex = ( r"^(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?(?:\.(?P0|[1-9]\d*))?" r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" ) + """The regular expression for a semantic version. + See + https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string.""" @no_type_check def __new__( @@ -111,7 +114,7 @@ def __init__( self._python_version = version version = ".".join(str(_) for _ in version.release) - match = re.match(self._regex, version) + match = re.match(self._semver_regex, version) if match is None: # Try to parse it as a Python version and try again try: @@ -125,7 +128,8 @@ def __init__( # Success. Now let's redo the SemVer.org regular expression match self._python_version = _python_version match = re.match( - self._regex, ".".join(str(_) for _ in _python_version.release) + self._semver_regex, + ".".join(str(_) for _ in _python_version.release), ) if match is None: # pragma: no cover # This should not really be possible at this point, as the @@ -219,45 +223,47 @@ def python_version(self) -> Version | None: def as_python_version(self, shortened: bool = True) -> Version: """Return the Python version as defined by `packaging.version.Version`.""" - if self.python_version: - # If the SemanticVersion was generated from a Version, return the original - # epoch (and the rest, if the release equals the current version). - # Otherwise, return it as a "base_version". - - # epoch - redone_version = ( - f"{self.python_version.epoch}!" - if self.python_version.epoch != 0 - else "" + if not self.python_version: + return Version( + self.shortened() + if shortened + else ".".join(str(_) for _ in (self.major, self.minor, self.patch)) ) - # release - if shortened: - redone_version += self.shortened() - else: - redone_version += ".".join( - str(_) for _ in (self.major, self.minor, self.patch) - ) + # The SemanticVersion was generated from a Version. Return the original + # epoch (and the rest, if the release equals the current version). - if (self.major, self.minor, self.patch)[ - : len(self.python_version.release) - ] == self.python_version.release: - # pre, post, dev, local - if self.python_version.pre is not None: - redone_version += "".join(str(_) for _ in self.python_version.pre) + # epoch + redone_version = ( + f"{self.python_version.epoch}!" if self.python_version.epoch != 0 else "" + ) + + # release + if shortened: + redone_version += self.shortened() + else: + redone_version += ".".join( + str(_) for _ in (self.major, self.minor, self.patch) + ) - if self.python_version.post is not None: - redone_version += f".post{self.python_version.post}" + if (self.major, self.minor, self.patch)[ + : len(self.python_version.release) + ] == self.python_version.release: + # The release is the same as the current version. Add the pre, post, dev, + # and local parts, if any. + if self.python_version.pre is not None: + redone_version += "".join(str(_) for _ in self.python_version.pre) - if self.python_version.dev is not None: - redone_version += f".dev{self.python_version.dev}" + if self.python_version.post is not None: + redone_version += f".post{self.python_version.post}" - if self.python_version.local is not None: - redone_version += f"+{self.python_version.local}" + if self.python_version.dev is not None: + redone_version += f".dev{self.python_version.dev}" - return Version(redone_version) + if self.python_version.local is not None: + redone_version += f"+{self.python_version.local}" - return Version(self.shortened()) + return Version(redone_version) def __str__(self) -> str: """Return the full version.""" diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py index c5176a9b..36124ea6 100644 --- a/tests/utils/test_versions.py +++ b/tests/utils/test_versions.py @@ -252,7 +252,8 @@ def test_semanticversion_python_version( if isinstance(version_, Version) or ( isinstance(version_, str) and re.match( - SemanticVersion._regex, version_ # pylint: disable=protected-access + SemanticVersion._semver_regex, # pylint: disable=protected-access + version_, ) is None ):