Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix ignore functionality (especially for 'version' rules) #140

Merged
merged 17 commits into from
May 23, 2023
Merged
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
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:
CasperWA marked this conversation as resolved.
Show resolved Hide resolved
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