Skip to content

versions

Handle versions.

PART_TO_LENGTH_MAPPING

Mapping of version-style name to their number of version parts.

E.g., a minor version has two parts, so the length is 2.

IgnoreEntryPair (tuple)

A key/value-pair within an ignore entry.

Source code in ci_cd/utils/versions.py
class IgnoreEntryPair(NamedTuple):
    """A key/value-pair within an ignore entry."""

    key: Literal["dependency-name", "versions", "update-types"]
    value: str

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in ci_cd/utils/versions.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)

__new__(_cls, key, value) special staticmethod

Create new instance of IgnoreEntryPair(key, value)

__repr__(self) special

Return a nicely formatted representation string

Source code in ci_cd/utils/versions.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self

SemanticVersion (str)

A semantic version.

See SemVer.org for more information about semantic versioning.

The semantic version is in this invocation considered to build up in the following way:

<major>.<minor>.<patch>-<pre_release>+<build>

Where the names in carets are callable attributes for the instance.

When casting instances of SemanticVersion to str, the full version will be returned, i.e., as shown above, with a minimum of major.minor.patch.

For example, for the version 1.5, i.e., major=1, minor=5, the returned str representation will be the full major.minor.patch version: 1.5.0. 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.

Parameters:

Name Type Description Default
major Union[str, int]

The major version.

''
minor Optional[Union[str, int]]

The minor version.

None
patch Optional[Union[str, int]]

The patch version.

None
pre_release Optional[str]

The pre-release part of the version, i.e., the part supplied after a minus (-), but before a plus (+).

None
build Optional[str]

The build metadata part of the version, i.e., the part supplied at the end of the version, after a plus (+).

None

Attributes:

Name Type Description
major int

The major version.

minor int

The minor version.

patch int

The patch version.

pre_release str

The pre-release part of the version, i.e., the part supplied after a minus (-), but before a plus (+).

build str

The build metadata part of the version, i.e., the part supplied at the end of the version, after a plus (+).

