From bdd1d93708e3eda90ed879814116bbf27e250d25 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Wed, 31 May 2023 19:16:16 -0400 Subject: [PATCH] versions: software version parsing utility The versions module contains classes that can parse Python and Git versions, providing methods to access the version details. Signed-off-by: Eric Callahan --- moonraker/utils/versions.py | 383 ++++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 moonraker/utils/versions.py diff --git a/moonraker/utils/versions.py b/moonraker/utils/versions.py new file mode 100644 index 0000000..51d8f4f --- /dev/null +++ b/moonraker/utils/versions.py @@ -0,0 +1,383 @@ +# Semantic Version Parsing and Comparison +# +# Copyright (C) 2023 Eric Callahan +# +# This file may be distributed under the terms of the GNU GPLv3 license. +from __future__ import annotations +import re +from enum import Flag, auto +from typing import Tuple, Optional, Dict, List + +# Python regex for parsing version strings from PEP 440 +# https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions +VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+GIT_VERSION_PATTERN = r"""
+    (?P
+        v?
+        (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
+        (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:
+        (?:-(?P[0-9]+))                            # dev count
+        (?:-g(?P[a-fA-F0-9]+))?                     # abbrev hash
+    )?
+    (?P-dirty)?
+    (?P-(?:inferred|shallow))?
+
+"""
+
+_py_version_regex = re.compile(
+    r"^\s*" + VERSION_PATTERN + r"\s*$",
+    re.VERBOSE | re.IGNORECASE,
+)
+
+_git_version_regex = re.compile(
+    r"^\s*" + GIT_VERSION_PATTERN + r"\s*$",
+    re.VERBOSE | re.IGNORECASE,
+)
+
+class ReleaseType(Flag):
+    FINAL = auto()
+    ALPHA = auto()
+    BETA = auto()
+    RELEASE_CANDIDATE = auto()
+    POST = auto()
+    DEV = auto()
+
+class BaseVersion:
+    def __init__(self, version: str) -> None:
+        self._release: str = "?"
+        self._release_type = ReleaseType(0)
+        self._tag: str = "?"
+        self._orig: str = version.strip()
+        self._release_tup: Tuple[int, ...] = tuple()
+        self._extra_tup: Tuple[int, ...] = tuple()
+        self._has_dev_part: bool = False
+        self._dev_count: int = 0
+        self._valid_version: bool = False
+
+    @property
+    def full_version(self) -> str:
+        return self._orig
+
+    @property
+    def release(self) -> str:
+        return self._release
+
+    @property
+    def tag(self) -> str:
+        return self._tag
+
+    @property
+    def release_type(self) -> ReleaseType:
+        return self._release_type
+
+    @property
+    def dev_count(self) -> int:
+        return self._dev_count
+
+    def is_pre_release(self) -> bool:
+        for pr_idx in (1, 2, 3):
+            if ReleaseType(1 << pr_idx) in self._release_type:
+                return True
+        return False
+
+    def is_post_release(self) -> bool:
+        return ReleaseType.POST in self._release_type
+
+    def is_dev_release(self) -> bool:
+        return ReleaseType.DEV in self._release_type
+
+    def is_alpha_release(self) -> bool:
+        return ReleaseType.ALPHA in self._release_type
+
+    def is_beta_release(self) -> bool:
+        return ReleaseType.BETA in self._release_type
+
+    def is_release_candidate(self) -> bool:
+        return ReleaseType.RELEASE_CANDIDATE in self._release_type
+
+    def is_final_release(self) -> bool:
+        return ReleaseType.FINAL in self._release_type
+
+    def is_valid_version(self) -> bool:
+        return self._valid_version
+
+    def __str__(self) -> str:
+        return self._orig
+
+    def _validate(self, other: BaseVersion) -> None:
+        if not self._valid_version:
+            raise ValueError(
+                f"Version {self._orig} is not a valid version string "
+                f"for type {type(self).__name__}"
+            )
+        if not other._valid_version:
+            raise ValueError(
+                f"Version {other._orig} is not a valid version string "
+                f"for type {type(self).__name__}"
+            )
+
+    def __eq__(self, __value: object) -> bool:
+        if not isinstance(__value, type(self)):
+            raise ValueError("Invalid type for comparison")
+        self._validate(__value)
+        if self._release_tup != __value._release_tup:
+            return False
+        if self._extra_tup != __value._extra_tup:
+            return False
+        if self._has_dev_part != __value._has_dev_part:
+            return False
+        if self._dev_count != __value._dev_count:
+            return False
+        return True
+
+    def __lt__(self, __value: object) -> bool:
+        if not isinstance(__value, type(self)):
+            raise ValueError("Invalid type for comparison")
+        self._validate(__value)
+        if self._release_tup != __value._release_tup:
+            return self._release_tup < __value._release_tup
+        if self._extra_tup != __value._extra_tup:
+            return self._extra_tup < __value._extra_tup
+        if self._has_dev_part != __value._has_dev_part:
+            return self._has_dev_part
+        return self._dev_count < __value._dev_count
+
+    def __le__(self, __value: object) -> bool:
+        if not isinstance(__value, type(self)):
+            raise ValueError("Invalid type for comparison")
+        self._validate(__value)
+        if self._release_tup > __value._release_tup:
+            return False
+        if self._extra_tup > __value._extra_tup:
+            return False
+        if self._has_dev_part != __value._has_dev_part:
+            return self._has_dev_part
+        return self._dev_count <= __value._dev_count
+
+    def __ne__(self, __value: object) -> bool:
+        if not isinstance(__value, type(self)):
+            raise ValueError("Invalid type for comparison")
+        self._validate(__value)
+        if self._release_tup != __value._release_tup:
+            return True
+        if self._extra_tup != __value._extra_tup:
+            return True
+        if self._has_dev_part != __value._has_dev_part:
+            return True
+        if self._dev_count != __value._dev_count:
+            return True
+        return False
+
+    def __gt__(self, __value: object) -> bool:
+        if not isinstance(__value, type(self)):
+            raise ValueError("Invalid type for comparison")
+        self._validate(__value)
+        if self._release_tup != __value._release_tup:
+            return self._release_tup > __value._release_tup
+        if self._extra_tup != __value._extra_tup:
+            return self._extra_tup > __value._extra_tup
+        if self._has_dev_part != __value._has_dev_part:
+            return __value._has_dev_part
+        return self._dev_count > __value._dev_count
+
+    def __ge__(self, __value: object) -> bool:
+        if not isinstance(__value, type(self)):
+            raise ValueError("Invalid type for comparison")
+        self._validate(__value)
+        if self._release_tup < __value._release_tup:
+            return False
+        if self._extra_tup < __value._extra_tup:
+            return False
+        if self._has_dev_part != __value._has_dev_part:
+            return __value._has_dev_part
+        return self._dev_count >= __value._dev_count
+
+
+class PyVersion(BaseVersion):
+    def __init__(self, version: str) -> None:
+        super().__init__(version)
+        ver_match = _py_version_regex.match(version)
+        if ver_match is None:
+            return
+        version_info = ver_match.groupdict()
+        release: Optional[str] = version_info["release"]
+        if release is None:
+            return
+        self._valid_version = True
+        self._release = release
+        self._tag = f"v{release}" if self._orig[0].lower() == "v" else release
+        self._release_tup = tuple(int(part) for part in release.split("."))
+        self._extra_tup = (1, 0, 0)
+        if version_info["pre"] is not None:
+            pre_conv = dict([("a", 1), ("b", 2), ("c", 3), ("r", 3), ("p", 3)])
+            lbl = version_info["pre_l"][0].lower()
+            self._extra_tup = (0, pre_conv.get(lbl, 0), int(version_info["pre_n"] or 0))
+            self._tag += version_info["pre"]
+            self._release_type |= ReleaseType(1 << pre_conv.get(lbl, 1))
+            if version_info["post"] is not None:
+                # strange combination of a "post" pre-release.
+                num = version_info["post_n1"] or version_info["post_n2"]
+                self._extra_tup += (int(num or 0),)
+                self._tag += version_info["post"]
+                self._release_type |= ReleaseType.POST
+        elif version_info["post"] is not None:
+            num = version_info["post_n1"] or version_info["post_n2"]
+            self._extra_tup = (2, int(num or 0), 0)
+            self._tag += version_info["post"]
+            self._release_type |= ReleaseType.POST
+        self._has_dev_part = version_info["dev"] is not None
+        if self._has_dev_part:
+            self._release_type |= ReleaseType.DEV
+        elif self._release_type.value == 0:
+            self._release_type = ReleaseType.FINAL
+        elif self._release_type.value == ReleaseType.POST.value:
+            self._release_type |= ReleaseType.FINAL
+        self._dev_count = int(version_info["dev_n"] or 0)
+        self.local: Optional[str] = version_info["local"]
+
+    def convert_to_git(self, version_info: Dict[str, Optional[str]]) -> GitVersion:
+        git_version: Optional[str] = version_info["release"]
+        if git_version is None:
+            raise ValueError("Invalid version string")
+        if self._orig[0].lower() == "v":
+            git_version == f"v{git_version}"
+        local: str = version_info["local"] or ""
+        # Assume semantic versioning, convert the version string.
+        if version_info["dev_n"] is not None:
+            major, _, minor = git_version.rpartition(".")
+            if major:
+                git_version = f"v{major}.{max(int(minor) - 1, 0)}"
+        if version_info["pre"] is not None:
+            git_version = f"{git_version}{version_info['pre']}"
+        dev_num = version_info["dev_n"] or 0
+        git_version = f"{git_version}-{dev_num}"
+        local_parts = local.split(".", 1)[0]
+        if local_parts[0]:
+            git_version = f"{git_version}-{local_parts[0]}"
+        if len(local_parts) > 1:
+            git_version = f"{git_version}-dirty"
+        return GitVersion(git_version)
+
+
+class GitVersion(BaseVersion):
+    def __init__(self, version: str) -> None:
+        super().__init__(version)
+        self._is_dirty: bool = False
+        self._is_inferred: bool = False
+        ver_match = _git_version_regex.match(version)
+        if ver_match is None:
+            # Check Fallback
+            fb_match = re.match(r"(?P[a-fA-F0-9]+)(?P-dirty)?", self._orig)
+            if fb_match is None:
+                return
+            self._tag = ""
+            self._release = fb_match["hash"]
+            self._is_dirty = fb_match["dirty"] is not None
+            self._is_inferred = True
+            return
+        version_info = ver_match.groupdict()
+        release: Optional[str] = version_info["release"]
+        if release is None:
+            return
+        self._valid_version = True
+        self._release = release
+        self._tag = version_info["tag"] or "?"
+        self._release_tup = tuple(int(part) for part in release.split("."))
+        self._extra_tup = (1, 0, 0)
+        if version_info["pre"] is not None:
+            pre_conv = dict([("a", 1), ("b", 2), ("c", 3), ("r", 3), ("p", 3)])
+            lbl = version_info["pre_l"][0].lower()
+            self._extra_tup = (0, pre_conv.get(lbl, 0), int(version_info["pre_n"] or 0))
+            self._release_type = ReleaseType(1 << pre_conv.get(lbl, 1))
+        # All git versions are considered to have a dev part.  Contrary to python
+        # versioning, a version with a dev number is greater than the same version
+        # without one.
+        self._has_dev_part = True
+        self._dev_count = int(version_info["dev_n"] or 0)
+        if self._dev_count > 0:
+            self._release_type |= ReleaseType.DEV
+        if self._release_type.value == 0:
+            self._release_type = ReleaseType.FINAL
+        self._is_inferred = version_info["inferred"] is not None
+        self._is_dirty = version_info["dirty"] is not None
+
+    @property
+    def short_version(self) -> str:
+        if not self._valid_version:
+            return "?"
+        return f"{self._tag}-{self._dev_count}"
+
+    @property
+    def dirty(self) -> bool:
+        return self._is_dirty
+
+    @property
+    def inferred(self) -> bool:
+        return self._is_inferred
+
+    def is_fallback(self) -> bool:
+        return self._is_inferred and not self._valid_version
+
+    def infer_last_tag(self) -> str:
+        if self._valid_version:
+            if self._is_inferred:
+                # We can't infer a previous release from another inferred release
+                return self._tag
+            type_choices = dict([(1, "a"), (2, "b"), (3, "rc")])
+            if self.is_pre_release() and self._extra_tup > (0, 1, 0):
+                type_idx = self._extra_tup[1]
+                type_count = self._extra_tup[2]
+                if type_count == 0:
+                    type_idx -= 1
+                else:
+                    type_count -= 1
+                pretype = type_choices.get(type_idx, "rc")
+                return f"{self._release}.{pretype}{type_count}"
+            else:
+                parts = [int(ver) for ver in self._release.split(".")]
+                new_ver: List[str] = []
+                need_decrement = True
+                for part in reversed(parts):
+                    if part > 0 and need_decrement:
+                        need_decrement = False
+                        part -= 1
+                    new_ver.insert(0, str(part))
+                return "v" + ".".join(new_ver)
+        return "v0.0.0"