Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user