mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-08 20:28:06 +00:00
* ci: run the whole test tree and make the plugin-safety job assert something real The unit-tests CI job ran an explicit 24-file allowlist that had rotted: 63 of 90 test files (display, vegas, store manager, web API, web_interface) never ran on a PR. The job now runs all of test/ (minus test/plugins, which the plugin-safety job owns) so new test files are enrolled by default and any exclusion needs a visible, commented --ignore. The plugin-safety job was a green no-op: plugins/ is empty in CI, so every test skipped with 'Manifest not found'. It now renders a bundled deterministic fixture plugin (test/fixtures/plugins/ci-fixture-plugin, golden images included for all 8 default sizes) via LEDMATRIX_PLUGINS_DIR, and sets LEDMATRIX_REQUIRE_PLUGINS=1 so discovering zero plugins fails loudly instead of skipping green. The per-plugin suites document that they target dev machines with real plugins installed. Coverage is now measured and enforced in exactly one place — the CI unit-tests step (--cov=src --cov=web_interface --cov-fail-under=45, from a measured 47% baseline). pytest.ini previously declared --cov-fail-under=30 but CI always passed --no-cov, so the gate had never run anywhere; local pytest is now coverage-free and fast. Enabling the 63 unenrolled files surfaced three cases of test rot, fixed here: test_display_controller_vegas_tick.py could not collect without the hardware rgbmatrix module (now uses the emulator convention), the state-reconciliation unrecoverable-cache tests broke when production added the is_plugin_uninstalled tombstone check (bare Mock returned truthy), and test_get_system_status assumed the optional psutil dependency (now installed via requirements-test.txt and guarded by importorskip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: replace can't-fail tests with real assertions test_font_manager.py was 5 of 6 tests shaped as 'try: call(); assert True / except: assert True' — running in CI while unable to fail on any regression. Rewritten against the real FontManager API and the bundled assets/fonts: returned font types, cache-hit identity, distinct entries per size, default-font fallback for unknown families and corrupt files (recorded in failed_loads), BDF native-size reading, text measurement, and cache lifecycle. test_display_manager.py's test_draw_text ended in 'assert True'; it now renders onto a known-black canvas and asserts pixels were actually lit — which required un-breaking the fixture's freetype MagicMock so draw_text's isinstance check doesn't silently swallow the draw. test_display_controller.py carried a permanently-skipped test whose skip reason already declared it redundant; deleted. Both display test files now set EMULATOR=true before importing display_manager (the same convention as test_display_dirty_tracking.py) so they collect standalone instead of depending on which test module imports display_manager first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: cover the untested fragile logic (compatibility gate, secrets, config merges, durations, skin cards) New unit tests for pure or filesystem-only logic that previously had zero direct coverage: - test_compatibility.py: the semver install gate (parse_semver suffix handling, every range operator, TRUSTWORTHY_FLOOR behavior for cores reporting untrustworthy versions, 'more restrictive wins', and the malformed-manifest shapes that used to raise). - test/web_interface/test_secret_helpers.py: the canonical x-secret helpers — find/separate/mask/remove, array-item secrets, no input mutation, and a separate->recombine round-trip. - test/web_interface/test_api_v3_helpers.py: the module-level helpers behind the plugin config save endpoint (_is_plugin_update_available, _coerce_to_bool including the int==1 quirk, deep_merge including its shared-subtree shallowness, _parse_form_value, dotted-key-aware _get_schema_property/_set_nested_value). - test_base_plugin_duration.py: get_display_duration's full coercion ladder (instance attr -> config -> 15.0), including the bool-is-int quirk where display_duration=True means one second. - test_config_manager_secrets.py: the secrets round-trip — deep-merge on load, strip on save, group pruning, the load fast path — and two characterized sharp edges marked SUSPECTED BUG: an unreadable secrets file at save time writes secrets into config.json in plaintext, and a same-mtime-same-size content swap is served stale. - test_schema_manager_merge.py: merge_with_defaults branch behavior (None replacement vs falsey preservation, dict-vs-scalar mismatches, arrays replaced wholesale, defaults never mutated). - test_skin_system.py (extended): render_skin_card shares _render_game's 3-strike counter but never resets it on success — the asymmetry is pinned in both directions, along with card fallthrough and the disable interaction between the two paths. Suspected bugs are characterized, not fixed — each carries a comment so a future behavior change is deliberate rather than accidental. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: add drift guards for cross-file contracts Three guard suites that pin contracts spanning multiple files, where one side changing unilaterally breaks the other silently: - test_version_comparison_consistency.py: the repo's four version comparators (compatibility.parse_semver, api_v3's packaging-based _is_plugin_update_available, store_manager update_plugin's raw string equality, skin_runtime._major) answer differently on the same inputs. A table pins each one's verdict; update_plugin is driven through its real code path to show the SUSPECTED BUGs: 'v1.2.0' vs '1.2.0' triggers a full reinstall the UI calls unnecessary, and a locally-ahead plugin gets downgraded. A pairwise-ordering check keeps parse_semver agreeing with packaging on plain X.Y.Z. - test/web_interface/test_secret_separation_parity.py: api_v3.py carries three inline copies of find_secret_fields/separate_secrets that lack the canonical module's array-item support. The copy count is asserted exact (it may only go down; new copies must import src/web_interface/secret_helpers), the missing-array-support gap is asserted so it can't grow silently, and the canonical behavior that migration will adopt is documented executably. - test_discovery_path_contract.py: the three 'where is plugin X' resolvers (PluginManager discovery, StoreManager._find_plugin_path, SchemaManager.get_schema_path) agree on the configured directory, and their divergent fallback chains are characterized. Also pins the .standalone-backup- naming contract shared by store rollback and discovery, and _resolve_skin_target's path-traversal rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: address review feedback — fixture lifecycle, test names, ClassVar - ci-fixture-plugin: call display_manager.clear() before rendering (per plugin guidelines — the fixture should model a well-behaved plugin), add a class docstring, and document why Pillow is deliberately not pinned in its requirements.txt (core dependency; harness installs nothing). - Rename two tests whose names contradicted their assertions: test_unparseable_core_version_is_compatible -> test_unparseable_core_with_high_floor_is_blocked, and test_unreadable_secrets_file... -> test_corrupt_secrets_file... - Annotate TestGetSchemaProperty.SCHEMA as ClassVar (RUF012). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * ci: allow manual test.yml runs via workflow_dispatch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * fix: unify version comparison, refuse secret-leaking saves, reset skin strikes on card success Fixes the three suspected bugs this PR's characterization tests pinned, flipping those tests to assert the corrected behavior: - plugins/store: ONE shared update comparator. New compatibility.is_update_available() (PEP 440 via packaging) is now used by both the web UI's update badge (api_v3._is_plugin_update_available is a thin alias) and store_manager.update_plugin's reinstall decision. Previously update_plugin used raw string equality: 'v1.2.0' vs '1.2.0' triggered a full reinstall the UI called unnecessary, and a locally- ahead plugin (2.0.0 installed, registry 1.9.0) was silently DOWNGRADED. Now equivalent spellings skip the reinstall and locally-ahead versions are never downgraded; unparseable versions still reconcile by reinstalling from the registry. - config: save_config and save_config_atomic now refuse (ConfigError) when config_secrets.json exists but cannot be loaded. Both previously proceeded without stripping, writing the merged secrets into config.json in plaintext. The shared _load_secrets_for_save() helper raises with an actionable message instead; a missing secrets file is still fine (nothing to strip), and _migrate_config's catch-all keeps boot resilient. - skins: render_skin_card resets _skin_failures on both success paths (vegas card returned, or mode renderer handled), mirroring _render_game. Transient card failures no longer accumulate across a session until they permanently disable a working skin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * fix: harden shared comparator edges from review - is_update_available: reject truthy non-string versions (a malformed manifest can carry a number; packaging raises TypeError on those) by surfacing the mismatch instead of raising. - store_manager.update_plugin: drop the truthiness gate around the comparator so a missing version on either side follows the shared 'no update' verdict, keeping the store consistent with the UI badge; a missing manifest still uses the reinstall recovery path. - config_manager._load_secrets_for_save: catch only expected read/parse failures (OSError/ValueError/RecursionError) so implementation bugs propagate as themselves, and log with traceback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh --------- Co-authored-by: Claude <noreply@anthropic.com>
293 lines
13 KiB
Python
293 lines
13 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 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
|
|
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+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]) -> 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
|