Source code in ci_cd/utils/versions.py
class SemanticVersion(str):
    """A semantic version.

    See [SemVer.org](https://semver.org) for more information about semantic
    versioning.

    The semantic version is in this invocation considered to build up in the following
    way:

        <major>.<minor>.<patch>-<pre_release>+<build>

    Where the names in carets are callable attributes for the instance.

    When casting instances of `SemanticVersion` to `str`, the full version will be
    returned, i.e., as shown above, with a minimum of major.minor.patch.

    For example, for the version `1.5`, i.e., `major=1, minor=5`, the returned `str`
    representation will be the full major.minor.patch version: `1.5.0`.
    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.
        patch (Optional[Union[str, int]]): The patch version.
        pre_release (Optional[str]): The pre-release part of the version, i.e., the
            part supplied after a minus (`-`), but before a plus (`+`).
        build (Optional[str]): The build metadata part of the version, i.e., the part
            supplied at the end of the version, after a plus (`+`).

    Attributes:
        major (int): The major version.
        minor (int): The minor version.
        patch (int): The patch version.
        pre_release (str): The pre-release part of the version, i.e., the part
            supplied after a minus (`-`), but before a plus (`+`).
        build (str): The build metadata part of the version, i.e., the part supplied at
            the end of the version, after a plus (`+`).

    """

    _semver_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-]+)*))?$"
    )
    """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__(cls, version: str | Version | None = None, **kwargs: str | int) -> Self:
        return super().__new__(
            cls, str(version) if version else cls._build_version(**kwargs)
        )

    def __init__(
        self,
        version: str | Version | None = None,
        *,
        major: str | int = "",
        minor: str | int | None = None,
        patch: str | int | None = None,
        pre_release: str | None = None,
        build: str | None = None,
    ) -> None:
        self._python_version: Version | 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"
                )

            if isinstance(version, Version):
                self._python_version = version
                version = ".".join(str(_) for _ in version.release)

            match = re.match(self._semver_regex, version)
            if match is None:
                # 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

                # Success. Now let's redo the SemVer.org regular expression match
                self._python_version = _python_version
                match = re.match(
                    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
                    # 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"
                    )

            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: str | int | None = None,
        minor: str | int | None = None,
        patch: str | int | None = None,
        pre_release: str | None = None,
        build: str | None = 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."""
        return self._major

    @property
    def minor(self) -> int:
        """The minor version."""
        return self._minor

    @property
    def patch(self) -> int:
        """The patch version."""
        return self._patch

    @property
    def pre_release(self) -> str | None:
        """The pre-release part of the version

        This is the part supplied after a minus (`-`), but before a plus (`+`).
        """
        return self._pre_release

    @property
    def build(self) -> 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) -> Version | None:
        """The Python version as defined by `packaging.version.Version`."""
        return self._python_version

    def as_python_version(self, shortened: bool = True) -> Version:
        """Return the Python version as defined by `packaging.version.Version`."""
        if not self.python_version:
            return Version(
                self.shortened()
                if shortened
                else ".".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).

        # 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.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.post is not None:
                redone_version += f".post{self.python_version.post}"

            if self.python_version.dev is not None:
                redone_version += f".dev{self.python_version.dev}"

            if self.python_version.local is not None:
                redone_version += f"+{self.python_version.local}"

        return Version(redone_version)

    def __str__(self) -> str:
        """Return the full version."""
        if self.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 ''}"
            f"{f'+{self.build}' if self.build else ''}"
        )

    def __repr__(self) -> str:
        """Return the string representation of the object."""
        return f"{self.__class__.__name__}({self.__str__()!r})"

    def __getattribute__(self, name: str) -> Any:
        """Return the attribute value."""
        accepted_python_attributes = (
            "epoch",
            "release",
            "pre",
            "post",
            "dev",
            "local",
            "public",
            "base_version",
            "micro",
        )

        try:
            return object.__getattribute__(self, name)
        except AttributeError as exc:
            # Try returning the attribute from the Python version, if it is in a list
            # of accepted attributes
            if name not in accepted_python_attributes:
                raise AttributeError(
                    f"{self.__class__.__name__} object has no attribute {name!r}"
                ) from exc

            python_version = object.__getattribute__(self, "as_python_version")(
                shortened=False
            )
            try:
                return getattr(python_version, name)
            except AttributeError as exc:
                raise AttributeError(
                    f"{self.__class__.__name__} object has no attribute {name!r}"
                ) from exc

    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, (Version, 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: object) -> 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: object) -> 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":
            next_version = f"{self.major + 1}.0.0"
        elif version_part == "minor":
            next_version = f"{self.major}.{self.minor + 1}.0"
        else:
            next_version = f"{self.major}.{self.minor}.{self.patch + 1}"

        return self.__class__(next_version)

    def previous_version(
        self, version_part: str, max_filler: str | int | None = None
    ) -> SemanticVersion:
        """Return the previous version for the specified version part.

        Parameters:
            version_part: The version part to decrement.
            max_filler: The maximum value for the version part to decrement.

        Returns:
            The previous 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 max_filler is None:
            max_filler = 99
        elif isinstance(max_filler, str):
            max_filler = int(max_filler)

        if not isinstance(max_filler, int):
            raise TypeError("max_filler must be an integer, string or None")

        if version_part == "major":
            prev_version = f"{self.major - 1}.{max_filler}.{max_filler}"

        elif version_part == "minor" or 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}"

        return self.__class__(prev_version)

    def shortened(self) -> str:
        """Return a shortened version of the version.

        The shortened version is the full version, but without the patch and/or minor
        version if they are `0`, and without the pre-release and build metadata parts.

        Returns:
            The shortened version.

        """
        if self.patch == 0:
            if self.minor == 0:
                return str(self.major)
            return f"{self.major}.{self.minor}"
        return f"{self.major}.{self.minor}.{self.patch}"

build: str | None property readonly

The build metadata part of the version.

This is the part supplied at the end of the version, after a plus (+).

major: int property readonly

The major version.

minor: int property readonly

The minor version.

patch: int property readonly

The patch version.

pre_release: str | None property readonly

The pre-release part of the version

This is the part supplied after a minus (-), but before a plus (+).

python_version: Version | None property readonly

The Python version as defined by packaging.version.Version.

__eq__(self, other) special

Equal to (==) rich comparison.

Source code in ci_cd/utils/versions.py
def __eq__(self, other: object) -> 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
    )

__ge__(self, other) special

Greater than or equal to (>=) rich comparison.

Source code in ci_cd/utils/versions.py
def __ge__(self, other: Any) -> bool:
    """Greater than or equal to (`>=`) rich comparison."""
    return not self.__lt__(other)

__getattribute__(self, name) special

Return the attribute value.

Source code in ci_cd/utils/versions.py
def __getattribute__(self, name: str) -> Any:
    """Return the attribute value."""
    accepted_python_attributes = (
        "epoch",
        "release",
        "pre",
        "post",
        "dev",
        "local",
        "public",
        "base_version",
        "micro",
    )

    try:
        return object.__getattribute__(self, name)
    except AttributeError as exc:
        # Try returning the attribute from the Python version, if it is in a list
        # of accepted attributes
        if name not in accepted_python_attributes:
            raise AttributeError(
                f"{self.__class__.__name__} object has no attribute {name!r}"
            ) from exc

        python_version = object.__getattribute__(self, "as_python_version")(
            shortened=False
        )
        try:
            return getattr(python_version, name)
        except AttributeError as exc:
            raise AttributeError(
                f"{self.__class__.__name__} object has no attribute {name!r}"
            ) from exc

__gt__(self, other) special

Greater than (>) rich comparison.

Source code in ci_cd/utils/versions.py
def __gt__(self, other: Any) -> bool:
    """Greater than (`>`) rich comparison."""
    return not self.__le__(other)

__le__(self, other) special

Less than or equal to (<=) rich comparison.

Source code in ci_cd/utils/versions.py
def __le__(self, other: Any) -> bool:
    """Less than or equal to (`<=`) rich comparison."""
    return self.__lt__(other) or self.__eq__(other)

__lt__(self, other) special

Less than (<) rich comparison.

Source code in ci_cd/utils/versions.py
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

__ne__(self, other) special

Not equal to (!=) rich comparison.

Source code in ci_cd/utils/versions.py
def __ne__(self, other: object) -> bool:
    """Not equal to (`!=`) rich comparison."""
    return not self.__eq__(other)

__new__(cls, version=None, **kwargs) special staticmethod

Create and return a new object. See help(type) for accurate signature.

Source code in ci_cd/utils/versions.py
@no_type_check
def __new__(cls, version: str | Version | None = None, **kwargs: str | int) -> Self:
    return super().__new__(
        cls, str(version) if version else cls._build_version(**kwargs)
    )

__repr__(self) special

Return the string representation of the object.

Source code in ci_cd/utils/versions.py
def __repr__(self) -> str:
    """Return the string representation of the object."""
    return f"{self.__class__.__name__}({self.__str__()!r})"

__str__(self) special

Return the full version.

Source code in ci_cd/utils/versions.py
def __str__(self) -> str:
    """Return the full version."""
    if self.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 ''}"
        f"{f'+{self.build}' if self.build else ''}"
    )

as_python_version(self, shortened=True)

Return the Python version as defined by packaging.version.Version.

Source code in ci_cd/utils/versions.py
def as_python_version(self, shortened: bool = True) -> Version:
    """Return the Python version as defined by `packaging.version.Version`."""
    if not self.python_version:
        return Version(
            self.shortened()
            if shortened
            else ".".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).

    # 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.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.post is not None:
            redone_version += f".post{self.python_version.post}"

        if self.python_version.dev is not None:
            redone_version += f".dev{self.python_version.dev}"

        if self.python_version.local is not None:
            redone_version += f"+{self.python_version.local}"

    return Version(redone_version)

next_version(self, version_part)

Return the next version for the specified version part.

Parameters:

Name Type Description Default
version_part str

The version part to increment.

required

Returns:

Type Description
SemanticVersion

The next version.

Exceptions:

Type Description
ValueError

If the version part is not one of major, minor, or patch.

Source code in ci_cd/utils/versions.py
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":
        next_version = f"{self.major + 1}.0.0"
    elif version_part == "minor":
        next_version = f"{self.major}.{self.minor + 1}.0"
    else:
        next_version = f"{self.major}.{self.minor}.{self.patch + 1}"

    return self.__class__(next_version)

previous_version(self, version_part, max_filler=None)

Return the previous version for the specified version part.

Parameters:

Name Type Description Default
version_part str

The version part to decrement.

required
max_filler str | int | None

The maximum value for the version part to decrement.

None

Returns:

Type Description
SemanticVersion

The previous version.

Exceptions:

Type Description
ValueError

If the version part is not one of major, minor, or patch.

Source code in ci_cd/utils/versions.py
def previous_version(
    self, version_part: str, max_filler: str | int | None = None
) -> SemanticVersion:
    """Return the previous version for the specified version part.

    Parameters:
        version_part: The version part to decrement.
        max_filler: The maximum value for the version part to decrement.

    Returns:
        The previous 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 max_filler is None:
        max_filler = 99
    elif isinstance(max_filler, str):
        max_filler = int(max_filler)

    if not isinstance(max_filler, int):
        raise TypeError("max_filler must be an integer, string or None")

    if version_part == "major":
        prev_version = f"{self.major - 1}.{max_filler}.{max_filler}"

    elif version_part == "minor" or 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}"

    return self.__class__(prev_version)

