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