mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* ci: mypy ratchet -- keep type-clean modules clean mypy-clean.txt lists the 71 modules under src/ that type-check clean; scripts/check_types.py runs mypy (--follow-imports=silent) on exactly those files and fails on any error or a missing/unsorted/duplicate entry. A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and pinned stubs; the manual pre-commit mypy hook now runs the same script (a local hook, so mypy sees the installed requirements like CI does). 35 modules were made clean with annotation-only fixes: hints, typing.cast, TYPE_CHECKING imports, implicit-Optional defaults made explicit, and annotations widened (never guards removed) where mypy called a defensive isinstance check unreachable. No runtime behaviour change. mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't parse at python_version 3.10, and orjson is optional, so seeing its stubs made the result depend on whether it was installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: annotate check_types.py's list-form mypy subprocess Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
15 KiB
Python
339 lines
15 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
|
|
|
|
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:
|
|
# Reachable: str.isdigit() accepts characters int() rejects, such as
|
|
# a superscript "\u00b2" -- "1.\u00b2.0" lands here.
|
|
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+1).0
|
|
return target <= core < (target[0], target[1] + 1, 0)
|
|
# "^": minor and patch changes: >=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]) -> Any:
|
|
"""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_version_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".
|
|
|
|
The value is returned as the manifest holds it -- normally a version
|
|
string, but nothing here checks that; callers hand it to
|
|
:func:`parse_semver`, which accepts anything.
|
|
"""
|
|
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: Any, latest_version: Any) -> 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
|