143 lines
4.6 KiB
Python
143 lines
4.6 KiB
Python
"""Version string parsing and comparison.
|
|||
|
|
|
||
|
|
AV vendors are inconsistent: "10.4.1", "16.4", "v7.19.1", "9.0.2 build 4",
|
||
|
|
"2026.1", "1.2.3b4", "3.4.0-beta.2". This normalises enough of that to make
|
||
|
|
"is the latest newer than what we run?" answerable, and is explicit about
|
||
|
|
when it cannot tell.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
|
||
|
|
# Leading marketing words, a 'v' prefix, trailing "Update"/"Release" noise.
|
||
|
|
_STRIP = re.compile(
|
||
|
|
r"^(?:version|ver|v|firmware|fw|release|update|software)\s*[:.]?\s*",
|
||
|
|
re.I,
|
||
|
|
)
|
||
|
|
_CORE = re.compile(r"(\d+(?:\.\d+)*)")
|
||
|
|
_PRERELEASE = re.compile(r"(alpha|beta|rc|b(?=\d)|preview|pre|dev)\.?(\d*)", re.I)
|
||
|
|
|
||
|
|
_PRE_RANK = {"alpha": 0, "dev": 0, "pre": 1, "preview": 1, "beta": 2, "b": 2, "rc": 3}
|
||
|
|
|
||
|
|
|
||
|
|
class Version:
|
||
|
|
"""A comparable version. ``parts`` is the numeric spine; ``pre`` ranks
|
||
|
|
pre-releases below the matching final release."""
|
||
|
|
|
||
|
|
__slots__ = ("raw", "parts", "pre", "pre_num", "valid")
|
||
|
|
|
||
|
|
def __init__(self, raw: str | None):
|
||
|
|
self.raw = (raw or "").strip()
|
||
|
|
self.parts: tuple[int, ...] = ()
|
||
|
|
self.pre: str | None = None
|
||
|
|
self.pre_num: int = 0
|
||
|
|
self.valid = False
|
||
|
|
if not self.raw:
|
||
|
|
return
|
||
|
|
|
||
|
|
text = _STRIP.sub("", self.raw)
|
||
|
|
m = _CORE.search(text)
|
||
|
|
if not m:
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
self.parts = tuple(int(p) for p in m.group(1).split("."))
|
||
|
|
except ValueError:
|
||
|
|
return
|
||
|
|
self.valid = True
|
||
|
|
|
||
|
|
tail = text[m.end():]
|
||
|
|
pm = _PRERELEASE.search(tail)
|
||
|
|
if pm:
|
||
|
|
self.pre = pm.group(1).lower()
|
||
|
|
self.pre_num = int(pm.group(2)) if pm.group(2) else 0
|
||
|
|
|
||
|
|
# -- comparison --------------------------------------------------------
|
||
|
|
|
||
|
|
def _key(self, width: int) -> tuple:
|
||
|
|
padded = self.parts + (0,) * (width - len(self.parts))
|
||
|
|
# A final release outranks any pre-release of the same number.
|
||
|
|
pre_rank = 99 if self.pre is None else _PRE_RANK.get(self.pre, 1)
|
||
|
|
return padded + (pre_rank, self.pre_num)
|
||
|
|
|
||
|
|
def __eq__(self, other: object) -> bool:
|
||
|
|
if not isinstance(other, Version):
|
||
|
|
return NotImplemented
|
||
|
|
if not (self.valid and other.valid):
|
||
|
|
return self.raw == other.raw
|
||
|
|
w = max(len(self.parts), len(other.parts))
|
||
|
|
return self._key(w) == other._key(w)
|
||
|
|
|
||
|
|
def __lt__(self, other: "Version") -> bool:
|
||
|
|
if not (self.valid and other.valid):
|
||
|
|
raise TypeError("cannot order unparseable versions")
|
||
|
|
w = max(len(self.parts), len(other.parts))
|
||
|
|
return self._key(w) < other._key(w)
|
||
|
|
|
||
|
|
def __le__(self, other: "Version") -> bool:
|
||
|
|
return self == other or self < other
|
||
|
|
|
||
|
|
def __gt__(self, other: "Version") -> bool:
|
||
|
|
return not self <= other
|
||
|
|
|
||
|
|
def __hash__(self) -> int:
|
||
|
|
return hash((self.parts, self.pre, self.pre_num))
|
||
|
|
|
||
|
|
def __repr__(self) -> str:
|
||
|
|
return f"Version({self.raw!r})"
|
||
|
|
|
||
|
|
def __str__(self) -> str:
|
||
|
|
return self.raw
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_prerelease(self) -> bool:
|
||
|
|
return self.pre is not None
|
||
|
|
|
||
|
|
def normalized(self) -> str:
|
||
|
|
return ".".join(str(p) for p in self.parts) if self.valid else self.raw
|
||
|
|
|
||
|
|
|
||
|
|
def parse(raw: str | None) -> Version:
|
||
|
|
return Version(raw)
|
||
|
|
|
||
|
|
|
||
|
|
def compare(current: str | None, latest: str | None) -> str:
|
||
|
|
"""Return one of: 'current', 'behind', 'ahead', 'unknown'.
|
||
|
|
|
||
|
|
'ahead' happens more often than you would think -- someone installs a
|
||
|
|
beta, or the inventory is wrong. It is worth surfacing rather than hiding.
|
||
|
|
"""
|
||
|
|
a, b = parse(current), parse(latest)
|
||
|
|
if not (a.valid and b.valid):
|
||
|
|
return "unknown"
|
||
|
|
if a == b:
|
||
|
|
return "current"
|
||
|
|
return "behind" if a < b else "ahead"
|
||
|
|
|
||
|
|
|
||
|
|
def bump_kind(current: str | None, latest: str | None) -> str:
|
||
|
|
"""Classify the size of the jump: 'major', 'minor', 'patch', 'none',
|
||
|
|
'unknown'. Feeds risk scoring -- a major jump on show-critical gear is a
|
||
|
|
different proposition from a patch."""
|
||
|
|
a, b = parse(current), parse(latest)
|
||
|
|
if not (a.valid and b.valid) or a >= b:
|
||
|
|
return "unknown" if not (a.valid and b.valid) else "none"
|
||
|
|
pa = a.parts + (0, 0, 0)
|
||
|
|
pb = b.parts + (0, 0, 0)
|
||
|
|
if pb[0] != pa[0]:
|
||
|
|
return "major"
|
||
|
|
if pb[1] != pa[1]:
|
||
|
|
return "minor"
|
||
|
|
return "patch"
|
||
|
|
|
||
|
|
|
||
|
|
def pick_latest(candidates: list[str]) -> str | None:
|
||
|
|
"""Highest parseable version from a list, ignoring pre-releases when any
|
||
|
|
final release is present."""
|
||
|
|
parsed = [p for p in (parse(c) for c in candidates) if p.valid]
|
||
|
|
if not parsed:
|
||
|
|
return None
|
||
|
|
finals = [p for p in parsed if not p.is_prerelease]
|
||
|
|
pool = finals or parsed
|
||
|
|
return max(pool).raw
|