shortened(self)

Return a shortened version of the version.

The shortened version is the full version, but without the patch and/or minor version if they are 0, and without the pre-release and build metadata parts.

Returns:

Type Description
str

The shortened version.

Source code in ci_cd/utils/versions.py
def shortened(self) -> str:
    """Return a shortened version of the version.

    The shortened version is the full version, but without the patch and/or minor
    version if they are `0`, and without the pre-release and build metadata parts.

    Returns:
        The shortened version.

    """
    if self.patch == 0:
        if self.minor == 0:
            return str(self.major)
        return f"{self.major}.{self.minor}"
    return f"{self.major}.{self.minor}.{self.patch}"

create_ignore_rules(specifier_set)

Create ignore rules based on version specifier set.

The only ignore rules needed are related to versions that should be explicitly avoided, i.e., the != operator. All other specifiers should require an explicit ignore rule by the user, if no update should be suggested.

Source code in ci_cd/utils/versions.py
def create_ignore_rules(specifier_set: SpecifierSet) -> IgnoreRules:
    """Create ignore rules based on version specifier set.

    The only ignore rules needed are related to versions that should be explicitly
    avoided, i.e., the `!=` operator. All other specifiers should require an explicit
    ignore rule by the user, if no update should be suggested.
    """
    return {
        "versions": [
            f"=={specifier.version}"
            for specifier in specifier_set
            if specifier.operator == "!="
        ]
    }

