mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 17:58:04 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c9cbb184 |
+124
-17
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||
| A plugin can drop its bundled copy safely | The **sunset rule**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). |
|
||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
||||
|
||||
The core API is **additive-only**. A method the plugins call is never removed or
|
||||
given a new required parameter; new behavior arrives as new methods with
|
||||
@@ -201,26 +201,133 @@ legacy compatibility rather than the mechanism.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Scope | Risk control |
|
||||
|---|---|---|
|
||||
| **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — |
|
||||
| **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended |
|
||||
| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||
| **B3** ✅ | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | Plugin copies remain until sunset; content building stays per-sport |
|
||||
| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min` → `ledmatrix_min_version` | Gives plugins a version to floor on |
|
||||
| **B5** ⏳ | Pilot one plugin per lineage (hockey, soccer, football) on core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each |
|
||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||
**rollout**, and it splits into three phases with very different risk profiles.
|
||||
The original plan folded the last two together; they are separated here because
|
||||
one of them is safe by construction and the other is not.
|
||||
|
||||
**B5 is blocked on this PR merging and 3.2.0 shipping** — a plugin cannot floor
|
||||
`ledmatrix_min_version` at a release that does not exist, and an unguarded
|
||||
`src.common.sports_scroll` import would break every user on 3.1.0.
|
||||
| Phase | Scope | Status | Gate |
|
||||
|---|---|---|---|
|
||||
| **B0** | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | ✅ | — |
|
||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
||||
|
||||
The hockey scroll-display pilot has been **validated ahead of that gate**:
|
||||
adopted against a core carrying 3.2.0, `scroll_display.py` went from 691 to 289
|
||||
lines and all 16 harness renders (8 sizes × 2 screens) came out byte-for-byte
|
||||
identical to the pre-adoption run. The adoption recipe and the two gotchas it
|
||||
surfaced are written up in the plugins repo's
|
||||
### B4 — what "ship 3.2.0" actually requires
|
||||
|
||||
Cutting the tag is the small part. The version *number* has to become something
|
||||
a floor can be trusted against, and today it is not:
|
||||
|
||||
- **The tag and `src.__version__` have never agreed.** `v3.1.0` was tagged
|
||||
2026-05-31; `__version__` only became `"3.1.0"` on 2026-07-12 (`7f7f0d64`).
|
||||
The v3.1.0 release therefore reports `__version__ = "1.0.0"`.
|
||||
- **Which silences the compatibility warning entirely for that population.**
|
||||
`PluginLoader._warn_if_incompatible` skips the check when the parsed core
|
||||
version is below `(2, 0, 0)` — an anti-spam guard that, given the above,
|
||||
matches exactly the users most likely to be behind.
|
||||
- **Nothing enforces a floor anyway.** The check is advisory (it logs and
|
||||
continues), and neither `StoreManager.install_plugin` nor
|
||||
`StoreManager.update_plugin` compares the core version at all — `update_plugin`
|
||||
compares the plugin's manifest version against the registry's
|
||||
`latest_version` and nothing else.
|
||||
|
||||
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
||||
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
||||
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
||||
compatibility gate that B6 depends on.
|
||||
|
||||
### B5 — adoption is safe by construction
|
||||
|
||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||
guarded import (see the Upgradability table above). On a core that ships the
|
||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
||||
exactly as it does today. There is no version of this step that breaks a user,
|
||||
which is why it does not wait for B6's gate.
|
||||
|
||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||
renders (8 sizes × 2 screens) came out byte-for-byte identical to the
|
||||
pre-adoption run. That byte-comparison is the acceptance gate for every
|
||||
adoption. The recipe and its two gotchas are in the plugins repo's
|
||||
`docs/plugin-development/08-shared-sports-code.md`.
|
||||
|
||||
### B6 — why the sunset needs more than a version floor
|
||||
|
||||
Deleting a bundled copy removes the fallback, so the guarded import becomes a
|
||||
hard dependency. On a core without the module the plugin raises
|
||||
`ModuleNotFoundError` at load; `PluginManager.load_plugin` catches it, records
|
||||
`PluginState.ERROR`, logs one line, and continues. Nothing crashes — the user
|
||||
simply loses that scoreboard, with no visible explanation.
|
||||
|
||||
Verified against a `v3.1.0` worktree: `src/common/sports_scroll.py`,
|
||||
`src/element_style.py` and the `src/base_classes/sports/` package are all absent
|
||||
there, and the import fails with `exc.name == 'src.common.sports_scroll'`. Guard
|
||||
sets must name that exact dotted path — `{"src"}` alone does not match it.
|
||||
|
||||
Combined with the B4 findings, a plugin that deletes its copy today reaches an
|
||||
un-updated user through a normal store update, fails to load, and warns nobody.
|
||||
**B6 therefore waits for B4's compatibility gate to have shipped and to have
|
||||
been in users' hands long enough that the population running a core without it
|
||||
is small.** The bundled copies cost disk space; deleting them early costs
|
||||
scoreboards, silently. That trade is not close.
|
||||
|
||||
Before the first sunset, add a **compatibility regression test**: load each
|
||||
adopted plugin with its bundled copy removed against a pinned old-core worktree
|
||||
and assert it fails loudly and specifically, then against current core and assert
|
||||
it works. That test is what turns "we think this is safe" into something CI
|
||||
re-checks on every change.
|
||||
|
||||
## What's next
|
||||
|
||||
In order. Each step is independently useful and independently revertible.
|
||||
|
||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
||||
"3.2.0"` refer to something real.
|
||||
2. **Make the version number honest.** Have the release process assert that the
|
||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
||||
cheaper than the confusion of the last two releases. Then revisit the
|
||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
||||
warning for the users who most need it.
|
||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
||||
`src.__version__`, and surface the reason in the store UI rather than only
|
||||
the log. This is the single change that turns the floor from documentation
|
||||
into a guarantee, and B6 depends on it.
|
||||
4. **Migrate the manifests** to `ledmatrix_min_version`. Currently 28 plugins
|
||||
spell it both ways across their `versions[]` entries, 12 use only the old
|
||||
spelling, and 2 only the new. Scope the sweep to the nine sports plugins if a
|
||||
42-plugin version-bump wave isn't worth it.
|
||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
||||
6. **Only then plan B6**, with the compatibility regression test described above
|
||||
in CI first.
|
||||
|
||||
## How to keep this project healthy
|
||||
|
||||
Lessons this migration paid for, worth applying beyond it:
|
||||
|
||||
- **A version number is a promise; keep it in one place.** Three different
|
||||
answers to "what version am I on" (tag, release, `__version__`) is what made
|
||||
the floor untrustworthy. Assert their agreement mechanically.
|
||||
- **Advisory checks protect nobody.** If a rule matters, enforce it where the
|
||||
action happens — the install path, not a log line the user will never read.
|
||||
If it doesn't matter enough to enforce, don't write the rule.
|
||||
- **Prefer failures that are loud and early.** A plugin that dies at load with
|
||||
one journal line is indistinguishable, to a user, from a plugin that was never
|
||||
installed. Surface plugin health in the UI.
|
||||
- **Keep the two repos' rules in sync deliberately.** The sunset rule lives in
|
||||
both this file and the plugins repo's
|
||||
`docs/plugin-development/08-shared-sports-code.md`. When one changes, change
|
||||
the other in the same PR — drift between them is how a contributor ends up
|
||||
following a rule that was superseded.
|
||||
- **Measure before and after, on real hardware.** Byte-identical harness renders
|
||||
and a device soak caught what unit tests could not. Reserve "it should be
|
||||
fine" for things you have actually looked at.
|
||||
|
||||
## Rules for contributors
|
||||
|
||||
- **Promote on evidence, not intuition.** A method moves to core when every copy
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assert that a release tag, the CHANGELOG, and `src.__version__` all agree.
|
||||
|
||||
Run it *before* creating a tag to check yourself:
|
||||
|
||||
python scripts/check_release_version.py v3.2.0
|
||||
|
||||
Wiring it into CI (on pushed `v*` tags and published releases) is a follow-up
|
||||
PR, so for now it is a manual pre-flight: run it before creating the tag and a
|
||||
mismatch shows up here rather than as a silent wrong answer on user devices.
|
||||
|
||||
Why this exists: `v3.1.0` was tagged 2026-05-31 while `src/__init__.py` still
|
||||
said `"1.0.0"`; the bump to `"3.1.0"` did not land until 2026-07-12. Devices
|
||||
installed from that release report `1.0.0`, which is below the `(2, 0, 0)` floor
|
||||
in `PluginLoader._warn_if_incompatible`, so they are silently exempt from every
|
||||
plugin compatibility warning. Plugin `ledmatrix_min_version` floors are only as
|
||||
trustworthy as this agreement. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
# [0-9] rather than \d, and [ \t] rather than \s: \d also matches non-ASCII
|
||||
# decimal digits (which int() parses), and \s matches newlines, so "##\n3.2.0"
|
||||
# would otherwise read as a version heading. Keep these in step with
|
||||
# test/test_version_consistency.py.
|
||||
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
HEADING = re.compile(
|
||||
r"^##[ \t]+(?P<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE)
|
||||
|
||||
|
||||
def normalize(tag: str) -> str:
|
||||
"""`v3.2.0` and `3.2.0` are the same release; tags here carry the `v`."""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
|
||||
def newest_changelog_version(changelog: Path) -> str | None:
|
||||
"""Newest version heading, or None when there is none.
|
||||
|
||||
Raises OSError if the file cannot be read; main() turns that into a clear
|
||||
message rather than a traceback, because this runs as a release gate and a
|
||||
traceback there reads as "the tooling is broken", not "your CHANGELOG is
|
||||
missing".
|
||||
"""
|
||||
headings = HEADING.findall(changelog.read_text(encoding="utf-8"))
|
||||
return headings[0] if headings else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"tag",
|
||||
help="Release tag to check, with or without the leading 'v' (e.g. v3.2.0)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from src import __version__ as core_version
|
||||
|
||||
tag_version = normalize(args.tag)
|
||||
changelog_path = REPO_ROOT / "CHANGELOG.md"
|
||||
|
||||
problems: list[str] = []
|
||||
|
||||
try:
|
||||
changelog_version = newest_changelog_version(changelog_path)
|
||||
except OSError as e:
|
||||
print(
|
||||
f"Release version check FAILED for tag {args.tag}:\n"
|
||||
f" - could not read {changelog_path}: {e}\n"
|
||||
f" Restore the file (git checkout -- CHANGELOG.md) and re-run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not SEMVER.match(tag_version):
|
||||
problems.append(
|
||||
f"tag {args.tag!r} is not vX.Y.Z. Older tags (v2.5) predate this "
|
||||
"check; new releases must be full semver so floors can parse them."
|
||||
)
|
||||
|
||||
if not SEMVER.match(core_version):
|
||||
problems.append(f"src.__version__ is {core_version!r}, which is not X.Y.Z")
|
||||
|
||||
if tag_version != core_version:
|
||||
problems.append(
|
||||
f"tag says {tag_version} but src.__version__ says {core_version}. "
|
||||
"Bump src/__init__.py to match the tag before releasing — devices "
|
||||
"report __version__, not the tag, and plugin floors compare "
|
||||
"against it."
|
||||
)
|
||||
|
||||
if changelog_version is None:
|
||||
problems.append("CHANGELOG.md has no '## X.Y.Z' version heading")
|
||||
elif changelog_version != core_version:
|
||||
problems.append(
|
||||
f"CHANGELOG.md's newest heading is {changelog_version} but "
|
||||
f"src.__version__ is {core_version}. Plugin authors read the "
|
||||
"CHANGELOG to pick a ledmatrix_min_version floor."
|
||||
)
|
||||
|
||||
if problems:
|
||||
print(f"Release version check FAILED for tag {args.tag}:", file=sys.stderr)
|
||||
for problem in problems:
|
||||
print(f" - {problem}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"OK: tag {args.tag}, src.__version__ {core_version}, and the CHANGELOG "
|
||||
"all agree."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,102 +0,0 @@
|
||||
"""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
|
||||
@@ -702,21 +702,30 @@ class PluginLoader:
|
||||
newer than the running core. Advisory only — never raises — so a
|
||||
plugin that guards optional features with try/except keeps working.
|
||||
"""
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
declared = (
|
||||
manifest.get('min_ledmatrix_version')
|
||||
or manifest.get('requires', {}).get('min_ledmatrix_version')
|
||||
)
|
||||
if not declared:
|
||||
versions = manifest.get('versions') or []
|
||||
if versions and isinstance(versions[0], dict):
|
||||
declared = (versions[0].get('ledmatrix_min_version')
|
||||
or versions[0].get('ledmatrix_min'))
|
||||
needed = self._parse_semver(declared)
|
||||
if needed is None:
|
||||
return
|
||||
|
||||
compatible, _reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
# Distinguish "fine" from "couldn't tell" for anyone reading logs:
|
||||
# a core below the trustworthy floor is skipped, not cleared.
|
||||
current = compatibility.parse_semver(core_version)
|
||||
if current is None or current < compatibility.TRUSTWORTHY_FLOOR:
|
||||
from src import __version__ as core_version
|
||||
current = self._parse_semver(core_version)
|
||||
# Anti-spam guard: if the core's own version number is stale (below
|
||||
# the ecosystem floor every shipped plugin declares), comparing would
|
||||
# warn on nearly everything — skip with a debug note instead.
|
||||
if current is None or current < (2, 0, 0):
|
||||
self.logger.debug(
|
||||
"Skipping version compatibility check for %s: core __version__ "
|
||||
"(%s) is below the ecosystem floor", plugin_id, core_version)
|
||||
return
|
||||
|
||||
declared = compatibility.declared_min_version(manifest)
|
||||
if needed > current:
|
||||
self.logger.warning(
|
||||
"Plugin %s declares min LEDMatrix version %s but this core is %s — "
|
||||
"features it relies on may be missing; update the core or expect "
|
||||
|
||||
@@ -149,27 +149,18 @@ class PluginStoreManager:
|
||||
# loser can end up renaming the winner's in-progress install aside
|
||||
# mid-download, stealing its own rollback safety net. Keyed by
|
||||
# plugin_id so unrelated plugins still update concurrently.
|
||||
# Reentrant: install_plugin takes this lock, and _reinstall_with_rollback
|
||||
# holds it across its call to install_plugin. A plain Lock would
|
||||
# self-deadlock on that nesting.
|
||||
self._reinstall_locks: Dict[str, "threading.RLock"] = {}
|
||||
self._reinstall_locks: Dict[str, threading.Lock] = {}
|
||||
self._reinstall_locks_guard = threading.Lock()
|
||||
|
||||
# Ensure plugins directory exists
|
||||
self.plugins_dir.mkdir(exist_ok=True)
|
||||
|
||||
def _get_reinstall_lock(self, plugin_id: str):
|
||||
"""Lazily create (or fetch) the per-plugin reinstall lock.
|
||||
|
||||
Reentrant by necessity: `install_plugin` acquires it to protect its
|
||||
set-aside/restore, and `_reinstall_with_rollback` holds it across its
|
||||
own call to `install_plugin`. With a plain `Lock` that nesting
|
||||
deadlocks the request thread.
|
||||
"""
|
||||
def _get_reinstall_lock(self, plugin_id: str) -> threading.Lock:
|
||||
"""Lazily create (or fetch) the per-plugin reinstall lock."""
|
||||
with self._reinstall_locks_guard:
|
||||
lock = self._reinstall_locks.get(plugin_id)
|
||||
if lock is None:
|
||||
lock = threading.RLock()
|
||||
lock = threading.Lock()
|
||||
self._reinstall_locks[plugin_id] = lock
|
||||
return lock
|
||||
|
||||
@@ -1201,90 +1192,6 @@ class PluginStoreManager:
|
||||
return next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||
|
||||
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
|
||||
"""Install a plugin, keeping any existing install until the new one is
|
||||
known good.
|
||||
|
||||
`_install_plugin_impl` deletes the existing directory *before*
|
||||
downloading, so every failure after that point — a dropped connection, a
|
||||
malformed manifest, or the compatibility gate refusing the new version —
|
||||
left the user with no plugin at all. `_reinstall_with_rollback` gives the
|
||||
*update* path exactly this protection; a direct install had none, and the
|
||||
compatibility gate added a new way to reach it.
|
||||
|
||||
Pass-through when nothing is installed, and when called from
|
||||
`_reinstall_with_rollback`, which has already moved the old copy aside.
|
||||
|
||||
The aside name embeds '.standalone-backup-' so plugin discovery
|
||||
(`plugin_manager._scan_directory_for_plugins`) skips it even though it
|
||||
still holds a manifest.json.
|
||||
|
||||
Held under the per-plugin reinstall lock for the same reason
|
||||
`_reinstall_with_rollback` is: the web UI runs Flask with
|
||||
threaded=True, so a double-clicked Install button gives two threads the
|
||||
same plugin_id. Interleaved, one thread's restore would delete the
|
||||
other's freshly installed copy. The lock is reentrant because the
|
||||
rollback path already holds it when it calls in here.
|
||||
"""
|
||||
with self._get_reinstall_lock(plugin_id):
|
||||
plugin_path = self.plugins_dir / plugin_id
|
||||
if not plugin_path.exists():
|
||||
return self._install_plugin_impl(plugin_id, branch)
|
||||
|
||||
backup_path = plugin_path.with_name(
|
||||
f"{plugin_path.name}.standalone-backup-preinstall")
|
||||
if backup_path.exists() and not self._safe_remove_directory(backup_path):
|
||||
# Can't stage a safety net. Better to attempt the install than
|
||||
# to refuse outright, which is what callers got before this
|
||||
# existed.
|
||||
self.logger.warning(
|
||||
"Could not clear stale pre-install backup for %s at %s; "
|
||||
"installing without a rollback net", plugin_id, backup_path)
|
||||
return self._install_plugin_impl(plugin_id, branch)
|
||||
|
||||
try:
|
||||
plugin_path.rename(backup_path)
|
||||
except OSError as e:
|
||||
self.logger.warning(
|
||||
"Could not set aside existing install of %s (%s); "
|
||||
"installing without a rollback net", plugin_id, e)
|
||||
return self._install_plugin_impl(plugin_id, branch)
|
||||
|
||||
try:
|
||||
installed = self._install_plugin_impl(plugin_id, branch)
|
||||
except Exception:
|
||||
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
|
||||
raise
|
||||
|
||||
if installed:
|
||||
if not self._safe_remove_directory(backup_path):
|
||||
self.logger.warning(
|
||||
"Install of %s succeeded but the previous copy at %s "
|
||||
"could not be removed; it will be cleared on the next "
|
||||
"install", plugin_id, backup_path)
|
||||
return True
|
||||
|
||||
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
|
||||
return False
|
||||
|
||||
def _restore_preinstall_backup(
|
||||
self, plugin_id: str, plugin_path: Path, backup_path: Path
|
||||
) -> None:
|
||||
"""Put the previous install back after a failed (re)install."""
|
||||
self.logger.error(
|
||||
"Install of %s failed; restoring the previous version", plugin_id)
|
||||
try:
|
||||
if plugin_path.exists():
|
||||
# Partial download debris from the failed install.
|
||||
self._safe_remove_directory(plugin_path)
|
||||
backup_path.rename(plugin_path)
|
||||
self.logger.info("Restored previous install of %s", plugin_id)
|
||||
except OSError as e:
|
||||
self.logger.error(
|
||||
"CRITICAL: could not restore %s from %s: %s. The previous "
|
||||
"install is preserved there — rename it back manually.",
|
||||
plugin_id, backup_path, e)
|
||||
|
||||
def _install_plugin_impl(self, plugin_id: str, branch: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Install a plugin from the official registry. Always installs the latest commit
|
||||
from the repository's default branch (or specified branch).
|
||||
@@ -1426,26 +1333,6 @@ class PluginStoreManager:
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
# Refuse a plugin that needs a newer core than this one. The
|
||||
# registry carries no compatibility field, so the floor is only
|
||||
# knowable once the files are down — checking here, before
|
||||
# dependency installation, is the earliest possible point.
|
||||
#
|
||||
# Refusing costs the user nothing: on an update this returns
|
||||
# False and _reinstall_with_rollback restores the version they
|
||||
# already had. Allowing it costs them a plugin that raises
|
||||
# ModuleNotFoundError at load and is reported only as one line
|
||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if not compatible:
|
||||
self.logger.error(
|
||||
"Refusing to install %s: %s", plugin_id, reason)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
if 'entry_point' not in manifest:
|
||||
manifest['entry_point'] = 'manager.py'
|
||||
manifest_modified = True
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"""A failed (re)install must not destroy the working plugin it replaced.
|
||||
|
||||
`_install_plugin_impl` deletes the existing plugin directory *before* it
|
||||
downloads anything, so any failure after that point used to leave the user with
|
||||
nothing. The update path was protected — `_reinstall_with_rollback` renames the
|
||||
old copy aside first — but a direct `install_plugin` was not, and the
|
||||
compatibility gate added a new way to fail late: a plugin whose declared floor
|
||||
exceeds the running core is now refused *after* the old copy is already gone.
|
||||
|
||||
Concretely, without the wrapper: a user on core 3.1.0 with a working
|
||||
hockey-scoreboard clicks Install; the new manifest floors at 3.2.0; the gate
|
||||
refuses; the plugin they had is deleted. Floors are hand-written and can be
|
||||
over-declared, so this could remove a plugin that was working fine.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
plugins_dir.mkdir()
|
||||
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||
mgr.logger = MagicMock()
|
||||
return mgr, plugins_dir
|
||||
|
||||
|
||||
def _existing_install(plugins_dir: Path, plugin_id: str, marker: str) -> Path:
|
||||
path = plugins_dir / plugin_id
|
||||
path.mkdir(parents=True)
|
||||
(path / "manifest.json").write_text(
|
||||
json.dumps({"id": plugin_id, "name": plugin_id, "class_name": "P",
|
||||
"display_modes": ["a"], "version": "1.0.0"}),
|
||||
encoding="utf-8")
|
||||
(path / "marker.txt").write_text(marker, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestFailedInstallPreservesPrevious:
|
||||
def test_failed_install_restores_the_old_copy(self, store, monkeypatch):
|
||||
mgr, plugins_dir = store
|
||||
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", lambda *a, **k: False)
|
||||
|
||||
assert mgr.install_plugin("hockey-scoreboard") is False
|
||||
assert path.exists(), "the previous install must be restored"
|
||||
assert (path / "marker.txt").read_text() == "the-original"
|
||||
|
||||
def test_raising_install_restores_and_reraises(self, store, monkeypatch):
|
||||
mgr, plugins_dir = store
|
||||
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("network died mid-install")
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", boom)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
mgr.install_plugin("hockey-scoreboard")
|
||||
assert path.exists()
|
||||
assert (path / "marker.txt").read_text() == "the-original"
|
||||
|
||||
def test_successful_install_clears_the_backup(self, store, monkeypatch):
|
||||
mgr, plugins_dir = store
|
||||
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
|
||||
def succeed(plugin_id, branch=None):
|
||||
_existing_install(plugins_dir, plugin_id, "the-new-one")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", succeed)
|
||||
|
||||
assert mgr.install_plugin("hockey-scoreboard") is True
|
||||
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-new-one"
|
||||
leftovers = [p.name for p in plugins_dir.iterdir() if "backup" in p.name]
|
||||
assert not leftovers, f"backup left behind: {leftovers}"
|
||||
|
||||
def test_backup_name_is_invisible_to_plugin_discovery(self, store, monkeypatch):
|
||||
"""A backup that discovery can see becomes a duplicate plugin entry;
|
||||
the marker '.standalone-backup-' is what makes it skip."""
|
||||
mgr, plugins_dir = store
|
||||
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
|
||||
seen = {}
|
||||
|
||||
def capture(plugin_id, branch=None):
|
||||
seen["dirs"] = sorted(p.name for p in plugins_dir.iterdir())
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", capture)
|
||||
mgr.install_plugin("hockey-scoreboard")
|
||||
|
||||
backups = [d for d in seen["dirs"] if d != "hockey-scoreboard"]
|
||||
assert backups, "expected the old copy to be set aside during install"
|
||||
for name in backups:
|
||||
assert ".standalone-backup-" in name, (
|
||||
f"{name} would be picked up by "
|
||||
"plugin_manager._scan_directory_for_plugins as a real plugin")
|
||||
|
||||
def test_fresh_install_is_a_pass_through(self, store, monkeypatch):
|
||||
"""Nothing installed means nothing to protect; don't create stray dirs."""
|
||||
mgr, plugins_dir = store
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mgr, "_install_plugin_impl",
|
||||
lambda *a, **k: calls.append(a) or True)
|
||||
|
||||
assert mgr.install_plugin("brand-new") is True
|
||||
assert calls, "the implementation must still be called"
|
||||
assert list(plugins_dir.iterdir()) == []
|
||||
|
||||
def test_stale_backup_from_a_crash_does_not_block(self, store, monkeypatch):
|
||||
mgr, plugins_dir = store
|
||||
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
stale = plugins_dir / "hockey-scoreboard.standalone-backup-preinstall"
|
||||
stale.mkdir()
|
||||
(stale / "junk.txt").write_text("from a previous crash", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", lambda *a, **k: False)
|
||||
|
||||
assert mgr.install_plugin("hockey-scoreboard") is False
|
||||
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-original"
|
||||
|
||||
|
||||
class TestUpdatePathStillWorks:
|
||||
def test_reinstall_with_rollback_is_not_double_wrapped(self, store, monkeypatch):
|
||||
"""_reinstall_with_rollback moves the plugin aside itself, so by the
|
||||
time install_plugin runs there is nothing at the original path and the
|
||||
wrapper must be a pass-through rather than staging a second backup."""
|
||||
mgr, plugins_dir = store
|
||||
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
|
||||
observed = {}
|
||||
|
||||
def impl(plugin_id, branch=None):
|
||||
observed["dirs"] = sorted(p.name for p in plugins_dir.iterdir())
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", impl)
|
||||
|
||||
assert mgr._reinstall_with_rollback("hockey-scoreboard", path) is False
|
||||
# Exactly one aside directory existed during the attempt — rollback's.
|
||||
assert observed["dirs"] == ["hockey-scoreboard.standalone-backup-migrating"]
|
||||
# And the user still has their plugin.
|
||||
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-original"
|
||||
|
||||
|
||||
class TestConcurrency:
|
||||
"""The web UI runs Flask threaded, so a double-clicked Install button puts
|
||||
two threads on the same plugin_id. `_reinstall_with_rollback` already
|
||||
guarded against this; the install wrapper has to as well, or one thread's
|
||||
restore deletes the other's freshly installed copy."""
|
||||
|
||||
def test_rollback_calling_install_does_not_deadlock(self, store, monkeypatch):
|
||||
"""The rollback path holds the per-plugin lock across its call to
|
||||
install_plugin. A non-reentrant lock would hang the request thread
|
||||
forever — this test would time out rather than fail."""
|
||||
import threading
|
||||
|
||||
mgr, plugins_dir = store
|
||||
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
monkeypatch.setattr(
|
||||
mgr, "_install_plugin_impl",
|
||||
lambda pid, branch=None: bool(_existing_install(plugins_dir, pid, "new")))
|
||||
|
||||
done = threading.Event()
|
||||
result = {}
|
||||
|
||||
def run():
|
||||
result["ok"] = mgr._reinstall_with_rollback("hockey-scoreboard", path)
|
||||
done.set()
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
t.start()
|
||||
assert done.wait(timeout=10), (
|
||||
"install_plugin deadlocked when called from _reinstall_with_rollback "
|
||||
"— the per-plugin lock must be reentrant"
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_concurrent_installs_serialize(self, store, monkeypatch):
|
||||
"""Two threads installing the same plugin must not interleave their
|
||||
set-aside/restore, and the survivor must be a complete install."""
|
||||
import threading
|
||||
|
||||
mgr, plugins_dir = store
|
||||
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
|
||||
|
||||
in_flight = []
|
||||
overlap = []
|
||||
|
||||
def slow_impl(plugin_id, branch=None):
|
||||
in_flight.append(1)
|
||||
if len(in_flight) > 1:
|
||||
overlap.append(1)
|
||||
threading.Event().wait(0.05)
|
||||
_existing_install(plugins_dir, plugin_id, "installed")
|
||||
in_flight.pop()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_plugin_impl", slow_impl)
|
||||
|
||||
threads = [threading.Thread(target=mgr.install_plugin,
|
||||
args=("hockey-scoreboard",), daemon=True)
|
||||
for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
assert not t.is_alive(), "concurrent install hung"
|
||||
|
||||
assert not overlap, "two installs of the same plugin ran concurrently"
|
||||
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").exists()
|
||||
leftovers = [p.name for p in plugins_dir.iterdir() if "backup" in p.name]
|
||||
assert not leftovers, f"backup left behind: {leftovers}"
|
||||
@@ -1,232 +0,0 @@
|
||||
"""The install/update gate, and the shared compatibility rules behind it.
|
||||
|
||||
Before this existed, `ledmatrix_min_version` was decoration: the loader logged
|
||||
an advisory warning and the store never looked at the core version at all, so a
|
||||
routine store update happily delivered a plugin that could not run. Deleting a
|
||||
plugin's bundled fallback under those conditions would have handed un-updated
|
||||
users a scoreboard that fails to load with one line in the journal.
|
||||
|
||||
The rules being pinned here, in priority order:
|
||||
|
||||
1. Refuse only on **evidence**. Undeclared floor, unparseable version on either
|
||||
side, or a core whose self-reported version is untrustworthy → allow. A
|
||||
wrong refusal breaks a working install; a wrong allowance degrades to the
|
||||
behavior we already had.
|
||||
2. A core below `TRUSTWORTHY_FLOOR` is *unknown*, not old. The v3.1.0 release
|
||||
reports `1.0.0` while nearly every manifest floors at `2.0.0`; blocking on
|
||||
that number would stop those users installing anything at all.
|
||||
3. The loader and the store must agree, because they read the same manifests.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system import compatibility
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Floor resolution — every spelling published plugins actually use
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestDeclaredMinVersion:
|
||||
def test_top_level_min_ledmatrix_version(self):
|
||||
assert compatibility.declared_min_version(
|
||||
{"min_ledmatrix_version": "3.2.0"}) == "3.2.0"
|
||||
|
||||
def test_requires_block(self):
|
||||
assert compatibility.declared_min_version(
|
||||
{"requires": {"min_ledmatrix_version": "3.1.0"}}) == "3.1.0"
|
||||
|
||||
def test_versions_array_new_spelling(self):
|
||||
assert compatibility.declared_min_version(
|
||||
{"versions": [{"ledmatrix_min_version": "3.2.0"}]}) == "3.2.0"
|
||||
|
||||
def test_versions_array_deprecated_spelling(self):
|
||||
"""Most published manifests still say `ledmatrix_min`; ignoring it
|
||||
would silently exempt them from the gate."""
|
||||
assert compatibility.declared_min_version(
|
||||
{"versions": [{"ledmatrix_min": "2.0.0"}]}) == "2.0.0"
|
||||
|
||||
def test_absent(self):
|
||||
assert compatibility.declared_min_version({"id": "x"}) is None
|
||||
|
||||
def test_requires_present_but_null(self):
|
||||
assert compatibility.declared_min_version({"requires": None}) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The decision itself
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestCheck:
|
||||
def test_blocks_when_plugin_needs_a_newer_core(self):
|
||||
ok, reason = compatibility.check(
|
||||
{"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.2.0"}, "3.1.0")
|
||||
assert ok is False
|
||||
assert "3.2.0" in reason and "3.1.0" in reason
|
||||
assert "Hockey Scoreboard" in reason
|
||||
|
||||
def test_allows_equal_version(self):
|
||||
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "3.2.0")
|
||||
assert ok is True
|
||||
|
||||
def test_allows_newer_core(self):
|
||||
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "4.0.0")
|
||||
assert ok is True
|
||||
|
||||
def test_allows_when_no_floor_declared(self):
|
||||
ok, reason = compatibility.check({"id": "x"}, "3.2.0")
|
||||
assert ok is True and reason is None
|
||||
|
||||
def test_untrustworthy_core_version_allows_everything(self):
|
||||
"""The v3.1.0 release reports 1.0.0. Nearly every manifest floors at
|
||||
2.0.0, so blocking here would stop those users installing any plugin
|
||||
at all — strictly worse than the problem being solved."""
|
||||
ok, reason = compatibility.check(
|
||||
{"min_ledmatrix_version": "3.2.0"}, "1.0.0")
|
||||
assert ok is True and reason is None
|
||||
|
||||
def test_unparseable_core_version_allows(self):
|
||||
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "not-a-version")
|
||||
assert ok is True
|
||||
|
||||
def test_unparseable_floor_allows(self):
|
||||
ok, _ = compatibility.check({"min_ledmatrix_version": {"nope": 1}}, "3.2.0")
|
||||
assert ok is True
|
||||
|
||||
def test_v_prefix_tolerated_on_both_sides(self):
|
||||
ok, _ = compatibility.check({"min_ledmatrix_version": "v3.3.0"}, "v3.2.0")
|
||||
assert ok is False
|
||||
|
||||
@pytest.mark.parametrize("floor,core,expected_ok", [
|
||||
("3.2.0", "3.2.1", True),
|
||||
("3.2.1", "3.2.0", False),
|
||||
("3.10.0", "3.9.0", False), # numeric compare, not lexical
|
||||
("3.9.0", "3.10.0", True),
|
||||
])
|
||||
def test_ordering(self, floor, core, expected_ok):
|
||||
ok, _ = compatibility.check({"min_ledmatrix_version": floor}, core)
|
||||
assert ok is expected_ok
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The gate in install_plugin
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _write_plugin(plugins_dir: Path, plugin_id: str, manifest: dict) -> Path:
|
||||
path = plugins_dir / plugin_id
|
||||
path.mkdir(parents=True)
|
||||
(path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
(path / "manager.py").write_text("class P: pass\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""A PluginStoreManager whose download step is stubbed to drop a plugin
|
||||
directory in place, so the test exercises the post-download validation
|
||||
path without touching the network."""
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
plugins_dir.mkdir()
|
||||
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||
mgr.logger = MagicMock()
|
||||
return mgr, plugins_dir
|
||||
|
||||
|
||||
class TestInstallGate:
|
||||
"""`install_plugin` is the chokepoint: `_reinstall_with_rollback` calls it,
|
||||
so gating there covers updates too, and a refused update restores the
|
||||
version the user already had."""
|
||||
|
||||
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
|
||||
mgr, plugins_dir = store
|
||||
plugin_id = manifest["id"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
mgr, "get_plugin_info",
|
||||
lambda *a, **k: {"repo": "https://example.invalid/r",
|
||||
"plugin_path": f"plugins/{plugin_id}",
|
||||
"branch": "main"})
|
||||
# Stand in for the download: put the files where install_plugin expects.
|
||||
monkeypatch.setattr(
|
||||
mgr, "_install_from_monorepo",
|
||||
lambda *a, **k: bool(_write_plugin(plugins_dir, plugin_id, manifest)))
|
||||
monkeypatch.setattr(mgr, "_install_from_monorepo_api", lambda *a, **k: False)
|
||||
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", core_version)
|
||||
return mgr.install_plugin(plugin_id), plugins_dir / plugin_id
|
||||
|
||||
def test_refuses_and_leaves_nothing_behind(self, store, monkeypatch):
|
||||
manifest = {
|
||||
"id": "needs-newer", "name": "Needs Newer", "class_name": "P",
|
||||
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||
}
|
||||
ok, path = self._install_with_manifest(store, manifest, "3.2.0", monkeypatch)
|
||||
|
||||
assert ok is False, "install must refuse a plugin that needs a newer core"
|
||||
assert not path.exists(), (
|
||||
"a refused install must not leave a half-installed directory — "
|
||||
"plugin discovery would pick it up and fail to load it")
|
||||
|
||||
def test_allows_a_compatible_plugin(self, store, monkeypatch):
|
||||
manifest = {
|
||||
"id": "fine", "name": "Fine", "class_name": "P",
|
||||
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
||||
}
|
||||
ok, path = self._install_with_manifest(store, manifest, "3.2.0", monkeypatch)
|
||||
|
||||
assert ok is True
|
||||
assert (path / "manifest.json").exists()
|
||||
|
||||
def test_untrustworthy_core_does_not_block_installs(self, store, monkeypatch):
|
||||
"""Regression guard for the worst possible outcome of this feature:
|
||||
users on the v3.1.0 release (which reports 1.0.0) must not be locked
|
||||
out of the plugin store entirely."""
|
||||
manifest = {
|
||||
"id": "floored", "name": "Floored", "class_name": "P",
|
||||
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
|
||||
}
|
||||
ok, path = self._install_with_manifest(store, manifest, "1.0.0", monkeypatch)
|
||||
|
||||
assert ok is True, (
|
||||
"a core below the trustworthy floor must not block installs — "
|
||||
"nearly every published manifest floors at 2.0.0")
|
||||
assert (path / "manifest.json").exists()
|
||||
|
||||
|
||||
class TestLoaderAndStoreAgree:
|
||||
"""Both read the same manifests; a disagreement means one of them is
|
||||
lying to the user."""
|
||||
|
||||
@pytest.mark.parametrize("manifest,core,expected", [
|
||||
({"min_ledmatrix_version": "3.2.0"}, "3.1.0", False),
|
||||
({"versions": [{"ledmatrix_min": "2.0.0"}]}, "3.2.0", True),
|
||||
({"versions": [{"ledmatrix_min_version": "9.0.0"}]}, "3.2.0", False),
|
||||
({}, "3.2.0", True),
|
||||
])
|
||||
def test_same_verdict(self, manifest, core, expected):
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
store_ok, _ = compatibility.check(manifest, core)
|
||||
assert store_ok is expected
|
||||
|
||||
# The loader resolves the floor through the same helper, so a
|
||||
# divergence in spelling handling would show up here.
|
||||
loader_needed = compatibility.parse_semver(
|
||||
compatibility.declared_min_version(manifest))
|
||||
current = compatibility.parse_semver(core)
|
||||
loader_would_warn = (
|
||||
loader_needed is not None
|
||||
and current is not None
|
||||
and current >= compatibility.TRUSTWORTHY_FLOOR
|
||||
and loader_needed > current
|
||||
)
|
||||
assert loader_would_warn is (not expected)
|
||||
assert hasattr(PluginLoader, "_warn_if_incompatible")
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Version reporting must have exactly one answer.
|
||||
|
||||
`src.__version__` is the canonical core version. The plugin loader compares
|
||||
plugin `ledmatrix_min_version` floors against it, and the plugin ecosystem
|
||||
floors on the number recorded in `CHANGELOG.md` — so if those two disagree, a
|
||||
plugin can declare a floor that is satisfied by a core which does not actually
|
||||
ship the module it needs.
|
||||
|
||||
This has already gone wrong once. The `v3.1.0` tag was cut 2026-05-31, but
|
||||
`src/__init__.py` was not bumped from `"1.0.0"` to `"3.1.0"` until 2026-07-12,
|
||||
six weeks later. Every device installed from that release reports `1.0.0`,
|
||||
which is below the `(2, 0, 0)` floor in `PluginLoader._warn_if_incompatible` —
|
||||
so those users get no compatibility warning at all. See
|
||||
`docs/SPORTS_UNIFICATION.md` (phase B4).
|
||||
|
||||
A tag is not available here, so the tag half of the check lives in
|
||||
`scripts/check_release_version.py`. Wiring that script into CI (on pushed `v*`
|
||||
tags and published releases) is a follow-up PR; until it lands, run it by hand
|
||||
before tagging:
|
||||
|
||||
python scripts/check_release_version.py v3.2.0
|
||||
|
||||
Note: `src.plugin_system.__version__` is deliberately NOT checked. That module
|
||||
versions the *plugin API* (it sits beside `__api_version__` and is documented as
|
||||
such), which moves independently of the core version.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
|
||||
|
||||
# [0-9] rather than \d: \d also matches non-ASCII decimal digits, which int()
|
||||
# happily parses, so a heading in Arabic-Indic numerals would pass the pattern
|
||||
# and then mismatch confusingly. [ \t] rather than \s for the same class of
|
||||
# reason -- \s matches newlines, so "##\n3.2.0" would read as a heading.
|
||||
SEMVER = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$")
|
||||
# Version headings look like "## 3.2.0". A leading "## Unreleased" section is
|
||||
# allowed and skipped -- it is where module additions are staged before a bump.
|
||||
HEADING = re.compile(
|
||||
r"^##[ \t]+(?P<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE)
|
||||
|
||||
|
||||
def test_core_version_is_semver():
|
||||
"""A floor comparison parses this string; it has to be parseable."""
|
||||
assert SEMVER.match(src.__version__), (
|
||||
f"src.__version__ is {src.__version__!r}, which is not X.Y.Z. "
|
||||
"The loader's floor comparison cannot parse it."
|
||||
)
|
||||
|
||||
|
||||
def test_changelog_documents_the_current_version():
|
||||
"""The newest versioned CHANGELOG heading is the version we claim to be.
|
||||
|
||||
Plugins floor on the version recorded in the CHANGELOG as first shipping a
|
||||
module. If the code says 3.2.0 and the CHANGELOG's newest entry is 3.1.0,
|
||||
that record points at the wrong release.
|
||||
"""
|
||||
text = CHANGELOG.read_text(encoding="utf-8")
|
||||
headings = HEADING.findall(text)
|
||||
assert headings, "CHANGELOG.md has no '## X.Y.Z' version headings"
|
||||
|
||||
newest = headings[0]
|
||||
assert newest == src.__version__, (
|
||||
f"src.__version__ is {src.__version__!r} but the newest CHANGELOG "
|
||||
f"heading is {newest!r}. Bump one to match the other: the CHANGELOG is "
|
||||
"what plugin authors read to pick a ledmatrix_min_version floor."
|
||||
)
|
||||
|
||||
|
||||
def test_changelog_versions_are_ordered_and_unique():
|
||||
"""A duplicated or out-of-order heading makes 'first release shipping X'
|
||||
ambiguous, which is exactly the question the sunset rule asks."""
|
||||
text = CHANGELOG.read_text(encoding="utf-8")
|
||||
versions = [tuple(int(p) for p in v.split(".")) for v in HEADING.findall(text)]
|
||||
|
||||
duplicates = {v for v in versions if versions.count(v) > 1}
|
||||
assert not duplicates, f"CHANGELOG.md has duplicate version headings: {duplicates}"
|
||||
|
||||
assert versions == sorted(versions, reverse=True), (
|
||||
"CHANGELOG.md version headings are not in descending order; "
|
||||
f"got {['.'.join(map(str, v)) for v in versions]}"
|
||||
)
|
||||
|
||||
|
||||
def test_web_interface_version_tracks_the_core():
|
||||
"""web_interface used to carry its own hardcoded "3.0.0", a third answer to
|
||||
'what version is this'. It now re-exports the canonical one."""
|
||||
web_interface = pytest.importorskip(
|
||||
"web_interface", reason="web_interface needs Flask, which is optional here"
|
||||
)
|
||||
assert getattr(web_interface, "__version__", None) == src.__version__, (
|
||||
"web_interface.__version__ has drifted from src.__version__; it should "
|
||||
"re-export the canonical value rather than hardcode its own."
|
||||
)
|
||||
|
||||
|
||||
def test_heading_pattern_is_strict_about_digits_and_whitespace():
|
||||
"""`\\d` also matches non-ASCII decimal digits and `\\s` matches newlines,
|
||||
either of which would let a malformed heading through and then fail the
|
||||
comparison with a confusing message. Pin the tightened patterns."""
|
||||
assert HEADING.findall("## 3.2.0\n") == ["3.2.0"]
|
||||
assert HEADING.findall("##\t3.2.0 \n") == ["3.2.0"]
|
||||
# A bare "##" whose version sits on the next line is not a heading.
|
||||
assert HEADING.findall("##\n3.2.0\n") == []
|
||||
# Arabic-Indic digits parse via int() but are not our version format.
|
||||
assert HEADING.findall("## ٣.٢.٠\n") == []
|
||||
assert SEMVER.match("٣.٢.٠") is None
|
||||
@@ -2,10 +2,5 @@
|
||||
LED Matrix Web Interface V3
|
||||
Modern web interface for controlling the LED Matrix display
|
||||
"""
|
||||
|
||||
# Re-exported, never hardcoded. This used to carry its own "3.0.0", a third
|
||||
# answer to "what version is this" alongside the tag and src.__version__ —
|
||||
# and disagreeing version numbers are what made plugin compatibility floors
|
||||
# untrustworthy (see docs/SPORTS_UNIFICATION.md, phase B4).
|
||||
from src import __version__ # noqa: F401
|
||||
__version__ = "3.0.0"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user