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