find_minimum_py_version(marker, project_py_version)

Find the minimum Python version from a marker.

Source code in ci_cd/utils/versions.py
def find_minimum_py_version(marker: Marker, project_py_version: str) -> str:
    """Find the minimum Python version from a marker."""
    split_py_version = project_py_version.split(".")

    def _next_version(_version: SemanticVersion) -> SemanticVersion:
        if len(split_py_version) == PART_TO_LENGTH_MAPPING["major"]:
            return _version.next_version("major")
        if len(split_py_version) == PART_TO_LENGTH_MAPPING["minor"]:
            return _version.next_version("minor")
        return _version.next_version("patch")

    min_py_version = SemanticVersion(project_py_version)

    environment_keys = default_environment().keys()
    empty_environment = {key: "" for key in environment_keys}
    python_version_centric_environment = empty_environment
    python_version_centric_environment.update({"python_version": min_py_version})

    while not _semi_valid_python_version(min_py_version) or not marker.evaluate(
        environment=python_version_centric_environment
    ):
        min_py_version = _next_version(min_py_version)
        python_version_centric_environment.update({"python_version": min_py_version})

    return min_py_version.shortened()

get_min_max_py_version(requires_python)

Get minimum or maximum Python version from requires_python.

This also works for parsing requirement markers specifying validity for a specific python_version.

A minimum version will be preferred.

This means all the minimum operators will be checked first, and if none of them match, then all the maximum operators will be checked. See below to understand which operators are minimum and which are maximum.

Note

The first operator to match will be used, so if a minimum operator matches, then the maximum operators will not be checked.

Whether it will minimum or maximum will depend on the operator: Minimum: >=, ==, ~=, > Maximum: <=, <

Returns:

Type Description
str

The minimum or maximum Python version. E.g., if requires_python is ">=3.6", then "3.6" (min) will be returned, and if requires_python is Marker("python_version < '3.6'"), then "3.5.99" (max) will be returned.

