mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* refactor(plugins): split PluginStoreManager into mixins src/plugin_system/store_manager.py (2,977 lines) keeps the class, its shared state, locks, the uninstall registry, directory lookup and uninstall; its methods are split by area into: - store_registry.py (_RegistryMixin): registry, GitHub metadata, search, manifest validation - store_install.py (_InstallMixin): install paths and dependencies - store_update.py (_UpdateMixin): updates, rollback, local git state Pure move: all 56 members are byte-identical (checked with ast) and the assembled class has exactly the same attributes as before (checked at runtime). PluginStoreManager is imported from store_manager.py as before. Tests that patched shared modules (subprocess, requests, tempfile, shutil) through store_manager now reach them through the module whose code they exercise; a source-text contract test reads all store_*.py modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: annotate findings the split moved into new store modules subprocess imports and a list-form git clone (no shell), and the config template's placeholder token string -- existing code that Codacy reported as new because it moved. Annotated with the repo's nosec/nosemgrep style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: annotate the default-branch git clone the split moved Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
726 lines
32 KiB
Python
726 lines
32 KiB
Python
"""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
|
|
import subprocess
|
|
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_allows_todays_ecosystem_floor(self):
|
|
"""The v3.1.0 release reports 1.0.0. Nearly every manifest floors at
|
|
2.0.0, so blocking *that* would stop those users installing any plugin
|
|
at all — strictly worse than the problem being solved.
|
|
|
|
A floor ABOVE 2.0.0 is refused instead; see
|
|
TestUntrustworthyCoreAndTheSunset for why that case is different."""
|
|
ok, reason = compatibility.check(
|
|
{"min_ledmatrix_version": "2.0.0"}, "1.0.0")
|
|
assert ok is True and reason is None
|
|
|
|
def test_unparseable_core_version_allows_the_ecosystem_floor(self):
|
|
"""An unidentifiable core is treated exactly like an untrustworthy one:
|
|
today's 2.0.0 floor is allowed, a post-sunset floor is not."""
|
|
ok, _ = compatibility.check({"min_ledmatrix_version": "2.0.0"}, "not-a-version")
|
|
assert ok is True
|
|
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "not-a-version")
|
|
assert ok is False
|
|
|
|
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 for every route that re-downloads:
|
|
`_reinstall_with_rollback` calls it, so a refused update restores the
|
|
version the user already had.
|
|
|
|
It is not the *only* route in, and saying so here once is cheaper than
|
|
rediscovering it. `update_plugin` also has a git branch that pulls in
|
|
place and never re-downloads; that one is gated separately by
|
|
`_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third
|
|
route, `install_from_url` (sideloading from a URL), is gated in that
|
|
function and pinned in `TestSideloadGate`. All three refuse on the same
|
|
rule.
|
|
"""
|
|
|
|
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 TestSideloadGate:
|
|
"""`install_from_url` never looked at the core version.
|
|
|
|
Sideloading is an explicit act rather than an automatic store update, so
|
|
the argument for gating it is different: not "the user did not choose
|
|
this", but that the floor states the plugin *cannot run here*. Letting it
|
|
through produces the same silent PluginState.ERROR at load that the store
|
|
gate exists to prevent, and the user who typed the URL is no better placed
|
|
to diagnose it than one who pressed Update.
|
|
"""
|
|
|
|
def _sideload(self, store, manifest, core_version, monkeypatch):
|
|
mgr, plugins_dir = store
|
|
plugin_id = manifest["id"]
|
|
|
|
def fake_clone(repo_url, target_path, branches=None):
|
|
target_path.mkdir(parents=True, exist_ok=True)
|
|
(target_path / "manifest.json").write_text(
|
|
json.dumps(manifest), encoding="utf-8")
|
|
(target_path / "manager.py").write_text(
|
|
"class P: pass\n", encoding="utf-8")
|
|
return "main"
|
|
|
|
monkeypatch.setattr(mgr, "_install_via_git", fake_clone)
|
|
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
|
|
|
import src
|
|
monkeypatch.setattr(src, "__version__", core_version)
|
|
return mgr.install_from_url("https://example.invalid/plugin"), \
|
|
plugins_dir / plugin_id
|
|
|
|
def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch):
|
|
manifest = {
|
|
"id": "sideload-newer", "name": "Sideload", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
|
}
|
|
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
|
|
|
assert result["success"] is False
|
|
assert "9.9.9" in result["error"], result["error"]
|
|
assert not path.exists(), (
|
|
"a refused sideload must not leave the plugin installed")
|
|
|
|
def test_allows_a_compatible_plugin(self, store, monkeypatch):
|
|
"""The guard against over-refusing: a gate that blocks everything
|
|
passes the test above and breaks sideloading entirely."""
|
|
manifest = {
|
|
"id": "sideload-fine", "name": "Sideload", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
|
}
|
|
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
|
|
|
assert result["success"] is True, result.get("error")
|
|
assert (path / "manifest.json").exists()
|
|
|
|
def test_untrustworthy_core_does_not_block_a_2_0_0_floor(
|
|
self, store, monkeypatch):
|
|
"""Same rule as the other two routes: a v3.1.0 release reports 1.0.0,
|
|
and nearly every published manifest floors at 2.0.0."""
|
|
manifest = {
|
|
"id": "sideload-floored", "name": "Sideload", "class_name": "P",
|
|
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
|
|
}
|
|
result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch)
|
|
|
|
assert result["success"] is True, result.get("error")
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The git-pull update path — the one route that does not re-download
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _git_available() -> bool:
|
|
# OSError, not just a non-zero exit: with no git on PATH subprocess raises
|
|
# FileNotFoundError, and this runs at import time -- before skipif can act,
|
|
# so the whole module would error out instead of skipping.
|
|
try:
|
|
return subprocess.run(
|
|
['git', '--version'], capture_output=True).returncode == 0
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
_HAS_GIT = _git_available()
|
|
|
|
|
|
def _git(*args, cwd):
|
|
return subprocess.run(['git', *args], cwd=str(cwd),
|
|
capture_output=True, text=True, check=True)
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_GIT, reason='git not available')
|
|
class TestGitPullGate:
|
|
"""A plugin installed as a git checkout updates by pulling in place, so it
|
|
never passes through `install_plugin` and was never gated.
|
|
|
|
Real git repositories rather than mocks, because the claim under test is
|
|
that `git reset --hard` puts the checkout back — a mock of git would only
|
|
prove the call was made, which is the easy half.
|
|
|
|
Only the handful of registry entries with no `plugin_path` reach this path
|
|
in practice; monorepo plugins install as archives and update through
|
|
`_reinstall_with_rollback`. It is gated anyway because the sunset rule in
|
|
`docs/plugin-development/08-shared-sports-code.md` states the core enforces
|
|
the floor "at install/update time", and a precondition that is documented
|
|
but not true is worse than one that is merely missing.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def checkout(self, tmp_path, monkeypatch):
|
|
"""An origin repo holding a plugin, and a clone of it installed as
|
|
`plugin-repos/gitplug`, with the store pointed at it."""
|
|
from src.plugin_system.store_manager import PluginStoreManager
|
|
|
|
origin = tmp_path / 'origin'
|
|
origin.mkdir()
|
|
_git('init', '--initial-branch=main', '--bare', cwd=origin)
|
|
|
|
seed = tmp_path / 'seed'
|
|
_git('clone', str(origin), str(seed), cwd=tmp_path)
|
|
_git('config', 'user.email', 'test@example.com', cwd=seed)
|
|
_git('config', 'user.name', 'Test', cwd=seed)
|
|
(seed / 'manifest.json').write_text(json.dumps({
|
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "2.0.0",
|
|
}), encoding='utf-8')
|
|
(seed / 'manager.py').write_text("class P: pass\n", encoding='utf-8')
|
|
_git('add', '-A', cwd=seed)
|
|
_git('commit', '-m', 'initial', cwd=seed)
|
|
_git('push', '-u', 'origin', 'main', cwd=seed)
|
|
|
|
plugins_dir = tmp_path / 'plugin-repos'
|
|
plugins_dir.mkdir()
|
|
work = plugins_dir / 'gitplug'
|
|
_git('clone', str(origin), str(work), cwd=tmp_path)
|
|
_git('config', 'user.email', 'test@example.com', cwd=work)
|
|
_git('config', 'user.name', 'Test', cwd=work)
|
|
|
|
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
|
|
mgr.logger = MagicMock()
|
|
# No registry entry, so update_plugin takes the plain-pull branch
|
|
# rather than the remote-mismatch or already-current shortcuts.
|
|
monkeypatch.setattr(mgr, 'fetch_registry', lambda *a, **k: None)
|
|
monkeypatch.setattr(mgr, 'get_plugin_info', lambda *a, **k: None)
|
|
monkeypatch.setattr(mgr, '_install_dependencies', lambda *a, **k: True)
|
|
return mgr, seed, work
|
|
|
|
def _push(self, seed, manifest_text):
|
|
(seed / 'manifest.json').write_text(manifest_text, encoding='utf-8')
|
|
_git('add', '-A', cwd=seed)
|
|
_git('commit', '-m', 'update', cwd=seed)
|
|
_git('push', 'origin', 'main', cwd=seed)
|
|
|
|
def _head(self, work):
|
|
return _git('rev-parse', 'HEAD', cwd=work).stdout.strip()
|
|
|
|
def test_refuses_a_pulled_commit_that_needs_a_newer_core(
|
|
self, checkout, monkeypatch):
|
|
mgr, seed, work = checkout
|
|
before = self._head(work)
|
|
self._push(seed, json.dumps({
|
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
|
}))
|
|
|
|
import src
|
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
|
assert mgr.update_plugin('gitplug') is False
|
|
|
|
assert self._head(work) == before, (
|
|
"a refused update must leave the checkout on the commit it was "
|
|
"already running, not on the one it cannot load")
|
|
floor = json.loads((work / 'manifest.json').read_text())
|
|
assert floor['min_ledmatrix_version'] == '2.0.0', (
|
|
"the reset must restore the working tree, not just the ref")
|
|
|
|
def test_allows_a_pulled_commit_the_core_can_run(
|
|
self, checkout, monkeypatch):
|
|
"""The guard against over-refusing. A gate that refuses everything
|
|
passes the test above and breaks every update."""
|
|
mgr, seed, work = checkout
|
|
before = self._head(work)
|
|
self._push(seed, json.dumps({
|
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
|
}))
|
|
|
|
import src
|
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
|
assert mgr.update_plugin('gitplug') is True
|
|
assert self._head(work) != before
|
|
|
|
def test_an_unreadable_manifest_after_pull_is_not_a_refusal(
|
|
self, checkout, monkeypatch):
|
|
"""Rule 1 of this file — refuse only on evidence. A manifest that
|
|
will not parse declares no floor, so it is not evidence of anything."""
|
|
mgr, seed, work = checkout
|
|
self._push(seed, '{ this is not json')
|
|
|
|
import src
|
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
|
assert mgr.update_plugin('gitplug') is True
|
|
|
|
def test_an_untrustworthy_core_does_not_block_a_2_0_0_floor_on_pull(
|
|
self, checkout, monkeypatch):
|
|
"""The same regression guard as
|
|
`test_untrustworthy_core_does_not_block_installs`, because this path
|
|
now shares that rule: a v3.1.0 release reports 1.0.0, and nearly every
|
|
published manifest floors at 2.0.0."""
|
|
mgr, seed, work = checkout
|
|
before = self._head(work)
|
|
self._push(seed, json.dumps({
|
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "2.0.0",
|
|
"description": "changed",
|
|
}))
|
|
|
|
import src
|
|
monkeypatch.setattr(src, '__version__', '1.0.0')
|
|
assert mgr.update_plugin('gitplug') is True
|
|
assert self._head(work) != before
|
|
|
|
def test_a_failed_stash_stops_the_update_before_pulling(
|
|
self, checkout, monkeypatch):
|
|
"""Uncommitted work is not collateral for the gate.
|
|
|
|
The gate's only rollback is `git reset --hard`, which discards
|
|
uncommitted tracked edits. update_plugin stashes them first -- but when
|
|
that stash fails it used to pull anyway, and a pull touching different
|
|
files succeeds on a dirty tree. Refuse, refuse the rollback's rollback,
|
|
and the user's edits go with it.
|
|
"""
|
|
mgr, seed, work = checkout
|
|
before = self._head(work)
|
|
(work / 'manager.py').write_text(
|
|
"class P:\n MINE = 'do not lose this'\n", encoding='utf-8')
|
|
self._push(seed, json.dumps({
|
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
|
}))
|
|
|
|
real_run = subprocess.run
|
|
|
|
def fail_stash(cmd, *a, **k):
|
|
if isinstance(cmd, list) and 'stash' in cmd:
|
|
return subprocess.CompletedProcess(cmd, 1, '', 'stash boom')
|
|
return real_run(cmd, *a, **k)
|
|
|
|
import src
|
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
|
monkeypatch.setattr(
|
|
'src.plugin_system.store_update.subprocess.run', fail_stash)
|
|
assert mgr.update_plugin('gitplug') is False
|
|
|
|
assert self._head(work) == before, "must not pull what it cannot undo"
|
|
assert 'do not lose this' in (work / 'manager.py').read_text(), (
|
|
"the local edit the stash failed to save must still be there")
|
|
|
|
def test_rollback_reports_the_recovery_command_when_git_fails(
|
|
self, checkout, monkeypatch):
|
|
"""If the reset itself fails the user is left on a version that cannot
|
|
load, so the log line has to carry the command that fixes it —
|
|
it is the only thing standing between them and a manual reinstall."""
|
|
mgr, seed, work = checkout
|
|
self._push(seed, json.dumps({
|
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
|
}))
|
|
|
|
real_run = subprocess.run
|
|
|
|
def fail_reset(cmd, *a, **k):
|
|
if isinstance(cmd, list) and 'reset' in cmd:
|
|
return subprocess.CompletedProcess(cmd, 1, '', 'reset boom')
|
|
return real_run(cmd, *a, **k)
|
|
|
|
import src
|
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
|
monkeypatch.setattr(
|
|
'src.plugin_system.store_update.subprocess.run', fail_reset)
|
|
assert mgr.update_plugin('gitplug') is False
|
|
|
|
logged = ' '.join(str(c) for c in mgr.logger.error.call_args_list)
|
|
assert 'reset --hard' in logged, logged
|
|
|
|
|
|
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")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# compatible_versions — the schema-required field, and the only one that can
|
|
# express an upper bound
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestCompatibleVersions:
|
|
@pytest.mark.parametrize("spec,core,expected", [
|
|
(">=2.0.0", "3.2.0", True),
|
|
(">=2.0.0", "1.9.9", False),
|
|
("<=3.0.0", "3.2.0", False),
|
|
("<=3.0.0", "2.9.0", True),
|
|
(">3.2.0", "3.2.0", False),
|
|
("<4.0.0", "3.2.0", True),
|
|
("3.2.0", "3.2.0", True), # bare == exact match
|
|
("3.2.0", "3.2.1", False),
|
|
("~3.2.0", "3.2.9", True), # patch-level only
|
|
("~3.2.0", "3.3.0", False),
|
|
("^3.2.0", "3.9.9", True), # minor + patch
|
|
("^3.2.0", "4.0.0", False),
|
|
("2.0.0 - 3.2.0", "3.2.0", True), # inclusive both ends
|
|
("2.0.0 - 3.2.0", "2.0.0", True),
|
|
("2.0.0 - 3.2.0", "3.2.1", False),
|
|
("v3.2.0", "3.2.0", True), # leading v tolerated
|
|
("3.2.0-beta.1", "3.2.0", True), # prerelease suffix ignored
|
|
])
|
|
def test_range_forms(self, spec, core, expected):
|
|
got = compatibility.satisfies_compatible_versions(
|
|
{"compatible_versions": [spec]}, compatibility.parse_semver(core))
|
|
assert got is expected, f"{spec!r} vs {core}"
|
|
|
|
def test_array_is_alternatives_not_conjunction(self):
|
|
"""Satisfying any one entry is enough — otherwise ['<2.0.0','>=3.0.0']
|
|
could never be satisfied by anything."""
|
|
m = {"compatible_versions": ["<2.0.0", ">=3.0.0"]}
|
|
assert compatibility.satisfies_compatible_versions(
|
|
m, compatibility.parse_semver("3.2.0")) is True
|
|
|
|
def test_absent_or_unparseable_is_no_evidence(self):
|
|
core = compatibility.parse_semver("3.2.0")
|
|
assert compatibility.satisfies_compatible_versions({}, core) is None
|
|
assert compatibility.satisfies_compatible_versions(
|
|
{"compatible_versions": []}, core) is None
|
|
assert compatibility.satisfies_compatible_versions(
|
|
{"compatible_versions": ["not a version"]}, core) is None
|
|
# One unparseable entry alongside a good one must not poison the result.
|
|
assert compatibility.satisfies_compatible_versions(
|
|
{"compatible_versions": ["garbage", ">=2.0.0"]}, core) is True
|
|
|
|
|
|
class TestMoreRestrictiveWins:
|
|
def test_upper_bound_blocks_a_core_that_clears_the_floor(self):
|
|
"""The gap this closes: the floor says 2.0.0 and the core is 3.2.0, so
|
|
the floor alone would allow it — but the plugin said it stops at 2.x."""
|
|
m = {"name": "Legacy Plugin",
|
|
"compatible_versions": ["2.0.0 - 2.9.9"],
|
|
"versions": [{"ledmatrix_min_version": "2.0.0"}]}
|
|
ok, reason = compatibility.check(m, "3.2.0")
|
|
assert ok is False
|
|
assert "2.0.0 - 2.9.9" in reason and "3.2.0" in reason
|
|
|
|
def test_floor_blocks_when_ranges_would_allow(self):
|
|
m = {"name": "Needs Newer",
|
|
"compatible_versions": [">=1.0.0"],
|
|
"versions": [{"ledmatrix_min_version": "9.9.9"}]}
|
|
ok, reason = compatibility.check(m, "3.2.0")
|
|
assert ok is False
|
|
assert "9.9.9" in reason
|
|
|
|
def test_both_satisfied_allows(self):
|
|
m = {"compatible_versions": [">=2.0.0"],
|
|
"versions": [{"ledmatrix_min_version": "2.0.0"}]}
|
|
assert compatibility.check(m, "3.2.0") == (True, None)
|
|
|
|
def test_untrustworthy_core_still_bypasses_both_checks(self):
|
|
"""A core reporting 1.0.0 fails `>=2.0.0`, which 41 of 42 published
|
|
manifests declare. Blocking there would empty the plugin store for
|
|
exactly the users who cannot be helped by it."""
|
|
m = {"compatible_versions": [">=2.0.0"],
|
|
"versions": [{"ledmatrix_min_version": "2.0.0"}]}
|
|
assert compatibility.check(m, "1.0.0") == (True, None)
|
|
|
|
|
|
class TestUntrustworthyCoreAndTheSunset:
|
|
"""The population B6 would otherwise break.
|
|
|
|
A device installed from the v3.1.0 release reports `__version__ = "1.0.0"`.
|
|
The gate cannot tell it apart from a genuine 1.0.0 install, so it cannot
|
|
reason about what that core actually has — and at the B6 sunset the
|
|
plugin's guarded-import fallback is gone. Without this rule the store hands
|
|
those users a 3.2.0-floored plugin that fails to load, and nothing else in
|
|
the system protects them.
|
|
|
|
The rule: on an untrustworthy core, refuse a floor *above* the ecosystem
|
|
baseline, allow anything at or below it. Every manifest published today
|
|
floors at exactly 2.0.0, so nobody is locked out of the store.
|
|
"""
|
|
|
|
UNTRUSTWORTHY = ["1.0.0", "0.9.0", "1.9.9"]
|
|
|
|
@pytest.mark.parametrize("core", UNTRUSTWORTHY)
|
|
def test_refuses_a_post_sunset_floor(self, core):
|
|
m = {"name": "Hockey Scoreboard",
|
|
"versions": [{"ledmatrix_min_version": "3.2.0"}]}
|
|
ok, reason = compatibility.check(m, core)
|
|
assert ok is False, (
|
|
f"core {core} must not receive a 3.2.0-floored plugin: after the "
|
|
"sunset there is no fallback and it will fail to load"
|
|
)
|
|
assert "3.2.0" in reason and core in reason
|
|
|
|
@pytest.mark.parametrize("core", UNTRUSTWORTHY)
|
|
def test_still_allows_todays_ecosystem_floor(self, core):
|
|
"""Regression guard: every published manifest floors at 2.0.0. If this
|
|
starts refusing, those users lose the plugin store entirely."""
|
|
m = {"versions": [{"ledmatrix_min": "2.0.0"}]}
|
|
assert compatibility.check(m, core) == (True, None)
|
|
|
|
@pytest.mark.parametrize("core", UNTRUSTWORTHY)
|
|
def test_still_allows_an_undeclared_floor(self, core):
|
|
assert compatibility.check({"id": "x"}, core) == (True, None)
|
|
|
|
def test_trustworthy_core_below_the_floor_is_unaffected(self):
|
|
"""A core that reports 3.1.0 is believable and already handled by the
|
|
ordinary comparison — not by this rule."""
|
|
m = {"name": "P", "versions": [{"ledmatrix_min_version": "3.2.0"}]}
|
|
ok, reason = compatibility.check(m, "3.1.0")
|
|
assert ok is False
|
|
assert "too old to identify" not in reason, (
|
|
"a believable version should get the ordinary message"
|
|
)
|
|
|
|
|
|
class TestMalformedManifests:
|
|
"""A manifest we cannot parse must read as "no declared floor", not raise.
|
|
|
|
This matters more since the untrustworthy-core branch of check() began
|
|
resolving the floor for *every* manifest: one hand-edited or third-party
|
|
file with the wrong shape would take down the whole install path rather
|
|
than just itself.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("manifest", [
|
|
{"requires": ["python>=3.9"]}, # a list, not a mapping
|
|
{"requires": "python>=3.9"}, # a bare string
|
|
{"versions": {"a": 1}}, # a mapping, not a list
|
|
{"versions": "1.0.0"}, # a bare string
|
|
{"versions": [None]}, # a list of the wrong thing
|
|
{"versions": []},
|
|
])
|
|
def test_shape_errors_read_as_no_floor(self, manifest):
|
|
assert compatibility.declared_min_version(manifest) is None
|
|
assert compatibility.check(manifest, "1.0.0") == (True, None)
|
|
assert compatibility.check(manifest, "3.2.0") == (True, None)
|
|
|
|
def test_a_valid_requires_block_still_works(self):
|
|
assert compatibility.declared_min_version(
|
|
{"requires": {"min_ledmatrix_version": "3.2.0"}}) == "3.2.0"
|
|
|
|
|
|
class TestSuffixedVersions:
|
|
"""Prerelease and build metadata must not leak into the numbers.
|
|
|
|
The digit scrape used to pull them in: "3.2.0+build42" became (3, 2, 42)
|
|
and "3.2.0-rc1" became (3, 2, 1) — a release candidate ranking above its
|
|
own release. Both fed reject decisions.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("text,expected", [
|
|
("3.2.0", (3, 2, 0)),
|
|
("3.2.0+build42", (3, 2, 0)),
|
|
("3.2.0-rc1", (3, 2, 0)),
|
|
("3.2.0-rc.1+build.9", (3, 2, 0)),
|
|
("v3.2.0+build42", (3, 2, 0)),
|
|
])
|
|
def test_suffixes_are_dropped(self, text, expected):
|
|
assert compatibility.parse_semver(text) == expected
|
|
|
|
def test_a_build_of_the_pinned_version_is_not_refused(self):
|
|
"""The regression: an exact "3.2.0" pin refused a core running
|
|
3.2.0+build42, which is that same version."""
|
|
ok, reason = compatibility.check(
|
|
{"compatible_versions": ["3.2.0"]}, "3.2.0+build42")
|
|
assert ok is True, f"refused a build of the pinned version: {reason}"
|
|
|
|
def test_a_release_candidate_does_not_outrank_its_release(self):
|
|
m = {"versions": [{"ledmatrix_min_version": "3.2.0"}]}
|
|
assert compatibility.check(m, "3.2.0-rc1")[0] is True
|
|
# ...and still refuses something genuinely older.
|
|
assert compatibility.check(m, "3.1.0-rc1")[0] is False
|