Skip to content

Commit

Permalink
Fix ignore functionality (especially for 'version' rules) (#140)
Browse files Browse the repository at this point in the history
Extend SemanticVersion class with rich comparisons.
Add __new__ to SemanticVersion for testing inputs.

Co-authored-by: Anders Eklund <[email protected]>
  • Loading branch information
CasperWA and ajeklund authored May 23, 2023
1 parent 7907901 commit ebe8eef
Show file tree
Hide file tree
Showing 5 changed files with 510 additions and 80 deletions.
74 changes: 32 additions & 42 deletions ci_cd/tasks/update_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -440,55 +445,40 @@ 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 "
"specified. E.g., '~=2' is disallowed, instead use '~=2.0' or "
"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.
Expand Down
170 changes: 166 additions & 4 deletions ci_cd/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -65,20 +68,88 @@ class SemanticVersion:
"""

_REGEX = (
r"^(?P<major>0|[1-9]\d*)(?:\.(?P<minor>0|[1-9]\d*))?(?:\.(?P<patch>0|[1-9]\d*))?"
r"(?:-(?P<pre_release>(?: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<build>[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."""
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading

0 comments on commit ebe8eef

Please sign in to comment.