Source code in ci_cd/utils/versions.py
def get_min_max_py_version(
    requires_python: str | Marker,
) -> str:
    """Get minimum or maximum Python version from `requires_python`.

    This also works for parsing requirement markers specifying validity for a specific
    `python_version`.

    _A minimum version will be preferred._

    This means all the minimum operators will be checked first, and if none of them
    match, then all the maximum operators will be checked.
    See below to understand which operators are minimum and which are maximum.

    Note:
        The first operator to match will be used, so if a minimum operator matches,
        then the maximum operators will not be checked.

    Whether it will minimum or maximum will depend on the operator:
    Minimum: `>=`, `==`, `~=`, `>`
    Maximum: `<=`, `<`

    Returns:
        The minimum or maximum Python version.
        E.g., if `requires_python` is `">=3.6"`, then `"3.6"` (min) will be returned,
        and if `requires_python` is `Marker("python_version < '3.6'")`, then `"3.5.99"`
        (max) will be returned.

    """
    if isinstance(requires_python, Marker):
        match = re.search(
            r"python_version\s*"
            r"(?P<operator>==|!=|<=|>=|<|>|~=)\s*"
            r"('|\")(?P<version>[0-9]+(?:\.[0-9]+)*)('|\")",
            str(requires_python),
        )

        if match is None:
            raise UnableToResolve("Could not retrieve 'python_version' marker.")

        requires_python = f"{match.group('operator')}{match.group('version')}"

    try:
        specifier_set = SpecifierSet(requires_python)
    except InvalidSpecifier as exc:
        raise UnableToResolve(
            "Cannot parse 'requires_python' as a specifier set."
        ) from exc

    py_version = ""
    min_or_max = "min"

    length_to_part_mapping = {
        1: "major",
        2: "minor",
        3: "patch",
    }

    # Minimum
    for specifier in specifier_set:
        if specifier.operator in [">=", "==", "~="]:
            py_version = specifier.version
            break

        if specifier.operator == ">":
            split_version = specifier.version.split(".")
            parsed_version = SemanticVersion(specifier.version)

            if len(split_version) == PART_TO_LENGTH_MAPPING["major"]:
                py_version = str(parsed_version.next_version("major").major)
            elif len(split_version) == PART_TO_LENGTH_MAPPING["minor"]:
                py_version = ".".join(
                    parsed_version.next_version("minor").split(".")[:2]
                )
            elif len(split_version) == PART_TO_LENGTH_MAPPING["patch"]:
                py_version = str(parsed_version.next_version("patch"))

            break
    else:
        # Maximum
        min_or_max = "max"

        for specifier in specifier_set:
            if specifier.operator == "<=":
                py_version = specifier.version
                break

            if specifier.operator == "<":
                split_version = specifier.version.split(".")
                parsed_version = SemanticVersion(specifier.version)

                if parsed_version == SemanticVersion("0"):
                    raise UnableToResolve(
                        f"{specifier} is not a valid Python version specifier."
                    )

                if len(split_version) not in length_to_part_mapping:
                    raise UnableToResolve(
                        f"{specifier} is not a valid Python version specifier. It was "
                        "expected to be a major, minor, or patch version specifier."
                    )

                # Fill with 0's and shorten. This is an attempt to return a full range
                # of previous versions.
                py_version = str(
                    parsed_version.previous_version(
                        length_to_part_mapping[len(split_version)],
                        max_filler=0,
                    ).shortened()
                )

                break
        else:
            raise UnableToResolve(
                "Cannot determine min/max Python version from version specifier(s): "
                f"{specifier_set}"
            )

    if py_version not in specifier_set:
        split_py_version = py_version.split(".")
        parsed_py_version = SemanticVersion(py_version)

        # See the _semi_valid_python_version() function for these values
        largest_value_for_a_patch_part = 18
        largest_value_for_a_minor_part = 12
        largest_value_for_a_major_part = 3
        largest_value_for_any_part = max(
            largest_value_for_a_patch_part,
            largest_value_for_a_minor_part,
            largest_value_for_a_major_part,
        )

        while (
            not _semi_valid_python_version(parsed_py_version)
            or py_version not in specifier_set
        ):
            if min_or_max == "min":
                if parsed_py_version.patch >= largest_value_for_a_patch_part:
                    parsed_py_version = parsed_py_version.next_version("minor")
                elif parsed_py_version.minor >= largest_value_for_a_minor_part:
                    parsed_py_version = parsed_py_version.next_version("major")
                else:
                    parsed_py_version = parsed_py_version.next_version("patch")
            else:
                parsed_py_version = parsed_py_version.previous_version(
                    length_to_part_mapping[len(split_py_version)],
                    max_filler=largest_value_for_any_part,
                )

            py_version = parsed_py_version.shortened()
            split_py_version = py_version.split(".")

    return py_version

