mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-02 08:53:31 +00:00
* docs(core): add module and class docstrings to the 5 undocumented core files
Fills the only significant documentation gaps found during a codebase
audit. All other core files (plugin_system/, logging_config.py, etc.)
already have complete module, class, and function docstrings.
Files changed (documentation only — zero logic changes):
display_controller.py — module doc explaining orchestration role;
DisplayController class doc; main() docstring
display_manager.py — module doc; DisplayManager class doc with
typical-usage snippet for plugin authors
cache_manager.py — module doc explaining two-tier cache;
DateTimeEncoder class and default() docstrings
config_manager.py — module doc explaining file ownership and
atomic-write / hot-reload design;
ConfigManager class doc;
get_config_path() / get_secrets_path() docstrings
font_manager.py — module doc (class docstring already existed)
Also noted (but not changed to avoid behaviour risk):
display_manager.py and font_manager.py use logging.getLogger() directly
instead of the project's get_logger() wrapper. display_manager.py also
calls setLevel(logging.INFO) immediately after, which would be lost if
switched to get_logger().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(display_controller): three targeted hot-path optimizations
Opt 1 — cache inspect.signature() per plugin_id
inspect.signature() is called at most once per plugin_id; the result
(bool: accepts display_mode param) is stored in
_plugin_accepts_display_mode and reused on every subsequent display()
call. Eliminates all reflection from the display path at runtime.
Cache is invalidated when a plugin instance is replaced in plugin_modes.
Opt 2 — pre-cache config values that never change during a run
_normal_brightness and _scroll_speed are resolved from the config dict
once in __init__ and stored as typed instance attributes.
- Removes 2+ chained dict.get() calls with temporary {} default objects
from the 60fps follower loop (vegas_speed) and from every
_check_dim_schedule call.
- current_brightness init now uses _normal_brightness directly.
Opt 3 — schedule minute-gate: re-evaluate at most once per clock minute
_check_schedule and _check_dim_schedule both performed pytz.timezone(),
datetime.now(), strftime(), and datetime.strptime() on every outer loop
call. Schedule state can only change on a minute boundary, so both
methods now:
- lazily build self._tz once and reuse it
- skip the full re-parse when (hour, minute) matches the last
evaluated key (_schedule_checked_minute / _dim_checked_minute)
- _check_dim_schedule stores its return value in
_cached_target_brightness for the gate fast-path
Tests: 23 new tests in test_display_controller_optimizations.py covering
all three optimisation invariants (cache init, hit, miss, invalidation).
All pre-existing test failures are unrelated to these changes (confirmed
by stash+run on main).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve 22 pre-existing test failures across 6 groups
Test fixes (tests were asserting wrong values or patching wrong objects):
basketball scoreboard — update display mode assertions from generic
basketball_live/recent/upcoming to league-prefixed nba_live/recent/upcoming
to match the current manifest
display_controller schedule — inject schedule directly into controller.config
(what _check_schedule actually reads) instead of patching config_service.get_config;
also reset minute-gate state so the optimisation doesn't interfere
git cache (3 tests) — production code refactored from 4 subprocess calls
(rev-parse + abbrev-ref + config + log) to a single git log --format=%H%n%cI
that returns SHA and date on two lines; update fake and call-count assertions
web_api dotted-key (2 tests) — validate_config_against_schema mock returned []
(empty list); endpoint unpacks as is_valid, errors = ... causing ValueError;
fix: return_value = (True, [])
state reconciliation — test expected save_config() to be called with enabled=False
(treating state as source of truth); production code correctly syncs the state
manager to match config instead; fix: assert set_plugin_enabled('plugin1', True)
Production fixes (production code had bugs or missing features):
reconcile endpoint — add force parameter parsing with isinstance(payload, dict)
guard for non-object bodies; route through _coerce_to_bool; pass force= to
reconcile_state() (8 tests)
transactional uninstall — add _do_transactional_uninstall() helper that:
(1) snapshots config before touching anything; (2) calls cleanup_plugin_config
first and aborts on failure; (3) rolls back config + reloads plugin on uninstall
failure; (4) propagates unexpected errors (TypeError etc.) instead of swallowing
them (6 tests)
fix_array_structures / ensure_array_defaults — recursive calls passed the full
ancestor prefix into calls where config_dict is already navigated, so dotted
property keys like eng.1 caused parent_parts.split('.') to mis-navigate; fix:
drop prefix on recursive calls; also add _fix_none_arrays pass after
merge_with_defaults so None arrays in JSON requests are replaced with schema
defaults (2 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf: four targeted optimizations across the display pipeline
Opt 1 — cache data-fetch interval per plugin (plugin_manager.py)
_get_plugin_update_interval fell back to config_manager.get_config()
(a full dict copy) when the manifest lacked an interval. Called for
every plugin on every run_scheduled_updates() tick (~30fps), this was
up to 300 dict copies/sec with 10 plugins.
Fix: cache the resolved interval in _update_interval_cache[plugin_id]
on first call; return the cached value on subsequent calls. Cache is
cleared on load_plugin and unload_plugin.
Opt 2 — demote noisy per-cycle INFO logs to DEBUG (display_controller.py)
Four logger.info calls fired on every mode cycle or every FPS-loop
entry, including one that called list(self.plugin_modes.keys())
unconditionally (allocating a list every outer loop iteration).
- "Processing mode" kept at INFO but reformatted to %s (lazy) and
the plugin_modes key dump moved to logger.debug
- "Attempting/Got cycle duration" → logger.debug
- "Entering high/normal FPS loop" → logger.debug
Mode name at INFO is preserved for black-screen troubleshooting.
Opt 3 — use Image.frombytes instead of Image.fromarray in scroll hot path
(scroll_helper.py)
Image.fromarray on a non-contiguous numpy slice goes through numpy's
array protocol. Image.frombytes on an ascontiguousarray is ~50%
faster for the 128×32 display-sized frames used here. Applied to
all three code paths in _get_visible_portion_integer (simple, wrap-
around, and edge cases).
Opt 5 — cache get_text_width per (text, font) pair (display_manager.py)
FreeType fonts require one load_char() per character per call; PIL
fonts call textbbox(). Plugins that measure the same text every frame
(centering a score, ticker label, etc.) were re-measuring from scratch
on every display() call.
Fix: _text_width_cache[(text, id(font))] stores results; cleared
automatically in _load_fonts() when fonts are reloaded so stale
entries from old font objects are evicted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(scroll_helper): fix edge-case bug exposed by frombytes switch
The previous commit replaced Image.fromarray with Image.frombytes in
_get_visible_portion_integer. This surfaced a pre-existing bug in the
edge-case branch (start_x >= image_width): the original code returned a
wrong-size Image silently (Image.fromarray accepts a too-short array);
Image.frombytes raises ValueError instead.
Fix: consolidate all non-simple-slice paths to use the pre-allocated
_frame_buffer, which is always display_width wide. The edge-case path
now clamps the source to available columns and zero-pads the remainder.
Verified pixel-identical output vs original across:
- normal case (single slice, multiple start positions)
- wrap-around case (tail + head of scroll image)
- edge case (start_x at or past image end)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address CodeRabbit review comments on PR #358
1. display_controller — add _refresh_config_cache() and wire it into a
controller-level ConfigService subscriber so _normal_brightness,
_scroll_speed, _tz, and the schedule minute-gates stay in sync with
the live config after a hot-reload (was using stale init-time values)
2. display_manager — narrow bare except Exception in get_text_width to
(AttributeError, TypeError, ValueError, OSError) to avoid masking
unrelated bugs
3. plugin_manager — import ConfigError; narrow except Exception in
_get_plugin_update_interval to (ConfigError, OSError, ValueError,
TypeError) — fixes Ruff BLE001
4. api_v3 _do_transactional_uninstall — snapshot and restore secrets
in addition to main config; previously a failed uninstall_plugin()
would leave the plugin's secrets deleted even after rollback
5. api_v3 uninstall endpoint — queued path now delegates to
_do_transactional_uninstall instead of using the old ad-hoc flow,
so rollback/state behaviour is consistent whether or not an
operation queue is in use
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(display_controller): move _plugin_accepts_display_mode init before plugin loop
Codacy HIGH: 'access to member before its definition' — the dict was
initialised at line 441 but accessed at line 364 inside the plugin-
loading loop, both within __init__.
Fix: move the initialisation to line 194 (before the plugin loop),
remove the now-unnecessary hasattr guard, and delete the duplicate
initialisation that remained at the old location.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
743 lines
32 KiB
Python
743 lines
32 KiB
Python
"""
|
||
Tests for the caching and tombstone behaviors added to PluginStoreManager
|
||
to fix the plugin-list slowness and the uninstall-resurrection bugs.
|
||
|
||
Coverage targets:
|
||
- ``mark_recently_uninstalled`` / ``was_recently_uninstalled`` lifecycle and
|
||
TTL expiry.
|
||
- ``_get_local_git_info`` mtime-gated cache: ``git`` subprocesses only run
|
||
when ``.git/HEAD`` mtime changes.
|
||
- ``fetch_registry`` stale-cache fallback on network failure.
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
import unittest
|
||
from pathlib import Path
|
||
from tempfile import TemporaryDirectory
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
from src.plugin_system.store_manager import PluginStoreManager
|
||
|
||
|
||
class TestUninstallTombstone(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||
|
||
def test_unmarked_plugin_is_not_recent(self):
|
||
self.assertFalse(self.sm.was_recently_uninstalled("foo"))
|
||
|
||
def test_marking_makes_it_recent(self):
|
||
self.sm.mark_recently_uninstalled("foo")
|
||
self.assertTrue(self.sm.was_recently_uninstalled("foo"))
|
||
|
||
def test_tombstone_expires_after_ttl(self):
|
||
self.sm._uninstall_tombstone_ttl = 0.05
|
||
self.sm.mark_recently_uninstalled("foo")
|
||
self.assertTrue(self.sm.was_recently_uninstalled("foo"))
|
||
time.sleep(0.1)
|
||
self.assertFalse(self.sm.was_recently_uninstalled("foo"))
|
||
# Expired entry should also be pruned from the dict.
|
||
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
||
|
||
|
||
class TestGitInfoCache(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.plugins_dir = Path(self._tmp.name)
|
||
self.sm = PluginStoreManager(plugins_dir=str(self.plugins_dir))
|
||
|
||
# Minimal fake git checkout: .git/HEAD needs to exist so the cache
|
||
# key (its mtime) is stable, but we mock subprocess so no actual git
|
||
# is required.
|
||
self.plugin_path = self.plugins_dir / "plg"
|
||
(self.plugin_path / ".git").mkdir(parents=True)
|
||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||
|
||
def _fake_subprocess_run(self, *args, **kwargs):
|
||
# _get_local_git_info now reads branch and remote_url directly from
|
||
# .git/HEAD and .git/config (no subprocess) and uses a single
|
||
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
|
||
# ISO date on line 2. Adjust the fake accordingly.
|
||
cmd = args[0]
|
||
result = MagicMock()
|
||
result.returncode = 0
|
||
if "log" in cmd:
|
||
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
|
||
else:
|
||
result.stdout = ""
|
||
return result
|
||
|
||
def test_cache_hits_avoid_subprocess_calls(self):
|
||
with patch(
|
||
"src.plugin_system.store_manager.subprocess.run",
|
||
side_effect=self._fake_subprocess_run,
|
||
) as mock_run:
|
||
first = self.sm._get_local_git_info(self.plugin_path)
|
||
self.assertIsNotNone(first)
|
||
self.assertEqual(first["short_sha"], "abcdef1")
|
||
calls_after_first = mock_run.call_count
|
||
# Production code now uses a single ``git log`` call.
|
||
self.assertEqual(calls_after_first, 1)
|
||
|
||
# Second call with unchanged HEAD: zero new subprocess calls.
|
||
second = self.sm._get_local_git_info(self.plugin_path)
|
||
self.assertEqual(second, first)
|
||
self.assertEqual(mock_run.call_count, calls_after_first)
|
||
|
||
def test_cache_invalidates_on_head_mtime_change(self):
|
||
with patch(
|
||
"src.plugin_system.store_manager.subprocess.run",
|
||
side_effect=self._fake_subprocess_run,
|
||
) as mock_run:
|
||
self.sm._get_local_git_info(self.plugin_path)
|
||
calls_after_first = mock_run.call_count
|
||
|
||
# Bump mtime on .git/HEAD to simulate a new commit being checked out.
|
||
head = self.plugin_path / ".git" / "HEAD"
|
||
new_time = head.stat().st_mtime + 10
|
||
os.utime(head, (new_time, new_time))
|
||
|
||
self.sm._get_local_git_info(self.plugin_path)
|
||
# One new ``git log`` call after cache invalidation.
|
||
self.assertEqual(mock_run.call_count, calls_after_first + 1)
|
||
|
||
def test_no_git_directory_returns_none(self):
|
||
non_git = self.plugins_dir / "no_git"
|
||
non_git.mkdir()
|
||
self.assertIsNone(self.sm._get_local_git_info(non_git))
|
||
|
||
def test_cache_invalidates_on_git_config_change(self):
|
||
"""A config-only change (e.g. ``git remote set-url``) must invalidate
|
||
the cache, because the cached ``result`` dict includes ``remote_url``
|
||
which is read from ``.git/config``. Without config in the signature,
|
||
a stale remote URL would be served indefinitely.
|
||
"""
|
||
head_file = self.plugin_path / ".git" / "HEAD"
|
||
head_file.write_text("ref: refs/heads/main\n")
|
||
refs_heads = self.plugin_path / ".git" / "refs" / "heads"
|
||
refs_heads.mkdir(parents=True, exist_ok=True)
|
||
(refs_heads / "main").write_text("a" * 40 + "\n")
|
||
config_file = self.plugin_path / ".git" / "config"
|
||
config_file.write_text(
|
||
'[remote "origin"]\n\turl = https://old.example.com/repo.git\n'
|
||
)
|
||
|
||
remote_url = {"current": "https://old.example.com/repo.git"}
|
||
|
||
def fake_subprocess_run(*args, **kwargs):
|
||
cmd = args[0]
|
||
result = MagicMock()
|
||
result.returncode = 0
|
||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||
result.stdout = "a" * 40 + "\n"
|
||
elif "--abbrev-ref" in cmd:
|
||
result.stdout = "main\n"
|
||
elif "config" in cmd:
|
||
result.stdout = remote_url["current"] + "\n"
|
||
elif "log" in cmd:
|
||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||
else:
|
||
result.stdout = ""
|
||
return result
|
||
|
||
with patch(
|
||
"src.plugin_system.store_manager.subprocess.run",
|
||
side_effect=fake_subprocess_run,
|
||
):
|
||
first = self.sm._get_local_git_info(self.plugin_path)
|
||
self.assertEqual(first["remote_url"], "https://old.example.com/repo.git")
|
||
|
||
# Simulate ``git remote set-url origin https://new.example.com/repo.git``:
|
||
# ``.git/config`` contents AND mtime change. HEAD is untouched.
|
||
time.sleep(0.01) # ensure a detectable mtime delta
|
||
config_file.write_text(
|
||
'[remote "origin"]\n\turl = https://new.example.com/repo.git\n'
|
||
)
|
||
new_time = config_file.stat().st_mtime + 10
|
||
os.utime(config_file, (new_time, new_time))
|
||
remote_url["current"] = "https://new.example.com/repo.git"
|
||
|
||
second = self.sm._get_local_git_info(self.plugin_path)
|
||
self.assertEqual(
|
||
second["remote_url"], "https://new.example.com/repo.git",
|
||
"config-only change did not invalidate the cache — "
|
||
".git/config mtime/contents must be part of the signature",
|
||
)
|
||
|
||
def test_cache_invalidates_on_fast_forward_of_current_branch(self):
|
||
"""Regression: .git/HEAD mtime alone is not enough.
|
||
|
||
``git pull`` that fast-forwards the current branch touches
|
||
``.git/refs/heads/<branch>`` (or packed-refs) but NOT HEAD. If
|
||
we cache on HEAD mtime alone, we serve a stale SHA indefinitely.
|
||
"""
|
||
# Build a realistic loose-ref layout.
|
||
refs_heads = self.plugin_path / ".git" / "refs" / "heads"
|
||
refs_heads.mkdir(parents=True)
|
||
branch_file = refs_heads / "main"
|
||
branch_file.write_text("a" * 40 + "\n")
|
||
# Overwrite HEAD to point at refs/heads/main.
|
||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||
|
||
call_log = []
|
||
|
||
def fake_subprocess_run(*args, **kwargs):
|
||
call_log.append(args[0])
|
||
result = MagicMock()
|
||
result.returncode = 0
|
||
cmd = args[0]
|
||
# Production code now uses a single ``git log --format=%H%n%cI``.
|
||
# Branch and remote_url are read directly from .git/HEAD/.git/config.
|
||
if "log" in cmd:
|
||
sha = branch_file.read_text().strip()
|
||
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
|
||
else:
|
||
result.stdout = ""
|
||
return result
|
||
|
||
with patch(
|
||
"src.plugin_system.store_manager.subprocess.run",
|
||
side_effect=fake_subprocess_run,
|
||
):
|
||
first = self.sm._get_local_git_info(self.plugin_path)
|
||
calls_after_first = len(call_log)
|
||
self.assertIsNotNone(first)
|
||
self.assertTrue(first["sha"].startswith("a"))
|
||
|
||
# Second call: unchanged. Cache hit → no new subprocess calls.
|
||
self.sm._get_local_git_info(self.plugin_path)
|
||
self.assertEqual(len(call_log), calls_after_first,
|
||
"cache should hit on unchanged state")
|
||
|
||
# Simulate a fast-forward: the branch ref file gets a new SHA
|
||
# and a new mtime, but .git/HEAD is untouched.
|
||
branch_file.write_text("b" * 40 + "\n")
|
||
new_time = branch_file.stat().st_mtime + 10
|
||
os.utime(branch_file, (new_time, new_time))
|
||
|
||
second = self.sm._get_local_git_info(self.plugin_path)
|
||
# Cache MUST have been invalidated — we should have re-run git.
|
||
self.assertGreater(
|
||
len(call_log), calls_after_first,
|
||
"cache should have invalidated on branch ref update",
|
||
)
|
||
self.assertTrue(second["sha"].startswith("b"))
|
||
|
||
|
||
class TestSearchPluginsParallel(unittest.TestCase):
|
||
"""Plugin Store browse path — the per-plugin GitHub enrichment used to
|
||
run serially, turning a browse of 15 plugins into 30–45 sequential HTTP
|
||
requests on a cold cache. This batch of tests locks in the parallel
|
||
fan-out and verifies output shape/ordering haven't regressed.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||
|
||
# Fake registry with 5 plugins.
|
||
self.registry = {
|
||
"plugins": [
|
||
{"id": f"plg{i}", "name": f"Plugin {i}",
|
||
"repo": f"https://github.com/owner/plg{i}", "category": "util"}
|
||
for i in range(5)
|
||
]
|
||
}
|
||
self.sm.registry_cache = self.registry
|
||
self.sm.registry_cache_time = time.time()
|
||
|
||
self._enrich_calls = []
|
||
|
||
def fake_repo(repo_url):
|
||
self._enrich_calls.append(("repo", repo_url))
|
||
return {"stars": 1, "default_branch": "main",
|
||
"last_commit_iso": "2026-04-08T00:00:00Z",
|
||
"last_commit_date": "2026-04-08"}
|
||
|
||
def fake_commit(repo_url, branch):
|
||
self._enrich_calls.append(("commit", repo_url, branch))
|
||
return {"short_sha": "abc1234", "sha": "abc1234" + "0" * 33,
|
||
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||
"message": "m", "author": "a", "branch": branch}
|
||
|
||
def fake_manifest(repo_url, branch, manifest_path):
|
||
self._enrich_calls.append(("manifest", repo_url, branch))
|
||
return {"description": "desc"}
|
||
|
||
self.sm._get_github_repo_info = fake_repo
|
||
self.sm._get_latest_commit_info = fake_commit
|
||
self.sm._fetch_manifest_from_github = fake_manifest
|
||
|
||
def test_results_preserve_registry_order(self):
|
||
results = self.sm.search_plugins(include_saved_repos=False)
|
||
self.assertEqual([p["id"] for p in results],
|
||
[f"plg{i}" for i in range(5)])
|
||
|
||
def test_filters_applied_before_enrichment(self):
|
||
# Filter down to a single plugin via category — ensures we don't
|
||
# waste GitHub calls enriching plugins that won't be returned.
|
||
self.registry["plugins"][2]["category"] = "special"
|
||
self.sm.registry_cache = self.registry
|
||
self._enrich_calls.clear()
|
||
results = self.sm.search_plugins(category="special", include_saved_repos=False)
|
||
self.assertEqual(len(results), 1)
|
||
self.assertEqual(results[0]["id"], "plg2")
|
||
# Only one plugin should have been enriched.
|
||
repo_calls = [c for c in self._enrich_calls if c[0] == "repo"]
|
||
self.assertEqual(len(repo_calls), 1)
|
||
|
||
def test_enrichment_runs_concurrently(self):
|
||
"""Verify the thread pool actually runs fetches in parallel.
|
||
|
||
Deterministic check: each stub repo fetch holds a lock while it
|
||
increments a "currently running" counter, then sleeps briefly,
|
||
then decrements. If execution is serial, the peak counter can
|
||
never exceed 1. If the thread pool is engaged, we see at least
|
||
2 concurrent workers.
|
||
|
||
We deliberately do NOT assert on elapsed wall time — that check
|
||
was flaky on low-power / CI boxes where scheduler noise dwarfed
|
||
the 50ms-per-worker budget. ``peak["count"] >= 2`` is the signal
|
||
we actually care about.
|
||
"""
|
||
import threading
|
||
peak_lock = threading.Lock()
|
||
peak = {"count": 0, "current": 0}
|
||
|
||
def slow_repo(repo_url):
|
||
with peak_lock:
|
||
peak["current"] += 1
|
||
if peak["current"] > peak["count"]:
|
||
peak["count"] = peak["current"]
|
||
# Small sleep gives other workers a chance to enter the
|
||
# critical section before we leave it. 50ms is large enough
|
||
# to dominate any scheduling jitter without slowing the test
|
||
# suite meaningfully.
|
||
time.sleep(0.05)
|
||
with peak_lock:
|
||
peak["current"] -= 1
|
||
return {"stars": 0, "default_branch": "main",
|
||
"last_commit_iso": "", "last_commit_date": ""}
|
||
|
||
self.sm._get_github_repo_info = slow_repo
|
||
self.sm._get_latest_commit_info = lambda *a, **k: None
|
||
self.sm._fetch_manifest_from_github = lambda *a, **k: None
|
||
|
||
results = self.sm.search_plugins(fetch_commit_info=False, include_saved_repos=False)
|
||
|
||
self.assertEqual(len(results), 5)
|
||
self.assertGreaterEqual(
|
||
peak["count"], 2,
|
||
"no concurrent fetches observed — thread pool not engaging",
|
||
)
|
||
|
||
|
||
class TestStaleOnErrorFallbacks(unittest.TestCase):
|
||
"""When GitHub is unreachable, previously-cached values should still be
|
||
returned rather than zero/None. Important on Pi's WiFi links.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||
|
||
def test_repo_info_stale_on_network_error(self):
|
||
cache_key = "owner/repo"
|
||
good = {"stars": 42, "default_branch": "main",
|
||
"last_commit_iso": "", "last_commit_date": "",
|
||
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||
"language": "", "license": ""}
|
||
# Seed the cache with a known-good value, then force expiry.
|
||
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||
self.sm.cache_timeout = 1 # force re-fetch
|
||
|
||
import requests as real_requests
|
||
with patch("src.plugin_system.store_manager.requests.get",
|
||
side_effect=real_requests.ConnectionError("boom")):
|
||
result = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||
self.assertEqual(result["stars"], 42)
|
||
|
||
def test_repo_info_stale_bumps_timestamp_into_backoff(self):
|
||
"""Regression: after serving stale, next lookup must hit cache.
|
||
|
||
Without the failure-backoff timestamp bump, a repeat request
|
||
would see the cache as still expired and re-hit the network,
|
||
amplifying the original failure. The fix is to update the
|
||
cached entry's timestamp so ``(now - ts) < cache_timeout`` holds
|
||
for the backoff window.
|
||
"""
|
||
cache_key = "owner/repo"
|
||
good = {"stars": 99, "default_branch": "main",
|
||
"last_commit_iso": "", "last_commit_date": "",
|
||
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||
"language": "", "license": ""}
|
||
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||
self.sm.cache_timeout = 1
|
||
self.sm._failure_backoff_seconds = 60
|
||
|
||
import requests as real_requests
|
||
call_count = {"n": 0}
|
||
|
||
def counting_get(*args, **kwargs):
|
||
call_count["n"] += 1
|
||
raise real_requests.ConnectionError("boom")
|
||
|
||
with patch("src.plugin_system.store_manager.requests.get", side_effect=counting_get):
|
||
first = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||
self.assertEqual(first["stars"], 99)
|
||
self.assertEqual(call_count["n"], 1)
|
||
|
||
# Second call must hit the bumped cache and NOT make another request.
|
||
second = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||
self.assertEqual(second["stars"], 99)
|
||
self.assertEqual(
|
||
call_count["n"], 1,
|
||
"stale-cache fallback must bump the timestamp to avoid "
|
||
"re-retrying on every request during the backoff window",
|
||
)
|
||
|
||
def test_repo_info_stale_on_403_also_backs_off(self):
|
||
"""Same backoff requirement for 403 rate-limit responses."""
|
||
cache_key = "owner/repo"
|
||
good = {"stars": 7, "default_branch": "main",
|
||
"last_commit_iso": "", "last_commit_date": "",
|
||
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||
"language": "", "license": ""}
|
||
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||
self.sm.cache_timeout = 1
|
||
|
||
rate_limited = MagicMock()
|
||
rate_limited.status_code = 403
|
||
rate_limited.text = "rate limited"
|
||
call_count = {"n": 0}
|
||
|
||
def counting_get(*args, **kwargs):
|
||
call_count["n"] += 1
|
||
return rate_limited
|
||
|
||
with patch("src.plugin_system.store_manager.requests.get", side_effect=counting_get):
|
||
self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||
self.assertEqual(call_count["n"], 1)
|
||
self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||
self.assertEqual(
|
||
call_count["n"], 1,
|
||
"403 stale fallback must also bump the timestamp",
|
||
)
|
||
|
||
def test_commit_info_stale_on_network_error(self):
|
||
cache_key = "owner/repo:main"
|
||
good = {"branch": "main", "sha": "a" * 40, "short_sha": "aaaaaaa",
|
||
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||
"author": "x", "message": "y"}
|
||
self.sm.commit_info_cache[cache_key] = (time.time() - 10_000, good)
|
||
self.sm.commit_cache_timeout = 1 # force re-fetch
|
||
|
||
import requests as real_requests
|
||
with patch("src.plugin_system.store_manager.requests.get",
|
||
side_effect=real_requests.ConnectionError("boom")):
|
||
result = self.sm._get_latest_commit_info(
|
||
"https://github.com/owner/repo", branch="main"
|
||
)
|
||
self.assertIsNotNone(result)
|
||
self.assertEqual(result["short_sha"], "aaaaaaa")
|
||
|
||
def test_commit_info_preserves_good_cache_on_all_branches_404(self):
|
||
"""Regression: all-branches-404 used to overwrite good cache with None.
|
||
|
||
The previous implementation unconditionally wrote
|
||
``self.commit_info_cache[cache_key] = (time.time(), None)`` after
|
||
the branch loop, which meant a single transient failure (e.g. an
|
||
odd 5xx or an ls-refs hiccup) wiped out the commit info we had
|
||
just served to the UI the previous minute.
|
||
"""
|
||
cache_key = "owner/repo:main"
|
||
good = {"branch": "main", "sha": "a" * 40, "short_sha": "aaaaaaa",
|
||
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||
"author": "x", "message": "y"}
|
||
self.sm.commit_info_cache[cache_key] = (time.time() - 10_000, good)
|
||
self.sm.commit_cache_timeout = 1
|
||
|
||
# Each branches_to_try attempt returns a 404. No network error
|
||
# exception — just a non-200 response. This is the code path
|
||
# that used to overwrite the cache with None.
|
||
not_found = MagicMock()
|
||
not_found.status_code = 404
|
||
not_found.text = "Not Found"
|
||
with patch("src.plugin_system.store_manager.requests.get", return_value=not_found):
|
||
result = self.sm._get_latest_commit_info(
|
||
"https://github.com/owner/repo", branch="main"
|
||
)
|
||
|
||
self.assertIsNotNone(result, "good cache was wiped out by transient 404s")
|
||
self.assertEqual(result["short_sha"], "aaaaaaa")
|
||
# The cache entry must still be the good value, not None.
|
||
self.assertIsNotNone(self.sm.commit_info_cache[cache_key][1])
|
||
|
||
|
||
class TestInstallUpdateUninstallInvariants(unittest.TestCase):
|
||
"""Regression guard: the caching and tombstone work added in this PR
|
||
must not break the install / update / uninstall code paths.
|
||
|
||
Specifically:
|
||
- ``install_plugin`` bypasses commit/manifest caches via force_refresh,
|
||
so the 5→30 min TTL bump cannot cause users to install a stale commit.
|
||
- ``update_plugin`` does the same.
|
||
- The uninstall tombstone is only honored by the state reconciler, not
|
||
by explicit ``install_plugin`` calls — so a user can uninstall and
|
||
immediately reinstall from the store UI without the tombstone getting
|
||
in the way.
|
||
- ``was_recently_uninstalled`` is not touched by ``install_plugin``.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||
|
||
def test_get_plugin_info_with_force_refresh_forwards_to_commit_fetch(self):
|
||
"""install_plugin's code path must reach the network bypass."""
|
||
self.sm.registry_cache = {
|
||
"plugins": [{"id": "foo", "repo": "https://github.com/o/r"}]
|
||
}
|
||
self.sm.registry_cache_time = time.time()
|
||
|
||
repo_calls = []
|
||
commit_calls = []
|
||
manifest_calls = []
|
||
|
||
def fake_repo(url):
|
||
repo_calls.append(url)
|
||
return {"default_branch": "main", "stars": 0,
|
||
"last_commit_iso": "", "last_commit_date": ""}
|
||
|
||
def fake_commit(url, branch, force_refresh=False):
|
||
commit_calls.append((url, branch, force_refresh))
|
||
return {"short_sha": "deadbee", "sha": "d" * 40,
|
||
"message": "m", "author": "a", "branch": branch,
|
||
"date": "2026-04-08", "date_iso": "2026-04-08T00:00:00Z"}
|
||
|
||
def fake_manifest(url, branch, manifest_path, force_refresh=False):
|
||
manifest_calls.append((url, branch, manifest_path, force_refresh))
|
||
return None
|
||
|
||
self.sm._get_github_repo_info = fake_repo
|
||
self.sm._get_latest_commit_info = fake_commit
|
||
self.sm._fetch_manifest_from_github = fake_manifest
|
||
|
||
info = self.sm.get_plugin_info("foo", fetch_latest_from_github=True, force_refresh=True)
|
||
|
||
self.assertIsNotNone(info)
|
||
self.assertEqual(info["last_commit_sha"], "d" * 40)
|
||
# force_refresh must have propagated through to the fetch helpers.
|
||
self.assertTrue(commit_calls, "commit fetch was not called")
|
||
self.assertTrue(commit_calls[0][2], "force_refresh=True did not reach _get_latest_commit_info")
|
||
self.assertTrue(manifest_calls, "manifest fetch was not called")
|
||
self.assertTrue(manifest_calls[0][3], "force_refresh=True did not reach _fetch_manifest_from_github")
|
||
|
||
def test_install_plugin_is_not_blocked_by_tombstone(self):
|
||
"""A tombstone must only gate the reconciler, not explicit installs.
|
||
|
||
Uses a complete, valid manifest stub and a no-op dependency
|
||
installer so ``install_plugin`` runs all the way through to a
|
||
True return. Anything less (e.g. swallowing exceptions) would
|
||
hide real regressions in the install path.
|
||
"""
|
||
import json as _json
|
||
self.sm.registry_cache = {
|
||
"plugins": [{"id": "bar", "repo": "https://github.com/o/bar",
|
||
"plugin_path": ""}]
|
||
}
|
||
self.sm.registry_cache_time = time.time()
|
||
|
||
# Mark it recently uninstalled (simulates a user who just clicked
|
||
# uninstall and then immediately clicked install again).
|
||
self.sm.mark_recently_uninstalled("bar")
|
||
self.assertTrue(self.sm.was_recently_uninstalled("bar"))
|
||
|
||
# Stub the heavy bits so install_plugin can run without network.
|
||
self.sm._get_github_repo_info = lambda url: {
|
||
"default_branch": "main", "stars": 0,
|
||
"last_commit_iso": "", "last_commit_date": ""
|
||
}
|
||
self.sm._get_latest_commit_info = lambda *a, **k: {
|
||
"short_sha": "abc1234", "sha": "a" * 40, "branch": "main",
|
||
"message": "m", "author": "a",
|
||
"date": "2026-04-08", "date_iso": "2026-04-08T00:00:00Z",
|
||
}
|
||
self.sm._fetch_manifest_from_github = lambda *a, **k: None
|
||
# Skip dependency install entirely (real install calls pip).
|
||
self.sm._install_dependencies = lambda *a, **k: True
|
||
|
||
def fake_install_via_git(repo_url, plugin_path, branches):
|
||
# Write a COMPLETE valid manifest so install_plugin's
|
||
# post-download validation succeeds. Required fields come
|
||
# from install_plugin itself: id, name, class_name, display_modes.
|
||
plugin_path.mkdir(parents=True, exist_ok=True)
|
||
manifest = {
|
||
"id": "bar",
|
||
"name": "Bar Plugin",
|
||
"version": "1.0.0",
|
||
"class_name": "BarPlugin",
|
||
"entry_point": "manager.py",
|
||
"display_modes": ["bar_mode"],
|
||
}
|
||
(plugin_path / "manifest.json").write_text(_json.dumps(manifest))
|
||
return branches[0]
|
||
|
||
self.sm._install_via_git = fake_install_via_git
|
||
|
||
# No exception-swallowing: if install_plugin fails for ANY reason
|
||
# unrelated to the tombstone, the test fails loudly.
|
||
result = self.sm.install_plugin("bar")
|
||
|
||
self.assertTrue(
|
||
result,
|
||
"install_plugin returned False — the tombstone should not gate "
|
||
"explicit installs and all other stubs should allow success.",
|
||
)
|
||
# Tombstone survives install (harmless — nothing reads it for installed plugins).
|
||
self.assertTrue(self.sm.was_recently_uninstalled("bar"))
|
||
|
||
|
||
class TestRegistryStaleCacheFallback(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||
|
||
def test_network_failure_returns_stale_cache(self):
|
||
# Prime the cache with a known-good registry.
|
||
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||
self.sm.registry_cache_time = time.time() - 10_000 # very old
|
||
self.sm.registry_cache_timeout = 1 # force re-fetch attempt
|
||
|
||
import requests as real_requests
|
||
with patch.object(
|
||
self.sm,
|
||
"_http_get_with_retries",
|
||
side_effect=real_requests.RequestException("boom"),
|
||
):
|
||
result = self.sm.fetch_registry()
|
||
|
||
self.assertEqual(result, {"plugins": [{"id": "cached"}]})
|
||
|
||
def test_network_failure_with_no_cache_returns_empty(self):
|
||
self.sm.registry_cache = None
|
||
import requests as real_requests
|
||
with patch.object(
|
||
self.sm,
|
||
"_http_get_with_retries",
|
||
side_effect=real_requests.RequestException("boom"),
|
||
):
|
||
result = self.sm.fetch_registry()
|
||
self.assertEqual(result, {"plugins": []})
|
||
|
||
def test_stale_fallback_bumps_timestamp_into_backoff(self):
|
||
"""Regression: after the stale-cache fallback fires, the next
|
||
fetch_registry call must NOT re-hit the network. Without the
|
||
timestamp bump, a flaky connection causes every request to pay
|
||
the network timeout before falling back to stale.
|
||
"""
|
||
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||
self.sm.registry_cache_time = time.time() - 10_000 # expired
|
||
self.sm.registry_cache_timeout = 1
|
||
self.sm._failure_backoff_seconds = 60
|
||
|
||
import requests as real_requests
|
||
call_count = {"n": 0}
|
||
|
||
def counting_get(*args, **kwargs):
|
||
call_count["n"] += 1
|
||
raise real_requests.ConnectionError("boom")
|
||
|
||
with patch.object(self.sm, "_http_get_with_retries", side_effect=counting_get):
|
||
first = self.sm.fetch_registry()
|
||
self.assertEqual(first, {"plugins": [{"id": "cached"}]})
|
||
self.assertEqual(call_count["n"], 1)
|
||
|
||
second = self.sm.fetch_registry()
|
||
self.assertEqual(second, {"plugins": [{"id": "cached"}]})
|
||
self.assertEqual(
|
||
call_count["n"], 1,
|
||
"stale registry fallback must bump registry_cache_time so "
|
||
"subsequent requests hit the cache instead of re-retrying",
|
||
)
|
||
|
||
|
||
class TestFetchRegistryRaiseOnFailure(unittest.TestCase):
|
||
"""``fetch_registry(raise_on_failure=True)`` must propagate errors
|
||
instead of silently falling back to the stale cache / empty dict.
|
||
|
||
Regression guard: the state reconciler relies on this to distinguish
|
||
"plugin genuinely not in registry" from "I can't reach the registry
|
||
right now". Without it, a fresh boot with flaky WiFi would poison
|
||
``_unrecoverable_missing_on_disk`` with every config entry.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self._tmp = TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||
|
||
def test_request_exception_propagates_when_flag_set(self):
|
||
import requests as real_requests
|
||
self.sm.registry_cache = None # no stale cache
|
||
with patch.object(
|
||
self.sm,
|
||
"_http_get_with_retries",
|
||
side_effect=real_requests.RequestException("boom"),
|
||
):
|
||
with self.assertRaises(real_requests.RequestException):
|
||
self.sm.fetch_registry(raise_on_failure=True)
|
||
|
||
def test_request_exception_propagates_even_with_stale_cache(self):
|
||
"""Explicit caller opt-in beats the stale-cache convenience."""
|
||
import requests as real_requests
|
||
self.sm.registry_cache = {"plugins": [{"id": "stale"}]}
|
||
self.sm.registry_cache_time = time.time() - 10_000
|
||
self.sm.registry_cache_timeout = 1
|
||
with patch.object(
|
||
self.sm,
|
||
"_http_get_with_retries",
|
||
side_effect=real_requests.RequestException("boom"),
|
||
):
|
||
with self.assertRaises(real_requests.RequestException):
|
||
self.sm.fetch_registry(raise_on_failure=True)
|
||
|
||
def test_json_decode_error_propagates_when_flag_set(self):
|
||
import json as _json
|
||
self.sm.registry_cache = None
|
||
bad_response = MagicMock()
|
||
bad_response.status_code = 200
|
||
bad_response.raise_for_status = MagicMock()
|
||
bad_response.json = MagicMock(
|
||
side_effect=_json.JSONDecodeError("bad", "", 0)
|
||
)
|
||
with patch.object(self.sm, "_http_get_with_retries", return_value=bad_response):
|
||
with self.assertRaises(_json.JSONDecodeError):
|
||
self.sm.fetch_registry(raise_on_failure=True)
|
||
|
||
def test_default_behavior_unchanged_by_new_parameter(self):
|
||
"""UI callers that don't pass the flag still get stale-cache fallback."""
|
||
import requests as real_requests
|
||
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||
self.sm.registry_cache_time = time.time() - 10_000
|
||
self.sm.registry_cache_timeout = 1
|
||
with patch.object(
|
||
self.sm,
|
||
"_http_get_with_retries",
|
||
side_effect=real_requests.RequestException("boom"),
|
||
):
|
||
result = self.sm.fetch_registry() # default raise_on_failure=False
|
||
self.assertEqual(result, {"plugins": [{"id": "cached"}]})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|