"""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