ignore_version(current, latest, version_rules, semver_rules)

Determine whether the latest version can be ignored.

Parameters:

Name Type Description Default
current list[str]

The current version as a list of version parts. It's expected, but not required, the version is a semantic version.

required
latest list[str]

The latest version as a list of version parts. It's expected, but not required, the version is a semantic version.

required
version_rules IgnoreVersions

Version ignore rules.

required
semver_rules IgnoreUpdateTypes

Semantic version ignore rules.

required

Returns:

Type Description
bool

Whether or not the latest version can be ignored based on the version and semantic version ignore rules.

Source code in ci_cd/utils/versions.py
def ignore_version(
    current: list[str],
    latest: list[str],
    version_rules: IgnoreVersions,
    semver_rules: IgnoreUpdateTypes,
) -> bool:
    """Determine whether the latest version can be ignored.

    Parameters:
        current: The current version as a list of version parts. It's expected, but not
            required, the version is a semantic version.
        latest: The latest version as a list of version parts. It's expected, but not
            required, the version is a semantic version.
        version_rules: Version ignore rules.
        semver_rules: Semantic version ignore rules.

    Returns:
        Whether or not the latest version can be ignored based on the version and
        semantic version ignore rules.

    """
    # ignore all updates
    if not version_rules and not semver_rules:
        # A package name has been specified without specific rules, ignore all updates
        # for package.
        return True

    # version rules
    if _ignore_version_rules_specifier_set(latest, version_rules):
        return True

    # semver rules
    return bool(
        "version-update" in semver_rules
        and _ignore_semver_rules(current, latest, semver_rules)
    )

parse_ignore_entries(entries, separator)

Parser for the --ignore option.

The --ignore option values are given as key/value-pairs in the form: key=value...key=value. Here ... is the separator value supplied by --ignore-separator.

Parameters:

Name Type Description Default
entries list[str]

The list of supplied --ignore options.

required
separator str

The supplied --ignore-separator value.

required

Returns:

Type Description
IgnoreRulesCollection

A parsed mapping of dependencies to ignore rules.

Source code in ci_cd/utils/versions.py
def parse_ignore_entries(entries: list[str], separator: str) -> IgnoreRulesCollection:
    """Parser for the `--ignore` option.

    The `--ignore` option values are given as key/value-pairs in the form:
    `key=value...key=value`. Here `...` is the separator value supplied by
    `--ignore-separator`.

    Parameters:
        entries: The list of supplied `--ignore` options.
        separator: The supplied `--ignore-separator` value.

    Returns:
        A parsed mapping of dependencies to ignore rules.

    """
    ignore_entries: IgnoreRulesCollection = {}

    for entry in entries:
        pairs = entry.split(separator, maxsplit=2)
        for pair in pairs:
            if separator in pair:
                raise InputParserError(
                    "More than three key/value-pairs were given for an `--ignore` "
                    "option, while there are only three allowed key names. Input "
                    f"value: --ignore={entry!r}"
                )

        ignore_entry: IgnoreEntry = {}
        for pair in pairs:
            match = re.match(
                r"^(?P<key>dependency-name|versions|update-types)=(?P<value>.*)$",
                pair,
            )
            if match is None:
                raise InputParserError(
                    f"Could not parse ignore configuration: {pair!r} (part of the "
                    f"ignore option: {entry!r})"
                )

            parsed_pair = IgnoreEntryPair(**match.groupdict())  # type: ignore[arg-type]

            if parsed_pair.key in ignore_entry:
                raise InputParserError(
                    "An ignore configuration can only be given once per option. The "
                    f"configuration key {parsed_pair.key!r} was found multiple "
                    f"times in the option {entry!r}"
                )

            ignore_entry[parsed_pair.key] = parsed_pair.value.strip()

        if "dependency-name" not in ignore_entry:
            raise InputError(
                "Ignore option entry missing required 'dependency-name' "
                f"configuration. Ignore option entry: {entry}"
            )

        dependency_name = ignore_entry["dependency-name"]
        if dependency_name not in ignore_entries:
            ignore_entries[dependency_name] = {
                key: [value]
                for key, value in ignore_entry.items()
                if key != "dependency-name"
            }
        else:
            for key, value in ignore_entry.items():
                if key != "dependency-name":
                    ignore_entries[dependency_name][key].append(value)

    return ignore_entries

