mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-21 18:39:06 +00:00
fix(plugins): say when discovery skips a directory
A plugin can be enabled in config, enabled in plugin state, present on disk with a valid manifest and an importable entry point -- and simply absent from the running process, with nothing anywhere to say why. That is not hypothetical. hockey-scoreboard on a live rig is enabled in both places, imports cleanly when loaded by hand, and is listed in the Vegas plugin order, but is not among the 22 plugins the process actually holds. Establishing even that much meant comparing cache-file mtimes to find it had last run three days earlier. The journal had nothing, because discovery does not report what it declines to load. Two paths were silent. A directory with no manifest.json was skipped without comment, which is defensible until it is the thing you are trying to explain. Quieter still, a manifest that parsed but carried no "id" was read successfully and then dropped on the floor -- no warning, no trace, and the plugin simply does not exist as far as the rest of the system is concerned. Both now log a warning naming the directory and the reason. This does not explain the rig above; its manifest has an id. It makes the next occurrence diagnosable from the journal instead of from file timestamps. Reverting the change fails both tests. 65 plugin-system tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf0a551f7b
commit
0a7d14d75e
@@ -195,18 +195,36 @@ class PluginManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
manifest_path = item / "manifest.json"
|
manifest_path = item / "manifest.json"
|
||||||
if manifest_path.exists():
|
if not manifest_path.exists():
|
||||||
try:
|
# A directory here that carries no manifest is not a
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
# plugin. Said once, because the alternative is a plugin
|
||||||
manifest = json.load(f)
|
# that is enabled in config, enabled in plugin state,
|
||||||
plugin_id = manifest.get('id')
|
# present on disk, and simply absent from the running
|
||||||
if plugin_id:
|
# process with nothing anywhere to say why. Working that
|
||||||
plugin_ids.append(plugin_id)
|
# out afterwards means reading cache-file mtimes.
|
||||||
new_manifests[plugin_id] = manifest
|
self.logger.warning(
|
||||||
new_directories[plugin_id] = item
|
"Skipping %s: no manifest.json, so it cannot be loaded "
|
||||||
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
"as a plugin", item.name)
|
||||||
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
continue
|
||||||
continue
|
try:
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
||||||
|
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_id = manifest.get('id')
|
||||||
|
if not plugin_id:
|
||||||
|
# Parsed but unusable. This was the quietest path of all:
|
||||||
|
# the manifest is read successfully and then dropped.
|
||||||
|
self.logger.warning(
|
||||||
|
"Skipping %s: its manifest.json has no \"id\", so there "
|
||||||
|
"is nothing to register it under", item.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_ids.append(plugin_id)
|
||||||
|
new_manifests[plugin_id] = manifest
|
||||||
|
new_directories[plugin_id] = item
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Discovery must say when it skips a directory.
|
||||||
|
|
||||||
|
A plugin can be enabled in config, enabled in plugin state, present on disk
|
||||||
|
with a valid entry point -- and simply absent from the running process, with
|
||||||
|
nothing in the journal to say why. Working that out afterwards meant comparing
|
||||||
|
cache-file mtimes to find when it had last run.
|
||||||
|
|
||||||
|
Two paths were silent. A directory with no manifest.json was ignored, and --
|
||||||
|
quieter still -- a manifest that parsed but carried no "id" was read
|
||||||
|
successfully and then dropped on the floor.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(tmp_path):
|
||||||
|
pm = PluginManager.__new__(PluginManager)
|
||||||
|
pm.plugins_dir = tmp_path
|
||||||
|
pm.logger = logging.getLogger("test.discovery")
|
||||||
|
pm.plugin_manifests = {}
|
||||||
|
pm.plugin_directories = {}
|
||||||
|
pm._discovery_lock = __import__("threading").RLock()
|
||||||
|
pm.schema_manager = MagicMock()
|
||||||
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_directory_without_a_manifest_is_reported(tmp_path, caplog):
|
||||||
|
(tmp_path / "not-a-plugin").mkdir()
|
||||||
|
pm = _manager(tmp_path)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
||||||
|
pm._scan_directory_for_plugins(tmp_path)
|
||||||
|
joined = " ".join(r.message for r in caplog.records)
|
||||||
|
assert "not-a-plugin" in joined and "manifest" in joined, (
|
||||||
|
f"skip was silent; log said: {joined!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_manifest_without_an_id_is_reported(tmp_path, caplog):
|
||||||
|
d = tmp_path / "idless"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "manifest.json").write_text(json.dumps({"name": "No Id", "version": "1.0.0"}))
|
||||||
|
pm = _manager(tmp_path)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
||||||
|
pm._scan_directory_for_plugins(tmp_path)
|
||||||
|
joined = " ".join(r.message for r in caplog.records)
|
||||||
|
assert "idless" in joined and "id" in joined, (
|
||||||
|
f"a parsed-but-unusable manifest vanished silently; log said: {joined!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_good_plugin_still_registers(tmp_path, caplog):
|
||||||
|
d = tmp_path / "real-plugin"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "manifest.json").write_text(json.dumps(
|
||||||
|
{"id": "real-plugin", "name": "Real", "version": "1.0.0"}))
|
||||||
|
pm = _manager(tmp_path)
|
||||||
|
pm._scan_directory_for_plugins(tmp_path)
|
||||||
|
assert "real-plugin" in pm.plugin_manifests, "a valid plugin was not registered"
|
||||||
Reference in New Issue
Block a user