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
+288
View File
@@ -0,0 +1,288 @@
"""Core data structures shared across scanning, risk analysis and reporting."""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Any
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def iso(dt: datetime | None) -> str | None:
return dt.astimezone(timezone.utc).isoformat() if dt else None
# --------------------------------------------------------------------------
# Inventory
# --------------------------------------------------------------------------
RELATIONS = {
# This item's version must exactly equal the other's, or they will not
# talk to each other. Shure TX/RX, Teradek TX/RX, SoundGrid.
"must_match",
# This item must be at or above the other's version. Control/config tools
# over the devices they configure.
"must_be_at_least",
# This item is the utility that actually flashes the other. Updating the
# carrier is step one; it also gates which image the device is offered.
"carries_firmware_for",
# This item is the platform the other runs on. OS over drivers, DAW over
# plugins, console over its expansion cards.
"host_of",
# This item is a show file, library, preset or calibration artifact
# belonging to the other. Usually forward-only.
"content_for",
}
# Update classes where "should we install it?" is the wrong question.
UPDATE_CLASSES = {
"normal", # we choose when to install
"vendor_pushed", # arrives whether we approve or not; warn, do not advise
"do_not_touch", # licensed contractor only (life safety, rigging, lifts)
"expiry", # fails on a date, not a version (licences, certs)
}
@dataclass
class Coupling:
"""A version dependency on another inventory item."""
id: str
relation: str
note: str | None = None
# For `host_of`: how big a jump counts as a platform change worth
# blocking on. Defaults to "major", which suits semver-ish products.
#
# Set "any" for vendors who put breaking changes in a low-order digit.
# NETGEAR's M4250 is the case that forced this: the SNTP removal, the
# forced-HTTPS config migration and the Dante spanning-tree network-loop
# warning all landed between 13.0.4.x and 13.0.5.x -- a "patch" by any
# normal reading, and the single most disruptive update in this fleet.
significant: str = "major" # major | minor | any
# For `must_match`: the vendor's own utility flashes both sides in one
# pass, so nobody needs reminding to do the second one.
#
# Blackmagic's ATEM Setup is the case that prompted this -- it updates the
# switcher, the panel and Software Control together, and the user has
# never updated them separately. Telling them to "do these in the same
# session" every week is noise about a step they cannot skip.
#
# A real divergence is still reported, and loudly: if these ever stop
# matching it means the automatic update FAILED on one of them -- the
# panel was offline, or the flash did not take. That is worth knowing
# precisely because it should be impossible.
auto: bool = False
@dataclass
class Item:
"""One tracked device or piece of software."""
id: str
name: str
vendor: str
category: str # video | audio | rf | lighting | plugin | os | network
installed: str | None = None # what we believe is running right now
criticality: str = "normal" # showstopper | high | normal | low
source: dict[str, Any] = field(default_factory=dict) # how to find the latest version
risk_queries: list[str] = field(default_factory=list) # extra community search terms
github_repo: str | None = None # enables issue-based bug signal
check_url: str | None = None # human deep-link for manual verification
notes: str | None = None
tags: list[str] = field(default_factory=list)
freeze: bool = False # never recommend updating (e.g. locked show build)
# -- added after a review found the model could not express the fleet ---
# "unconfirmed" means the make/model is a placeholder we inferred rather
# than something you told us. Update advice is suppressed entirely for
# these: searching on a guessed vendor returns a real release note for
# the WRONG product, which is worse than saying nothing.
status: str = "confirmed" # confirmed | unconfirmed
# Version dependencies on other items.
coupled_with: list[Coupling] = field(default_factory=list)
# Items sharing an update_group must be planned, updated and frozen as
# one set. Per-item freeze is not enough: freezing the ATEM while its
# Software Control keeps moving creates the mismatch the freeze existed
# to prevent.
update_group: str | None = None
update_class: str = "normal"
# For update_class "expiry": when the licence/cert/contract lapses.
expires: str | None = None
# Where a person physically looks to read the running version off this
# thing. Printed by `avwatch worksheet`, because "go record your installed
# versions" is not an instruction anyone can act on -- "front panel,
# Setup, then Global" is.
version_hint: str | None = None
# Which inventory file declared this item. Set by the loader, not by the
# YAML. Matters because "your own list" and "things AVWatch proposed" are
# different kinds of claim and should never be presented as one list.
source_file: str | None = None
@property
def is_user_declared(self) -> bool:
"""True for items from the main inventory.yaml -- the user's own gear.
False for anything from an inventory-*.yaml expansion file."""
return self.source_file == "inventory.yaml"
@property
def source_kind(self) -> str:
return self.source.get("kind", "manual")
@property
def is_showstopper(self) -> bool:
return self.criticality in ("showstopper", "high")
@property
def is_confirmed(self) -> bool:
return self.status == "confirmed"
@property
def advisable(self) -> bool:
"""Whether it is meaningful to recommend installing an update."""
return (
self.is_confirmed
and not self.freeze
and self.update_class == "normal"
)
# --------------------------------------------------------------------------
# Scan results
# --------------------------------------------------------------------------
@dataclass
class VersionCheck:
"""Outcome of asking 'what is the newest version of this?'"""
item_id: str
latest: str | None = None
released: datetime | None = None
status: str = "unknown" # ok | error | manual | unsupported
detail: str | None = None # error text, or a human note
release_notes: str | None = None
release_url: str | None = None
checked_at: datetime = field(default_factory=utcnow)
@property
def age_days(self) -> float | None:
if not self.released:
return None
return (utcnow() - self.released).total_seconds() / 86400.0
@dataclass
class Evidence:
"""A single community or vendor post that says something about a release."""
source: str # reddit | github-issues | hackernews | web | vendor
title: str
url: str
snippet: str = ""
published: datetime | None = None
score: int | None = None # upvotes / reactions where available
sentiment: str = "unknown" # negative | neutral | positive | unknown
severity: str = "info" # brick | crash | regression | annoyance | info
matched_terms: list[str] = field(default_factory=list)
# True when the post actually names the release under consideration.
# Any active project has open crash reports; only the ones naming this
# version are evidence about *this* release.
mentions_version: bool = False
def to_dict(self) -> dict:
d = asdict(self)
d["published"] = iso(self.published)
return d
@dataclass
class Verdict:
"""The recommendation, plus how it was reached."""
recommendation: str # go | wait | avoid | manual-check | unknown
confidence: str = "low" # high | medium | low
engine: str = "heuristic" # heuristic | claude | claude+heuristic
headline: str = ""
reasons: list[str] = field(default_factory=list)
risk_score: float = 0.0 # 0 (safe) .. 100 (do not touch)
heuristic_score: float = 0.0
heuristic_recommendation: str = "unknown"
cited: list[str] = field(default_factory=list) # URLs the reasoning leaned on
suggested_wait_days: int | None = None
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class CouplingWarning:
"""A version-dependency problem found by comparing two items."""
item_id: str
other_id: str
other_name: str
relation: str
severity: str # blocking | warning | info
message: str
@dataclass
class Finding:
"""Everything known about one item at one point in time: the version gap,
the evidence, and the verdict. This is the unit the report renders."""
item: Item
check: VersionCheck
comparison: str = "unknown" # current | behind | ahead | unknown
bump: str = "unknown" # major | minor | patch | none | unknown
evidence: list[Evidence] = field(default_factory=list)
verdict: Verdict | None = None
is_new_since_last_run: bool = False
coupling: list[CouplingWarning] = field(default_factory=list)
@property
def has_blocking_coupling(self) -> bool:
return any(c.severity == "blocking" for c in self.coupling)
@property
def needs_attention(self) -> bool:
return self.comparison == "behind" or self.check.status == "error"
@property
def negative_evidence(self) -> list[Evidence]:
return [e for e in self.evidence if e.sentiment == "negative"]
@property
def worst_severity(self) -> str:
order = ["info", "annoyance", "regression", "crash", "brick"]
worst = "info"
for e in self.evidence:
if order.index(e.severity) > order.index(worst):
worst = e.severity
return worst
@dataclass
class RunSummary:
"""One scan run, for the store and the digest header."""
run_id: str
started: datetime
finished: datetime | None = None
items_checked: int = 0
updates_found: int = 0
errors: int = 0
new_since_last: int = 0
http_requests: int = 0
cache_hits: int = 0
mode: str = "scan" # scan | digest