parse_ignore_rules(rules)

Parser for a specific set of ignore rules.

Parameters:

Name Type Description Default
rules IgnoreRules

A set of ignore rules for one or more packages.

required

Returns:

Type Description
tuple[IgnoreVersions, IgnoreUpdateTypes]

A tuple of the parsed 'versions' and 'update-types' entries as dictionaries.

Source code in ci_cd/utils/versions.py
def parse_ignore_rules(
    rules: IgnoreRules,
) -> tuple[IgnoreVersions, IgnoreUpdateTypes]:
    """Parser for a specific set of ignore rules.

    Parameters:
        rules: A set of ignore rules for one or more packages.

    Returns:
        A tuple of the parsed 'versions' and 'update-types' entries as dictionaries.

    """
    if not rules:
        # Ignore package altogether
        return [{"operator": ">=", "version": "0"}], {}

    versions: IgnoreVersions = []
    update_types: IgnoreUpdateTypes = {}

    if "versions" in rules:
        for versions_entry in rules["versions"]:
            match = re.match(
                r"^(?P<operator>>|<|<=|>=|==|!=|~=)\s*(?P<version>\S+)$",
                versions_entry,
            )
            if match is None:
                raise InputParserError(
                    "Ignore option's 'versions' value cannot be parsed. It "
                    "must be a single operator followed by a version number.\n"
                    f"Unparseable 'versions' value: {versions_entry!r}"
                )
            versions.append(match.groupdict())  # type: ignore[arg-type]

    if "update-types" in rules:
        update_types["version-update"] = []
        for update_type_entry in rules["update-types"]:
            match = re.match(
                r"^version-update:semver-(?P<semver_part>major|minor|patch)$",
                update_type_entry,
            )
            if match is None:
                raise InputParserError(
                    "Ignore option's 'update-types' value cannot be parsed."
                    " It must be either: 'version-update:semver-major', "
                    "'version-update:semver-minor' or "
                    "'version-update:semver-patch'.\nUnparseable 'update-types' "
                    f"value: {update_type_entry!r}"
                )
            update_types["version-update"].append(match.group("semver_part"))  # type: ignore[arg-type]

    return versions, update_types

regenerate_requirement(requirement, *, name=None, extras=None, specifier=None, url=None, marker=None, post_name_space=False)

Regenerate a requirement string including the given parameters.

Parameters:

Name Type Description Default
requirement Requirement

The requirement to regenerate and fallback to.

required
name str | None

A new name to use for the requirement.

None
extras set[str] | None

New extras to use for the requirement.

None
specifier SpecifierSet | str | None

A new specifier set to use for the requirement.

None
url str | None

A new URL to use for the requirement.

None
marker Marker | str | None

A new marker to use for the requirement.

None
post_name_space bool

Whether or not to add a single space after the name (possibly including extras), but before the specifier.

False

Returns:

Type Description
str

The regenerated requirement string.

Source code in ci_cd/utils/versions.py
def regenerate_requirement(
    requirement: Requirement,
    *,
    name: str | None = None,
    extras: set[str] | None = None,
    specifier: SpecifierSet | str | None = None,
    url: str | None = None,
    marker: Marker | str | None = None,
    post_name_space: bool = False,
) -> str:
    """Regenerate a requirement string including the given parameters.

    Parameters:
        requirement: The requirement to regenerate and fallback to.
        name: A new name to use for the requirement.
        extras: New extras to use for the requirement.
        specifier: A new specifier set to use for the requirement.
        url: A new URL to use for the requirement.
        marker: A new marker to use for the requirement.
        post_name_space: Whether or not to add a single space after the name (possibly
            including extras), but before the specifier.

    Returns:
        The regenerated requirement string.

    """
    updated_dependency = name or requirement.name

    if extras or requirement.extras:
        formatted_extras = ",".join(sorted(extras or requirement.extras))
        updated_dependency += f"[{formatted_extras}]"

    if post_name_space:
        updated_dependency += " "

    if specifier or requirement.specifier:
        if specifier and not isinstance(specifier, SpecifierSet):
            specifier = SpecifierSet(specifier)
        updated_dependency += ",".join(
            str(_)
            for _ in sorted(
                specifier or requirement.specifier,
                key=lambda spec: spec.operator,
                reverse=True,
            )
        )

    if url or requirement.url:
        updated_dependency += f"@ {url or requirement.url}"
        if marker or requirement.marker:
            updated_dependency += " "

    if marker or requirement.marker:
        updated_dependency += f"; {marker or requirement.marker}"

    return updated_dependency

