"""One place that answers "can this plugin run on this core?". Two callers ask that question and they must not drift apart: - `PluginLoader._warn_if_incompatible` — at load time, **advisory**. A plugin already on disk keeps loading regardless, because the guarded-import pattern means most incompatibilities degrade rather than break. - `PluginStoreManager.install_plugin` — at install/update time, **blocking**. This is the point where refusing costs the user nothing (they keep the version they already had) and allowing can cost them a plugin that fails to load with only a log line to explain it. ## The trustworthiness problem The core's own `__version__` has not always been right. `v3.1.0` was tagged 2026-05-31 while `src/__init__.py` still said `"1.0.0"`; the bump landed 2026-07-12. Devices installed from that release report `1.0.0` — below the floor that essentially every published plugin declares. So a core reporting a version below `TRUSTWORTHY_FLOOR` is treated as **unknown, not old**: it neither warns nor blocks. Blocking on it would be far worse than the problem being solved — nearly every manifest in the ecosystem floors at `2.0.0`, so a strict gate would stop those users installing *any* plugin. They are unprotected until they update the core, which is also what fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4. """ from __future__ import annotations import re from pathlib import Path from typing import Any, Dict, Optional, Tuple # Below this, the core's self-reported version is not evidence of anything. # See the module docstring. TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0) # ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/ _VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py" _VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M) def current_core_version() -> str: """The core version as it is on disk right now, not as it was at import. ``from src import __version__`` binds whatever the process loaded at start. The web UI runs as its own long-lived service (``ledmatrix-web.service``), and updating the core replaces files on disk without restarting it -- the update route says so explicitly and asks the user to restart. Its prompt names the *display* service, so a user who follows it leaves the web process holding the old number. The plugin store's gate lives in that web process. Stale by one release is exactly the case that matters: every plugin flooring on the release you just installed gets refused, with a message blaming a core version that is already correct on disk. 3.3.0 is the first release where that hits a whole plugin family at once -- all eight sports scoreboards floor there. Reading the file costs one stat and a small read per call, and only on the install/update path. Any failure falls back to the imported value, so this can only ever be as wrong as before, never worse. """ try: text = _VERSION_FILE.read_text(encoding="utf-8") match = _VERSION_RE.search(text) if match: return match.group(1) except (OSError, UnicodeDecodeError): pass try: from src import __version__ as imported return imported except Exception: # noqa: BLE001 - never let this raise return "0.0.0" def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]: """Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable 3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated.""" if not isinstance(value, str): return None text = value.strip().lstrip('v') # Drop the prerelease/build suffix before scraping digits. Without this the # scrape pulls them into the numbers: "3.2.0+build42" parsed as (3, 2, 42) # and "3.2.0-rc1" as (3, 2, 1) -- a release candidate ranking *above* its # own release, and a build of 3.2.0 failing an exact "3.2.0" match. # # Prereleases compare equal to their release here rather than below it. # Full prerelease ordering is more than any caller needs, and equal is far # closer to right than the old behaviour. for sep in ('+', '-'): head, found, _tail = text.partition(sep) if found: text = head parts = text.split('.') try: nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]] except ValueError: return None while len(nums) < 3: nums.append(0) return tuple(nums) # type: ignore[return-value] # `parse_semver` is deliberately lenient — it strips non-digits and yields # (0, 0, 0) for a string with no numbers at all, which is fine for a floor # (a floor of 0.0.0 never blocks anything) but wrong for a range, where the # same leniency would turn an unreadable spec into a *refusal*. Range specs # are therefore validated against this first, so garbage reads as "no # evidence" rather than "incompatible". _VERSION_TOKEN = re.compile(r"^v?\d+(\.\d+){0,2}(-[\w.-]+)?(\+[\w.-]+)?$") def _parse_strict(value: str) -> Optional[Tuple[int, int, int]]: """`parse_semver`, but ``None`` unless the string really looks like one.""" if not isinstance(value, str) or not _VERSION_TOKEN.match(value.strip()): return None return parse_semver(value) def _satisfies_range(core: Tuple[int, int, int], spec: str) -> Optional[bool]: """Does ``core`` satisfy one `compatible_versions` entry? Returns ``None`` when the spec cannot be parsed — the caller treats that as "no evidence" rather than as a refusal, so an unrecognised spelling never costs a user a working install. Supports the forms `schema/manifest_schema.json` permits: `>=`, `<=`, `>`, `<`, `~`, `^`, a bare exact version, and an inclusive `A - B` range. Prerelease/build suffixes are tolerated and ignored, matching `parse_semver`. """ spec = spec.strip() if not spec: return None if " - " in spec: # inclusive range, e.g. "2.0.0 - 3.1.0" low_raw, _, high_raw = spec.partition(" - ") low, high = _parse_strict(low_raw), _parse_strict(high_raw) if low is None or high is None: return None return low <= core <= high for op in (">=", "<=", ">", "<", "~", "^"): if spec.startswith(op): target = _parse_strict(spec[len(op):]) if target is None: return None if op == ">=": return core >= target if op == "<=": return core <= target if op == ">": return core > target if op == "<": return core < target if op == "~": # Patch-level changes only: >=X.Y.Z, =X.Y.Z, <(X+1).0.0 return target <= core < (target[0] + 1, 0, 0) exact = _parse_strict(spec) return None if exact is None else core == exact def satisfies_compatible_versions( manifest: Dict[str, Any], core: Tuple[int, int, int] ) -> Optional[bool]: """Evaluate the manifest's `compatible_versions` array against ``core``. The array is a set of *alternatives*: satisfying any one entry means the plugin declares itself compatible. Returns ``None`` when the field is absent or no entry could be parsed, so callers can distinguish "declared incompatible" from "did not say". This is the field `schema/manifest_schema.json` marks **required**, and it is the only one that can express an upper bound — `ledmatrix_min_version` is a floor and cannot say "not compatible with 4.x". """ specs = manifest.get('compatible_versions') if not isinstance(specs, list) or not specs: return None verdicts = [_satisfies_range(core, s) for s in specs if isinstance(s, str)] parsed = [v for v in verdicts if v is not None] if not parsed: return None return any(parsed) def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]: """The core version this plugin says it needs, or ``None`` if it doesn't say. Checked in order of specificity. `ledmatrix_min` is the deprecated spelling of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags it); both are read because a large share of published manifests still carry the old one. Container types are validated rather than assumed. A hand-edited or third-party manifest can carry `requires` as a list or `versions` as a mapping, and both used to raise out of here (`AttributeError` and `KeyError` respectively). That now matters far more than it did: the untrustworthy-core branch of :func:`check` calls this for *every* manifest, so one malformed file would take down the install path rather than just itself. A shape we do not recognise means "no declared floor". """ declared = manifest.get('min_ledmatrix_version') if not declared: requires = manifest.get('requires') if isinstance(requires, dict): declared = requires.get('min_ledmatrix_version') if declared: return declared versions = manifest.get('versions') if isinstance(versions, list) and versions and isinstance(versions[0], dict): return (versions[0].get('ledmatrix_min_version') or versions[0].get('ledmatrix_min')) return None def is_update_available(installed_version: str, latest_version: str) -> bool: """Return True when the registry's ``latest_version`` is strictly newer than the installed version. THE shared comparator for "should this plugin be updated?" — used by both the web UI's update badge (`api_v3._is_plugin_update_available`) and the store's `update_plugin` reinstall decision, so the two can never disagree. Uses PEP 440-aware comparison (``packaging``), which also normalizes equivalent spellings: ``v1.2.0`` == ``1.2.0`` and ``1.2`` == ``1.2.0``, so cosmetic differences never trigger a reinstall — and a locally modified plugin whose version is *ahead* of the registry is never "updated" (downgraded). If either version string can't be parsed the mismatch is surfaced (True) so the user can reconcile, rather than silently hiding a potential update. """ if not installed_version or not latest_version: return False if not isinstance(installed_version, str) or not isinstance(latest_version, str): # A malformed manifest/registry can carry a number (1.2) or worse; # packaging would raise TypeError. Surface the mismatch instead. return True if installed_version == latest_version: return False try: from packaging.version import parse as _parse_version, InvalidVersion except ImportError: # packaging is a core dependency, but if it's somehow unavailable we # can't compare semantically — surface the mismatch we already know # exists (the two strings differ). return True try: return _parse_version(latest_version) > _parse_version(installed_version) except InvalidVersion: # Unparseable version string: we can't tell direction, so surface the # mismatch rather than silently hiding a potential update. return True def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]: """Return ``(compatible, reason)``. Two fields can say a plugin is incompatible and **the more restrictive wins**: - `compatible_versions` — the schema-required array of semver ranges, and the only one that can express an upper bound. - `ledmatrix_min_version` (or the deprecated `ledmatrix_min`) — the per-release floor inside `versions[]`. They agree across every published manifest today except `7-segment-clock`, but they *can* disagree, and a plugin that says `["2.0.0 - 2.9.9"]` means "not compatible with 3.x" no matter what its floor says. ``compatible`` is False **only** on evidence: the core reports a parseable, trustworthy version and a field genuinely excludes it. Every uncertain case resolves to compatible — nothing declared, an unparseable version on either side, or a core below `TRUSTWORTHY_FLOOR`. Refusing on a guess breaks a working install, which is the more expensive mistake here. ``reason`` is user-facing text, present only when incompatible. """ current = parse_semver(core_version) name = manifest.get('name') or manifest.get('id') or 'This plugin' if current is None or current < TRUSTWORTHY_FLOOR: # The version is not evidence of what this core HAS. But a floor above # the ecosystem baseline says the plugin needs modules that arrived # *after* 2.0.0 — and a core reporting below that either is the v3.1.0 # release (which ships __version__ = "1.0.0" and has none of the 3.2.0 # modules) or is genuinely ancient. Either way it will not have them. # # This is the only protection available to that population: they cannot # be told apart from a real 1.0.0 install, so the gate cannot reason # about them, and the *plugin's* guarded-import fallback disappears at # the B6 sunset. Refusing the install leaves them on the version they # already run instead of handing them one that fails to load. # # Floors at or below 2.0.0 are still allowed, which is every manifest # published today — so this does not lock anyone out of the store. declared = declared_min_version(manifest) needed = parse_semver(declared) if needed is not None and needed > TRUSTWORTHY_FLOOR: return False, ( f"{name} requires LEDMatrix {declared} or newer. This system " f"reports {core_version}, which is too old to identify " f"reliably — update LEDMatrix, then install it." ) return True, None # Ranges first: they are the canonical field and can rule out a core that # clears the floor. if satisfies_compatible_versions(manifest, current) is False: specs = ", ".join( s for s in manifest.get('compatible_versions', []) if isinstance(s, str)) return False, ( f"{name} supports LEDMatrix {specs}, but this system is running " f"{core_version}. Install a build in that range, or a plugin " f"version that supports {core_version}." ) declared = declared_min_version(manifest) needed = parse_semver(declared) if needed is not None and needed > current: return False, ( f"{name} requires LEDMatrix {declared} or newer, but this system is " f"running {core_version}. Update LEDMatrix first, then install it." ) return True, None