refactor(plugins): one plugin-directory resolver (#623)

* refactor(plugins): one resolver for plugin id -> directory

Five places mapped a plugin id to its directory, each with its own rules
and each re-reading manifests per lookup: PluginManager discovery and
get_plugin_directory, PluginLoader.find_plugin_directory,
PluginStoreManager._find_plugin_path / list_installed_plugins, and
state_reconciliation.disk_plugin_ids. They disagreed on backup dirs,
on whether the manifest id or the directory name is the id, on duplicate
ids and on path safety.

src/plugin_system/plugin_dirs.py now holds the rules once:
PluginDirectoryIndex scans one directory and reads each manifest once;
resolve_plugin_dir() searches directories in order. What legitimately
differs per caller is an explicit argument: search dirs (discovery and
the loader: configured dir only; the store: configured then sibling
plugins/), ledmatrix- prefix (not for the store), case folding (loader
only), manifest pass (not for get_plugin_directory, whose discovery map
already holds it).

Behaviour changes, all for layouts installs do not produce:
- a directory whose manifest declares the id beats one merely named for
  it (discovery already worked this way; the loader and store now agree)
- the store searches the configured dir completely before plugins/
- backup and hidden dirs are skipped everywhere (the loader's case and
  manifest scans and list_installed_plugins used to return them)
- duplicate ids resolve deterministically (exact name, then
  ledmatrix-<id>, then by name) with a one-time warning; discovery no
  longer lists the id twice
- disk_plugin_ids / list_installed_plugins report manifest ids, falling
  back to the directory name; auto-update looks the directory up
- ids that are not one plain path segment resolve to nothing in every
  caller (the loader used to truncate them, the store to join them)

The .standalone-backup- marker is one constant, BACKUP_MARKER, used by
store_manager's rename-aside names and every lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): one plugin-directory resolver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 15:52:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 13bbb537f3
commit ece416c4e5
9 changed files with 895 additions and 295 deletions
+8
View File
@@ -27,6 +27,14 @@ accepts both, but the store flags the old spelling as deprecated
(`web_interface/cache.py`) now honours the TTL a value was stored with and is (`web_interface/cache.py`) now honours the TTL a value was stored with and is
thread-safe. thread-safe.
- One plugin-directory resolver, `src/plugin_system/plugin_dirs.py`, behind
discovery, `PluginManager.get_plugin_directory`, `PluginLoader`, the store and
state reconciliation. A manifest's `id` wins over a directory merely named for
the id; hidden and `.standalone-backup-` directories are never treated as
plugins (auto-update could previously try to update a backup); ids like
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
named for its manifest id, the installer's layout, behave as before.
- `FontManager.get_font()` returns a BDF font at its native size when asked for - `FontManager.get_font()` returns a BDF font at its native size when asked for
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
return PIL's default font, a different typeface, so a plugin that relied on return PIL's default font, a different typeface, so a plugin that relied on
+345
View File
@@ -0,0 +1,345 @@
"""
One answer to "which directory holds plugin X?".
Five places used to answer it, each with its own rules and each re-parsing
every manifest per lookup: ``PluginManager`` discovery and
``get_plugin_directory``, ``PluginLoader.find_plugin_directory``,
``PluginStoreManager._find_plugin_path`` / ``list_installed_plugins`` and
``state_reconciliation.disk_plugin_ids``. The rules now live here once; what
still legitimately differs between callers (which directories to search,
whether a ``ledmatrix-`` prefix or a case difference counts as a match) is a
keyword argument at the call site, so a difference is always a visible choice
rather than an accident of which copy you read.
The rules
---------
* A directory is a *candidate* when it is a directory (a symlink to one counts:
dev plugins are symlinked in) and its name is neither hidden (leading ``.``)
nor carries :data:`BACKUP_MARKER`. store_manager renames a plugin aside with
that marker during install/rollback; the aside still holds a manifest, so
treating it as a plugin would resurrect a ghost.
* A plugin's id is its manifest ``id``. The directory name is only a fallback,
for callers that must still see a plugin whose manifest is missing an id.
* Resolving an id within one directory: a directory whose manifest declares
the id wins; among several, the one named exactly for the id, then
``ledmatrix-<id>``, then by name. Only when no manifest claims the id do
directory names count: ``<id>``, then ``ledmatrix-<id>`` (``prefix=True``),
then either of those ignoring case (``case_insensitive=True``). The name
fallback still returns a directory whose manifest is unreadable -- that is
how a broken plugin gets uninstalled or reinstalled. ``by_manifest=False``
(``PluginManager.get_plugin_directory``, whose discovery map already holds
the manifest answer) skips straight to the names.
* Several directories are searched one at a time, in the order given: the
first directory that resolves the id at all wins, by manifest or by name.
* The id must be one plain path segment (``safe_path_component``); anything
else resolves to nothing rather than being joined or truncated.
* Returned paths are ``search_dir / name`` and are not resolved, so a
symlinked dev plugin keeps the path that lies inside the search directory.
Manifests are read at most once per :class:`PluginDirectoryIndex`; one index is
one scan.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Union
from src.common.path_safety import safe_path_component
__all__ = [
'BACKUP_MARKER',
'PLUGIN_DIR_PREFIX',
'ManifestStatus',
'PluginDirEntry',
'PluginDirectoryIndex',
'is_ignored_dir_name',
'resolve_plugin_dir',
'store_search_dirs',
]
#: Substring store_manager embeds in a plugin directory it has set aside
#: (``<id>.standalone-backup-preinstall`` / ``-migrating``). Existing debris on
#: devices carries exactly this text, so it must never change.
BACKUP_MARKER = '.standalone-backup-'
#: Legacy repository naming (``ledmatrix-<id>``); some installs still use it
#: as the directory name.
PLUGIN_DIR_PREFIX = 'ledmatrix-'
PathLike = Union[str, Path]
class ManifestStatus:
"""What reading ``manifest.json`` in a candidate directory produced."""
OK = 'ok' # a JSON object with a non-empty "id"
MISSING = 'missing' # no manifest.json
UNREADABLE = 'unreadable' # I/O error or invalid JSON
NOT_OBJECT = 'not_object' # valid JSON, but not an object
NO_ID = 'no_id' # an object without a usable "id"
def is_ignored_dir_name(name: str) -> bool:
"""True for names that are never a plugin: hidden, or set aside mid-install."""
return name.startswith('.') or BACKUP_MARKER in name
@dataclass
class PluginDirEntry:
"""One candidate directory and its manifest, read once."""
path: Path
status: str
manifest: Optional[Any] = None
error: Optional[BaseException] = None
@property
def name(self) -> str:
return self.path.name
@property
def manifest_id(self) -> Optional[str]:
"""The manifest's ``id`` when the manifest is usable, else None."""
if self.status != ManifestStatus.OK:
return None
return self.manifest['id']
@property
def manifest_parses(self) -> bool:
"""The manifest exists and is valid JSON (of any shape)."""
return self.status in (ManifestStatus.OK, ManifestStatus.NOT_OBJECT,
ManifestStatus.NO_ID)
@property
def installed_id(self) -> str:
"""The manifest id, falling back to the directory name."""
return self.manifest_id or self.name
def _read_entry(path: Path) -> PluginDirEntry:
manifest_path = path / 'manifest.json'
if not manifest_path.is_file():
return PluginDirEntry(path, ManifestStatus.MISSING)
try:
with open(manifest_path, 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
except (OSError, ValueError) as exc: # ValueError covers JSON + decode errors
return PluginDirEntry(path, ManifestStatus.UNREADABLE, error=exc)
if not isinstance(manifest, dict):
return PluginDirEntry(path, ManifestStatus.NOT_OBJECT, manifest)
plugin_id = manifest.get('id')
if not plugin_id or not isinstance(plugin_id, str):
return PluginDirEntry(path, ManifestStatus.NO_ID, manifest)
return PluginDirEntry(path, ManifestStatus.OK, manifest)
def _preference(plugin_id: str, name: str) -> tuple:
"""Sort key among directories that all claim ``plugin_id``."""
if name == plugin_id:
rank = 0
elif name == PLUGIN_DIR_PREFIX + plugin_id:
rank = 1
else:
rank = 2
return (rank, name)
@dataclass
class PluginDirectoryIndex:
"""Every candidate directory directly under ``root``, manifests read once.
Build one with :meth:`scan`. It is a snapshot: a directory added or
removed afterwards is not seen until the next scan.
"""
root: Path
entries: List[PluginDirEntry] = field(default_factory=list)
#: Set when ``root`` exists but could not be listed.
error: Optional[BaseException] = None
_plugins: Optional[Dict[str, PluginDirEntry]] = field(
default=None, init=False, repr=False, compare=False)
@classmethod
def scan(cls, root: PathLike) -> 'PluginDirectoryIndex':
root = Path(root)
index = cls(root)
try:
children = sorted(root.iterdir(), key=lambda p: p.name)
except FileNotFoundError:
return index
except OSError as exc:
index.error = exc
return index
for child in children:
if is_ignored_dir_name(child.name):
continue
try:
if not child.is_dir():
continue
except OSError:
continue
index.entries.append(_read_entry(child))
return index
# -- listing ----------------------------------------------------------
def plugins(self) -> Dict[str, PluginDirEntry]:
"""Manifest id -> entry, one entry per id.
When several directories declare the same id, the one named for it
wins, then ``ledmatrix-<id>``, then the first by name; see
:meth:`duplicates` for the losers.
"""
if self._plugins is not None:
return self._plugins
chosen: Dict[str, PluginDirEntry] = {}
for entry in self.entries:
plugin_id = entry.manifest_id
if plugin_id is None:
continue
current = chosen.get(plugin_id)
if current is None or (_preference(plugin_id, entry.name)
< _preference(plugin_id, current.name)):
chosen[plugin_id] = entry
self._plugins = chosen
return chosen
def duplicates(self) -> Dict[str, List[PluginDirEntry]]:
"""Ids declared by more than one directory -> every such entry."""
seen: Dict[str, List[PluginDirEntry]] = {}
for entry in self.entries:
if entry.manifest_id is not None:
seen.setdefault(entry.manifest_id, []).append(entry)
return {k: v for k, v in seen.items() if len(v) > 1}
def installed_ids(self, *, require_parseable_manifest: bool) -> Set[str]:
"""Ids of everything that counts as installed.
A directory counts when it has a manifest.json -- which must also be
valid JSON when ``require_parseable_manifest``. Its id is the manifest
id, or the directory name when the manifest does not carry one.
"""
ids: Set[str] = set()
for entry in self.entries:
if entry.status == ManifestStatus.MISSING:
continue
if require_parseable_manifest and not entry.manifest_parses:
continue
ids.add(entry.installed_id)
return ids
def entry_for_installed_id(self, plugin_id: str) -> Optional[PluginDirEntry]:
"""The entry :meth:`installed_ids` reported as ``plugin_id``."""
entry = self.plugins().get(plugin_id)
if entry is not None:
return entry
for entry in self.entries:
if entry.manifest_id is None and entry.name == plugin_id:
return entry
return None
# -- lookup -----------------------------------------------------------
def find(self, plugin_id: str, *, prefix: bool, case_insensitive: bool,
by_manifest: bool = True) -> Optional[Path]:
"""Resolve ``plugin_id`` within this directory (rules in the module doc)."""
plugin_id = _lookup_id(plugin_id)
if plugin_id is None:
return None
if by_manifest:
entry = self.plugins().get(plugin_id)
if entry is not None:
return entry.path
names = _candidate_names(plugin_id, prefix)
by_name = {e.name: e for e in self.entries}
for name in names:
if name in by_name:
return by_name[name].path
if case_insensitive:
for low in (n.lower() for n in names):
for entry in self.entries:
if entry.name.lower() == low:
return entry.path
return None
def _lookup_id(plugin_id: Any) -> Optional[str]:
"""``plugin_id`` if it can name a plugin directory at all, else None."""
plugin_id = safe_path_component(plugin_id)
if plugin_id is None or is_ignored_dir_name(plugin_id):
return None
return plugin_id
def _candidate_names(plugin_id: str, prefix: bool) -> List[str]:
names = [plugin_id]
if prefix:
names.append(PLUGIN_DIR_PREFIX + plugin_id)
return names
def _is_dir(path: Path) -> bool:
try:
return path.is_dir()
except OSError:
return False
def resolve_plugin_dir(plugin_id: Any, search_dirs: Iterable[PathLike], *,
prefix: bool, case_insensitive: bool = False,
by_manifest: bool = True) -> Optional[Path]:
"""The directory holding ``plugin_id``, searching ``search_dirs`` in order.
Each search directory is scanned once and each manifest in it read once.
``by_manifest=False`` skips the manifest pass and matches directory names
only, which reads no manifests at all.
Names are compared against the directory listing, never by probing
``search_dir / name``: on a case-insensitive filesystem that probe says
``Demo`` exists when the directory is ``demo``, which made the answer
depend on the platform.
"""
plugin_id = _lookup_id(plugin_id)
if plugin_id is None:
return None
for search_dir in search_dirs:
search_dir = Path(search_dir)
if by_manifest or case_insensitive:
found = PluginDirectoryIndex.scan(search_dir).find(
plugin_id, prefix=prefix, case_insensitive=case_insensitive,
by_manifest=by_manifest)
else:
found = _find_by_name(search_dir, _candidate_names(plugin_id, prefix))
if found is not None:
return found
return None
def _find_by_name(search_dir: Path, names: List[str]) -> Optional[Path]:
try:
present = {child.name for child in search_dir.iterdir()}
except OSError:
return None
for name in names:
if name in present and _is_dir(search_dir / name):
return search_dir / name
return None
def store_search_dirs(plugins_dir: PathLike) -> List[Path]:
"""Directories the plugin store searches: the configured one, then a
sibling ``plugins/`` (the legacy/dev location) when that is a different
directory. Discovery deliberately does NOT use this -- it scans only the
configured directory (see CLAUDE.md, test_discovery_path_contract.py)."""
plugins_dir = Path(plugins_dir)
dirs = [plugins_dir]
try:
base = plugins_dir if plugins_dir.is_absolute() else plugins_dir.resolve()
sibling = base.parent / 'plugins'
if sibling != base:
dirs.append(sibling)
except (OSError, ValueError):
pass
return dirs
+15 -64
View File
@@ -8,7 +8,6 @@ Extracted from PluginManager to improve separation of concerns.
import importlib import importlib
import importlib.metadata import importlib.metadata
import importlib.util import importlib.util
import json
import os import os
import sys import sys
import subprocess import subprocess
@@ -21,6 +20,7 @@ from packaging.requirements import InvalidRequirement, Requirement
from src.exceptions import PluginError from src.exceptions import PluginError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import resolve_plugin_dir
def requirements_has_real_deps(requirements_file: str) -> bool: def requirements_has_real_deps(requirements_file: str) -> bool:
@@ -215,11 +215,12 @@ class PluginLoader:
""" """
Find the plugin directory for a given plugin ID. Find the plugin directory for a given plugin ID.
Tries multiple strategies: 1. The discovery mapping, when it has the id and the path exists.
1. Use plugin_directories mapping if available 2. ``plugins_dir`` only, by the shared rules in
2. Direct path matching ``src/plugin_system/plugin_dirs.py``: a directory whose manifest
3. Case-insensitive directory matching declares the id wins; otherwise ``<id>`` or ``ledmatrix-<id>``,
4. Manifest-based search matched case-insensitively. Backup and hidden directories are
never matched.
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
@@ -227,13 +228,9 @@ class PluginLoader:
plugin_directories: Optional mapping of plugin_id to directory plugin_directories: Optional mapping of plugin_id to directory
Returns: Returns:
Path to plugin directory or None if not found Path to plugin directory or None if not found. An id that is not
one plain path segment finds nothing.
""" """
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
# Strategy 1: Use mapping from discovery # Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories: if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id] plugin_dir = plugin_directories[plugin_id]
@@ -241,58 +238,12 @@ class PluginLoader:
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir) self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir plugin_dir = resolve_plugin_dir(
plugins_dir_resolved = plugins_dir.resolve() plugin_id, [plugins_dir], prefix=True, case_insensitive=True)
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"): if plugin_dir is not None and plugin_dir.name != plugin_id:
_candidate = (plugins_dir_resolved / _candidate_name).resolve() self.logger.debug("Found plugin %s in directory %s",
try: plugin_id, plugin_dir.name)
_candidate.relative_to(plugins_dir_resolved) return plugin_dir
except ValueError:
continue
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower()
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
item_name = item.name
if item_name.lower() == normalized_id:
return item
if item_name.lower() == f"ledmatrix-{plugin_id}".lower():
return item
# Strategy 4: Manifest-based search
self.logger.debug("Directory name search failed for %s, searching by manifest...", plugin_id)
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
# Skip if already checked
if item.name.lower() == normalized_id or item.name.lower() == f"ledmatrix-{plugin_id}".lower():
continue
manifest_path = item / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
item_manifest = json.load(f)
item_manifest_id = item_manifest.get('id')
if item_manifest_id == plugin_id:
self.logger.info(
"Found plugin %s in directory %s (manifest ID matches)",
plugin_id,
item.name
)
return item
except (json.JSONDecodeError, Exception) as e:
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
continue
return None
def install_dependencies( def install_dependencies(
self, self,
+77 -80
View File
@@ -25,7 +25,9 @@ from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import ( from src.plugin_system.schema_manager import (
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans, CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
) )
from src.common.path_safety import safe_path_component from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
from src.deprecation import deprecated from src.deprecation import deprecated
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
@@ -175,89 +177,88 @@ class PluginManager:
self.logger.error("Could not create plugins directory %s: %s", self.plugins_dir, e, exc_info=True) self.logger.error("Could not create plugins directory %s: %s", self.plugins_dir, e, exc_info=True)
raise PluginError(f"Could not create plugins directory: {self.plugins_dir}", context={'error': str(e)}) from e raise PluginError(f"Could not create plugins directory: {self.plugins_dir}", context={'error': str(e)}) from e
def _report_skip_once(self, key: str, message: str, *args: Any) -> None:
"""Warn about a skipped directory once per process, not per scan.
Discovery runs on every web UI page load and every config reconcile,
so warning unconditionally would put a line in the journal each time
someone opened a page -- the same log-volume problem this is meant to
help diagnose.
"""
reported = self.__dict__.setdefault('_skip_reported', set())
if key in reported:
return
reported.add(key)
self.logger.warning(message, *args)
def _scan_directory_for_plugins(self, directory: Path) -> List[str]: def _scan_directory_for_plugins(self, directory: Path) -> List[str]:
""" """
Scan a directory for plugins. Scan a directory for plugins.
Which directories count and how an id maps to one is decided by
:class:`PluginDirectoryIndex` (``src/plugin_system/plugin_dirs.py``),
shared with the loader, the store and reconciliation. Only
``directory`` is scanned: discovery has no fallback to ``plugins/``.
Directories set aside mid-install (``BACKUP_MARKER`` in the name) are
skipped so they don't overwrite live entries.
Args: Args:
directory: Directory to scan directory: Directory to scan
Returns: Returns:
List of plugin IDs found List of plugin IDs found
""" """
plugin_ids = []
if not directory.exists(): if not directory.exists():
return plugin_ids return []
# Build new state locally before acquiring lock # Build new state locally before acquiring lock
new_manifests: Dict[str, Dict[str, Any]] = {} index = PluginDirectoryIndex.scan(directory)
new_directories: Dict[str, Path] = {} if index.error is not None:
self.logger.error("Error scanning directory %s: %s", directory,
try: index.error, exc_info=index.error)
for item in directory.iterdir():
if not item.is_dir():
continue
# Skip backup directories so they don't overwrite live entries
if '.standalone-backup-' in item.name:
continue
manifest_path = item / "manifest.json"
if not manifest_path.exists():
# Once per directory per process. Discovery runs on every
# web UI page load and every config reconcile, so warning
# unconditionally would put a line in the journal each
# time someone opened a page -- the same log-volume
# problem this is meant to help diagnose.
# A directory here that carries no manifest is not a
# plugin. Said once, because the alternative is a plugin
# that is enabled in config, enabled in plugin state,
# present on disk, and simply absent from the running
# process with nothing anywhere to say why. Working that
# out afterwards means reading cache-file mtimes.
if item.name not in self._skip_reported:
self._skip_reported.add(item.name)
self.logger.warning(
"Skipping %s: no manifest.json, so it cannot be "
"loaded as a plugin", item.name)
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
for entry in index.entries:
if entry.status == ManifestStatus.MISSING:
# A directory here that carries no manifest is not a plugin.
# Said once, because the alternative is a plugin that is
# enabled in config, enabled in plugin state, present on disk,
# and simply absent from the running process with nothing
# anywhere to say why. Working that out afterwards means
# reading cache-file mtimes.
self._report_skip_once(
entry.name, "Skipping %s: no manifest.json, so it cannot be "
"loaded as a plugin", entry.name)
elif entry.status == ManifestStatus.UNREADABLE:
self.logger.warning("Error reading manifest from %s: %s",
entry.path / "manifest.json", entry.error,
exc_info=entry.error)
elif entry.status == ManifestStatus.NOT_OBJECT:
# json.load accepts any JSON value, so a manifest holding # json.load accepts any JSON value, so a manifest holding
# null, [] or "text" parses and then raises AttributeError on # null, [] or "text" parses. It once raised AttributeError on
# .get(). Nothing here catches that -- the outer handler takes # .get() and aborted the whole scan, so every other plugin on
# OSError/PermissionError only -- so a single malformed
# manifest aborted the whole scan and every other plugin on
# disk, however healthy, silently failed to register. # disk, however healthy, silently failed to register.
if not isinstance(manifest, dict): self._report_skip_once(
if item.name not in self._skip_reported: entry.name, "Skipping %s: its manifest.json is %s, not a "
self._skip_reported.add(item.name) "JSON object", entry.name, type(entry.manifest).__name__)
self.logger.warning( elif entry.status == ManifestStatus.NO_ID:
"Skipping %s: its manifest.json is %s, not a JSON " # Parsed but unusable. This was the quietest path of all: the
"object", item.name, type(manifest).__name__) # manifest is read successfully and then dropped.
continue self._report_skip_once(
entry.name, "Skipping %s: its manifest.json has no \"id\", "
"so there is nothing to register it under", entry.name)
plugin_id = manifest.get('id') plugins = index.plugins()
if not plugin_id: for plugin_id, entries in index.duplicates().items():
# Parsed but unusable. This was the quietest path of all: self._report_skip_once(
# the manifest is read successfully and then dropped. "duplicate:" + plugin_id,
if item.name not in self._skip_reported: "Plugin id %r is declared by %d directories (%s); using %s",
self._skip_reported.add(item.name) plugin_id, len(entries), ", ".join(e.name for e in entries),
self.logger.warning( plugins[plugin_id].name)
"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: Dict[str, Dict[str, Any]] = {
new_manifests[plugin_id] = manifest plugin_id: entry.manifest for plugin_id, entry in plugins.items()}
new_directories[plugin_id] = item new_directories: Dict[str, Path] = {
except (OSError, PermissionError) as e: plugin_id: entry.path for plugin_id, entry in plugins.items()}
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
# Replace shared state under lock so uninstalled plugins don't linger # Replace shared state under lock so uninstalled plugins don't linger
with self._discovery_lock: with self._discovery_lock:
@@ -266,7 +267,7 @@ class PluginManager:
self.plugin_directories.clear() self.plugin_directories.clear()
self.plugin_directories.update(new_directories) self.plugin_directories.update(new_directories)
return plugin_ids return list(plugins)
def discover_plugins(self) -> List[str]: def discover_plugins(self) -> List[str]:
""" """
@@ -772,24 +773,20 @@ class PluginManager:
not one plain path segment (``..``, ``a/b``, an absolute path) is not one plain path segment (``..``, ``a/b``, an absolute path) is
refused instead of being joined onto ``plugins_dir``. The join is not refused instead of being joined onto ``plugins_dir``. The join is not
resolved further: dev plugins are symlinks into ``plugins_dir``. resolved further: dev plugins are symlinks into ``plugins_dir``.
The discovery map is authoritative. For an id discovery has not seen,
only directory names are tried -- ``<id>`` then ``ledmatrix-<id>``,
in ``plugins_dir`` only -- so a miss on a web request never reads
every manifest on disk. Rules: ``src/plugin_system/plugin_dirs.py``.
""" """
with self._discovery_lock: with self._discovery_lock:
if plugin_id in self.plugin_directories: if plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id]) return str(self.plugin_directories[plugin_id])
plugin_id = safe_path_component(plugin_id) plugin_dir = resolve_plugin_dir(
if plugin_id is None: plugin_id, [self.plugins_dir], prefix=True, case_insensitive=False,
return None by_manifest=False)
return str(plugin_dir) if plugin_dir is not None else None
plugin_dir = self.plugins_dir / plugin_id
if plugin_dir.exists():
return str(plugin_dir)
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
if plugin_dir.exists():
return str(plugin_dir)
return None
def get_plugin_display_modes(self, plugin_id: str) -> List[str]: def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
""" """
+26 -32
View File
@@ -15,6 +15,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS from src.core_config_keys import CORE_CONFIG_KEYS
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
from src.plugin_system.state_manager import PluginStateManager from src.plugin_system.state_manager import PluginStateManager
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -102,31 +103,25 @@ def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str
def disk_plugin_ids(plugins_dir) -> Set[str]: def disk_plugin_ids(plugins_dir) -> Set[str]:
"""Plugin ids actually installed on disk. """Plugin ids actually installed on disk.
A directory counts only when it is not a standalone backup and its A directory counts only when it is not a standalone backup (or hidden)
manifest.json parses. A corrupt manifest must not read as installed, or a and its manifest.json parses. A corrupt manifest must not read as
live "in config but not on disk" finding gets cleared on the strength of an installed, or a live "in config but not on disk" finding gets cleared on
unreadable file. the strength of an unreadable file.
The id is the manifest's ``id`` -- what discovery registers and what the
config is keyed by -- and the directory name only when the manifest has
none. Directory names alone made a plugin living in ``ledmatrix-stocks/``
with id ``stocks`` read as both "stocks in config but not on disk" and
"ledmatrix-stocks on disk but not in config".
""" """
ids: Set[str] = set()
root = Path(plugins_dir)
try: try:
if not root.exists(): return _disk_index(plugins_dir).installed_ids(require_parseable_manifest=True)
return ids
for entry in root.iterdir():
if not entry.is_dir() or '.standalone-backup-' in entry.name:
continue
manifest = entry / "manifest.json"
if not manifest.exists():
continue
try:
with open(manifest, 'r') as f:
json.load(f)
except (OSError, ValueError):
continue
ids.add(entry.name)
except OSError: except OSError:
return ids return set()
return ids
def _disk_index(plugins_dir) -> PluginDirectoryIndex:
return PluginDirectoryIndex.scan(Path(plugins_dir))
def still_unresolved(entries: List[Dict[str, Any]], def still_unresolved(entries: List[Dict[str, Any]],
@@ -326,16 +321,15 @@ class StateReconciliation:
"""Get plugin state from disk (installed plugins).""" """Get plugin state from disk (installed plugins)."""
state = {} state = {}
try: try:
# Membership comes from the shared extractor so the web interface # Membership uses the same index and rule as disk_plugin_ids, so
# re-checks stored findings against this same definition; the # the web interface re-checks stored findings against this same
# manifest is then re-read here only for version/name. # definition; each manifest is read once, by the scan.
for plugin_id in disk_plugin_ids(self.plugins_dir): index = _disk_index(self.plugins_dir)
manifest_path = self.plugins_dir / plugin_id / "manifest.json" for plugin_id in index.installed_ids(require_parseable_manifest=True):
try: entry = index.entry_for_installed_id(plugin_id)
with open(manifest_path, 'r') as f: manifest = entry.manifest if entry is not None else None
manifest = json.load(f) if not isinstance(manifest, dict):
except (OSError, ValueError): # nosec B112 - raced or corrupt; skip manifest = {}
continue
state[plugin_id] = { state[plugin_id] = {
'exists_on_disk': True, 'exists_on_disk': True,
'version': manifest.get('version'), 'version': manifest.get('version'),
+45 -94
View File
@@ -28,6 +28,9 @@ from src.common.permission_utils import sudo_remove_directory, install_requireme
from src.plugin_system.plugin_loader import ( from src.plugin_system.plugin_loader import (
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
) )
from src.plugin_system.plugin_dirs import (
BACKUP_MARKER, PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
)
try: try:
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
@@ -1233,9 +1236,9 @@ class PluginStoreManager:
Pass-through when nothing is installed, and when called from Pass-through when nothing is installed, and when called from
`_reinstall_with_rollback`, which has already moved the old copy aside. `_reinstall_with_rollback`, which has already moved the old copy aside.
The aside name embeds '.standalone-backup-' so plugin discovery The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
(`plugin_manager._scan_directory_for_plugins`) skips it even though it plugin directory lookup (src/plugin_system/plugin_dirs.py) skips it
still holds a manifest.json. even though it still holds a manifest.json.
Held under the per-plugin reinstall lock for the same reason Held under the per-plugin reinstall lock for the same reason
`_reinstall_with_rollback` is: the web UI runs Flask with `_reinstall_with_rollback` is: the web UI runs Flask with
@@ -1250,7 +1253,7 @@ class PluginStoreManager:
return self._install_plugin_impl(plugin_id, branch) return self._install_plugin_impl(plugin_id, branch)
backup_path = plugin_path.with_name( backup_path = plugin_path.with_name(
f"{plugin_path.name}.standalone-backup-preinstall") f"{plugin_path.name}{BACKUP_MARKER}preinstall")
if backup_path.exists() and not self._safe_remove_directory(backup_path): if backup_path.exists() and not self._safe_remove_directory(backup_path):
# Can't stage a safety net. Better to attempt the install than # Can't stage a safety net. Better to attempt the install than
# to refuse outright, which is what callers got before this # to refuse outright, which is what callers got before this
@@ -2394,93 +2397,41 @@ class PluginStoreManager:
""" """
Find the plugin path by checking the configured directory and standard plugins directory. Find the plugin path by checking the configured directory and standard plugins directory.
Searches the configured directory, then a sibling ``plugins/`` (the
case where plugins sit in plugins/ but config says plugin-repos/) --
a store-only fallback; discovery scans the configured directory only.
Each directory is searched completely before the next, by the shared
rules in ``src/plugin_system/plugin_dirs.py``: a directory whose
manifest declares the id wins, then a directory named exactly for it.
The manifest match matters because a directory name can differ from
the id its manifest declares (a hand-made or legacy layout such as
`ledmatrix-stocks/` holding id `stocks`); a lookup by directory name
alone reported such a plugin as not installed, so update_plugin()
silently did nothing.
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
a store operation may delete what this returns, so it only accepts a
directory that names the id exactly or declares it. Note that this
leaves registry ids like `stocks` unresolved when the installed
plugin is `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
monorepo's leaderboard, music, stocks and weather); passing
``prefix=True`` would resolve them, but update_plugin()'s reinstall
path has not been checked against that yet.
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
Returns: Returns:
Path to plugin directory if found, None otherwise Path to plugin directory if found, None otherwise
""" """
# First check the configured plugins directory return resolve_plugin_dir(
plugin_path = self.plugins_dir / plugin_id plugin_id, self._candidate_plugin_dirs(), prefix=False,
if plugin_path.exists(): case_insensitive=False)
return plugin_path
# Also check the standard 'plugins/' directory if it's different
# This handles the case where plugins are in plugins/ but config says plugin-repos/
try:
if self.plugins_dir.is_absolute():
project_root = self.plugins_dir.parent
else:
project_root = self.plugins_dir.resolve().parent
standard_plugins_dir = project_root / 'plugins'
if standard_plugins_dir.exists() and standard_plugins_dir != self.plugins_dir:
plugin_path = standard_plugins_dir / plugin_id
if plugin_path.exists():
return plugin_path
except (OSError, ValueError):
pass
# Last resort: the directory name may differ from the id being looked
# up. install_plugin() deliberately renames a plugin's directory to the
# MANIFEST id when it differs from the REGISTRY id (see the rename near
# "doesn't match registry ID" above), so `stocks` in the registry lands
# in `ledmatrix-stocks/`. Every lookup above is by directory name, so
# update_plugin("stocks") found nothing and reported the plugin as not
# installed -- silently, and for good: the user sees no error and stays
# on a stale version. Four installed plugins hit this in practice
# (leaderboard, music, stocks, weather).
#
# Deliberately last so the two lookups above keep their exact meaning;
# this only runs when a direct hit already failed. See
# test_discovery_path_contract.py, which pins that ordering.
for search_dir in self._candidate_plugin_dirs():
match = self._find_by_manifest_id(search_dir, plugin_id)
if match is not None:
self.logger.debug(
"Resolved plugin '%s' to %s via its manifest id "
"(directory name differs from the id)", plugin_id, match)
return match
return None
def _candidate_plugin_dirs(self) -> List[Path]: def _candidate_plugin_dirs(self) -> List[Path]:
"""Directories that may hold installed plugins, configured one first.""" """Directories that may hold installed plugins, configured one first."""
dirs = [self.plugins_dir] return [d for d in store_search_dirs(self.plugins_dir) if d.exists()]
try:
base = self.plugins_dir if self.plugins_dir.is_absolute() else self.plugins_dir.resolve()
sibling = base.parent / 'plugins'
if sibling != self.plugins_dir:
dirs.append(sibling)
except (OSError, ValueError):
pass
return [d for d in dirs if d.exists()]
@staticmethod
def _find_by_manifest_id(search_dir: Path, plugin_id: str) -> Optional[Path]:
"""A subdirectory of `search_dir` whose manifest declares `plugin_id`.
Skips half-finished installs: store_manager renames a directory aside
with '.standalone-backup-' during install and rollback, and treating
one as installed would resurrect a ghost plugin.
"""
try:
entries = sorted(search_dir.iterdir())
except (OSError, ValueError):
return None
for entry in entries:
if not entry.is_dir() or '.standalone-backup-' in entry.name:
continue
manifest = entry / 'manifest.json'
if not manifest.is_file():
continue
try:
with open(manifest, 'r', encoding='utf-8') as handle:
if json.load(handle).get('id') == plugin_id:
return entry
except (OSError, ValueError):
continue
return None
def uninstall_plugin(self, plugin_id: str) -> bool: def uninstall_plugin(self, plugin_id: str) -> bool:
""" """
@@ -2600,9 +2551,9 @@ class PluginStoreManager:
field during the monorepo migration on a Pi with broken DNS — every field during the monorepo migration on a Pi with broken DNS — every
old-remote plugin was deleted and none could be re-downloaded). old-remote plugin was deleted and none could be re-downloaded).
The aside name embeds '.standalone-backup-' so plugin discovery The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
(plugin_manager._scan_directory_for_plugins) ignores it even though plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
it still contains a manifest.json. even though it still contains a manifest.json.
Held for the whole operation under a per-plugin_id lock: two Held for the whole operation under a per-plugin_id lock: two
overlapping requests for the same plugin (double-click, two overlapping requests for the same plugin (double-click, two
@@ -2612,7 +2563,7 @@ class PluginStoreManager:
""" """
with self._get_reinstall_lock(plugin_id): with self._get_reinstall_lock(plugin_id):
backup_path = plugin_path.with_name( backup_path = plugin_path.with_name(
f"{plugin_path.name}.standalone-backup-migrating") f"{plugin_path.name}{BACKUP_MARKER}migrating")
# A stale aside from a previous crash would block the rename # A stale aside from a previous crash would block the rename
if backup_path.exists(): if backup_path.exists():
if not self._safe_remove_directory(backup_path): if not self._safe_remove_directory(backup_path):
@@ -3085,15 +3036,15 @@ class PluginStoreManager:
""" """
Get list of installed plugin IDs. Get list of installed plugin IDs.
One entry per plugin directory in the configured directory that has a
manifest.json, named by the manifest's id (the directory name when
the manifest carries none, e.g. because it does not parse). Backup
and hidden directories are not plugins.
Returns: Returns:
List of plugin IDs List of plugin IDs, sorted
""" """
if not self.plugins_dir.exists(): if not self.plugins_dir.exists():
return [] return []
index = PluginDirectoryIndex.scan(self.plugins_dir)
installed = [] return sorted(index.installed_ids(require_parseable_manifest=False))
for item in self.plugins_dir.iterdir():
if item.is_dir() and (item / "manifest.json").exists():
installed.append(item.name)
return installed
+19 -13
View File
@@ -16,9 +16,13 @@ change to the fallback chains is a deliberate one.
The `.standalone-backup-` contract: store_manager renames a plugin dir aside The `.standalone-backup-` contract: store_manager renames a plugin dir aside
with that substring during install/rollback; discovery MUST skip such dirs with that substring during install/rollback; discovery MUST skip such dirs
or a half-finished install would surface a ghost plugin. The substring is or a half-finished install would surface a ghost plugin. The substring now
duplicated as a literal in both files — this test breaks if either side lives once, as plugin_dirs.BACKUP_MARKER, which both sides import; its value
changes it unilaterally. is pinned because debris already on devices carries exactly that text.
All of them now resolve through src/plugin_system/plugin_dirs.py; the
per-caller differences pinned here are explicit arguments there. The rules
themselves are covered table-style in test_plugin_dirs.py.
""" """
import json import json
@@ -198,14 +202,16 @@ class TestStandaloneBackupContract:
found = _scanner()._scan_directory_for_plugins(plugins_dir) found = _scanner()._scan_directory_for_plugins(plugins_dir)
assert found == ["real-plugin"] assert found == ["real-plugin"]
def test_backup_substring_literal_matches_across_files(self): def test_backup_marker_is_shared_and_unchanged(self):
"""The substring is duplicated in plugin_manager (skip check) and """store_manager (rename-aside names) and every lookup (skip check)
store_manager (rename-aside names). If either side changes it, the must agree on the marker. Both now import one constant; the value is
other silently stops honoring the contract — this test is the pinned because renaming it would make existing debris on devices
tripwire.""" visible as plugins again."""
from src.plugin_system import plugin_dirs
assert plugin_dirs.BACKUP_MARKER == '.standalone-backup-'
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
pm_text = (root / "src/plugin_system/plugin_manager.py").read_text() sm_text = (root / "src/plugin_system/store_manager.py").read_text(encoding="utf-8")
sm_text = (root / "src/plugin_system/store_manager.py").read_text() assert "{BACKUP_MARKER}preinstall" in sm_text
assert "'.standalone-backup-'" in pm_text.replace('"', "'") assert "{BACKUP_MARKER}migrating" in sm_text
assert ".standalone-backup-" in sm_text assert plugin_dirs.is_ignored_dir_name(
"demo" + plugin_dirs.BACKUP_MARKER + "preinstall")
+343
View File
@@ -0,0 +1,343 @@
"""
Every "which directory holds plugin X?" answer, from one tree, per caller.
src/plugin_system/plugin_dirs.py holds the rules; the callers differ only in
explicit arguments (search dirs, ``ledmatrix-`` prefix, case folding, whether
the manifest pass runs). This file builds one project tree that exercises
every rule and pins each caller's answer for each id, so a change to either
the shared rules or a caller's arguments shows up as a table row.
Callers:
discovery PluginManager._scan_directory_for_plugins -> plugin_directories
pm_get PluginManager.get_plugin_directory before discovery has run
loader PluginLoader.find_plugin_directory (no discovery mapping)
store PluginStoreManager._find_plugin_path
"""
import json
import logging
import os
import sys
import threading
from pathlib import Path
import pytest
from src.plugin_system import plugin_dirs
from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.state_reconciliation import (
StateReconciliation, disk_plugin_ids,
)
from src.plugin_system.store_manager import PluginStoreManager
CONFIGURED = "plugin-repos"
SIBLING = "plugins"
def _write(base: Path, dir_name: str, manifest) -> Path:
d = base / dir_name
d.mkdir(parents=True)
if manifest is not None:
text = manifest if isinstance(manifest, str) else json.dumps(manifest)
(d / "manifest.json").write_text(text, encoding="utf-8")
return d
def _plugin(base: Path, dir_name: str, plugin_id: str, **extra) -> Path:
return _write(base, dir_name, dict({"id": plugin_id, "name": plugin_id,
"version": "1.0.0"}, **extra))
def _link_dir(link: Path, target: Path) -> None:
"""A symlink where the OS allows one; on Windows without the privilege,
a directory junction, which the code under test sees the same way
(is_dir() follows it, iterdir() lists it under the link's name)."""
try:
os.symlink(target, link, target_is_directory=True)
except OSError:
if sys.platform != "win32":
raise
import _winapi
_winapi.CreateJunction(str(target), str(link))
@pytest.fixture
def tree(tmp_path):
repos = tmp_path / CONFIGURED
legacy = tmp_path / SIBLING
repos.mkdir()
legacy.mkdir()
# configured dir (plugin-repos/)
_plugin(repos, "exact", "exact") # id == dir name
_plugin(repos, "ledmatrix-stocks", "stocks") # manifest id != dir name
_plugin(repos, "ledmatrix-legacy", "ledmatrix-legacy") # prefix only by name
_plugin(repos, "MixedCase", "MixedCase") # case differences
_plugin(repos, "renamed-dir", "other-id") # dir name belongs to no id
_plugin(repos, "shadow", "not-shadow") # name says one id ...
_plugin(repos, "real-shadow", "shadow") # ... manifest says it's here
_plugin(repos, "ghost.standalone-backup-preinstall", "ghost") # set aside
_plugin(repos, "exact.standalone-backup-migrating", "exact") # set aside, dup id
_plugin(repos, ".hidden", "hidden") # hidden / staging
_plugin(repos, "zz-dupe", "dupe") # duplicate ids:
_plugin(repos, "ledmatrix-dupe", "dupe") # prefix beats other,
_plugin(repos, "dupe", "dupe") # exact beats prefix
_write(repos, "broken", "{ not json") # unreadable manifest
_write(repos, "noid", {"name": "No id"}) # parses, no id
_write(repos, "listy", [1, 2]) # parses, not an object
_write(repos, "nomanifest", None) # not a plugin
_plugin(repos, "both", "both") # also in plugins/
_plugin(repos, "ledmatrix-weather", "weather") # manifest hit here vs
(repos / "README.md").write_text("not a dir") # a file, never a match
dev_target = _plugin(tmp_path / "dev-checkouts", "devplug-src", "devplug")
_link_dir(repos / "dev-link", dev_target) # symlinked dev plugin
# sibling dir (plugins/): store fallback only
_plugin(legacy, "both", "both")
_plugin(legacy, "legacy-only", "legacy-only")
_plugin(legacy, "ledmatrix-sibling", "sibling")
_plugin(legacy, "weather", "weather") # ... a name hit here
return tmp_path
def _discovery(root: Path) -> PluginManager:
pm = object.__new__(PluginManager)
pm.logger = logging.getLogger("test_plugin_dirs")
pm._discovery_lock = threading.RLock()
pm._skip_reported = set()
pm.plugin_manifests = {}
pm.plugin_directories = {}
pm.plugins_dir = root / CONFIGURED
return pm
def _answers(root: Path, plugin_id: str) -> dict:
repos = root / CONFIGURED
discovered = _discovery(root)
discovered._scan_directory_for_plugins(repos)
fresh = _discovery(root) # discovery not run: exercises the disk rules
store = PluginStoreManager(plugins_dir=str(repos),
uninstalled_registry_path=str(root / "u.json"))
def rel(p):
return None if p is None else Path(p).relative_to(root).as_posix()
return {
"discovery": rel(discovered.plugin_directories.get(plugin_id)),
"pm_get": rel(fresh.get_plugin_directory(plugin_id)),
"loader": rel(PluginLoader().find_plugin_directory(plugin_id, repos)),
"store": rel(store._find_plugin_path(plugin_id)),
}
R = CONFIGURED + "/"
S = SIBLING + "/"
# id discovery pm_get loader store
TABLE = [
("exact", R + "exact", R + "exact", R + "exact", R + "exact"),
# manifest id != dir name: the manifest finds it; pm_get by prefix
("stocks", R + "ledmatrix-stocks", R + "ledmatrix-stocks",
R + "ledmatrix-stocks", R + "ledmatrix-stocks"),
# ledmatrix- prefix: name-only callers with prefix=True; the store has none
("legacy", None, R + "ledmatrix-legacy", R + "ledmatrix-legacy", None),
("ledmatrix-legacy", R + "ledmatrix-legacy", R + "ledmatrix-legacy",
R + "ledmatrix-legacy", R + "ledmatrix-legacy"),
# case: only the loader folds case
("mixedcase", None, None, R + "MixedCase", None),
("MixedCase", R + "MixedCase", R + "MixedCase", R + "MixedCase", R + "MixedCase"),
# a directory name no manifest claims still resolves by name
("renamed-dir", None, R + "renamed-dir", R + "renamed-dir", R + "renamed-dir"),
("other-id", R + "renamed-dir", None, R + "renamed-dir", R + "renamed-dir"),
# manifest id wins over directory name (pm_get has no manifest pass)
("shadow", R + "real-shadow", R + "shadow", R + "real-shadow", R + "real-shadow"),
# backups and hidden dirs are never plugins, by id or by name
("ghost", None, None, None, None),
("ghost.standalone-backup-preinstall", None, None, None, None),
("hidden", None, None, None, None),
(".hidden", None, None, None, None),
# duplicate ids: exact name, then ledmatrix-<id>, then by name
("dupe", R + "dupe", R + "dupe", R + "dupe", R + "dupe"),
# unreadable manifest: found by name so it can be repaired/removed
("broken", None, R + "broken", R + "broken", R + "broken"),
("noid", None, R + "noid", R + "noid", R + "noid"),
("nomanifest", None, R + "nomanifest", R + "nomanifest", R + "nomanifest"),
("README.md", None, None, None, None),
# symlinked dev plugin: found through the link, path kept inside the dir
("devplug", R + "dev-link", None, R + "dev-link", R + "dev-link"),
("dev-link", None, R + "dev-link", R + "dev-link", R + "dev-link"),
# search order: only the store looks in plugins/, and configured first
("both", R + "both", R + "both", R + "both", R + "both"),
("legacy-only", None, None, None, S + "legacy-only"),
("sibling", None, None, None, S + "ledmatrix-sibling"),
# configured dir searched completely (manifest hit) before plugins/ (name hit)
("weather", R + "ledmatrix-weather", R + "ledmatrix-weather",
R + "ledmatrix-weather", R + "ledmatrix-weather"),
# not one plain path segment: nothing, never a join or a truncation
("../plugins/both", None, None, None, None),
("plugin-repos/exact", None, None, None, None),
("", None, None, None, None),
]
@pytest.mark.parametrize("plugin_id,discovery,pm_get,loader,store", TABLE,
ids=[row[0] or "<empty>" for row in TABLE])
def test_each_caller_resolves_each_id(tree, plugin_id, discovery, pm_get, loader, store):
assert _answers(tree, plugin_id) == {
"discovery": discovery, "pm_get": pm_get, "loader": loader, "store": store,
}
class TestListings:
def test_discovery_registers_manifest_ids_only(self, tree):
pm = _discovery(tree)
found = pm._scan_directory_for_plugins(tree / CONFIGURED)
assert sorted(found) == sorted([
"exact", "stocks", "ledmatrix-legacy", "MixedCase", "other-id",
"not-shadow", "shadow", "dupe", "both", "weather", "devplug",
])
assert len(found) == len(set(found)), "a duplicate id was listed twice"
assert set(pm.plugin_manifests) == set(found)
def test_store_lists_every_dir_with_a_manifest(self, tree):
store = PluginStoreManager(plugins_dir=str(tree / CONFIGURED),
uninstalled_registry_path=str(tree / "u.json"))
assert store.list_installed_plugins() == sorted([
"exact", "stocks", "ledmatrix-legacy", "MixedCase", "other-id",
"not-shadow", "shadow", "dupe", "both", "weather", "devplug",
# manifest present but no usable id: listed by directory name
"broken", "noid", "listy",
])
def test_reconciliation_counts_parseable_manifests(self, tree):
assert disk_plugin_ids(tree / CONFIGURED) == {
"exact", "stocks", "ledmatrix-legacy", "MixedCase", "other-id",
"not-shadow", "shadow", "dupe", "both", "weather", "devplug",
"noid", "listy",
}
def test_reconciliation_disk_state_is_keyed_like_config(self, tree):
recon = object.__new__(StateReconciliation)
recon.plugins_dir = tree / CONFIGURED
recon.logger = logging.getLogger("test_plugin_dirs")
state = recon._get_disk_state()
assert state["stocks"] == {"exists_on_disk": True, "version": "1.0.0",
"name": "stocks"}
assert "ledmatrix-stocks" not in state
# A manifest that is valid JSON but not an object used to abort the
# whole disk state with AttributeError.
assert state["listy"] == {"exists_on_disk": True, "version": None, "name": None}
def test_all_listings_skip_backups_and_hidden(self, tree):
store = PluginStoreManager(plugins_dir=str(tree / CONFIGURED),
uninstalled_registry_path=str(tree / "u.json"))
pm = _discovery(tree)
listings = {
"discovery": set(pm._scan_directory_for_plugins(tree / CONFIGURED)),
"store": set(store.list_installed_plugins()),
"reconciliation": disk_plugin_ids(tree / CONFIGURED),
}
for name, ids in listings.items():
assert not {"ghost", "hidden", ".hidden"} & ids, name
assert not any(plugin_dirs.BACKUP_MARKER in i for i in ids), name
# the backup of `exact` did not replace the live one
assert pm.plugin_directories["exact"] == tree / CONFIGURED / "exact"
class TestIndex:
def test_each_manifest_is_read_once_per_scan(self, tree, monkeypatch):
reads = []
real = plugin_dirs._read_entry
monkeypatch.setattr(plugin_dirs, "_read_entry",
lambda p: reads.append(p.name) or real(p))
index = PluginDirectoryIndex.scan(tree / CONFIGURED)
for plugin_id in ("exact", "stocks", "dupe", "shadow", "nope"):
index.find(plugin_id, prefix=True, case_insensitive=True)
index.plugins()
index.installed_ids(require_parseable_manifest=True)
assert len(reads) == len(set(reads)) == len(index.entries)
def test_manifest_statuses(self, tree):
index = PluginDirectoryIndex.scan(tree / CONFIGURED)
status = {e.name: e.status for e in index.entries}
assert status["exact"] == ManifestStatus.OK
assert status["broken"] == ManifestStatus.UNREADABLE
assert status["noid"] == ManifestStatus.NO_ID
assert status["listy"] == ManifestStatus.NOT_OBJECT
assert status["nomanifest"] == ManifestStatus.MISSING
assert "README.md" not in status
def test_duplicates_are_reported_with_every_claimant(self, tree):
dupes = PluginDirectoryIndex.scan(tree / CONFIGURED).duplicates()
assert sorted(e.name for e in dupes["dupe"]) == \
["dupe", "ledmatrix-dupe", "zz-dupe"]
# the backup of `exact` is not a claimant
assert "exact" not in dupes
def test_duplicate_preference_without_an_exact_name(self, tmp_path):
_plugin(tmp_path, "zz-dupe", "dupe")
_plugin(tmp_path, "aa-dupe", "dupe")
_plugin(tmp_path, "ledmatrix-dupe", "dupe")
assert resolve_plugin_dir("dupe", [tmp_path], prefix=False) == \
tmp_path / "ledmatrix-dupe"
(tmp_path / "ledmatrix-dupe" / "manifest.json").unlink()
assert resolve_plugin_dir("dupe", [tmp_path], prefix=False) == \
tmp_path / "aa-dupe"
def test_exact_name_beats_prefix_even_when_it_sorts_later(self, tmp_path):
_plugin(tmp_path, "ledmatrix-zeta", "zeta")
_plugin(tmp_path, "zeta", "zeta")
index = PluginDirectoryIndex.scan(tmp_path)
assert index.plugins()["zeta"].name == "zeta"
assert resolve_plugin_dir("zeta", [tmp_path], prefix=True) == tmp_path / "zeta"
@pytest.mark.parametrize("bad", [None, 5, b"exact", ["exact"]])
def test_non_string_ids_resolve_to_nothing(self, tree, bad):
for kwargs in ({"prefix": True}, {"prefix": True, "by_manifest": False},
{"prefix": False, "case_insensitive": True}):
assert resolve_plugin_dir(bad, [tree / CONFIGURED], **kwargs) is None
def test_missing_search_dir_is_empty_not_an_error(self, tmp_path):
index = PluginDirectoryIndex.scan(tmp_path / "absent")
assert index.entries == [] and index.error is None
assert resolve_plugin_dir("x", [tmp_path / "absent"], prefix=True) is None
def test_discovery_warns_once_about_a_duplicate(self, tree, caplog):
pm = _discovery(tree)
with caplog.at_level(logging.WARNING, logger="test_plugin_dirs"):
for _ in range(3):
pm._scan_directory_for_plugins(tree / CONFIGURED)
hits = [r for r in caplog.records if "'dupe'" in r.getMessage()]
assert len(hits) == 1
assert "zz-dupe" in hits[0].getMessage()
class TestAutoUpdateUsesManifestIds:
def test_update_targets_manifest_id_and_its_directory(self, tree, monkeypatch):
from web_interface import auto_update
store = PluginStoreManager(plugins_dir=str(tree / CONFIGURED),
uninstalled_registry_path=str(tree / "u.json"))
calls = []
def fake_update(plugin_id):
calls.append(plugin_id)
if plugin_id == "stocks":
path = tree / CONFIGURED / "ledmatrix-stocks" / "manifest.json"
m = json.loads(path.read_text(encoding="utf-8"))
m["version"] = "2.0.0"
path.write_text(json.dumps(m), encoding="utf-8")
return True
monkeypatch.setattr(store, "update_plugin", fake_update)
monkeypatch.setattr(store, "_get_local_git_info", lambda p: None)
updated, failed = auto_update.update_plugins(store)
assert "stocks" in calls and "ledmatrix-stocks" not in calls
assert not any(plugin_dirs.BACKUP_MARKER in c for c in calls)
# the version change was seen in ledmatrix-stocks/, not a missing stocks/
assert updated == ["stocks"] and failed == []
+9 -4
View File
@@ -172,9 +172,10 @@ def _plugin_fingerprint(store_manager, plugin_dir):
try: try:
with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f: with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f:
manifest = json.load(f) manifest = json.load(f)
if manifest.get('local_only'): if isinstance(manifest, dict): # valid JSON need not be an object
return None if manifest.get('local_only'):
version = manifest.get('version') return None
version = manifest.get('version')
except (OSError, ValueError): except (OSError, ValueError):
pass pass
sha = None sha = None
@@ -190,8 +191,12 @@ def update_plugins(store_manager, operation_history=None):
"""Update every installed plugin that has an update. Returns (updated, failed).""" """Update every installed plugin that has an update. Returns (updated, failed)."""
updated, failed = [], [] updated, failed = [], []
plugins_dir = Path(store_manager.plugins_dir) plugins_dir = Path(store_manager.plugins_dir)
# list_installed_plugins() reports manifest ids, and a plugin's directory
# may be named differently (ledmatrix-stocks/ holding id "stocks"), so
# the directory comes from the store's own lookup, not a join.
find_dir = getattr(store_manager, '_find_plugin_path', None)
for plugin_id in sorted(store_manager.list_installed_plugins()): for plugin_id in sorted(store_manager.list_installed_plugins()):
plugin_dir = plugins_dir / plugin_id plugin_dir = (find_dir(plugin_id) if find_dir else None) or plugins_dir / plugin_id
before = _plugin_fingerprint(store_manager, plugin_dir) before = _plugin_fingerprint(store_manager, plugin_dir)
if before is None: if before is None:
continue # local_only: managed by hand, never from the registry continue # local_only: managed by hand, never from the registry