update_specifier_set(latest_version, current_specifier_set)

Update the specifier set to include the latest version.

Source code in ci_cd/utils/versions.py
def update_specifier_set(
    latest_version: SemanticVersion | Version | str, current_specifier_set: SpecifierSet
) -> SpecifierSet:
    """Update the specifier set to include the latest version."""
    logger = logging.getLogger(__name__)

    latest_version = SemanticVersion(latest_version)

    new_specifier_set = set(current_specifier_set)
    updated_specifiers = []
    split_latest_version = (
        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",
        latest_version,
        current_specifier_set,
    )

    if latest_version in current_specifier_set:
        # The latest version is already included in the specifier set.
        # Update specifier set if the latest version is included via a `~=` or a `==`
        # operator.
        for specifier in current_specifier_set:
            if specifier.operator in ["~=", "=="]:
                split_specifier_version = specifier.version.split(".")
                updated_version = ".".join(
                    split_latest_version[: len(split_specifier_version)]
                )
                updated_specifiers.append(f"{specifier.operator}{updated_version}")
                new_specifier_set.remove(specifier)
                break
        else:
            # The latest version is already included in the specifier set, and the set
            # 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
            # using either of the `<` or `<=` operators.
            if specifier.operator == "<=":
                split_specifier_version = specifier.version.split(".")
                updated_version = ".".join(
                    split_latest_version[: len(split_specifier_version)]
                )

                updated_specifiers.append(f"{specifier.operator}{updated_version}")
                new_specifier_set.remove(specifier)
                break

            if specifier.operator == "<":
                # Update to include latest version by upping to the next
                # 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) == PART_TO_LENGTH_MAPPING["major"]:
                    updated_version += str(latest_version.next_version("major").major)
                elif len(split_specifier_version) == PART_TO_LENGTH_MAPPING["minor"]:
                    updated_version += ".".join(
                        latest_version.next_version("minor").split(".")[:2]
                    )
                elif len(split_specifier_version) == PART_TO_LENGTH_MAPPING["patch"]:
                    updated_version += latest_version.next_version("patch")
                else:
                    raise UnableToResolve(
                        "Invalid/unable to handle number of version parts: "
                        f"{len(split_specifier_version)}"
                    )

                updated_specifiers.append(f"{specifier.operator}{updated_version}")
                new_specifier_set.remove(specifier)
                break

            if specifier.operator == "~=":
                # Expand and change ~= to >= and < operators if the latest version
                # changes major version. Otherwise, update to include latest version as
                # 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

                    # >= current_version (fully padded)
                    updated_specifiers.append(f">={current_version}")

                    # < next major version up from latest_version
                    updated_specifiers.append(
                        f"<{epoch}{latest_version.next_version('major').major!s}"
                    )
                else:
                    # Keep the ~= operator, but update to include the latest version as
                    # the minimum version
                    split_specifier_version = specifier.version.split(".")
                    updated_version = ".".join(
                        split_latest_version[: len(split_specifier_version)]
                    )
                    updated_specifiers.append(f"{specifier.operator}{updated_version}")

                new_specifier_set.remove(specifier)
                break

    # Finally, add updated specifier(s) to new specifier set or raise.
    if updated_specifiers:
        # If updated_specifiers includes only an empty string, it means that the
        # current specifier set is valid as is and already includes the latest version
        if updated_specifiers != [""]:
            # Otherwise, add updated specifier(s) to new specifier set
            new_specifier_set |= {Specifier(_) for _ in updated_specifiers}
    else:
        raise UnableToResolve(
            "Cannot resolve how to update specifier set to include latest version."
        )

    return SpecifierSet(",".join(str(_) for _ in new_specifier_set))