Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+142
View File
@@ -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