From ebe8eefaa08668f22cf3a7ab79c47271a70481c3 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen <43357585+CasperWA@users.noreply.github.com> Date: Tue, 23 May 2023 14:25:22 +0200 Subject: [PATCH] Fix ignore functionality (especially for 'version' rules) (#140) Extend SemanticVersion class with rich comparisons. Add __new__ to SemanticVersion for testing inputs. Co-authored-by: Anders Eklund --- ci_cd/tasks/update_deps.py | 74 ++++++------- ci_cd/utils.py | 170 ++++++++++++++++++++++++++++- pyproject.toml | 1 + tests/tasks/test_update_deps.py | 159 +++++++++++++++++++++------ tests/test_utils.py | 186 ++++++++++++++++++++++++++++++++ 5 files changed, 510 insertions(+), 80 deletions(-) create mode 100644 tests/test_utils.py diff --git a/ci_cd/tasks/update_deps.py b/ci_cd/tasks/update_deps.py index a02d6830..a7152e08 100644 --- a/ci_cd/tasks/update_deps.py +++ b/ci_cd/tasks/update_deps.py @@ -17,7 +17,7 @@ from invoke import task from ci_cd.exceptions import CICDException, InputError, InputParserError -from ci_cd.utils import Emoji, update_file +from ci_cd.utils import Emoji, SemanticVersion, update_file if TYPE_CHECKING: # pragma: no cover from typing import Literal @@ -243,6 +243,10 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s versions.extend(parsed_rules[0]) update_types.update(parsed_rules[1]) + LOGGER.debug( + "Ignore rules:\nversions: %s\nupdate_types: %s", versions, update_types + ) + if ignore_version( current=version_spec.version.split("."), latest=latest_version, @@ -271,9 +275,11 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s ), ) already_handled_packages.add(version_spec.package) - updated_packages[ - version_spec.full_dependency - ] = f"{version_spec.operator}{updated_version}" + updated_packages[version_spec.full_dependency] = ( + f"{version_spec.operator}{updated_version}" + f"{version_spec.extra_operator_version if version_spec.extra_operator_version else ''}" # pylint: disable=line-too-long + f"{' ' + version_spec.environment_marker if version_spec.environment_marker else ''}" # pylint: disable=line-too-long + ) if error: sys.exit( @@ -285,9 +291,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s f"{Emoji.PARTY_POPPER.value} Successfully updated the following " "dependencies:\n" + "\n".join( - f" {package} ({version}" - f"{version_spec.extra_operator_version if version_spec.extra_operator_version else ''}" # pylint: disable=line-too-long - f"{' ' + version_spec.environment_marker if version_spec.environment_marker else ''})" # pylint: disable=line-too-long + f" {package} ({version})" for package, version in updated_packages.items() ) + "\n" @@ -428,6 +432,7 @@ def _ignore_version_rules( version_rules: "list[dict[Literal['operator', 'version'], str]]", ) -> bool: """Determine whether to ignore package based on `versions` input.""" + semver_latest = SemanticVersion(".".join(latest)) operators_mapping = { ">": operator.gt, "<": operator.lt, @@ -440,31 +445,15 @@ def _ignore_version_rules( decision_version_rules = [] for version_rule in version_rules: decision_version_rule = False - split_version_rule = version_rule.get("version", "").split(".") - - if version_rule.get("operator", "") in operators_mapping: - # Extend version rule with zeros if needed - if len(split_version_rule) < len(latest): - split_version_rule.extend( - ["0"] * (len(latest) - len(split_version_rule)) - ) - if len(split_version_rule) != len(latest): - raise CICDException("Zero-filling failed for version.") + semver_version_rule = SemanticVersion(version_rule["version"]) - any_all_logic = ( - all - if "=" in version_rule["operator"] and version_rule["operator"] != "!=" - else any - ) - if any_all_logic( - operators_mapping[version_rule["operator"]]( - latest_part, version_rule_part - ) - for latest_part, version_rule_part in zip(latest, split_version_rule) + if version_rule["operator"] in operators_mapping: + if operators_mapping[version_rule["operator"]]( + semver_latest, semver_version_rule ): decision_version_rule = True - elif "~=" == version_rule.get("operator", ""): - if len(split_version_rule) == 1: + elif "~=" == version_rule["operator"]: + if "." not in version_rule["version"]: raise InputError( "Ignore option value error. For the 'versions' config key, when " "using the '~=' operator more than a single version part MUST be " @@ -472,23 +461,24 @@ def _ignore_version_rules( "similar." ) - if all( - latest_part >= version_rule_part - for latest_part, version_rule_part in zip(latest, split_version_rule) - ) and all( - latest_part == version_rule_part - for latest_part, version_rule_part in zip( - latest[:-1], split_version_rule[:-1] - ) + upper_limit = ( + "major" if version_rule["version"].count(".") == 1 else "minor" + ) + + if ( + semver_version_rule + <= semver_latest + < semver_version_rule.next_version(upper_limit) ): decision_version_rule = True - elif version_rule.get("operator", ""): - # Should not be possible to reach if using `parse_ignore_rules()` - # But for completion, and understanding, this is still kept. + else: raise InputParserError( - "Unknown ignore options 'versions' config value operator: " - f"{version_rule['operator']}" + "Ignore option value error. The 'versions' config key only " + "supports the following operators: '>', '<', '<=', '>=', '==', " + "'!=', '~='.\n" + f"Unparseable 'versions' value: {version_rule!r}" ) + decision_version_rules.append(decision_version_rule) # If ALL version rules AND'ed together are True, ignore the version. diff --git a/ci_cd/utils.py b/ci_cd/utils.py index 4c694b70..70d1e534 100644 --- a/ci_cd/utils.py +++ b/ci_cd/utils.py @@ -5,10 +5,10 @@ import re from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, no_type_check if TYPE_CHECKING: # pragma: no cover - from typing import Optional, Tuple, Union + from typing import Any, Optional, Tuple, Union LOGGER = logging.getLogger(__file__) @@ -24,7 +24,7 @@ class Emoji(str, Enum): CURLY_LOOP = "\u27b0" -class SemanticVersion: +class SemanticVersion(str): """A semantic version. See [SemVer.org](https://semver.org) for more information about semantic @@ -45,6 +45,9 @@ class SemanticVersion: The `patch` attribute will default to `0` while `pre_release` and `build` will be `None`, when asked for explicitly. + Precedence for comparing versions is done according to the rules outlined in point + 11 of the specification found at [SemVer.org](https://semver.org/#spec-item-11). + Parameters: major (Union[str, int]): The major version. minor (Optional[Union[str, int]]): The minor version. @@ -65,20 +68,88 @@ class SemanticVersion: """ + _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-]+)*))?$" + ) + + @no_type_check + def __new__( + cls, version: "Optional[str]" = None, **kwargs: "Union[str, int]" + ) -> "SemanticVersion": + return super().__new__( + cls, version if version else cls._build_version(**kwargs) + ) + def __init__( self, - major: "Union[str, int]", + version: "Optional[str]" = None, + *, + major: "Union[str, int]" = "", minor: "Optional[Union[str, int]]" = None, patch: "Optional[Union[str, int]]" = None, pre_release: "Optional[str]" = None, build: "Optional[str]" = None, ) -> None: + if version is not None: + if major or minor or patch or pre_release or build: + raise ValueError( + "version cannot be specified along with other parameters" + ) + + 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" + ) + major, minor, patch, pre_release, build = match.groups() + self._major = int(major) self._minor = int(minor) if minor else 0 self._patch = int(patch) if patch else 0 self._pre_release = pre_release if pre_release else None self._build = build if build else None + @classmethod + def _build_version( + cls, + major: "Optional[Union[str, int]]" = None, + minor: "Optional[Union[str, int]]" = None, + patch: "Optional[Union[str, int]]" = None, + pre_release: "Optional[str]" = None, + build: "Optional[str]" = None, + ) -> str: + """Build a version from the given parameters.""" + if major is None: + raise ValueError("At least major must be given") + version = str(major) + if minor is not None: + version += f".{minor}" + if patch is not None: + if minor is None: + raise ValueError("Minor must be given if patch is given") + version += f".{patch}" + if pre_release is not None: + # semver spec #9: A pre-release version MAY be denoted by appending a + # hyphen and a series of dot separated identifiers immediately following + # the patch version. + # https://semver.org/#spec-item-9 + if patch is None: + raise ValueError("Patch must be given if pre_release is given") + version += f"-{pre_release}" + if build is not None: + # semver spec #10: Build metadata MAY be denoted by appending a plus sign + # and a series of dot separated identifiers immediately following the patch + # or pre-release version. + # https://semver.org/#spec-item-10 + if patch is None: + raise ValueError("Patch must be given if build is given") + version += f"+{build}" + return version + @property def major(self) -> int: """The major version.""" @@ -122,6 +193,97 @@ def __repr__(self) -> str: """Return the string representation of the object.""" return repr(self.__str__()) + def _validate_other_type(self, other: "Any") -> "SemanticVersion": + """Initial check/validation of `other` before rich comparisons.""" + not_implemented_exc = NotImplementedError( + f"Rich comparison not implemented between {self.__class__.__name__} and " + f"{type(other)}" + ) + + if isinstance(other, self.__class__): + return other + + if isinstance(other, str): + try: + return self.__class__(other) + except (TypeError, ValueError) as exc: + raise not_implemented_exc from exc + + raise not_implemented_exc + + def __lt__(self, other: "Any") -> bool: + """Less than (`<`) rich comparison.""" + other_semver = self._validate_other_type(other) + + if self.major < other_semver.major: + return True + if self.major == other_semver.major: + if self.minor < other_semver.minor: + return True + if self.minor == other_semver.minor: + if self.patch < other_semver.patch: + return True + if self.patch == other_semver.patch: + if self.pre_release is None: + return False + if other_semver.pre_release is None: + return True + return self.pre_release < other_semver.pre_release + return False + + def __le__(self, other: "Any") -> bool: + """Less than or equal to (`<=`) rich comparison.""" + return self.__lt__(other) or self.__eq__(other) + + def __eq__(self, other: "Any") -> bool: + """Equal to (`==`) rich comparison.""" + other_semver = self._validate_other_type(other) + + return ( + self.major == other_semver.major + and self.minor == other_semver.minor + and self.patch == other_semver.patch + and self.pre_release == other_semver.pre_release + ) + + def __ne__(self, other: "Any") -> bool: + """Not equal to (`!=`) rich comparison.""" + return not self.__eq__(other) + + def __ge__(self, other: "Any") -> bool: + """Greater than or equal to (`>=`) rich comparison.""" + return not self.__lt__(other) + + def __gt__(self, other: "Any") -> bool: + """Greater than (`>`) rich comparison.""" + return not self.__le__(other) + + def next_version(self, version_part: str) -> "SemanticVersion": + """Return the next version for the specified version part. + + Parameters: + version_part: The version part to increment. + + Returns: + The next version. + + Raises: + ValueError: If the version part is not one of `major`, `minor`, or `patch`. + + """ + if version_part not in ("major", "minor", "patch"): + raise ValueError( + "version_part must be one of 'major', 'minor', or 'patch', not " + f"{version_part!r}" + ) + + if version_part == "major": + return self.__class__(f"{self.major + 1}.0.0") + if version_part == "minor": + return self.__class__(f"{self.major}.{self.minor + 1}.0") + + return self.__class__(f"{self.major}.{self.minor}.{self.patch + 1}") + def update_file( filename: Path, sub_line: "Tuple[str, str]", strip: "Optional[str]" = None diff --git a/pyproject.toml b/pyproject.toml index b947a2e9..9257c930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ max-line-length = 90 disable = [] max-args = 15 max-branches = 15 +max-returns = 10 [tool.pytest.ini_options] minversion = "7.0" diff --git a/tests/tasks/test_update_deps.py b/tests/tasks/test_update_deps.py index eac767bc..09ecc04b 100644 --- a/tests/tasks/test_update_deps.py +++ b/tests/tasks/test_update_deps.py @@ -1,5 +1,5 @@ """Test `ci_cd.tasks.update_deps()`.""" -# pylint: disable=line-too-long,too-many-lines +# pylint: disable=line-too-long,too-many-lines,too-many-locals from __future__ import annotations from typing import TYPE_CHECKING @@ -935,7 +935,9 @@ def test_ignore_version_fails() -> None: from ci_cd.exceptions import InputError, InputParserError from ci_cd.tasks.update_deps import ignore_version - with pytest.raises(InputParserError, match=r"^Unknown ignore options 'versions'.*"): + with pytest.raises( + InputParserError, match="only supports the following operators:" + ): ignore_version( current="1.1.1".split("."), latest="2.2.2".split("."), @@ -954,7 +956,7 @@ def test_ignore_version_fails() -> None: ) with pytest.raises( - InputError, match=r"Ignore option value error. For the 'versions' config key.*" + InputError, match="when using the '~=' operator more than a single version part" ): ignore_version( current="1.1.1".split("."), @@ -964,7 +966,117 @@ def test_ignore_version_fails() -> None: ) -def test_ignore_rules_logic(tmp_path: "Path") -> None: +@pytest.mark.parametrize( + ("ignore_rules", "expected_result"), + [ + ( + ["dependency-name=*...update-types=version-update:semver-major"], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=0.11.4", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.2", + "pytest-cov": "pytest-cov ~=3.1", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.14", + "Sphinx": "Sphinx >=4.5.0,<6", + }, + ), + ( + ["dependency-name=invoke...versions=>=2"], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=1.0.0", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.2", + "pytest-cov": "pytest-cov ~=3.1", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.14", + "Sphinx": "Sphinx >=6.1.3,<6", + }, + ), + ( + [ + "dependency-name=mike...versions=<1", + "dependency-name=mike...versions=<=1.0.0", + ], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=1.0.0", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.2", + "pytest-cov": "pytest-cov ~=3.1", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.14", + "Sphinx": "Sphinx >=6.1.3,<6", + }, + ), + ( + ["dependency-name=pylint...versions=~=2.14"], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=1.0.0", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.2", + "pytest-cov": "pytest-cov ~=3.1", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.13", + "Sphinx": "Sphinx >=6.1.3,<6", + }, + ), + ( + ["dependency-name=pytest"], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=1.0.0", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.1", + "pytest-cov": "pytest-cov ~=3.1", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.14", + "Sphinx": "Sphinx >=6.1.3,<6", + }, + ), + ( + ["dependency-name=pytest-cov...update-types=version-update:semver-minor"], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=1.0.0", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.2", + "pytest-cov": "pytest-cov ~=3.0", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.14", + "Sphinx": "Sphinx >=6.1.3,<6", # This should be fixed! + }, + ), + ( + ["dependency-name=Sphinx...versions=>=4.5.0"], + { + "invoke": "invoke ~=1.7", + "tomlkit[test,docs]": "tomlkit[test,docs] ~=1.0.0", + "mike": "mike >=1.0,<3", + "pytest": "pytest ~=7.2", + "pytest-cov": "pytest-cov ~=3.1", + "pre-commit": "pre-commit ~=2.20", + "pylint": "pylint ~=2.14", + "Sphinx": "Sphinx >=4.5.0,<6", + }, + ), + ], + ids=[ + "* semver-major", + "invoke >=2", + "mike <1 <=1.0.0", + "pylint ~=2.14", + "pytest", + "pytest-cov semver-minor", + "Sphinx >=4.5.0", + ], +) +def test_ignore_rules_logic( + tmp_path: "Path", ignore_rules: list[str], expected_result: dict[str, str] +) -> None: """Check the workflow of multiple interconnecting ignore rules are respected.""" import re @@ -981,6 +1093,7 @@ def test_ignore_rules_logic(tmp_path: "Path") -> None: "pytest-cov": "3.0", "pre-commit": "2.20", "pylint": "2.13", + "Sphinx": "4.5.0", } pyproject_file = tmp_path / "pyproject.toml" @@ -997,6 +1110,7 @@ def test_ignore_rules_logic(tmp_path: "Path") -> None: [project.optional-dependencies] docs = [ "mike >={original_dependencies['mike']},<3", + "Sphinx >={original_dependencies['Sphinx']},<6", ] testing = [ "pytest ~={original_dependencies['pytest']}", @@ -1008,6 +1122,7 @@ def test_ignore_rules_logic(tmp_path: "Path") -> None: "pytest-cov ~={original_dependencies['pytest-cov']}", "pre-commit ~={original_dependencies['pre-commit']}", "pylint ~={original_dependencies['pylint']}", + "Sphinx >={original_dependencies['Sphinx']},<6", ] """, encoding="utf8", @@ -1022,21 +1137,14 @@ def test_ignore_rules_logic(tmp_path: "Path") -> None: re.compile(r".*pytest-cov$"): "pytest-cov (3.1.0)", re.compile(r".*pre-commit$"): "pre-commit (2.20.0)", re.compile(r".*pylint$"): "pylint (2.14.0)", + re.compile(r".*Sphinx$"): "Sphinx (6.1.3)", }, ) update_deps( context, root_repo_path=str(tmp_path), - ignore=[ - "dependency-name=*...update-types=version-update:semver-major", - "dependency-name=invoke...versions=>=2", - "dependency-name=mike...versions=<1", - "dependency-name=mike...versions=<=1.0.0", - "dependency-name=pylint...versions=~=2.14", - "dependency-name=pytest-cov...update-types=version-update:semver-minor", - "dependency-name=pytest", - ], + ignore=ignore_rules, ignore_separator="...", ) @@ -1049,26 +1157,9 @@ def test_ignore_rules_logic(tmp_path: "Path") -> None: dependencies.extend(optional_deps) for line in dependencies: - if "invoke" in line: - # Not affected by package-specific rule - assert line == "invoke ~=1.7" - elif "tomlkit" in line: - # Affected by "*" rule - assert line == "tomlkit[test,docs] ~=0.11.4" - elif "mike" in line: - # Not affected by any of its package-specific rules - assert line == "mike >=1.0,<3" - elif "pytest-cov" in line: - # Affected by its package-specific update-types rule - assert line == "pytest-cov ~=3.0" - elif "pytest" in line: - # Affected by package-specific rule (ignore all updates) - assert line == "pytest ~=7.1" - elif "pre-commit" in line: - # Not affected by "*" rule - no updates - assert line == "pre-commit ~=2.20" - elif "pylint" in line: - # Affected by its package-specific version rule - assert line == "pylint ~=2.13" + for dependency, dependency_requirement in expected_result.items(): + if f"{dependency} " in line: + assert line == dependency_requirement + break else: pytest.fail(f"Unknown package in line: {line}") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..7266ffb6 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,186 @@ +"""Tests for utils.py""" +import pytest + + +def test_semanticversion() -> None: + """Test SemanticVersion class.""" + from ci_cd.utils import SemanticVersion + + valid_inputs = [ + "1.0.0", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-0.3.7", + "1.0.0-x.7.z.92", + "1.0.0-alpha+001", + "1.0.0+20130313144700", + "1.0.0-beta+exp.sha.5114f85", + "1.0.0-beta.11+exp.sha.5114f85", + "1.0.0-rc.1+exp.sha.5114f85", + ({"major": 1, "minor": 0, "patch": 0}, "1.0.0"), + ({"major": 1, "minor": 0, "patch": 0, "pre_release": "alpha"}, "1.0.0-alpha"), + ( + {"major": 1, "minor": 0, "patch": 0, "pre_release": "alpha.1"}, + "1.0.0-alpha.1", + ), + ({"major": 1, "minor": 0, "patch": 0, "pre_release": "0.3.7"}, "1.0.0-0.3.7"), + ( + {"major": 1, "minor": 0, "patch": 0, "pre_release": "x.7.z.92"}, + "1.0.0-x.7.z.92", + ), + ( + { + "major": 1, + "minor": 0, + "patch": 0, + "pre_release": "alpha", + "build": "001", + }, + "1.0.0-alpha+001", + ), + ( + {"major": 1, "minor": 0, "patch": 0, "build": "20130313144700"}, + "1.0.0+20130313144700", + ), + ( + { + "major": 1, + "minor": 0, + "patch": 0, + "pre_release": "beta", + "build": "exp.sha.5114f85", + }, + "1.0.0-beta+exp.sha.5114f85", + ), + ( + { + "major": 1, + "minor": 0, + "patch": 0, + "pre_release": "beta.11", + "build": "exp.sha.5114f85", + }, + "1.0.0-beta.11+exp.sha.5114f85", + ), + ( + { + "major": 1, + "minor": 0, + "patch": 0, + "pre_release": "rc.1", + "build": "exp.sha.5114f85", + }, + "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) + ) + + +def test_semanticversion_invalid() -> None: + """Test SemanticVersion class with invalid inputs.""" + from ci_cd.utils import SemanticVersion + + invalid_inputs = [ + ("1.0.0-", "cannot be parsed as a semantic version"), + ("1.0.0-+", "cannot be parsed as a semantic version"), + ("1.0.0-.", "cannot be parsed as a semantic version"), + ("1.0.0-..", "cannot be parsed as a semantic version"), + ("1.0.0-+.", "cannot be parsed as a semantic version"), + ("1.0.0-+..", "cannot be parsed as a semantic version"), + ( + {"version": "1.0.0", "major": 1, "minor": 0, "patch": 0}, + "version cannot be specified along with other parameters", + ), + ( + {"major": 1, "patch": 0}, + "Minor must be given if patch is given", + ), + ( + { + "major": 1, + "minor": 0, + "pre_release": "alpha", + }, + "Patch must be given if pre_release is given", + ), + ( + { + "major": 1, + "minor": 0, + "build": "001", + }, + "Patch must be given if build is given", + ), + ("", "At least major must be given"), + ({}, "At least major must be given"), + ] + for input_, exc_msg in invalid_inputs: + with pytest.raises(ValueError, match=exc_msg): + SemanticVersion( # pylint: disable=expression-not-assigned + **input_ + ) if isinstance(input_, dict) else SemanticVersion(input_) + + +def test_semanticversion_invalid_comparisons() -> None: + """Test invalid comparisons with SemanticVersion class.""" + import operator + + from ci_cd.utils import SemanticVersion + + operators_mapping = { + ">": operator.gt, + "<": operator.lt, + "<=": operator.le, + ">=": operator.ge, + "==": operator.eq, + "!=": operator.ne, + } + + for operator_ in ("<", "<=", ">", ">=", "==", "!="): + with pytest.raises( + NotImplementedError, match="comparison not implemented between" + ): + operators_mapping[operator_](SemanticVersion("1.0.0"), 1) + with pytest.raises( + NotImplementedError, match="comparison not implemented between" + ): + operators_mapping[operator_](SemanticVersion("1.0.0"), "test") + + +def test_semanticversion_next_version() -> None: + """Test the next_version method of SemanticVersion class.""" + from ci_cd.utils import SemanticVersion + + valid_inputs = [ + ("1.0.0", "major", "2.0.0"), + ("1.0.0", "minor", "1.1.0"), + ("1.0.0", "patch", "1.0.1"), + ] + + for version, version_part, next_version in valid_inputs: + assert SemanticVersion(version).next_version(version_part) == next_version + + +def test_semanticversion_next_version_invalid() -> None: + """Test the next_version method of SemanticVersion class with invalid inputs.""" + from ci_cd.utils import SemanticVersion + + invalid_inputs = [ + "invalid", + "pre_release", + "build", + ] + + for version_part in invalid_inputs: + with pytest.raises(ValueError, match="version_part must be one of"): + SemanticVersion("1.0.0").next_version(version_part)