mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-04 10:18:05 +00:00
`ledmatrix_min_version` was decoration. The loader logged an advisory warning and continued; the store never compared the core version at all, so a routine "update" delivered a plugin that could not run. That is the gap phase B6 (the sports-unification sunset) cannot be done over: deleting a plugin's bundled fallback while nothing enforces the floor hands un-updated users a scoreboard that raises ModuleNotFoundError at load and is reported only as one line in the journal. The gate lives in install_plugin, after the manifest is on disk and before dependencies are installed. That is the earliest knowable point -- the registry carries no compatibility field, so the floor is not visible until the files are down -- and it is also the chokepoint: _reinstall_with_rollback calls install_plugin, so a refused *update* restores the version the user already had, for free. Floor resolution and the comparison move to src/plugin_system/compatibility.py, shared with the loader so the two cannot drift. Both read all four spellings published manifests use, including the deprecated `ledmatrix_min`. Refusal requires evidence. An undeclared floor, an unparseable version on either side, or a core below TRUSTWORTHY_FLOOR (2.0.0) all allow the install. That last one is deliberate and load-bearing: the v3.1.0 release reports __version__ = "1.0.0" while nearly every published manifest floors at 2.0.0, so a strict gate would lock those users out of the plugin store entirely -- much worse than the problem being solved. They stay unprotected until they update the core, which is also what fixes their version string. Verified: 782 core unit tests pass, including 25 new ones and the existing loader-warning suite unchanged (the refactor is behavior-preserving). The install tests drive the real install_plugin path with the download stubbed -- the allow and refuse cases differ only in the declared floor, so the refusal is demonstrably the gate and not an earlier bail-out. Follow-ups, deliberately not in this PR: surfacing the reason in the store UI rather than only the log, and publishing the floor in plugins.json so the store can refuse before downloading. Phase B4 in docs/SPORTS_UNIFICATION.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
103 lines
4.1 KiB
Python
103 lines
4.1 KiB
Python
"""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
|
|
|
|
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)
|
|
|
|
|
|
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
|
|
parts = value.strip().lstrip('v').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]
|
|
|
|
|
|
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.
|
|
"""
|
|
declared = (
|
|
manifest.get('min_ledmatrix_version')
|
|
or (manifest.get('requires') or {}).get('min_ledmatrix_version')
|
|
)
|
|
if declared:
|
|
return declared
|
|
|
|
versions = manifest.get('versions') or []
|
|
if versions and isinstance(versions[0], dict):
|
|
return (versions[0].get('ledmatrix_min_version')
|
|
or versions[0].get('ledmatrix_min'))
|
|
return None
|
|
|
|
|
|
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
|
|
"""Return ``(compatible, reason)``.
|
|
|
|
``compatible`` is False **only** when the plugin declares a parseable floor,
|
|
the core reports a parseable and trustworthy version, and the floor is
|
|
genuinely above it. Every uncertain case resolves to compatible: an
|
|
undeclared floor, an unparseable version on either side, or a core whose
|
|
version is below `TRUSTWORTHY_FLOOR`. Refusing on a guess would break
|
|
working installs, which is the more expensive mistake here.
|
|
|
|
``reason`` is user-facing text, present only when incompatible.
|
|
"""
|
|
declared = declared_min_version(manifest)
|
|
needed = parse_semver(declared)
|
|
if needed is None:
|
|
return True, None
|
|
|
|
current = parse_semver(core_version)
|
|
if current is None or current < TRUSTWORTHY_FLOOR:
|
|
return True, None
|
|
|
|
if needed > current:
|
|
name = manifest.get('name') or manifest.get('id') or 'This plugin'
|
|
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
|