refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)

The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 10:39:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba6eccb489
commit 7ab6fb1aff
74 changed files with 1759 additions and 676 deletions
+34
View File
@@ -142,6 +142,40 @@ read any of them:
on the store card and linked to the plugin's source at that commit. on the store card and linked to the plugin's source at that commit.
Informational only; installs still come from the branch head. Informational only; installs still come from the branch head.
### Changes
- The web interface no longer loads or runs plugins (web plugin catalog,
stage 1). It built its own `PluginManager` and loaded plugins into the web
process: store installs and updates loaded or reloaded a web-side copy, and
config saves and enable/disable called `on_config_change`, `on_enable` and
`on_disable` on it. None of that reached the panel. The web process now
reads plugins as files through the new `PluginCatalog`
(`src/plugin_system/plugin_catalog.py`); only the display runs them, and
config changes reach them through its config watcher, as they already did.
- A plugin update, an install of a plugin that is already enabled, or an
uninstall that keeps an enabled plugin's config now answers
`restart_required: true` and shows the restart banner, because the
running display keeps the code it loaded until it restarts. Before, the
update looked applied and the panel kept the old version.
- The restart banner follows `restart_required` in any response
(`POST /api/v3/config/main` sends it) rather than the URL that was
called.
- `/api/v3/plugins/installed` reports `loaded`, `state` and `error_info`
as `null`: the display does not publish them, and the old values
described web-side copies. `enabled` follows the display's rule, so a
plugin whose config has no `enabled` flag shows as disabled (it never
ran). `vegas_mode` is the configured value only.
- `vegas_participation` there is the user's setting, else the manifest's
declaration, with a new `vegas_participation_source` (`config` or
`manifest`). When only the plugin's code decides it (a
`get_vegas_participation()` override or the legacy Vegas hooks) it is
`null` with source `runtime`: the display derives it, and the web no
longer asks a web-side plugin instance.
- Starlark routes always use their on-disk path. The one place the web
process still imports plugin code -- the Starlark helper modules and an
`oauth_flow` action script -- is `_import_plugin_code_in_web_process()`,
until a plugin web-entry contract replaces it.
### Fixes ### Fixes
- Reinstalling a plugin by its registry id when it is installed under its - Reinstalling a plugin by its registry id when it is installed under its
+60 -3
View File
@@ -57,6 +57,61 @@ The on-demand start route starts `ledmatrix.service` when it is not running
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
sleep, its render loops and Vegas's interrupt check as well as the main loop. sleep, its render loops and Vegas's interrupt check as well as the main loop.
### Web and display processes: who runs plugins
Only the display process imports plugin code, instantiates plugins and calls
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
`on_disable`). The web process is metadata-only: it reads plugins as files
through `PluginCatalog`
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
-- manifests, config schemas (through `SchemaManager`), each plugin's
section of `config.json`, and installed versions. The catalog keeps the
read-only method names of `PluginManager` and has nothing that can run a
plugin (no `load_plugin`, `get_plugin` or `plugins`).
How a web-side change reaches the running plugins:
| Change | How the display picks it up |
|---|---|
| Plugin settings saved, config reset | `ConfigService` sees the new `config.json` and calls the plugin's `on_config_change` with the prepared section |
| Plugin enabled or disabled | `ConfigService` → `_controller_config_change` flags a reconcile; `_reconcile_enabled_plugins` loads it (fresh from disk) or unloads it on the render thread |
| Plugin uninstalled (config removed) | the removed section flips its `enabled` flag, and the reconcile unloads it |
| Plugin installed, not enabled | nothing to do until it is enabled, which loads it |
| Plugin installed while already enabled, updated while enabled, or uninstalled with its config kept | **not picked up**: the display keeps running what it loaded. The route answers `restart_required: true` and the UI shows its restart banner |
`display_restart_required()` in `plugin_catalog.py` holds that last rule;
routes return it as `restart_required` (with the banner's wording in
`restart_message`), and `window.noteRestartRequired()` in
`static/v3/app.js` raises the banner for any response that carries it,
`POST /api/v3/config/main` included.
Runtime state shown in the UI comes from what the display publishes (the
table above): health and metrics (`/api/v3/plugins/health`,
`/plugins/metrics`), errors (`/api/v3/errors/*`) and the current mode. The
display does not publish which plugins it has loaded or its plugin state
machine, so `/api/v3/plugins/installed` reports `loaded`, `state` and
`error_info` as `null` rather than guessing; `enabled` is read from
`config.json` by the display's rule (a missing flag is disabled).
Plugin code still runs in the web process in one place,
`_import_plugin_code_in_web_process()` in
[`api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py): the
Starlark routes import the starlark-apps plugin's `tronbyte_repository` and
`pixlet_renderer` helper modules (never the plugin class), and a web-UI
action with `oauth_flow` imports its script for `get_auth_url()`. Every
other web-UI action runs its script as a subprocess. A later, explicit
**plugin web-entry contract** -- a declared entry point for plugin web code
-- replaces that function.
Remaining plugin state outside the display: `data/plugin_state.json`
(`PluginStateManager` in `state_manager.py`, written by the web process),
a second class also named `PluginStateManager` in `plugin_state.py` (the
display's in-memory state machine), and `state_reconciliation.py`, which
compares config, disk and `plugin_state.json` at web startup. These are the
next stages: retire `plugin_state.json`, merge the two state classes, and
give the web process a control socket to the display (reload one plugin,
report the loaded set) in place of `restart_required`.
## Display loop ## Display loop
[`src/display_controller.py`](../src/display_controller.py), class [`src/display_controller.py`](../src/display_controller.py), class
@@ -128,7 +183,8 @@ then normal rotation.
|---|---| |---|---|
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) | | Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` | | Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
| Discovery, load, unload, scheduled updates | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) | | Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) | | Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) | | Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) | | Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
@@ -155,8 +211,9 @@ everything else through `_reinstall_with_rollback()`.
## Web interface ## Web interface
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the - **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
Flask `app` at import time, creates the managers, and registers two Flask `app` at import time, creates the managers -- a `PluginCatalog`,
blueprints. `web_interface/start.py` runs it on port 5000. never a `PluginManager` -- and registers two blueprints.
`web_interface/start.py` runs it on port 5000.
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py) - **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
serves the shell `templates/v3/base.html` at `/` and each tab as a serves the shell `templates/v3/base.html` at `/` and each tab as a
partial at `/partials/<name>` (templates in partial at `/partials/<name>` (templates in
+9 -3
View File
@@ -149,7 +149,11 @@ Clean up resources when plugin is unloaded. Override to close connections, stop
#### `on_config_change(new_config: Dict[str, Any]) -> None` #### `on_config_change(new_config: Dict[str, Any]) -> None`
Called after plugin configuration is updated via web API. Called after the plugin's section of `config.json` changes -- a save in the
web UI, say. Every lifecycle hook runs in the display process, which is the
only process that runs plugins: the web interface writes `config.json`, and
the display's config watcher calls this with the prepared section. See
[ARCHITECTURE.md](ARCHITECTURE.md#web-and-display-processes-who-runs-plugins).
In the display service it runs on the config watcher thread while holding In the display service it runs on the config watcher thread while holding
the plugin's lock, so it never overlaps your `update()` or `display()`. If the plugin's lock, so it never overlaps your `update()` or `display()`. If
@@ -159,11 +163,13 @@ from the update thread: as soon as the plugin is free, and before its next
#### `on_enable() -> None` #### `on_enable() -> None`
Called when plugin is enabled. Called when the display loads the plugin enabled: at startup, or when it is
switched on in the web UI.
#### `on_disable() -> None` #### `on_disable() -> None`
Called when plugin is disabled. Called when the display unloads the plugin, e.g. when it is switched off in
the web UI.
#### `get_update_interval() -> Optional[float]` #### `get_update_interval() -> Optional[float]`
+55 -12
View File
@@ -154,10 +154,17 @@ there an unchecked checkbox — which the browser omits — is saved as
```json ```json
{ {
"status": "success", "status": "success",
"message": "Configuration saved successfully" "message": "Configuration saved successfully",
"restart_required": true
} }
``` ```
`restart_required` is always true here: display hardware, rotation,
durations and general settings take effect when the display restarts, and
the web UI shows its restart banner on the flag. (Plugin sections saved
through this route reach the running plugin live, like
`POST /plugins/config`.)
Invalid values (e.g. an out-of-range `target_fps`, a hardware option the Invalid values (e.g. an out-of-range `target_fps`, a hardware option the
Raspberry Pi 5 driver cannot use) are rejected with `400` and nothing is Raspberry Pi 5 driver cannot use) are rejected with `400` and nothing is
saved. saved.
@@ -502,8 +509,8 @@ List all installed plugins with their status and metadata.
"tags": ["sports", "football", "nfl"], "tags": ["sports", "football", "nfl"],
"enabled": true, "enabled": true,
"verified": true, "verified": true,
"loaded": true, "loaded": null,
"state": "loaded", "state": null,
"error_info": null, "error_info": null,
"last_updated": "2025-01-15T10:30:00Z", "last_updated": "2025-01-15T10:30:00Z",
"last_commit": "abc1234", "last_commit": "abc1234",
@@ -512,19 +519,34 @@ List all installed plugins with their status and metadata.
"web_ui_actions": [], "web_ui_actions": [],
"vegas_mode": null, "vegas_mode": null,
"vegas_content_type": null, "vegas_content_type": null,
"vegas_participation": "scroll" "vegas_participation": "scroll",
"vegas_participation_source": "manifest"
} }
] ]
} }
} }
``` ```
Metadata comes from each plugin's files on disk; `enabled` is the plugin's
`enabled` flag in `config.json` (missing means disabled, as the display
reads it). `loaded`, `state` and `error_info` are always `null`: the web
process runs no plugin code, and the display does not publish which plugins
it has loaded. What the display does publish is at
[`/plugins/health`](#get-plugin-health), `/plugins/metrics` and `/errors/*`.
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`, `vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
`"pause"` or `"exclude"` (see `"pause"` or `"exclude"` (see
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)). [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)),
For a plugin that is not loaded it is only the user's own and `vegas_participation_source` says where it came from. The web reads it
`vegas_participation` setting, or `null`. `vegas_mode` and the way the display resolves it, as far as files can tell: the user's own
`vegas_content_type` are the legacy hooks' raw answers. `vegas_participation` setting (`"config"`), else the manifest's declared
`vegas_participation` (`"manifest"`). Past those the display derives it from
the plugin's code -- a `get_vegas_participation()` override or the legacy
Vegas hooks -- which the web process never runs, so `vegas_participation`
is `null` and the source is `"runtime"`. A plugin that overrides
`get_vegas_participation()` decides at run time and can differ from its
manifest's declaration. `vegas_mode` is the plugin's configured
`vegas_mode`, or `null`; `vegas_content_type` is always `null`.
### Get Plugin Configuration ### Get Plugin Configuration
@@ -685,7 +707,13 @@ Install a plugin from the plugin store.
``` ```
When the operation queue is unavailable the install runs synchronously and When the operation queue is unavailable the install runs synchronously and
the response has only a `message`. the response has only a `message` and the restart fields below.
The finished operation's `result` (from `/plugins/operation/<operation_id>`)
carries `restart_required`: true when the plugin is already enabled in
`config.json`, because the running display does not load newly installed
files by itself; `restart_message` then holds the restart banner's wording.
A plugin that is not enabled needs no restart: enabling it loads it.
A plugin whose registry entry (or downloaded manifest) needs a newer A plugin whose registry entry (or downloaded manifest) needs a newer
LEDMatrix is refused: the synchronous install answers `409` with a message LEDMatrix is refused: the synchronous install answers `409` with a message
@@ -716,6 +744,11 @@ Remove an installed plugin.
} }
``` ```
The finished operation's `result` carries `restart_required`. Removing the
plugin's config (the default) lets the display unload it by itself, so it is
false; with `preserve_config: true` an enabled plugin keeps running until
the display restarts, and it is true.
### Update Plugin ### Update Plugin
**POST** `/api/v3/plugins/update` **POST** `/api/v3/plugins/update`
@@ -736,11 +769,18 @@ Update a plugin to the latest version. Runs synchronously.
"message": "Plugin football-scoreboard updated ...", "message": "Plugin football-scoreboard updated ...",
"data": { "data": {
"last_updated": "2025-01-15T10:30:00Z", "last_updated": "2025-01-15T10:30:00Z",
"commit": "abc1234..." "commit": "abc1234...",
} "update_status": "updated"
},
"restart_required": true,
"restart_message": "Plugin updated — restart the display to run the new version"
} }
``` ```
`update_status` is `updated`, `up_to_date` or `local_only`.
`restart_required` is true when the plugin changed and is enabled: the
running display keeps the code it loaded until it restarts.
An update this core cannot run answers `409` with `Plugin update refused:` An update this core cannot run answers `409` with `Plugin update refused:`
and the reason; the installed version is left as it was. and the reason; the installed version is left as it was.
@@ -772,10 +812,13 @@ Install a plugin directly from a GitHub repository URL. Runs synchronously.
"message": "Plugin my-plugin installed successfully", "message": "Plugin my-plugin installed successfully",
"plugin_id": "my-plugin", "plugin_id": "my-plugin",
"name": "My Plugin", "name": "My Plugin",
"branch": "main" "branch": "main",
"restart_required": false
} }
``` ```
`restart_required` follows the same rule as `/plugins/install`.
### Load Registry from URL ### Load Registry from URL
**POST** `/api/v3/plugins/registry-from-url` **POST** `/api/v3/plugins/registry-from-url`
+1
View File
@@ -54,6 +54,7 @@ src/plugin_system/compatibility.py
src/plugin_system/operation_history.py src/plugin_system/operation_history.py
src/plugin_system/operation_queue.py src/plugin_system/operation_queue.py
src/plugin_system/operation_types.py src/plugin_system/operation_types.py
src/plugin_system/plugin_catalog.py
src/plugin_system/plugin_dirs.py src/plugin_system/plugin_dirs.py
src/plugin_system/plugin_executor.py src/plugin_system/plugin_executor.py
src/plugin_system/plugin_health.py src/plugin_system/plugin_health.py
+256
View File
@@ -0,0 +1,256 @@
"""
Plugin catalog: what the web process knows about installed plugins.
The web interface and the display run as two processes. Only the display
imports plugin code and runs it; the web process reads plugins as files --
manifest, config schema, the plugin's section of config.json, the installed
version -- and never imports a plugin module, instantiates a plugin class or
calls a plugin lifecycle hook. This class is that read side.
It keeps the method names of the read-only part of :class:`PluginManager`
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
``get_plugin_directory``, ``get_plugin_display_modes``,
``find_plugin_for_mode``), so code that only ever read through a manager
reads through a catalog unchanged. It has nothing that runs a plugin: no
``load_plugin``, ``get_plugin`` or ``plugins``.
Runtime state -- whether the display has a plugin loaded, its health, its
errors -- is not here either. The display process publishes what it knows to
the shared cache (health and resource metrics, the current mode, the error
aggregator snapshot), and the web routes read those publications. What the
display does not publish (which plugins it has loaded, its plugin state
machine) the web cannot know, and reports as unknown.
See docs/ARCHITECTURE.md ("Web and display processes").
"""
import json
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, cast
from src.common.permission_utils import (
ensure_directory_permissions, get_plugin_dir_mode,
)
from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
PathLike = Union[str, Path]
class PluginCatalog:
"""Manifests, schemas, config and versions of the installed plugins.
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
rescans the plugins directory and replaces the manifest map, so an
uninstalled plugin disappears and a new one appears.
"""
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
schema_manager: Optional[Any] = None) -> None:
self.plugins_dir: Path = Path(plugins_dir)
self.config_manager = config_manager
self.schema_manager = schema_manager
self.logger = get_logger(__name__)
# Guards plugin_manifests/plugin_directories: request threads read
# them while another request (or startup reconciliation) rescans.
self._lock = threading.RLock()
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
self.plugin_directories: Dict[str, Path] = {}
self._skip_reported: set = set()
# The Plugin Store installs into this directory, so it has to exist.
# The display service logs its own error if it cannot use it; the web
# interface stays up either way.
try:
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
except OSError as exc:
self.logger.warning("Could not create plugins directory %s: %s",
self.plugins_dir, exc)
# -- discovery --------------------------------------------------------
def discover_plugins(self) -> List[str]:
"""Rescan the plugins directory; return the discovered plugin ids.
The rules for what counts as a plugin and which directory wins for a
duplicated id are :class:`PluginDirectoryIndex`'s, the same ones the
display process loads by. Only the configured directory is scanned.
"""
index = PluginDirectoryIndex.scan(self.plugins_dir)
if index.error is not None:
self.logger.error("Error scanning plugins directory %s: %s",
self.plugins_dir, index.error)
for entry in index.entries:
if entry.status in (ManifestStatus.UNREADABLE, ManifestStatus.NOT_OBJECT,
ManifestStatus.NO_ID):
# The display logs these at load time; once per process is
# enough here, since discovery runs on page loads.
if entry.name not in self._skip_reported:
self._skip_reported.add(entry.name)
self.logger.info("Not listing %s: its manifest.json is unusable (%s)",
entry.name, entry.status)
plugins = index.plugins()
manifests = {pid: entry.manifest for pid, entry in plugins.items()}
directories = {pid: entry.path for pid, entry in plugins.items()}
with self._lock:
self.plugin_manifests.clear()
self.plugin_manifests.update(manifests)
self.plugin_directories.clear()
self.plugin_directories.update(directories)
return list(plugins)
def discovered_plugin_ids(self) -> set:
"""Snapshot of the discovered ids, taken under the lock."""
with self._lock:
return set(self.plugin_manifests)
# -- manifests --------------------------------------------------------
def get_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""A copy of the manifest discovery read for ``plugin_id``, or None."""
with self._lock:
manifest = self.plugin_manifests.get(plugin_id)
return dict(manifest) if manifest else None
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""The plugin's manifest, as a new dict -- metadata only.
Unlike ``PluginManager.get_plugin_info`` there are no ``loaded``,
``runtime_info`` or ``state`` keys: those described plugin instances
in this process, which no longer exist.
"""
return self.get_manifest(plugin_id)
def get_all_plugin_info(self) -> List[Dict[str, Any]]:
""":meth:`get_plugin_info` for every discovered plugin."""
with self._lock:
ids = list(self.plugin_manifests)
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
def read_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""The manifest as it is on disk now, not as discovery last saw it.
For reads that must reflect a change made since the last scan -- the
version just after an update, say. None when the plugin has no
directory or its manifest is missing, unreadable or not an object.
"""
plugin_dir = self.get_plugin_directory(plugin_id)
if plugin_dir is None:
return None
try:
with open(Path(plugin_dir) / 'manifest.json', 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, ValueError) as exc:
self.logger.debug("Could not read manifest for %s: %s", plugin_id, exc)
return None
return manifest if isinstance(manifest, dict) else None
def get_installed_version(self, plugin_id: str) -> str:
"""The installed version from the on-disk manifest, or ''."""
manifest = self.read_manifest(plugin_id) or {}
version = manifest.get('version', '')
return version if isinstance(version, str) else str(version)
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
"""Where ``plugin_id`` is installed, or None.
Same rules as ``PluginManager.get_plugin_directory``: the discovered
directory, else ``<id>`` then ``ledmatrix-<id>`` by name within the
plugins directory. An id that is not one plain path segment is
refused rather than joined onto the plugins directory.
"""
with self._lock:
if plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
plugin_dir = resolve_plugin_dir(
plugin_id, [self.plugins_dir], prefix=True, case_insensitive=False,
by_manifest=False)
return str(plugin_dir) if plugin_dir is not None else None
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
"""The manifest's ``display_modes``, or [].
What the display actually rotates can differ: a plugin may compute
its modes at run time (``plugin.modes``). This is the declared list.
"""
with self._lock:
manifest = self.plugin_manifests.get(plugin_id)
modes = (manifest or {}).get('display_modes', [])
return list(modes) if isinstance(modes, list) else []
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
wanted = mode.strip().lower()
with self._lock:
manifests = dict(self.plugin_manifests)
for plugin_id, manifest in manifests.items():
modes = manifest.get('display_modes')
if isinstance(modes, list) and any(
isinstance(m, str) and m.lower() == wanted for m in modes):
return plugin_id
return None
# -- schema and config ------------------------------------------------
def get_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
"""The plugin's config schema through SchemaManager, or None."""
if self.schema_manager is None:
return None
schema = self.schema_manager.load_schema(plugin_id, use_cache=use_cache)
return cast(Optional[Dict[str, Any]], schema)
def get_config(self, plugin_id: str) -> Dict[str, Any]:
"""The plugin's section of config.json (secrets merged), or {}."""
if self.config_manager is None:
return {}
section = (self.config_manager.load_config() or {}).get(plugin_id)
return section if isinstance(section, dict) else {}
def is_enabled(self, plugin_id: str) -> bool:
"""Whether config.json enables the plugin, by the display's rule.
The display loads a plugin only when its section says
``"enabled": true``; a missing flag or section means disabled
(``DisplayController._reconcile_enabled_plugins``).
"""
return bool(self.get_config(plugin_id).get('enabled', False))
def display_restart_required(action: str, plugin_enabled: bool, *,
changed: bool = True,
preserve_config: bool = False) -> bool:
"""Whether a store operation needs a display restart to reach the panel.
The display loads and unloads plugins live only through its config
watcher: when a plugin's ``enabled`` flag changes it reconciles the
running set (``DisplayController._reconcile_enabled_plugins``), and
loading reads the plugin fresh from disk. Nothing makes it reload a
plugin it is already running, and nothing tells it about files changing
under a plugin whose flag did not move. So:
- ``install``: a plugin that is not enabled needs nothing -- enabling it
later loads it. One already enabled in config (a reinstall, or a
config carried over) is not picked up until a restart.
- ``update``: the display keeps running the code it loaded until it
restarts, if it runs the plugin at all -- only when it is enabled.
``changed=False`` (already up to date) needs nothing.
- ``uninstall``: removing the plugin's config section flips its enabled
flag, and the reconcile unloads it. With ``preserve_config`` the flag
stays, and an enabled plugin keeps running until a restart.
``plugin_enabled`` is the config flag as it was before the operation.
"""
if not plugin_enabled:
return False
if action == 'install':
return True
if action == 'update':
return changed
if action == 'uninstall':
return preserve_config
raise ValueError(f"unknown store action: {action!r}")
+6 -1
View File
@@ -15,7 +15,8 @@ from src.web_interface.errors import ErrorCode, WebInterfaceError
def success_response( def success_response(
data: Any = None, data: Any = None,
message: Optional[str] = None, message: Optional[str] = None,
metadata: Optional[Dict] = None metadata: Optional[Dict] = None,
extra: Optional[Dict[str, Any]] = None
): ):
""" """
Create a standardized success response. Create a standardized success response.
@@ -24,11 +25,15 @@ def success_response(
data: Response data data: Response data
message: Optional success message message: Optional success message
metadata: Optional metadata (timing, version, etc.) metadata: Optional metadata (timing, version, etc.)
extra: Optional top-level fields beside ``status``/``data``, such as
``restart_required``; they cannot replace the standard keys
Returns: Returns:
Flask jsonify response Flask jsonify response
""" """
response_data = create_success_response(data, message, metadata) response_data = create_success_response(data, message, metadata)
for key, value in (extra or {}).items():
response_data.setdefault(key, value)
# Timing is merged into whatever the caller passed, without inventing a # Timing is merged into whatever the caller passed, without inventing a
# metadata block for responses that have neither. # metadata block for responses that have neither.
+21 -2
View File
@@ -21,14 +21,32 @@ from flask import Flask
# Every manager attribute the blueprint reads. Anything missing here keeps # Every manager attribute the blueprint reads. Anything missing here keeps
# whatever a previously-run test left on the singleton. # whatever a previously-run test left on the singleton.
API_V3_MANAGER_ATTRS = ( API_V3_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager', 'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager', 'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager', 'operation_queue', 'operation_history', 'cache_manager',
'health_tracker', 'resource_monitor',
) )
_SENTINEL = object() _SENTINEL = object()
def mock_plugin_catalog():
"""A MagicMock shaped like PluginCatalog, and only like it.
``spec`` makes anything a PluginCatalog lacks raise AttributeError --
``get_plugin``, ``load_plugin``, ``plugins`` -- so a route that reached
for a plugin instance in the web process fails the test that drives it
instead of quietly calling a mock. The instance attributes the spec
cannot see are set explicitly.
"""
from src.plugin_system.plugin_catalog import PluginCatalog
catalog = MagicMock(spec=PluginCatalog)
for name in ('plugins_dir', 'config_manager', 'schema_manager',
'plugin_manifests', 'plugin_directories'):
setattr(catalog, name, MagicMock())
return catalog
def build_app(blueprint): def build_app(blueprint):
app = Flask(__name__) app = Flask(__name__)
app.config['TESTING'] = True app.config['TESTING'] = True
@@ -52,7 +70,8 @@ def api_v3_module():
for name in API_V3_MANAGER_ATTRS for name in API_V3_MANAGER_ATTRS
} }
for name in API_V3_MANAGER_ATTRS: for name in API_V3_MANAGER_ATTRS:
setattr(module.api_v3, name, MagicMock()) setattr(module.api_v3, name,
mock_plugin_catalog() if name == 'plugin_catalog' else MagicMock())
# Default to the direct path; queue tests opt in explicitly. # Default to the direct path; queue tests opt in explicitly.
module.api_v3.operation_queue = None module.api_v3.operation_queue = None
+1 -1
View File
@@ -22,7 +22,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_style_editor_layout_leaf_collision.js', 'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js', 'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js', 'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js']; 'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js']; 'dom/test_tools_sections.js'];
+104
View File
@@ -0,0 +1,104 @@
// The "restart the display" banner is raised by the server's answer, not by
// which URL was called.
//
// app.js used to show it after any successful POST to /api/v3/config/main and
// nothing else. A plugin update, install or uninstall that the running display
// cannot pick up live now answers `restart_required: true` (with the banner's
// wording in `restart_message`), and so does a main-config save. The htmx
// after-request handler and window.noteRestartRequired must both follow the
// flag. Runs the shipped app.js in a vm with a minimal fake DOM -- no jsdom and
// no server needed, so it runs under test/test_js_unit_suites.py too.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function load() {
const handlers = {};
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
const banner = { style: { display: 'none' } };
const text = { dataset: {}, textContent: ' Configuration saved — restart the display to apply the changes ' };
const store = {};
const document = {
body: { addEventListener: listen('body') },
addEventListener: listen('document'),
getElementById: (id) => ({ 'restart-pending-banner': banner, 'restart-pending-text': text })[id] || null,
querySelector: () => null,
querySelectorAll: () => [],
};
const window = {
addEventListener: listen('window'),
getApp: () => null,
};
const context = {
window, document, console,
sessionStorage: {
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
getItem: (k) => (k in store ? store[k] : null),
},
showNotification: () => {},
setTimeout: () => 0,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
const elt = { closest: () => (reportsItself ? {} : null) };
afterRequest({
target: { closest: () => null },
detail: {
xhr: { status, responseText: body === undefined ? '' : (typeof body === 'string' ? body : JSON.stringify(body)) },
elt,
requestConfig: { verb: 'post', path: reqPath },
},
});
};
return { window, banner, text, store, fire, afterRequest };
}
console.log('\nwindow.noteRestartRequired');
{
const t = load();
ok('app.js defines it', typeof t.window.noteRestartRequired === 'function');
ok('no flag, no banner', t.window.noteRestartRequired({ status: 'success' }) === false
&& t.banner.style.display === 'none');
ok('a missing body is ignored', t.window.noteRestartRequired(null) === false);
ok('restart_required: false is not a request',
t.window.noteRestartRequired({ restart_required: false }) === false && t.banner.style.display === 'none');
ok('restart_required: true shows the banner',
t.window.noteRestartRequired({ restart_required: true }) === true && t.banner.style.display === 'block');
ok('without a message the template wording stays',
t.text.textContent === 'Configuration saved — restart the display to apply the changes', t.text.textContent);
t.window.noteRestartRequired({ restart_required: true, restart_message: 'Plugin updated — restart the display to run the new version' });
ok('restart_message becomes the wording',
t.text.textContent === 'Plugin updated — restart the display to run the new version', t.text.textContent);
ok('and survives a reload with the flag', t.store['ledmatrix-restart-pending'] === '1'
&& t.store['ledmatrix-restart-pending-text'] === 'Plugin updated — restart the display to run the new version');
}
console.log('\nhtmx after-request follows the flag, not the URL');
{
const t = load();
ok('the handler is registered', typeof t.afterRequest === 'function');
t.fire({ path: '/api/v3/config/main', body: { status: 'success', message: 'Configuration saved successfully' } });
ok('a /config/main answer without the flag raises nothing', t.banner.style.display === 'none');
t.fire({ path: '/api/v3/plugins/update', status: 500, body: { status: 'error', restart_required: true } });
ok('an error answer never raises it', t.banner.style.display === 'none');
t.fire({ path: '/api/v3/whatever', body: '<html>not json' });
ok('a non-JSON answer is ignored', t.banner.style.display === 'none');
// The main-config forms report their own result (hx-on after-request), so
// the flag must be read even when the toast is not this handler's to show.
t.fire({ path: '/api/v3/config/main', reportsItself: true,
body: { status: 'success', message: 'Configuration saved successfully', restart_required: true } });
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+72
View File
@@ -229,6 +229,78 @@ const noSleep = { sleep: async () => {} };
allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop); allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop);
} }
console.log('\nrestart banner: driven by the server\'s restart_required');
{
const body = (restart_required, restart_message) => ({
success: true,
result: { status: 'success', data: { update_status: 'updated' }, restart_required, restart_message },
});
const needed = body(true, 'Plugin updated — restart the display to run the new version');
ok('an update the display is running asks for the banner, with its wording',
Manager.restartRequest([body(false), needed, body(true, 'second')]) === needed.result);
ok('updates the display does not run need no restart',
Manager.restartRequest([body(false), body(false)]) === null);
ok('a failed request never raises the banner',
Manager.restartRequest([{ success: false, error: { restart_required: true } }]) === null);
ok('an older server that sends no flag raises nothing',
Manager.restartRequest([{ success: true, result: { status: 'success' } }]) === null);
ok('no results, no banner', Manager.restartRequest(undefined) === null);
// The first request's answer was lost; the re-sent one finds nothing to do.
const lost = (enabled, update_status = 'up_to_date') => ({
pluginId: 'clock', success: true, afterLostAnswer: true, enabled,
result: { status: 'success', data: { update_status }, restart_required: false },
});
const maybe = Manager.restartRequest([body(false), lost(true)]);
ok('an enabled plugin up to date after a lost answer may have been updated: banner',
maybe && maybe.restart_required === true && /clock/.test(maybe.restart_message), maybe);
ok('...but an explicit answer still wins, with its wording',
Manager.restartRequest([lost(true), needed]) === needed.result);
ok('a disabled one needs no restart (enabling it loads it)',
Manager.restartRequest([lost(false)]) === null);
ok('nor does one that was not retried',
Manager.restartRequest([{ ...lost(true), afterLostAnswer: undefined }]) === null);
}
console.log('\nupdateAll keeps what the banner needs');
{
// ledmatrix-flights (enabled) loses its first answer, then is up to date.
const api = fakeApi({
'ledmatrix-flights': (n) => {
if (n === 1) throw netErr();
return { status: 'success', data: { update_status: 'up_to_date' }, restart_required: false };
},
});
setup(api, { windowList: INSTALLED });
const results = await Manager.updateAll(null, noSleep);
const flights = results.find(r => r.pluginId === 'ledmatrix-flights');
ok('a retried entry is marked, with the plugin\'s enabled flag',
flights.afterLostAnswer === true && flights.enabled === true, flights);
ok('an entry answered first time is not marked',
results.filter(r => r.afterLostAnswer).length === 1, results);
ok('...so the run asks for a restart', Manager.restartRequest(results) !== null);
}
{
const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true };
const api = fakeApi({ 'ledmatrix-flights': () => answer });
setup(api, { stateList: INSTALLED });
window.PluginStateManager.loadInstalledPlugins = async () => { throw new Error('refresh failed'); };
const warn = console.warn;
console.warn = () => {};
let results;
try {
results = await Manager.updateAll(null, noSleep);
} catch (e) {
results = e;
} finally {
console.warn = warn;
}
ok('a failed list refresh still returns the results',
Array.isArray(results) && results.length === EXPECTED.length, String(results));
ok('...with the restart flag intact',
Array.isArray(results) && Manager.restartRequest(results) === answer);
}
console.log(`\n${pass} passed, ${fail} failed`); console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); }); })().catch(e => { console.error(e); process.exit(1); });
+1 -1
View File
@@ -52,7 +52,7 @@ class TestPluginToggle:
class TestOnDemandStart: class TestOnDemandStart:
@pytest.fixture @pytest.fixture
def service(self, api_v3_module): def service(self, api_v3_module):
api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.plugin_catalog = None
api_v3_module.api_v3.config_manager = None api_v3_module.api_v3.config_manager = None
with patch("web_interface.blueprints.api_v3.display._get_display_service_status", with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
return_value={"active": True}), \ return_value={"active": True}), \
+2 -2
View File
@@ -45,7 +45,7 @@ VALID_CREDENTIALS = {
def plugin_dir(tmp_path, api_v3_module): def plugin_dir(tmp_path, api_v3_module):
directory = tmp_path / "plugins" / "calendar" directory = tmp_path / "plugins" / "calendar"
directory.mkdir(parents=True) directory.mkdir(parents=True)
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory) api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(directory)
return directory return directory
@@ -98,7 +98,7 @@ class TestRequestValidation:
assert not (plugin_dir / "credentials.json").exists() assert not (plugin_dir / "credentials.json").exists()
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path): def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str( api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(
tmp_path / "not-installed") tmp_path / "not-installed")
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404 assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
+1 -1
View File
@@ -229,7 +229,7 @@ def display_page(monkeypatch):
config_manager.get_config_path.return_value = 'config/config.json' config_manager.get_config_path.return_value = 'config/config.json'
config_manager.get_secrets_path.return_value = 'config/config_secrets.json' config_manager.get_secrets_path.return_value = 'config/config_secrets.json'
monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False) monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False)
monkeypatch.setattr(pv.pages_v3, 'plugin_manager', MagicMock(plugins={}), raising=False) monkeypatch.setattr(pv.pages_v3, 'plugin_catalog', MagicMock(), raising=False)
app.register_blueprint(pv.pages_v3, url_prefix='/v3') app.register_blueprint(pv.pages_v3, url_prefix='/v3')
response = app.test_client().get('/v3/partials/display') response = app.test_client().get('/v3/partials/display')
assert response.status_code == 200 assert response.status_code == 200
+7 -7
View File
@@ -34,7 +34,7 @@ CONFIG = {
@pytest.fixture @pytest.fixture
def client(api_v3_module, api_v3_client): def client(api_v3_module, api_v3_client):
pm = api_v3_module.api_v3.plugin_manager pm = api_v3_module.api_v3.plugin_catalog
pm.plugin_manifests = MANIFESTS pm.plugin_manifests = MANIFESTS
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS)) pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
pm.get_plugin_display_modes = MagicMock( pm.get_plugin_display_modes = MagicMock(
@@ -85,17 +85,17 @@ class TestItWorksForACallerThatNeverOpensTheDashboard:
"""Discovery is lazy and normally runs because a person loaded the """Discovery is lazy and normally runs because a person loaded the
dashboard; a bridge or script would otherwise get an empty list.""" dashboard; a bridge or script would otherwise get an empty list."""
client.get('/api/v3/display/modes') client.get('/api/v3/display/modes')
api_v3_module.api_v3.plugin_manager.discover_plugins.assert_called_once() api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once()
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client): def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.plugin_catalog = None
response = api_v3_client.get('/api/v3/display/modes') response = api_v3_client.get('/api/v3/display/modes')
assert response.status_code == 500 assert response.status_code == 500
assert response.get_json()['status'] == 'error' assert response.get_json()['status'] == 'error'
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module): def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
"""Its mode is its own id -- the same fallback the controller uses.""" """Its mode is its own id -- the same fallback the controller uses."""
pm = api_v3_module.api_v3.plugin_manager pm = api_v3_module.api_v3.plugin_catalog
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}} pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
pm.get_plugin_display_modes = MagicMock(return_value=[]) pm.get_plugin_display_modes = MagicMock(return_value=[])
api_v3_module.api_v3.config_manager.load_config = MagicMock( api_v3_module.api_v3.config_manager.load_config = MagicMock(
@@ -116,7 +116,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
@pytest.fixture @pytest.fixture
def client_with_bad_section(self, api_v3_module, api_v3_client): def client_with_bad_section(self, api_v3_module, api_v3_client):
pm = api_v3_module.api_v3.plugin_manager pm = api_v3_module.api_v3.plugin_catalog
pm.plugin_manifests = MANIFESTS pm.plugin_manifests = MANIFESTS
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS)) pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
pm.get_plugin_display_modes = MagicMock( pm.get_plugin_display_modes = MagicMock(
@@ -143,7 +143,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
self, api_v3_module, api_v3_client): self, api_v3_module, api_v3_client):
"""describe_exception, per test_web_error_detail's contract -- an """describe_exception, per test_web_error_detail's contract -- an
opaque "see logs for details" is what that test exists to prevent.""" opaque "see logs for details" is what that test exists to prevent."""
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock( api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
side_effect=RuntimeError("disk is gone")) side_effect=RuntimeError("disk is gone"))
resp = api_v3_client.get('/api/v3/display/modes') resp = api_v3_client.get('/api/v3/display/modes')
assert resp.status_code == 500 assert resp.status_code == 500
@@ -151,7 +151,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client): def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
"""describe_exception is what makes returning detail safe.""" """describe_exception is what makes returning detail safe."""
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock( api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed")) side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
body = api_v3_client.get('/api/v3/display/modes').get_json() body = api_v3_client.get('/api/v3/display/modes').get_json()
assert 'SEC123' not in json.dumps(body) assert 'SEC123' not in json.dumps(body)
+2 -2
View File
@@ -32,7 +32,7 @@ def _checks(client):
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module): def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_manager.plugin_manifests = { api_v3_module.api_v3.plugin_catalog.plugin_manifests = {
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"}, "clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
} }
@@ -42,7 +42,7 @@ def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module): def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
pm = api_v3_module.api_v3.plugin_manager pm = api_v3_module.api_v3.plugin_catalog
pm.plugin_manifests = {} pm.plugin_manifests = {}
def discover(): def discover():
+2 -3
View File
@@ -20,9 +20,8 @@ def installed(api_v3_module, api_v3_client, tmp_path):
api = api_v3_module.api_v3 api = api_v3_module.api_v3
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': False} info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': False}
info.update(manifest_extra) info.update(manifest_extra)
api.plugin_manager.plugins_dir = str(tmp_path) # no manifest on disk api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info]) api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=None)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None) api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={}) api.config_manager.load_config = MagicMock(return_value={})
response = api_v3_client.get('/api/v3/plugins/installed') response = api_v3_client.get('/api/v3/plugins/installed')
+6 -6
View File
@@ -16,7 +16,7 @@ callers (the Home Assistant MQTT bridge, scripts) saw it after every restart.
undiscovered plugin section skipped secret separation and wrote its API key undiscovered plugin section skipped secret separation and wrote its API key
into config.json in plain text. into config.json in plain text.
A real PluginManager over a temporary plugins directory, so "empty until A real PluginCatalog over a temporary plugins directory, so "empty until
discovered" is the real behaviour rather than a mock's. discovered" is the real behaviour rather than a mock's.
""" """
@@ -30,7 +30,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager # noqa: E402 from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.plugin_manager import PluginManager # noqa: E402 from src.plugin_system.plugin_catalog import PluginCatalog # noqa: E402
from src.plugin_system.schema_manager import SchemaManager # noqa: E402 from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
@@ -69,10 +69,10 @@ def plugins_dir(tmp_path):
@pytest.fixture @pytest.fixture
def fresh_web_process(api_v3_module, plugins_dir): def fresh_web_process(api_v3_module, plugins_dir):
"""The web process right after a restart: nothing discovered yet.""" """The web process right after a restart: nothing discovered yet."""
manager = PluginManager(plugins_dir=str(plugins_dir)) catalog = PluginCatalog(plugins_dir=plugins_dir)
assert not manager.plugin_manifests assert not catalog.plugin_manifests
api_v3_module.api_v3.plugin_manager = manager api_v3_module.api_v3.plugin_catalog = catalog
return manager return catalog
@pytest.fixture @pytest.fixture
+1 -1
View File
@@ -47,7 +47,7 @@ def service(api_v3_module):
resolution (not what is under test here). The cache is the blueprint's resolution (not what is under test here). The cache is the blueprint's
MagicMock cache_manager, so mailbox writes are visible as set() calls. MagicMock cache_manager, so mailbox writes are visible as set() calls.
""" """
api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.plugin_catalog = None
api_v3_module.api_v3.config_manager = None api_v3_module.api_v3.config_manager = None
state = {"active": True} state = {"active": True}
+2 -3
View File
@@ -35,9 +35,8 @@ class SharedCache:
@pytest.fixture @pytest.fixture
def shared_cache(api_v3_module): def shared_cache(api_v3_module):
cache = SharedCache() cache = SharedCache()
pm = api_v3_module.api_v3.plugin_manager api_v3_module.api_v3.health_tracker = PluginHealthTracker(cache)
pm.health_tracker = PluginHealthTracker(cache) api_v3_module.api_v3.resource_monitor = PluginResourceMonitor(cache)
pm.resource_monitor = PluginResourceMonitor(cache)
return cache return cache
+14 -12
View File
@@ -5,6 +5,11 @@ Both were only ever tested at the PluginStoreManager layer, so the route
logic — the queue-vs-direct branch, schema invalidation, plugin discovery, logic — the queue-vs-direct branch, schema invalidation, plugin discovery,
state and history recording — was unexercised. state and history recording — was unexercised.
Neither route loads the plugin: the web process only lists it (the catalog
has no load_plugin, so calling one fails these tests). The display loads it
when it is enabled; restart_required says when that won't happen by itself
(test/web_interface/test_web_process_runs_no_plugin_code.py).
/plugins/install carries the same install logic twice: once inside the /plugins/install carries the same install logic twice: once inside the
operation-queue callback and once in the direct fallback. The paired operation-queue callback and once in the direct fallback. The paired
tests below assert both branches produce the same side effects, so the tests below assert both branches produce the same side effects, so the
@@ -44,8 +49,7 @@ def side_effects(module):
api = module.api_v3 api = module.api_v3
return { return {
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list, "schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
"discovered": api.plugin_manager.discover_plugins.call_count, "discovered": api.plugin_catalog.discover_plugins.call_count,
"loaded": api.plugin_manager.load_plugin.call_args_list,
"state_set": api.plugin_state_manager.set_plugin_installed.call_args_list, "state_set": api.plugin_state_manager.set_plugin_installed.call_args_list,
"history": api.operation_history.record_operation.call_args_list, "history": api.operation_history.record_operation.call_args_list,
} }
@@ -83,7 +87,6 @@ class TestInstallDirectPath:
effects = side_effects(api_v3_module) effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})] assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1 assert effects["discovered"] == 1
assert effects["loaded"] == [(("clock",), {})]
assert effects["state_set"] == [(("clock",), {})] assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success" assert effects["history"][0].kwargs["status"] == "success"
@@ -130,7 +133,7 @@ class TestInstallDirectPath:
api_v3_client.post(INSTALL, json={"plugin_id": "clock"}) api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
effects = side_effects(api_v3_module) effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [] assert effects["schema_invalidated"] == []
assert effects["loaded"] == [] assert effects["discovered"] == 0
assert effects["state_set"] == [] assert effects["state_set"] == []
@@ -154,7 +157,6 @@ class TestInstallQueuedPath:
effects = side_effects(api_v3_module) effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})] assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1 assert effects["discovered"] == 1
assert effects["loaded"] == [(("clock",), {})]
assert effects["state_set"] == [(("clock",), {})] assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success" assert effects["history"][0].kwargs["status"] == "success"
@@ -196,7 +198,7 @@ class TestInstallPathsAgree:
# Reset and re-run through the queue. # Reset and re-run through the queue.
for mock in (api_v3_module.api_v3.schema_manager, for mock in (api_v3_module.api_v3.schema_manager,
api_v3_module.api_v3.plugin_manager, api_v3_module.api_v3.plugin_catalog,
api_v3_module.api_v3.plugin_state_manager, api_v3_module.api_v3.plugin_state_manager,
api_v3_module.api_v3.operation_history): api_v3_module.api_v3.operation_history):
mock.reset_mock() mock.reset_mock()
@@ -208,7 +210,6 @@ class TestInstallPathsAgree:
assert direct["schema_invalidated"] == queued["schema_invalidated"] assert direct["schema_invalidated"] == queued["schema_invalidated"]
assert direct["discovered"] == queued["discovered"] assert direct["discovered"] == queued["discovered"]
assert direct["loaded"] == queued["loaded"]
assert direct["state_set"] == queued["state_set"] assert direct["state_set"] == queued["state_set"]
assert (direct["history"][0].kwargs["status"] assert (direct["history"][0].kwargs["status"]
== queued["history"][0].kwargs["status"]) == queued["history"][0].kwargs["status"])
@@ -260,21 +261,22 @@ class TestInstallFromUrl:
branch="dev", branch="dev",
) )
def test_success_invalidates_schema_and_loads_plugin(self, api_v3_client, api_v3_module): def test_success_invalidates_schema_and_lists_plugin(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = { api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": "clock"} "success": True, "plugin_id": "clock"}
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}) response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
assert response.status_code == 200, response.get_json()
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock") api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
api_v3_module.api_v3.plugin_manager.load_plugin.assert_called_once_with("clock") api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once_with()
def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module): def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module):
# install_from_url can succeed without naming the plugin; there is # install_from_url can succeed without naming the plugin; there is
# then nothing to invalidate or load. # then nothing to invalidate or list.
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = { api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": None} "success": True, "plugin_id": None}
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}) api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called() api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called()
api_v3_module.api_v3.plugin_manager.load_plugin.assert_not_called() api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_not_called()
def test_branch_from_result_included(self, api_v3_client, api_v3_module): def test_branch_from_result_included(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = { api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
+1 -1
View File
@@ -110,7 +110,7 @@ class TestCalendarCredentials:
def plugin_dir(self, tmp_path, api_v3_module): def plugin_dir(self, tmp_path, api_v3_module):
directory = tmp_path / "plugins" / "calendar" directory = tmp_path / "plugins" / "calendar"
directory.mkdir(parents=True) directory.mkdir(parents=True)
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory) api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(directory)
return directory return directory
def _post(self, client): def _post(self, client):
+1 -1
View File
@@ -793,7 +793,7 @@ def api_client(monkeypatch):
cm.get_raw_file_content.return_value = {} cm.get_raw_file_content.return_value = {}
cm.save_config_atomic.return_value = MagicMock(status=MagicMock(value='success'), message=None) cm.save_config_atomic.return_value = MagicMock(status=MagicMock(value='success'), message=None)
api_v3.config_manager = cm api_v3.config_manager = cm
api_v3.plugin_manager = MagicMock(plugins={}) api_v3.plugin_catalog = MagicMock()
# Never restart a real display from a test run. # Never restart a real display from a test run.
setup_calls = [] setup_calls = []
monkeypatch.setattr(au, 'start_setup_if_needed', monkeypatch.setattr(au, 'start_setup_if_needed',
+1 -1
View File
@@ -241,7 +241,7 @@ class TestTheStatusEndpoint:
pm = MagicMock() pm = MagicMock()
pm.plugins_dir = str(plugins_dir) pm.plugins_dir = str(plugins_dir)
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False) monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False) monkeypatch.setattr(api_v3, "plugin_catalog", pm, raising=False)
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3") app.register_blueprint(api_v3, url_prefix="/api/v3")
+3 -4
View File
@@ -57,7 +57,7 @@ def render(config):
# pages_v3 is a module-level singleton shared across the test process; # pages_v3 is a module-level singleton shared across the test process;
# restore whatever the previous test left on it. # restore whatever the previous test left on it.
original_cm = getattr(pv.pages_v3, "config_manager", None) original_cm = getattr(pv.pages_v3, "config_manager", None)
original_pm = getattr(pv.pages_v3, "plugin_manager", None) original_pm = getattr(pv.pages_v3, "plugin_catalog", None)
mock_cm = MagicMock() mock_cm = MagicMock()
mock_cm.load_config.return_value = config mock_cm.load_config.return_value = config
@@ -65,10 +65,9 @@ def render(config):
pv.pages_v3.config_manager = mock_cm pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock() mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = [] mock_pm.get_all_plugin_info.return_value = []
mock_pm.get_plugin_display_modes.side_effect = lambda pid: [] mock_pm.get_plugin_display_modes.side_effect = lambda pid: []
pv.pages_v3.plugin_manager = mock_pm pv.pages_v3.plugin_catalog = mock_pm
app.register_blueprint(pv.pages_v3, url_prefix="") app.register_blueprint(pv.pages_v3, url_prefix="")
try: try:
@@ -77,7 +76,7 @@ def render(config):
return resp.get_data(as_text=True) return resp.get_data(as_text=True)
finally: finally:
pv.pages_v3.config_manager = original_cm pv.pages_v3.config_manager = original_cm
pv.pages_v3.plugin_manager = original_pm pv.pages_v3.plugin_catalog = original_pm
def timezone_step(body): def timezone_step(body):
+1 -1
View File
@@ -17,7 +17,7 @@ from web_interface.blueprints import pages_v3 as module # noqa: E402
def client(tmp_path, monkeypatch): def client(tmp_path, monkeypatch):
plugin_manager = MagicMock() plugin_manager = MagicMock()
plugin_manager.plugins_dir = tmp_path plugin_manager.plugins_dir = tmp_path
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False) monkeypatch.setattr(module.pages_v3, "plugin_catalog", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "config_manager", monkeypatch.setattr(module.pages_v3, "config_manager",
MagicMock(load_config=lambda: {}), raising=False) MagicMock(load_config=lambda: {}), raising=False)
app = Flask(__name__, template_folder=str( app = Flask(__name__, template_folder=str(
+3 -4
View File
@@ -39,19 +39,18 @@ def pages(tmp_path):
"<p>panel</p>", encoding="utf-8" "<p>panel</p>", encoding="utf-8"
) )
original_pm = getattr(module.pages_v3, "plugin_manager", None) original_pm = getattr(module.pages_v3, "plugin_catalog", None)
original_cm = getattr(module.pages_v3, "config_manager", None) original_cm = getattr(module.pages_v3, "config_manager", None)
plugin_manager = MagicMock() plugin_manager = MagicMock()
plugin_manager.plugins_dir = plugins_dir plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"} plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}
plugin_manager.get_plugin.return_value = None module.pages_v3.plugin_catalog = plugin_manager
module.pages_v3.plugin_manager = plugin_manager
module.pages_v3.config_manager = MagicMock(load_config=lambda: {}) module.pages_v3.config_manager = MagicMock(load_config=lambda: {})
yield module, plugins_dir yield module, plugins_dir
module.pages_v3.plugin_manager = original_pm module.pages_v3.plugin_catalog = original_pm
module.pages_v3.config_manager = original_cm module.pages_v3.config_manager = original_cm
+11 -6
View File
@@ -132,8 +132,8 @@ class TestServePluginStatic:
candidate = plugins / plugin_id candidate = plugins / plugin_id
return str(candidate) if candidate.exists() else None return str(candidate) if candidate.exists() else None
api_v3_module.api_v3.plugin_manager = MagicMock() api_v3_module.api_v3.plugin_catalog = MagicMock()
api_v3_module.api_v3.plugin_manager.get_plugin_directory = get_plugin_directory api_v3_module.api_v3.plugin_catalog.get_plugin_directory = get_plugin_directory
return plugins return plugins
def test_a_real_plugin_file_is_still_served( def test_a_real_plugin_file_is_still_served(
@@ -397,10 +397,13 @@ class TestPluginActionDirectory:
'../elsewhere' ran a script from any directory holding a manifest. '../elsewhere' ran a script from any directory holding a manifest.
""" """
@pytest.fixture @pytest.fixture(params=["catalog", "manager"])
def tree(self, tmp_path): def tree(self, request, tmp_path):
"""The web process resolves through PluginCatalog; the display
through PluginManager. Both apply the same rules."""
import threading import threading
from src.plugin_system.plugin_catalog import PluginCatalog
from src.plugin_system.plugin_manager import PluginManager from src.plugin_system.plugin_manager import PluginManager
plugins = tmp_path / "plugin-repos" plugins = tmp_path / "plugin-repos"
@@ -415,6 +418,8 @@ class TestPluginActionDirectory:
(outside / "s.py").write_text( (outside / "s.py").write_text(
"open(%r, 'w').write('ran')\n" % str(marker), encoding="utf-8" "open(%r, 'w').write('ran')\n" % str(marker), encoding="utf-8"
) )
if request.param == "catalog":
return PluginCatalog(plugins), plugins, marker
manager = MagicMock() manager = MagicMock()
manager.plugins_dir = plugins manager.plugins_dir = plugins
manager._discovery_lock = threading.Lock() manager._discovery_lock = threading.Lock()
@@ -446,7 +451,7 @@ class TestPluginActionDirectory:
self, api_v3_client, api_v3_module, tree self, api_v3_client, api_v3_module, tree
): ):
manager, _, marker = tree manager, _, marker = tree
api_v3_module.api_v3.plugin_manager = manager api_v3_module.api_v3.plugin_catalog = manager
response = api_v3_client.post( response = api_v3_client.post(
"/api/v3/plugins/action", "/api/v3/plugins/action",
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}}, json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
@@ -457,7 +462,7 @@ class TestPluginActionDirectory:
def test_the_fallback_without_a_plugin_manager_is_guarded_too( def test_the_fallback_without_a_plugin_manager_is_guarded_too(
self, api_v3_client, api_v3_module self, api_v3_client, api_v3_module
): ):
api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.plugin_catalog = None
response = api_v3_client.post( response = api_v3_client.post(
"/api/v3/plugins/action", "/api/v3/plugins/action",
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}}, json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
+1 -1
View File
@@ -64,7 +64,7 @@ def client(tmp_path, monkeypatch):
# singleton, so assigning them directly leaks mocks -- pointing at a # singleton, so assigning them directly leaks mocks -- pointing at a
# deleted tmp_path -- into every later test that imports api_v3. # deleted tmp_path -- into every later test that imports api_v3.
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False) monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False) monkeypatch.setattr(api_v3, "plugin_catalog", pm, raising=False)
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
+1 -1
View File
@@ -39,7 +39,7 @@ class SharedCache:
@pytest.fixture @pytest.fixture
def shared_cache(api_v3_module): def shared_cache(api_v3_module):
cache = SharedCache() cache = SharedCache()
api_v3_module.api_v3.plugin_manager.resource_monitor = PluginResourceMonitor( api_v3_module.api_v3.resource_monitor = PluginResourceMonitor(
cache, enable_monitoring=False) cache, enable_monitoring=False)
return cache return cache
+3 -3
View File
@@ -342,9 +342,9 @@ def web_store(api_v3_module, tmp_path, monkeypatch):
fake = FakeStore(tmp_path, monkeypatch, [], manifest("2.0.0")) fake = FakeStore(tmp_path, monkeypatch, [], manifest("2.0.0"))
api_v3_module.api_v3.plugin_store_manager = fake.store api_v3_module.api_v3.plugin_store_manager = fake.store
api_v3_module.api_v3.operation_queue = None api_v3_module.api_v3.operation_queue = None
# No plugin manager: the routes skip discovery and reload, and the update # No plugin catalog: the routes skip discovery, and the update route
# route finds the directory through the store alone. # finds the directory through the store alone.
api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.plugin_catalog = None
return fake return fake
+1 -1
View File
@@ -33,7 +33,7 @@ def client():
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
for attr in ("config_manager", "plugin_manager", "cache_manager"): for attr in ("config_manager", "plugin_catalog", "cache_manager"):
setattr(api_v3, attr, MagicMock()) setattr(api_v3, attr, MagicMock())
if "api_v3" not in app.blueprints: if "api_v3" not in app.blueprints:
app.register_blueprint(api_v3, url_prefix="/api/v3") app.register_blueprint(api_v3, url_prefix="/api/v3")
+20 -48
View File
@@ -28,11 +28,14 @@ sys.path.insert(0, str(project_root))
from flask import Flask from flask import Flask
from test._api_v3_test_helpers import mock_plugin_catalog
_API_V3_MOCKED_ATTRS = ( _API_V3_MOCKED_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager', 'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager', 'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager', 'operation_queue', 'operation_history', 'cache_manager',
'health_tracker', 'resource_monitor',
) )
@@ -58,9 +61,8 @@ def _make_client():
api_v3.config_manager = MagicMock() api_v3.config_manager = MagicMock()
api_v3.config_manager.get_raw_file_content.return_value = {} api_v3.config_manager.get_raw_file_content.return_value = {}
api_v3.config_manager.secrets_path = "/tmp/nonexistent_secrets.json" api_v3.config_manager.secrets_path = "/tmp/nonexistent_secrets.json"
api_v3.plugin_manager = MagicMock() api_v3.plugin_catalog = mock_plugin_catalog()
api_v3.plugin_manager.plugins = {} api_v3.plugin_catalog.plugins_dir = "/tmp"
api_v3.plugin_manager.plugins_dir = "/tmp"
api_v3.plugin_store_manager = MagicMock() api_v3.plugin_store_manager = MagicMock()
api_v3.plugin_state_manager = MagicMock() api_v3.plugin_state_manager = MagicMock()
api_v3.plugin_state_manager.get_all_states.return_value = {} api_v3.plugin_state_manager.get_all_states.return_value = {}
@@ -202,22 +204,20 @@ class TestTransactionalUninstall(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing') self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing')
def test_file_removal_failure_reloads_previously_loaded_plugin(self): def test_file_removal_failure_rolls_back_config_only(self):
"""Regression: rollback must restore BOTH config AND runtime state. """The web process has no running plugin to unload or reload.
If the plugin was loaded at runtime before the uninstall The display runs the plugin, and unloads it when the removed config
request, and file removal fails after unload has already section reaches its config watcher. A failed file removal restores
succeeded, the rollback must call ``load_plugin`` so the user that section, so the display sees no change at all; there is nothing
doesn't end up in a state where the files exist and the config in this process to put back. (The catalog mock has no
exists but the plugin is no longer loaded. unload_plugin/load_plugin: calling either fails with a different
message than the one asserted.)
""" """
# Plugin is currently loaded.
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
self.api_v3.config_manager.get_raw_file_content.return_value = { self.api_v3.config_manager.get_raw_file_content.return_value = {
'thing': {'enabled': True} 'thing': {'enabled': True}
} }
self.api_v3.config_manager.cleanup_plugin_config.return_value = None self.api_v3.config_manager.cleanup_plugin_config.return_value = None
self.api_v3.plugin_manager.unload_plugin.return_value = None
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False
response = self.client.post( response = self.client.post(
@@ -227,11 +227,10 @@ class TestTransactionalUninstall(unittest.TestCase):
) )
self.assertEqual(response.status_code, 500) self.assertEqual(response.status_code, 500)
# Unload did happen (it's part of the uninstall sequence)... self.assertIn('Failed to uninstall plugin thing', response.get_json()['message'])
self.api_v3.plugin_manager.unload_plugin.assert_called_once_with('thing') calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
# ...and because file removal failed, the rollback must have self.assertTrue(any(c.args[0] == 'main' for c in calls),
# called load_plugin to restore runtime state. "main config was not restored after the removal failed")
self.api_v3.plugin_manager.load_plugin.assert_called_once_with('thing')
def test_snapshot_survives_config_read_error(self): def test_snapshot_survives_config_read_error(self):
"""Regression: if get_raw_file_content raises an expected error """Regression: if get_raw_file_content raises an expected error
@@ -283,33 +282,6 @@ class TestTransactionalUninstall(unittest.TestCase):
# exception bubbled up before we got that far. # exception bubbled up before we got that far.
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called() self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
def test_unload_failure_restores_config_and_does_not_call_uninstall(self):
"""If unload_plugin itself raises, config must be restored and
uninstall_plugin must NOT be called."""
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
self.api_v3.config_manager.get_raw_file_content.return_value = {
'thing': {'enabled': True}
}
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
self.api_v3.plugin_manager.unload_plugin.side_effect = RuntimeError("unload boom")
response = self.client.post(
'/api/v3/plugins/uninstall',
data=json.dumps({'plugin_id': 'thing'}),
content_type='application/json',
)
self.assertEqual(response.status_code, 500)
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
# Config should have been restored.
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
self.assertTrue(
any(c.args[0] == 'main' for c in calls),
"main config was not restored after unload_plugin raised",
)
# load_plugin must NOT have been called — unload didn't succeed,
# so runtime state is still what it was.
self.api_v3.plugin_manager.load_plugin.assert_not_called()
class TestReconcileEndpointPayload(unittest.TestCase): class TestReconcileEndpointPayload(unittest.TestCase):
@@ -402,7 +374,7 @@ class TestNonPluginIdsAreRefused(unittest.TestCase):
self.client, self.mod, _cleanup = _make_client() self.client, self.mod, _cleanup = _make_client()
self.addCleanup(_cleanup) self.addCleanup(_cleanup)
self.api_v3 = self.mod.api_v3 self.api_v3 = self.mod.api_v3
self.api_v3.plugin_manager.plugin_manifests = {'thing': {'id': 'thing'}} self.api_v3.plugin_catalog.plugin_manifests = {'thing': {'id': 'thing'}}
def _post(self, url, body): def _post(self, url, body):
return self.client.post(url, data=json.dumps(body), return self.client.post(url, data=json.dumps(body),
@@ -427,7 +399,7 @@ class TestNonPluginIdsAreRefused(unittest.TestCase):
'gone-plugin', remove_secrets=True) 'gone-plugin', remove_secrets=True)
def test_secrets_only_core_key_is_allowed_when_a_plugin_has_that_id(self): def test_secrets_only_core_key_is_allowed_when_a_plugin_has_that_id(self):
self.api_v3.plugin_manager.plugin_manifests = {'youtube': {'id': 'youtube'}} self.api_v3.plugin_catalog.plugin_manifests = {'youtube': {'id': 'youtube'}}
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
response = self._post('/api/v3/plugins/uninstall', {'plugin_id': 'youtube'}) response = self._post('/api/v3/plugins/uninstall', {'plugin_id': 'youtube'})
+33 -15
View File
@@ -434,28 +434,46 @@ class TestSchema:
class TestInstalledPluginsApi: class TestInstalledPluginsApi:
"""The web process runs no plugin code (it has a PluginCatalog, not a
PluginManager), so it reports what the files say -- the user's setting,
then the manifest -- and leaves the rest to the display."""
@pytest.fixture @pytest.fixture
def installed(self, api_v3_module, api_v3_client, tmp_path): def installed(self, api_v3_module, api_v3_client):
def _get(instance, config): def _get(config, manifest_extra=None):
api = api_v3_module.api_v3 api = api_v3_module.api_v3
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': True} info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0',
api.plugin_manager.plugins_dir = str(tmp_path) **(manifest_extra or {})}
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info]) api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=instance) api.plugin_catalog.get_plugin_directory = MagicMock(return_value=None)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None) api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={'demo': config}) api.config_manager.load_config = MagicMock(return_value={'demo': config})
response = api_v3_client.get('/api/v3/plugins/installed') response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200 assert response.status_code == 200
return [p for p in response.get_json()['data']['plugins'] entry = [p for p in response.get_json()['data']['plugins']
if p['id'] == 'demo'][0] if p['id'] == 'demo'][0]
return entry['vegas_participation'], entry['vegas_participation_source']
return _get return _get
def test_a_loaded_plugin_reports_its_participation(self, installed): def test_the_user_setting_wins(self, installed):
plugin = _plugin({'vegas_mode': 'static'}) assert installed({'vegas_participation': 'exclude'},
assert installed(plugin, plugin.config)['vegas_participation'] == 'pause' {'vegas_participation': 'pause'}) == ('exclude', 'config')
def test_an_unloaded_plugin_reports_only_the_user_setting(self, installed): def test_then_the_manifest_declaration(self, installed):
assert installed(None, {'vegas_participation': 'exclude'})[ assert installed({}, {'vegas_participation': 'Pause'}) == ('pause', 'manifest')
'vegas_participation'] == 'exclude' # An invalid setting is ignored, as the display ignores it.
assert installed(None, {})['vegas_participation'] is None assert installed({'vegas_participation': 'fixed'},
{'vegas_participation': 'scroll'}) == ('scroll', 'manifest')
def test_otherwise_it_is_decided_at_run_time(self, installed):
# The legacy hooks would say 'pause' for vegas_mode 'static', but
# only the display runs them: nothing is guessed here.
assert installed({'vegas_mode': 'static'}) == (None, 'runtime')
assert installed({}, {'vegas_participation': 'sometimes'}) == (None, 'runtime')
def test_it_matches_the_display_where_the_files_decide(self, installed):
for config, manifest in (({'vegas_participation': 'pause'}, None),
({}, {'vegas_participation': 'exclude'})):
plugin = _plugin(config, manifest)
assert installed(config, manifest)[0] == resolve_vegas_participation(plugin)
+47 -51
View File
@@ -43,18 +43,15 @@ def mock_config_manager():
@pytest.fixture @pytest.fixture
def mock_plugin_manager(): def mock_plugin_catalog():
"""Create a mock plugin manager.""" """Create a mock plugin catalog."""
mock = MagicMock() mock = MagicMock()
mock.plugins = {}
mock.discover_plugins.return_value = [] mock.discover_plugins.return_value = []
mock.health_tracker = MagicMock()
mock.health_tracker.get_health_status.return_value = {'healthy': True}
return mock return mock
@pytest.fixture @pytest.fixture
def client(mock_config_manager, mock_plugin_manager): def client(mock_config_manager, mock_plugin_catalog):
"""Create a Flask test client with mocked dependencies.""" """Create a Flask test client with mocked dependencies."""
# Create a minimal Flask app for testing # Create a minimal Flask app for testing
test_app = Flask(__name__) test_app = Flask(__name__)
@@ -66,7 +63,7 @@ def client(mock_config_manager, mock_plugin_manager):
# Mock the managers on the blueprint # Mock the managers on the blueprint
api_v3.config_manager = mock_config_manager api_v3.config_manager = mock_config_manager
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
api_v3.plugin_store_manager = MagicMock() api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock() api_v3.saved_repositories_manager = MagicMock()
api_v3.schema_manager = MagicMock() api_v3.schema_manager = MagicMock()
@@ -74,6 +71,9 @@ def client(mock_config_manager, mock_plugin_manager):
api_v3.plugin_state_manager = MagicMock() api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock() api_v3.operation_history = MagicMock()
api_v3.cache_manager = MagicMock() api_v3.cache_manager = MagicMock()
# Readers of what the display publishes (app.py wires real ones).
api_v3.health_tracker = MagicMock()
api_v3.resource_monitor = MagicMock()
# Setup operation queue mocks # Setup operation queue mocks
mock_operation = MagicMock() mock_operation = MagicMock()
@@ -128,7 +128,7 @@ class TestConfigAPI:
mock_config_manager.save_config_atomic.assert_called_once() mock_config_manager.save_config_atomic.assert_called_once()
def test_save_main_config_fails_closed_when_schema_path_is_unresolvable( def test_save_main_config_fails_closed_when_schema_path_is_unresolvable(
self, client, mock_config_manager, mock_plugin_manager self, client, mock_config_manager, mock_plugin_catalog
): ):
"""A plugin id whose schema path fails safe-resolution must not have """A plugin id whose schema path fails safe-resolution must not have
its config saved with secret_fields left empty. its config saved with secret_fields left empty.
@@ -140,7 +140,7 @@ class TestConfigAPI:
the plugin's submitted config -- credentials included -- as the plugin's submitted config -- credentials included -- as
ordinary, unencrypted configuration instead of refusing the request. ordinary, unencrypted configuration instead of refusing the request.
""" """
mock_plugin_manager.plugin_manifests = {'evil': {}} mock_plugin_catalog.plugin_manifests = {'evil': {}}
with patch('web_interface.blueprints.api_v3.config.resolve_under', return_value=None): with patch('web_interface.blueprints.api_v3.config.resolve_under', return_value=None):
response = client.post( response = client.post(
@@ -545,38 +545,35 @@ class TestDisplayAPI:
class TestPluginsAPI: class TestPluginsAPI:
"""Test plugins API endpoints.""" """Test plugins API endpoints."""
def test_get_installed_plugins(self, client, mock_plugin_manager): def test_get_installed_plugins(self, client, mock_plugin_catalog):
"""Test getting list of installed plugins.""" """Test getting list of installed plugins."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_manager.plugins = { mock_plugin_catalog.plugins_dir = '/nonexistent-plugins-dir'
'weather': MagicMock(plugin_id='weather'), mock_plugin_catalog.get_plugin_directory.return_value = None
'clock': MagicMock(plugin_id='clock') mock_plugin_catalog.get_all_plugin_info.return_value = [
} {'id': 'weather', 'name': 'Weather Plugin'}
mock_plugin_manager.get_plugin_metadata.return_value = { ]
'id': 'weather', api_v3.plugin_store_manager.get_registry_info.return_value = None
'name': 'Weather Plugin'
}
response = client.get('/api/v3/plugins/installed') response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200 assert response.status_code == 200
data = json.loads(response.data) data = json.loads(response.data)
assert isinstance(data, (list, dict)) names = [p['name'] for p in data['data']['plugins']]
assert 'Weather Plugin' in names
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager): def test_installed_plugins_report_update_available(self, client, mock_plugin_catalog):
"""Installed-plugin entries surface latest_version + update_available """Installed-plugin entries surface latest_version + update_available
by comparing the on-disk manifest version to the registry.""" by comparing the on-disk manifest version to the registry."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
# No on-disk manifest to merge — keep the version we hand in below. # No on-disk manifest to merge — keep the version we hand in below.
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir' mock_plugin_catalog.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [ mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'} {'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
] ]
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
mock_plugin_manager.get_plugin.return_value = None
# Registry advertises a newer version than the installed one. # Registry advertises a newer version than the installed one.
api_v3.plugin_store_manager.get_registry_info.return_value = { api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0' 'verified': True, 'latest_version': '1.2.0'
@@ -591,15 +588,14 @@ class TestPluginsAPI:
assert entry['latest_version'] == '1.2.0' assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is True assert entry['update_available'] is True
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager): def test_installed_plugins_no_update_when_current(self, client, mock_plugin_catalog):
"""No update is flagged when installed version matches the registry.""" """No update is flagged when installed version matches the registry."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir' mock_plugin_catalog.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [ mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'} {'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
] ]
mock_plugin_manager.get_plugin.return_value = None
api_v3.plugin_store_manager.get_registry_info.return_value = { api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0' 'verified': True, 'latest_version': '1.2.0'
} }
@@ -625,17 +621,17 @@ class TestPluginsAPI:
# mismatch rather than hiding a possible update. # mismatch rather than hiding a possible update.
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
def test_get_plugin_health(self, client, mock_plugin_manager): def test_get_plugin_health(self, client, mock_plugin_catalog):
"""Test getting plugin health information.""" """Test getting plugin health information."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
# Setup health tracker # Setup health tracker
mock_health_tracker = MagicMock() mock_health_tracker = MagicMock()
mock_health_tracker.get_all_health_summaries.return_value = { mock_health_tracker.get_all_health_summaries.return_value = {
'weather': {'healthy': True} 'weather': {'healthy': True}
} }
mock_plugin_manager.health_tracker = mock_health_tracker api_v3.health_tracker = mock_health_tracker
response = client.get('/api/v3/plugins/health') response = client.get('/api/v3/plugins/health')
@@ -643,10 +639,10 @@ class TestPluginsAPI:
data = json.loads(response.data) data = json.loads(response.data)
assert isinstance(data, (list, dict)) assert isinstance(data, (list, dict))
def test_get_plugin_health_single(self, client, mock_plugin_manager): def test_get_plugin_health_single(self, client, mock_plugin_catalog):
"""Test getting health for single plugin.""" """Test getting health for single plugin."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
# Setup health tracker with proper method (endpoint calls get_health_summary) # Setup health tracker with proper method (endpoint calls get_health_summary)
mock_health_tracker = MagicMock() mock_health_tracker = MagicMock()
@@ -655,7 +651,7 @@ class TestPluginsAPI:
'failures': 0, 'failures': 0,
'last_success': '2024-01-01T00:00:00' 'last_success': '2024-01-01T00:00:00'
} }
mock_plugin_manager.health_tracker = mock_health_tracker api_v3.health_tracker = mock_health_tracker
response = client.get('/api/v3/plugins/health/weather') response = client.get('/api/v3/plugins/health/weather')
@@ -663,16 +659,16 @@ class TestPluginsAPI:
data = json.loads(response.data) data = json.loads(response.data)
assert 'healthy' in data.get('data', {}) or 'data' in data assert 'healthy' in data.get('data', {}) or 'data' in data
def test_toggle_plugin(self, client, mock_config_manager, mock_plugin_manager): def test_toggle_plugin(self, client, mock_config_manager, mock_plugin_catalog):
"""Test toggling plugin enabled state.""" """Test toggling plugin enabled state."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager api_v3.config_manager = mock_config_manager
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
api_v3.plugin_state_manager = MagicMock() api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock() api_v3.operation_history = MagicMock()
# Setup plugin manifests # Setup plugin manifests
mock_plugin_manager.plugin_manifests = {'weather': {}} mock_plugin_catalog.plugin_manifests = {'weather': {}}
request_data = { request_data = {
'plugin_id': 'weather', 'plugin_id': 'weather',
@@ -1006,15 +1002,15 @@ class TestPluginHealthRoutes:
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so """Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
they surface cross-process data persisted by the display service.""" they surface cross-process data persisted by the display service."""
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager): def test_health_route_builds_per_installed_id(self, client, mock_plugin_catalog):
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.plugin_health import PluginHealthTracker from src.plugin_system.plugin_health import PluginHealthTracker
cache = MagicMock() cache = MagicMock()
cache.get.return_value = None cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}} mock_plugin_catalog.plugin_manifests = {'p1': {}, 'p2': {}}
mock_plugin_manager.health_tracker = PluginHealthTracker(cache) api_v3.health_tracker = PluginHealthTracker(cache)
resp = client.get('/api/v3/plugins/health') resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200 assert resp.status_code == 200
@@ -1023,10 +1019,10 @@ class TestPluginHealthRoutes:
assert data['p1']['is_healthy'] is True assert data['p1']['is_healthy'] is True
assert data['p1']['degraded'] is False assert data['p1']['degraded'] is False
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager): def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_catalog):
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_manager.health_tracker = None api_v3.health_tracker = None
resp = client.get('/api/v3/plugins/health') resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200 assert resp.status_code == 200
@@ -1034,15 +1030,15 @@ class TestPluginHealthRoutes:
assert body['data'] == {} assert body['data'] == {}
assert 'not available' in body['message'].lower() assert 'not available' in body['message'].lower()
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager): def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_catalog):
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.resource_monitor import PluginResourceMonitor from src.plugin_system.resource_monitor import PluginResourceMonitor
cache = MagicMock() cache = MagicMock()
cache.get.return_value = None cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_manager.plugin_manifests = {'p1': {}} mock_plugin_catalog.plugin_manifests = {'p1': {}}
mock_plugin_manager.resource_monitor = PluginResourceMonitor( api_v3.resource_monitor = PluginResourceMonitor(
cache, enable_monitoring=False cache, enable_monitoring=False
) )
+4 -8
View File
@@ -46,8 +46,8 @@ def prefixed_plugin(tmp_path, api_v3_module):
}), encoding="utf-8") }), encoding="utf-8")
api = api_v3_module.api_v3 api = api_v3_module.api_v3
api.plugin_manager.plugins_dir = str(plugins_dir) api.plugin_catalog.plugins_dir = str(plugins_dir)
api.plugin_manager.get_plugin_directory = MagicMock(side_effect=_resolver(plugins_dir)) api.plugin_catalog.get_plugin_directory = MagicMock(side_effect=_resolver(plugins_dir))
api.plugin_store_manager.plugins_dir = str(plugins_dir) api.plugin_store_manager.plugins_dir = str(plugins_dir)
return plugin_dir return plugin_dir
@@ -58,8 +58,7 @@ class TestInstalledList:
api = api_v3_module.api_v3 api = api_v3_module.api_v3
info = {"id": "demo", "name": "Demo", "version": "1.0.0", info = {"id": "demo", "name": "Demo", "version": "1.0.0",
"description": "stale cached copy", "loaded": False} "description": "stale cached copy", "loaded": False}
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info]) api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=None)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None) api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None) api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={}) api.config_manager.load_config = MagicMock(return_value={})
@@ -87,7 +86,6 @@ class TestUpdateRoute:
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None) api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
api.plugin_store_manager.update_plugin = MagicMock(return_value=True) api.plugin_store_manager.update_plugin = MagicMock(return_value=True)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None) api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.plugin_manager.plugins = {}
api.schema_manager = None api.schema_manager = None
api.plugin_state_manager = None api.plugin_state_manager = None
api.operation_history = None api.operation_history = None
@@ -121,7 +119,6 @@ class TestUpdateRoute:
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None) api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
api.plugin_store_manager.update_plugin = MagicMock(return_value=False) api.plugin_store_manager.update_plugin = MagicMock(return_value=False)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None) api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.plugin_manager.plugins = {}
api.operation_history = None api.operation_history = None
response = api_v3_client.post("/api/v3/plugins/update", json={"plugin_id": "ghost"}) response = api_v3_client.post("/api/v3/plugins/update", json={"plugin_id": "ghost"})
@@ -140,8 +137,7 @@ def pages(tmp_path, monkeypatch):
plugins_dir.mkdir() plugins_dir.mkdir()
plugin_manager = MagicMock() plugin_manager = MagicMock()
plugin_manager.plugins_dir = plugins_dir plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None monkeypatch.setattr(module.pages_v3, "plugin_catalog", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "config_manager", monkeypatch.setattr(module.pages_v3, "config_manager",
MagicMock(load_config=lambda: {}), raising=False) MagicMock(load_config=lambda: {}), raising=False)
monkeypatch.setattr(module.pages_v3, "schema_manager", None, raising=False) monkeypatch.setattr(module.pages_v3, "schema_manager", None, raising=False)
+1 -1
View File
@@ -78,7 +78,7 @@ def client():
mock_cm.get_config_path.return_value = "config/config.json" mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json" mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm pv.pages_v3.config_manager = mock_cm
pv.pages_v3.plugin_manager = MagicMock(plugins={}) pv.pages_v3.plugin_catalog = MagicMock()
app.register_blueprint(pv.pages_v3, url_prefix="/v3") app.register_blueprint(pv.pages_v3, url_prefix="/v3")
return app.test_client() return app.test_client()
+3 -4
View File
@@ -82,7 +82,7 @@ def client():
# the originals and restore them on teardown so this fixture can't leak # the originals and restore them on teardown so this fixture can't leak
# its mocks into tests that run afterward. # its mocks into tests that run afterward.
original_config_manager = getattr(pv.pages_v3, "config_manager", None) original_config_manager = getattr(pv.pages_v3, "config_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None) original_plugin_manager = getattr(pv.pages_v3, "plugin_catalog", None)
mock_cm = MagicMock() mock_cm = MagicMock()
mock_cm.load_config.return_value = SMOKE_CONFIG mock_cm.load_config.return_value = SMOKE_CONFIG
@@ -92,7 +92,6 @@ def client():
pv.pages_v3.config_manager = mock_cm pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock() mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = [ mock_pm.get_all_plugin_info.return_value = [
{"id": "clock", "name": "Clock"}, {"id": "clock", "name": "Clock"},
{"id": "ledmatrix-weather", "name": "Weather"}, {"id": "ledmatrix-weather", "name": "Weather"},
@@ -100,7 +99,7 @@ def client():
mock_pm.get_plugin_display_modes.side_effect = ( mock_pm.get_plugin_display_modes.side_effect = (
lambda pid: PLUGIN_MODES.get(pid, []) lambda pid: PLUGIN_MODES.get(pid, [])
) )
pv.pages_v3.plugin_manager = mock_pm pv.pages_v3.plugin_catalog = mock_pm
# Same dual registration as web_interface/app.py: un-prefixed primary, # Same dual registration as web_interface/app.py: un-prefixed primary,
# /v3 kept as a working legacy alias. # /v3 kept as a working legacy alias.
@@ -110,7 +109,7 @@ def client():
yield app.test_client() yield app.test_client()
finally: finally:
pv.pages_v3.config_manager = original_config_manager pv.pages_v3.config_manager = original_config_manager
pv.pages_v3.plugin_manager = original_plugin_manager pv.pages_v3.plugin_catalog = original_plugin_manager
# (path, [markers that must appear in the body]) # (path, [markers that must appear in the body])
@@ -29,7 +29,7 @@ from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402 from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
_MANAGER_ATTRS = ( _MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager', 'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager', 'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager', 'operation_queue', 'operation_history', 'cache_manager',
) )
@@ -32,7 +32,7 @@ from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
URL = "/api/v3/backup/restore" URL = "/api/v3/backup/restore"
_MANAGER_ATTRS = ( _MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager', 'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager', 'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager', 'operation_queue', 'operation_history', 'cache_manager',
) )
+1 -1
View File
@@ -43,7 +43,7 @@ def env(tmp_path):
config_manager.template_path = str(tmp_path / "no-template.json") config_manager.template_path = str(tmp_path / "no-template.json")
_SENTINEL = object() _SENTINEL = object()
attrs = ('config_manager', 'plugin_manager', 'plugin_store_manager', attrs = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'plugin_state_manager', 'saved_repositories_manager',
'schema_manager', 'operation_queue', 'operation_history', 'schema_manager', 'operation_queue', 'operation_history',
'cache_manager') 'cache_manager')
@@ -93,13 +93,12 @@ def env(tmp_path, api_v3_module):
api = api_v3_module.api_v3 api = api_v3_module.api_v3
api.config_manager = config_manager api.config_manager = config_manager
api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path) api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api.plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}, api.plugin_catalog.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
NEWS_ID: {"id": NEWS_ID}} NEWS_ID: {"id": NEWS_ID}}
api.plugin_manager.get_plugin.return_value = None
class Env: class Env:
client = build_app(api).test_client() client = build_app(api).test_client()
plugin_manager = api.plugin_manager plugin_catalog = api.plugin_catalog
@staticmethod @staticmethod
def stored(plugin_id=PLUGIN_ID): def stored(plugin_id=PLUGIN_ID):
@@ -183,19 +182,18 @@ class TestReset:
assert calls == [True] assert calls == [True]
assert env.stored()["stock_symbols"] == ["AAPL"] assert env.stored()["stock_symbols"] == ["AAPL"]
def test_reset_notifies_the_plugin_with_its_prepared_config(self, env): def test_reset_runs_no_plugin_code_in_the_web_process(self, env):
plugin = MagicMock() # The running plugin gets the reset config from the display's config
env.plugin_manager.get_plugin.return_value = plugin # watcher (on_config_change there, with the prepared section). The
env.plugin_manager.prepare_plugin_config.side_effect = ( # catalog has no get_plugin, so a route that still reached for a
lambda _pid, raw: {**raw, "prepared": True}) # plugin instance here would fail this request.
assert not hasattr(env.plugin_catalog, "get_plugin")
response = env.client.post("/api/v3/plugins/config/reset", response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID}) json={"plugin_id": PLUGIN_ID})
assert response.status_code == 200, response.get_json() assert response.status_code == 200, response.get_json()
handed_over = plugin.on_config_change.call_args.args[0] assert env.stored()["stock_symbols"] == ["AAPL"]
assert handed_over["prepared"] is True
assert handed_over["stock_symbols"] == ["AAPL"]
def test_a_failed_save_is_reported(self, env, monkeypatch): def test_a_failed_save_is_reported(self, env, monkeypatch):
failed = MagicMock(message="disk full") failed = MagicMock(message="disk full")
@@ -79,11 +79,10 @@ def env(tmp_path):
plugin_manager = MagicMock() plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}} plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
api_v3.config_manager = config_manager api_v3.config_manager = config_manager
api_v3.schema_manager = schema_manager api_v3.schema_manager = schema_manager
api_v3.plugin_manager = plugin_manager api_v3.plugin_catalog = plugin_manager
api_v3.plugin_store_manager = MagicMock() api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock() api_v3.saved_repositories_manager = MagicMock()
api_v3.operation_queue = MagicMock() api_v3.operation_queue = MagicMock()
@@ -39,7 +39,7 @@ EXPECTED = {
'details': describe_exception(FORCED), 'details': describe_exception(FORCED),
} }
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager", MANAGERS = ("config_manager", "plugin_catalog", "plugin_store_manager",
"saved_repositories_manager", "schema_manager", "operation_queue", "saved_repositories_manager", "schema_manager", "operation_queue",
"plugin_state_manager", "operation_history", "cache_manager") "plugin_state_manager", "operation_history", "cache_manager")
@@ -222,7 +222,7 @@ class TestPluginActionStep1:
from unittest.mock import MagicMock from unittest.mock import MagicMock
manager = MagicMock() manager = MagicMock()
manager.get_plugin_directory.return_value = str(plugin_dir) manager.get_plugin_directory.return_value = str(plugin_dir)
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False) monkeypatch.setattr(api_v3, "plugin_catalog", manager, raising=False)
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(api_v3, url_prefix="/api/v3") app.register_blueprint(api_v3, url_prefix="/api/v3")
@@ -113,7 +113,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None) originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")} for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo" pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True) pdir.mkdir(parents=True)
@@ -137,9 +137,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path) project_root=tmp_path)
pm = MagicMock() pm = MagicMock()
pm.plugins = {} a.api_v3.plugin_catalog = pm
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
@@ -99,7 +99,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None) originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")} for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo" pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True) pdir.mkdir(parents=True)
@@ -122,9 +122,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path) project_root=tmp_path)
pm = MagicMock() pm = MagicMock()
pm.plugins = {} a.api_v3.plugin_catalog = pm
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
@@ -115,19 +115,17 @@ def app_client(tmp_path):
cm.save_config_atomic.side_effect = _save cm.save_config_atomic.side_effect = _save
pm = MagicMock() pm = MagicMock()
pm.plugins = {}
pm.plugins_dir = plugins_dir pm.plugins_dir = plugins_dir
pm.get_plugin.return_value = None
pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"} pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path) sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
names = ("config_manager", "schema_manager", "plugin_manager") names = ("config_manager", "schema_manager", "plugin_catalog")
originals = {(bp, k): getattr(bp, k, None) originals = {(bp, k): getattr(bp, k, None)
for bp in (api.api_v3, pages.pages_v3) for k in names} for bp in (api.api_v3, pages.pages_v3) for k in names}
for bp in (api.api_v3, pages.pages_v3): for bp in (api.api_v3, pages.pages_v3):
bp.config_manager = cm bp.config_manager = cm
bp.schema_manager = sm bp.schema_manager = sm
bp.plugin_manager = pm bp.plugin_catalog = pm
base = Path(pages.__file__).resolve().parent.parent base = Path(pages.__file__).resolve().parent.parent
app = Flask(__name__, template_folder=str(base / "templates"), app = Flask(__name__, template_folder=str(base / "templates"),
@@ -72,7 +72,7 @@ STORED = {
"skin_options": {"accent": "#00ff00"}, "skin_options": {"accent": "#00ff00"},
} }
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager', _ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager', 'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager') 'operation_queue', 'operation_history', 'cache_manager')
@@ -96,13 +96,12 @@ def env(tmp_path):
plugin_manager = MagicMock() plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}} plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
for name in _ATTRS: for name in _ATTRS:
setattr(api_v3, name, MagicMock()) setattr(api_v3, name, MagicMock())
api_v3.config_manager = config_manager api_v3.config_manager = config_manager
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path) api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api_v3.plugin_manager = plugin_manager api_v3.plugin_catalog = plugin_manager
api_v3.operation_queue = None api_v3.operation_queue = None
app = Flask(__name__) app = Flask(__name__)
@@ -110,7 +110,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None) originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")} for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo" pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True) pdir.mkdir(parents=True)
@@ -134,9 +134,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path) project_root=tmp_path)
pm = MagicMock() pm = MagicMock()
pm.plugins = {} a.api_v3.plugin_catalog = pm
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
@@ -71,7 +71,7 @@ def render(tmp_path):
from web_interface.blueprints import pages_v3 as pv from web_interface.blueprints import pages_v3 as pv
from src.plugin_system.schema_manager import SchemaManager from src.plugin_system.schema_manager import SchemaManager
orig_pm = getattr(pv.pages_v3, "plugin_manager", None) orig_pm = getattr(pv.pages_v3, "plugin_catalog", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None) orig_cm = getattr(pv.pages_v3, "config_manager", None)
orig_sm = getattr(pv.pages_v3, "schema_manager", None) orig_sm = getattr(pv.pages_v3, "schema_manager", None)
@@ -86,8 +86,7 @@ def render(tmp_path):
pm = MagicMock() pm = MagicMock()
pm.plugins_dir = str(tmp_path / "plugins") pm.plugins_dir = str(tmp_path / "plugins")
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id} pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
pm.get_plugin.return_value = None pv.pages_v3.plugin_catalog = pm
pv.pages_v3.plugin_manager = pm
cm = MagicMock() cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}} cm.load_config.return_value = {plugin_id: {"enabled": True}}
@@ -111,7 +110,7 @@ def render(tmp_path):
try: try:
yield _render yield _render
finally: finally:
pv.pages_v3.plugin_manager = orig_pm pv.pages_v3.plugin_catalog = orig_pm
pv.pages_v3.config_manager = orig_cm pv.pages_v3.config_manager = orig_cm
pv.pages_v3.schema_manager = orig_sm pv.pages_v3.schema_manager = orig_sm
@@ -52,7 +52,7 @@ def make_client(tmp_path):
""" """
from web_interface.blueprints import pages_v3 as pv from web_interface.blueprints import pages_v3 as pv
original_pm = getattr(pv.pages_v3, "plugin_manager", None) original_pm = getattr(pv.pages_v3, "plugin_catalog", None)
def _build(plugins_dir=None, plugin_manager=_UNSET): def _build(plugins_dir=None, plugin_manager=_UNSET):
base = PROJECT_ROOT / "web_interface" base = PROJECT_ROOT / "web_interface"
@@ -64,7 +64,7 @@ def make_client(tmp_path):
if plugin_manager is _UNSET: if plugin_manager is _UNSET:
plugin_manager = MagicMock() plugin_manager = MagicMock()
plugin_manager.plugins_dir = str(plugins_dir or tmp_path) plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
pv.pages_v3.plugin_manager = plugin_manager pv.pages_v3.plugin_catalog = plugin_manager
app.register_blueprint(pv.pages_v3, url_prefix="") app.register_blueprint(pv.pages_v3, url_prefix="")
return app.test_client() return app.test_client()
@@ -72,7 +72,7 @@ def make_client(tmp_path):
try: try:
yield _build yield _build
finally: finally:
pv.pages_v3.plugin_manager = original_pm pv.pages_v3.plugin_catalog = original_pm
URL = "/static/plugin-widgets/{}/{}.js" URL = "/static/plugin-widgets/{}/{}.js"
@@ -211,7 +211,7 @@ def config_form(tmp_path):
"""Render a plugin's config partial with a temp plugin on disk.""" """Render a plugin's config partial with a temp plugin on disk."""
from web_interface.blueprints import pages_v3 as pv from web_interface.blueprints import pages_v3 as pv
orig_pm = getattr(pv.pages_v3, "plugin_manager", None) orig_pm = getattr(pv.pages_v3, "plugin_catalog", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None) orig_cm = getattr(pv.pages_v3, "config_manager", None)
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None, def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
@@ -226,8 +226,7 @@ def config_form(tmp_path):
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id} pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
if version is not None: if version is not None:
pm.get_plugin_info.return_value["version"] = version pm.get_plugin_info.return_value["version"] = version
pm.get_plugin.return_value = None pv.pages_v3.plugin_catalog = pm
pv.pages_v3.plugin_manager = pm
cm = MagicMock() cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}} cm.load_config.return_value = {plugin_id: {"enabled": True}}
@@ -244,7 +243,7 @@ def config_form(tmp_path):
try: try:
yield _render yield _render
finally: finally:
pv.pages_v3.plugin_manager = orig_pm pv.pages_v3.plugin_catalog = orig_pm
pv.pages_v3.config_manager = orig_cm pv.pages_v3.config_manager = orig_cm
@@ -64,7 +64,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None) originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")} for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo" pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True) pdir.mkdir(parents=True)
@@ -86,9 +86,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path) project_root=tmp_path)
pm = MagicMock() pm = MagicMock()
pm.plugins = {} a.api_v3.plugin_catalog = pm
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
@@ -62,7 +62,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None) originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")} for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo" pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True) pdir.mkdir(parents=True)
@@ -85,9 +85,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path) project_root=tmp_path)
pm = MagicMock() pm = MagicMock()
pm.plugins = {} a.api_v3.plugin_catalog = pm
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
@@ -41,7 +41,7 @@ def store(tmp_path):
sm.get_plugin_info.return_value = None sm.get_plugin_info.return_value = None
sm.update_plugin.return_value = True sm.update_plugin.return_value = True
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \ with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
patch.object(api_v3, 'plugin_manager', None, create=True), \ patch.object(api_v3, 'plugin_catalog', None, create=True), \
patch.object(api_v3, 'schema_manager', None, create=True), \ patch.object(api_v3, 'schema_manager', None, create=True), \
patch.object(api_v3, 'plugin_state_manager', None, create=True), \ patch.object(api_v3, 'plugin_state_manager', None, create=True), \
patch.object(api_v3, 'operation_history', None, create=True): patch.object(api_v3, 'operation_history', None, create=True):
@@ -0,0 +1,460 @@
"""The web process reads plugins; only the display process runs them.
The web interface used to build its own PluginManager and load plugins into
the web process: store installs and updates loaded or reloaded a web-side
copy, and config saves and enable/disable called on_config_change,
on_enable and on_disable on it. None of that reached the panel -- the
display process runs its own instances -- and the web then reported
"runtime" state from copies nothing displayed. An update in particular
looked applied while the display kept running the old code until it
restarted, and nothing said so.
Now the web process has a PluginCatalog (manifests, schemas, config,
installed versions) and nothing that can run a plugin:
- every route a user drives for a plugin works without importing the
plugin's module at all -- the plugin below records any import and any
lifecycle call to a file, and the file must never appear;
- the catalog reads what is really installed, in plugin-repos/ and in the
test fixtures;
- a store install, update or uninstall answers ``restart_required`` exactly
when the running display will not pick the change up by itself.
"""
import json
import shutil
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.plugin_catalog import ( # noqa: E402
PluginCatalog, display_restart_required,
)
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_module # noqa: F401,E402
PLUGIN_ID = "tripwire"
# A plugin that leaves evidence of being run: importing its module, building
# it, or calling any lifecycle hook appends a line to TRIPWIRE_LOG.
MANAGER_PY = '''
import os
LOG = os.environ.get("TRIPWIRE_LOG") or {log!r}
def _note(what):
with open(LOG, "a", encoding="utf-8") as f:
f.write(what + "\\n")
_note("imported")
from src.plugin_system.base_plugin import BasePlugin
class TripwirePlugin(BasePlugin):
def __init__(self, *args, **kwargs):
_note("instantiated")
super().__init__(*args, **kwargs)
def update(self):
_note("update")
def display(self, force_clear=False):
_note("display")
def on_config_change(self, new_config):
_note("on_config_change")
def on_enable(self):
_note("on_enable")
def on_disable(self):
_note("on_disable")
'''
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"message": {"type": "string", "default": "hello"},
},
}
def _write_plugin(plugins_dir, log, version="1.0.0", plugin_id=PLUGIN_ID):
plugin_dir = plugins_dir / plugin_id
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": plugin_id, "name": "Tripwire", "version": version,
"entry_point": "manager.py", "class_name": "TripwirePlugin",
"display_modes": ["tripwire"],
}), encoding="utf-8")
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
(plugin_dir / "manager.py").write_text(MANAGER_PY.format(log=str(log)), encoding="utf-8")
return plugin_dir
class Web:
"""The api_v3 and pages_v3 blueprints over a real catalog and config."""
def __init__(self, api_v3_module, tmp_path, monkeypatch, enabled=True):
self.log = tmp_path / "tripwire.log"
monkeypatch.setenv("TRIPWIRE_LOG", str(self.log))
self.plugins_dir = tmp_path / "plugin-repos"
self.plugins_dir.mkdir()
_write_plugin(self.plugins_dir, self.log)
self.config_file = tmp_path / "config.json"
self.config_file.write_text(json.dumps(
{PLUGIN_ID: {"enabled": enabled, "message": "hi"}}), encoding="utf-8")
self.config_manager = ConfigManager(
config_path=str(self.config_file),
secrets_path=str(tmp_path / "config_secrets.json"))
self.config_manager.template_path = str(tmp_path / "no-template.json")
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path,
config_manager=self.config_manager)
self.catalog = PluginCatalog(self.plugins_dir, self.config_manager, self.schema_manager)
api = self.api = api_v3_module.api_v3
api.config_manager = self.config_manager
api.schema_manager = self.schema_manager
api.plugin_catalog = self.catalog
store = api.plugin_store_manager
store.plugins_dir = str(self.plugins_dir)
store.get_registry_info.return_value = None
store.get_plugin_info.return_value = None
store._get_local_git_info.return_value = None
store.install_plugin.return_value = True
store.uninstall_plugin.return_value = True
store.update_plugin.return_value = True
from web_interface.blueprints import pages_v3 as pages_module
pages = pages_module.pages_v3
for name, value in (("config_manager", self.config_manager),
("schema_manager", self.schema_manager),
("plugin_catalog", self.catalog)):
monkeypatch.setattr(pages, name, value, raising=False)
app = Flask(__name__, template_folder=str(PROJECT_ROOT / "web_interface" / "templates"))
app.config["TESTING"] = True
app.register_blueprint(api, url_prefix="/api/v3")
app.register_blueprint(pages, url_prefix="")
self.client = app.test_client()
def ran(self):
"""What the plugin recorded: [] when none of its code ever ran."""
return self.log.read_text(encoding="utf-8").split() if self.log.exists() else []
def stored(self):
return json.loads(self.config_file.read_text(encoding="utf-8")).get(PLUGIN_ID, {})
def post(self, url, body):
response = self.client.post(url, json=body)
assert response.status_code == 200, (url, response.get_json())
return response.get_json()
@pytest.fixture
def web(api_v3_module, tmp_path, monkeypatch):
return Web(api_v3_module, tmp_path, monkeypatch)
@pytest.fixture
def disabled_web(api_v3_module, tmp_path, monkeypatch):
return Web(api_v3_module, tmp_path, monkeypatch, enabled=False)
def _bump_version(web, version):
"""What a store update does to the files on disk."""
def update(plugin_id):
_write_plugin(web.plugins_dir, web.log, version=version, plugin_id=plugin_id)
return True
web.api.plugin_store_manager.update_plugin.side_effect = update
class TestTheWebProcessNeverRunsAPlugin:
"""Every plugin route, against a plugin that reports being run."""
def test_the_tripwire_works(self, web, tmp_path):
# Sanity: importing the module does leave the mark this suite checks for.
import importlib.util
spec = importlib.util.spec_from_file_location(
"tripwire_probe", web.plugins_dir / PLUGIN_ID / "manager.py")
spec.loader.exec_module(importlib.util.module_from_spec(spec))
assert web.ran() == ["imported"]
def test_listing_the_installed_plugins(self, web):
body = web.client.get("/api/v3/plugins/installed").get_json()
entry = next(p for p in body["data"]["plugins"] if p["id"] == PLUGIN_ID)
assert entry["version"] == "1.0.0"
assert entry["enabled"] is True
# Not published by the display, so not invented here.
assert entry["loaded"] is None and entry["state"] is None
# Nothing in its files declares a participation; the display derives
# one from its hooks, which are not called here.
assert (entry["vegas_participation"], entry["vegas_participation_source"]) == (
None, "runtime")
assert web.ran() == []
def test_enabling_and_disabling(self, web):
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": False})
assert web.stored()["enabled"] is False
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": True})
assert web.stored()["enabled"] is True
assert web.ran() == []
def test_saving_its_config(self, web):
web.post("/api/v3/plugins/config",
{"plugin_id": PLUGIN_ID, "config": {"message": "changed"}})
assert web.stored()["message"] == "changed"
assert web.ran() == []
def test_saving_its_section_through_the_main_config(self, web):
body = web.post("/api/v3/config/main", {PLUGIN_ID: {"message": "via main"}})
assert web.stored()["message"] == "via main"
assert body["restart_required"] is True
assert web.ran() == []
def test_resetting_its_config(self, web):
web.post("/api/v3/plugins/config/reset", {"plugin_id": PLUGIN_ID})
assert web.stored()["message"] == "hello"
assert web.ran() == []
def test_rendering_its_settings_page(self, web):
response = web.client.get(f"/partials/plugin-config/{PLUGIN_ID}")
assert response.status_code == 200
assert "Tripwire" in response.get_data(as_text=True)
assert web.ran() == []
def test_updating_it(self, web):
_bump_version(web, "1.1.0")
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "updated"
assert web.catalog.get_installed_version(PLUGIN_ID) == "1.1.0"
assert web.ran() == []
def test_installing_it(self, web):
web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert PLUGIN_ID in web.catalog.plugin_manifests
assert web.ran() == []
def test_uninstalling_it(self, web):
web.post("/api/v3/plugins/uninstall", {"plugin_id": PLUGIN_ID})
web.api.plugin_store_manager.uninstall_plugin.assert_called_once_with(PLUGIN_ID)
assert web.ran() == []
def test_no_plugin_module_is_registered_in_this_process(self, web):
web.client.get("/api/v3/plugins/installed")
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": True})
assert not [name for name, mod in list(sys.modules.items())
if str(web.plugins_dir) in str(getattr(mod, "__file__", "") or "")]
def test_the_web_app_has_a_catalog_and_no_plugin_manager(self):
"""app.py wires a PluginCatalog, and nothing named like a manager."""
source = (PROJECT_ROOT / "web_interface" / "app.py").read_text(encoding="utf-8")
assert "PluginCatalog(" in source
assert "PluginManager" not in source
class TestNoLifecycleCallsInWebCode:
"""A static backstop for the behavioural tests above: the web process's
own code has no call that runs a plugin. Comments may mention the hooks;
calls may not."""
FORBIDDEN = (".on_enable(", ".on_disable(", ".on_config_change(",
".load_plugin(", ".unload_plugin(", ".reload_plugin(",
".get_plugin(", ".update(force", ".display(force_clear",
# Vegas participation past config and manifest calls the
# plugin's hooks; the installed route reports it from files.
"resolve_vegas_participation(", "legacy_vegas_participation(",
".get_vegas_participation(")
def _code_lines(self):
for path in sorted((PROJECT_ROOT / "web_interface").rglob("*.py")):
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
code = line.split("#", 1)[0]
yield path.relative_to(PROJECT_ROOT), number, code
def test_no_web_module_calls_a_plugin_lifecycle_hook(self):
offenders = [f"{path}:{number}: {code.strip()}"
for path, number, code in self._code_lines()
if any(call in code for call in self.FORBIDDEN)]
assert offenders == []
def test_plugin_code_is_imported_in_exactly_one_place(self):
"""exec_module in the web blueprints happens only in the seam named
for it (Starlark helpers and oauth_flow action scripts)."""
blueprints = PROJECT_ROOT / "web_interface" / "blueprints"
hits = [f"{path.name}:{n}" for path in sorted(blueprints.rglob("*.py"))
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if "exec_module(" in line.split("#", 1)[0]]
assert len(hits) == 1, hits
source = (blueprints / "api_v3" / "__init__.py").read_text(encoding="utf-8")
seam = source.index("def _import_plugin_code_in_web_process(")
assert source.index("exec_module(", seam) < source.index("\ndef ", seam + 1)
class TestStoreOperationsReachTheDisplay:
"""Each answer says whether the display needs a restart to show it."""
def test_an_update_of_a_plugin_the_display_runs_asks_for_a_restart(self, web):
_bump_version(web, "2.0.0")
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True
assert "restart the display" in body["restart_message"]
def test_an_update_of_a_disabled_plugin_does_not(self, disabled_web):
_bump_version(disabled_web, "2.0.0")
body = disabled_web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "updated"
assert body["restart_required"] is False
assert "restart_message" not in body
def test_an_update_that_changed_nothing_does_not(self, web):
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "up_to_date"
assert body["restart_required"] is False
def test_installing_a_plugin_config_already_enables_asks_for_a_restart(self, web):
body = web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True
def test_installing_a_disabled_plugin_does_not(self, disabled_web):
# Enabling it later loads it live, from disk, in the display.
body = disabled_web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is False
def test_the_queued_install_carries_the_flag_in_the_operation_result(self, web):
results = []
def enqueue(operation_type, plugin_id, operation_callback=None):
results.append(operation_callback(MagicMock()))
return "op-1"
web.api.operation_queue = MagicMock()
web.api.operation_queue.enqueue_operation.side_effect = enqueue
web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert results[0]["success"] is True
assert results[0]["restart_required"] is True
def test_install_from_url_carries_the_flag(self, web):
web.api.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": PLUGIN_ID}
body = web.post("/api/v3/plugins/install-from-url", {"repo_url": "https://example.invalid/r"})
assert body["restart_required"] is True
def test_uninstall_that_removes_the_config_needs_no_restart(self, web):
# The removed section reaches the display's config watcher, and its
# reconcile unloads the plugin.
body = web.post("/api/v3/plugins/uninstall", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is False
assert PLUGIN_ID not in json.loads(web.config_file.read_text(encoding="utf-8"))
def test_uninstall_that_keeps_an_enabled_config_asks_for_a_restart(self, web):
body = web.post("/api/v3/plugins/uninstall",
{"plugin_id": PLUGIN_ID, "preserve_config": True})
assert body["restart_required"] is True
@pytest.mark.parametrize("action,enabled,kwargs,expected", [
("install", False, {}, False),
("install", True, {}, True),
("update", True, {"changed": True}, True),
("update", True, {"changed": False}, False),
("update", False, {"changed": True}, False),
("uninstall", True, {}, False),
("uninstall", True, {"preserve_config": True}, True),
("uninstall", False, {"preserve_config": True}, False),
])
def test_the_rule(self, action, enabled, kwargs, expected):
assert display_restart_required(action, enabled, **kwargs) is expected
def test_an_unknown_action_is_a_programming_error(self):
with pytest.raises(ValueError):
display_restart_required("reinstall", True)
class TestCatalogReadsWhatIsInstalled:
"""Real plugin directories: the repository's plugin-repos/ and fixtures."""
@staticmethod
def _manifests(root):
found = {}
for child in sorted(Path(root).iterdir()):
manifest = child / "manifest.json"
if child.is_dir() and manifest.exists():
data = json.loads(manifest.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("id"):
found[data["id"]] = (child, data)
return found
@pytest.mark.parametrize("root", [
PROJECT_ROOT / "plugin-repos",
PROJECT_ROOT / "test" / "fixtures" / "plugins",
], ids=["plugin-repos", "fixtures"])
def test_every_installed_plugin_is_read_from_its_files(self, root):
expected = self._manifests(root)
assert expected, f"no plugins under {root}"
before = set(sys.modules)
schema_manager = SchemaManager(plugins_dir=root, project_root=PROJECT_ROOT)
catalog = PluginCatalog(root, schema_manager=schema_manager)
assert set(catalog.discover_plugins()) == set(expected)
for plugin_id, (plugin_dir, manifest) in expected.items():
assert catalog.get_manifest(plugin_id) == manifest
assert catalog.get_plugin_directory(plugin_id) == str(plugin_dir)
assert catalog.get_installed_version(plugin_id) == manifest.get("version", "")
assert catalog.get_plugin_display_modes(plugin_id) == manifest.get("display_modes", [])
if (plugin_dir / "config_schema.json").exists():
schema = catalog.get_schema(plugin_id, use_cache=False)
assert isinstance(schema, dict) and "properties" in schema, plugin_id
imported = [name for name in set(sys.modules) - before
if str(root) in str(getattr(sys.modules[name], "__file__", "") or "")]
assert imported == [], "reading the catalog imported plugin code"
def test_a_mode_finds_its_plugin(self):
catalog = PluginCatalog(PROJECT_ROOT / "test" / "fixtures" / "plugins")
catalog.discover_plugins()
assert catalog.find_plugin_for_mode("CI-Fixture") == "ci-fixture-plugin"
assert catalog.find_plugin_for_mode("nope") is None
def test_discovery_follows_the_directory(self, tmp_path):
plugins = tmp_path / "plugin-repos"
root = PROJECT_ROOT / "test" / "fixtures" / "plugins" / "ci-fixture-plugin"
shutil.copytree(root, plugins / "ledmatrix-ci-fixture-plugin")
(plugins / "broken").mkdir()
(plugins / "broken" / "manifest.json").write_text("[]", encoding="utf-8")
catalog = PluginCatalog(plugins)
assert catalog.discover_plugins() == ["ci-fixture-plugin"]
# Installed as ledmatrix-<id>, found by its manifest id.
assert catalog.get_plugin_directory("ci-fixture-plugin").endswith("ledmatrix-ci-fixture-plugin")
# Not a plain name: refused, never joined onto the plugins directory.
assert catalog.get_plugin_directory("../plugin-repos") is None
shutil.rmtree(plugins / "ledmatrix-ci-fixture-plugin")
assert catalog.discover_plugins() == []
assert catalog.get_manifest("ci-fixture-plugin") is None
def test_enabled_follows_the_display_rule(self, tmp_path):
config = MagicMock()
config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"}
catalog = PluginCatalog(tmp_path, config_manager=config)
assert catalog.is_enabled("a") is True
# The display runs a plugin only when its section says so.
assert catalog.is_enabled("b") is False
assert catalog.is_enabled("c") is False
assert catalog.is_enabled("missing") is False
assert catalog.get_config("c") == {}
def test_it_has_nothing_that_runs_a_plugin(self, tmp_path):
catalog = PluginCatalog(tmp_path)
for name in ("load_plugin", "unload_plugin", "reload_plugin", "get_plugin",
"plugins", "run_scheduled_updates"):
assert not hasattr(catalog, name), name
+2 -4
View File
@@ -189,7 +189,7 @@ def _make_post(tmp_path, schema, stored, plugin_id="demo"):
from web_interface.blueprints import api_v3 as a from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None) originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")} for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / plugin_id pdir = tmp_path / "plugins" / plugin_id
pdir.mkdir(parents=True) pdir.mkdir(parents=True)
@@ -213,9 +213,7 @@ def _make_post(tmp_path, schema, stored, plugin_id="demo"):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path) project_root=tmp_path)
pm = MagicMock() pm = MagicMock()
pm.plugins = {} a.api_v3.plugin_catalog = pm
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
+27 -19
View File
@@ -34,7 +34,7 @@ from src.common.path_safety import (
) )
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from src.exceptions import ConfigError from src.exceptions import ConfigError
from src.plugin_system.plugin_manager import PluginManager from src.plugin_system.plugin_catalog import PluginCatalog
from src.plugin_system.store_manager import PluginStoreManager from src.plugin_system.store_manager import PluginStoreManager
from src.plugin_system.saved_repositories import SavedRepositoriesManager from src.plugin_system.saved_repositories import SavedRepositoriesManager
from src.plugin_system.schema_manager import SchemaManager from src.plugin_system.schema_manager import SchemaManager
@@ -117,12 +117,6 @@ else:
# If relative, resolve relative to the project root # If relative, resolve relative to the project root
plugins_dir = project_root / plugins_dir_name plugins_dir = project_root / plugins_dir_name
plugin_manager = PluginManager(
plugins_dir=str(plugins_dir),
config_manager=config_manager,
display_manager=None, # Not needed for web interface
cache_manager=None # Not needed for web interface
)
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir)) plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
# A core `git pull` update (or any checkout) restores built-in plugins # A core `git pull` update (or any checkout) restores built-in plugins
# committed under plugin-repos/, even ones the user uninstalled. Re-remove any # committed under plugin-repos/, even ones the user uninstalled. Re-remove any
@@ -149,6 +143,18 @@ schema_manager = SchemaManager(
config_manager=config_manager config_manager=config_manager
) )
# The web process reads plugins as files and never runs them: no plugin module
# is imported, no plugin class instantiated, no lifecycle hook called here.
# Only the display process (src/display_controller.py) does that. Config
# saves reach the running plugins through the display's config watcher; what
# the display knows at run time (health, metrics, errors, current mode) it
# publishes to the shared cache. See docs/ARCHITECTURE.md.
plugin_catalog = PluginCatalog(
plugins_dir=plugins_dir,
config_manager=config_manager,
schema_manager=schema_manager,
)
# Initialize operation queue for plugin operations # Initialize operation queue for plugin operations
operation_queue = PluginOperationQueue(max_history=500) operation_queue = PluginOperationQueue(max_history=500)
@@ -169,7 +175,7 @@ operation_history = OperationHistory(
) )
# Plugin discovery is deferred until first API request that needs it # Plugin discovery is deferred until first API request that needs it
# This improves startup time - endpoints will call discover_plugins() when needed # This improves startup time - endpoints call plugin_catalog.discover_plugins() when needed
# Register blueprints # Register blueprints
from web_interface.blueprints.pages_v3 import pages_v3 from web_interface.blueprints.pages_v3 import pages_v3
@@ -177,13 +183,13 @@ from web_interface.blueprints.api_v3 import api_v3
# Initialize managers in blueprints # Initialize managers in blueprints
pages_v3.config_manager = config_manager pages_v3.config_manager = config_manager
pages_v3.plugin_manager = plugin_manager pages_v3.plugin_catalog = plugin_catalog
pages_v3.plugin_store_manager = plugin_store_manager pages_v3.plugin_store_manager = plugin_store_manager
pages_v3.saved_repositories_manager = saved_repositories_manager pages_v3.saved_repositories_manager = saved_repositories_manager
pages_v3.schema_manager = schema_manager pages_v3.schema_manager = schema_manager
api_v3.config_manager = config_manager api_v3.config_manager = config_manager
api_v3.plugin_manager = plugin_manager api_v3.plugin_catalog = plugin_catalog
api_v3.plugin_store_manager = plugin_store_manager api_v3.plugin_store_manager = plugin_store_manager
api_v3.saved_repositories_manager = saved_repositories_manager api_v3.saved_repositories_manager = saved_repositories_manager
api_v3.schema_manager = schema_manager api_v3.schema_manager = schema_manager
@@ -194,17 +200,19 @@ api_v3.operation_history = operation_history
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager() api_v3.cache_manager = CacheManager()
# Wire plugin health/metrics for the web process. The display service records # Plugin health and metrics as the display publishes them. The display service
# health and execution-time metrics to the shared on-disk cache; giving the web # records health and execution-time metrics to the shared on-disk cache; a
# process its own tracker/monitor backed by that same cache lets the health API # tracker/monitor backed by that same cache lets the health API routes
# routes (/api/v3/plugins/health, /plugins/metrics) read that persisted data. # (/api/v3/plugins/health, /plugins/metrics) read what it wrote.
# Guarded so any init failure degrades to "not available" rather than breaking # Guarded so any init failure degrades to "not available" rather than breaking
# the web server. # the web server.
api_v3.health_tracker = None
api_v3.resource_monitor = None
try: try:
from src.plugin_system.plugin_health import PluginHealthTracker from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor from src.plugin_system.resource_monitor import PluginResourceMonitor
plugin_manager.health_tracker = PluginHealthTracker(api_v3.cache_manager) api_v3.health_tracker = PluginHealthTracker(api_v3.cache_manager)
plugin_manager.resource_monitor = PluginResourceMonitor(api_v3.cache_manager) api_v3.resource_monitor = PluginResourceMonitor(api_v3.cache_manager)
except Exception as _hm_err: # pragma: no cover - defensive startup guard except Exception as _hm_err: # pragma: no cover - defensive startup guard
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"Could not enable plugin health/metrics for web UI: %s", _hm_err "Could not enable plugin health/metrics for web UI: %s", _hm_err
@@ -960,7 +968,7 @@ def _run_startup_reconciliation() -> None:
reconciler = StateReconciliation( reconciler = StateReconciliation(
state_manager=plugin_state_manager, state_manager=plugin_state_manager,
config_manager=config_manager, config_manager=config_manager,
plugin_manager=plugin_manager, plugin_manager=plugin_catalog,
plugins_dir=plugins_dir, plugins_dir=plugins_dir,
store_manager=plugin_store_manager store_manager=plugin_store_manager
) )
@@ -968,7 +976,7 @@ def _run_startup_reconciliation() -> None:
if result.inconsistencies_found: if result.inconsistencies_found:
_logger.info("[Reconciliation] %s", result.message) _logger.info("[Reconciliation] %s", result.message)
if result.inconsistencies_fixed: if result.inconsistencies_fixed:
plugin_manager.discover_plugins() plugin_catalog.discover_plugins()
if not result.reconciliation_successful: if not result.reconciliation_successful:
_logger.warning( _logger.warning(
"[Reconciliation] Finished with %d unresolved issue(s); " "[Reconciliation] Finished with %d unresolved issue(s); "
@@ -1043,7 +1051,7 @@ def start_auto_update_scheduler():
config_manager=config_manager, config_manager=config_manager,
core_update=perform_core_update, core_update=perform_core_update,
store_manager=plugin_store_manager, store_manager=plugin_store_manager,
plugin_manager=plugin_manager, plugin_catalog=plugin_catalog,
schema_manager=schema_manager, schema_manager=schema_manager,
operation_history=operation_history, operation_history=operation_history,
) )
+7 -4
View File
@@ -329,7 +329,7 @@ class AutoUpdater:
"""Decides when an automatic update is due and runs it.""" """Decides when an automatic update is due and runs it."""
def __init__(self, config_manager, core_update, store_manager=None, def __init__(self, config_manager, core_update, store_manager=None,
plugin_manager=None, schema_manager=None, operation_history=None, plugin_catalog=None, schema_manager=None, operation_history=None,
project_root=PROJECT_ROOT, state_file=None, clock=time.time, project_root=PROJECT_ROOT, state_file=None, clock=time.time,
restart=restart_service, run=subprocess.run, restart=restart_service, run=subprocess.run,
service_active=_service_active, helper_ready=helper_ready, service_active=_service_active, helper_ready=helper_ready,
@@ -337,7 +337,10 @@ class AutoUpdater:
self.config_manager = config_manager self.config_manager = config_manager
self.core_update = core_update self.core_update = core_update
self.store_manager = store_manager self.store_manager = store_manager
self.plugin_manager = plugin_manager # The web process's PluginCatalog: rescanned after plugin updates.
# The display picks the new code up when _run_deferred_plugins
# restarts it.
self.plugin_catalog = plugin_catalog
self.schema_manager = schema_manager self.schema_manager = schema_manager
self.operation_history = operation_history self.operation_history = operation_history
self.project_root = Path(project_root) self.project_root = Path(project_root)
@@ -658,9 +661,9 @@ class AutoUpdater:
for plugin_id in updated: for plugin_id in updated:
if self.schema_manager: if self.schema_manager:
self.schema_manager.invalidate_cache(plugin_id) self.schema_manager.invalidate_cache(plugin_id)
if updated and self.plugin_manager: if updated and self.plugin_catalog:
try: try:
self.plugin_manager.discover_plugins() self.plugin_catalog.discover_plugins()
except Exception: except Exception:
logger.debug("discover_plugins after auto-update failed", exc_info=True) logger.debug("discover_plugins after auto-update failed", exc_info=True)
return updated, failed return updated, failed
+129 -119
View File
@@ -91,8 +91,9 @@ def _scrub_git_remote_url(url: str) -> str:
pass pass
return url return url
# NOTE: the managers live on the blueprint object (app.py sets # NOTE: the managers live on the blueprint object (app.py sets
# api_v3.config_manager, api_v3.plugin_manager, api_v3.cache_manager and # api_v3.config_manager, api_v3.plugin_catalog, api_v3.cache_manager and
# the rest). Deliberately not mirrored as module globals: a bare # the rest). There is no plugin manager: the web process reads plugins
# through a PluginCatalog and never runs them (docs/ARCHITECTURE.md). Deliberately not mirrored as module globals: a bare
# `config_manager` used to resolve to a None that was never assigned, which # `config_manager` used to resolve to a None that was never assigned, which
# silently disabled the /health checks and made /display/current fall back # silently disabled the /health checks and made /display/current fall back
# to a hardcoded 128x64. # to a hardcoded 128x64.
@@ -631,7 +632,7 @@ def _non_plugin_id_error(plugin_id):
status_code=400) status_code=400)
return None return None
def _discovered_plugin_manifests(plugin_id=None, rescan=False): def _discovered_plugin_manifests(plugin_id=None, rescan=False):
"""The plugin manager's manifests, discovering plugins first if needed. """The plugin catalog's manifests, discovering plugins first if needed.
The web process discovers plugins lazily (see app.py): nothing scans at The web process discovers plugins lazily (see app.py): nothing scans at
startup, so plugin_manifests is empty until some endpoint calls startup, so plugin_manifests is empty until some endpoint calls
@@ -646,18 +647,18 @@ def _discovered_plugin_manifests(plugin_id=None, rescan=False):
Otherwise the existing map is reused, so a steady stream of requests Otherwise the existing map is reused, so a steady stream of requests
for known plugins costs nothing. for known plugins costs nothing.
Returns the manifest map, or {} when there is no plugin manager. Returns the manifest map, or {} when there is no plugin catalog.
""" """
pm = api_v3.plugin_manager catalog = getattr(api_v3, 'plugin_catalog', None)
if pm is None: if catalog is None:
return {} return {}
manifests = getattr(pm, 'plugin_manifests', None) manifests = getattr(catalog, 'plugin_manifests', None)
if not manifests or rescan or (plugin_id is not None and plugin_id not in manifests): if not manifests or rescan or (plugin_id is not None and plugin_id not in manifests):
try: try:
pm.discover_plugins() catalog.discover_plugins()
except Exception: except Exception:
logger.warning('Plugin discovery failed', exc_info=True) logger.warning('Plugin discovery failed', exc_info=True)
manifests = getattr(pm, 'plugin_manifests', None) manifests = getattr(catalog, 'plugin_manifests', None)
return manifests or {} return manifests or {}
def _do_transactional_uninstall(plugin_id, preserve_config): def _do_transactional_uninstall(plugin_id, preserve_config):
"""Execute an uninstall with snapshot-based rollback. """Execute an uninstall with snapshot-based rollback.
@@ -665,12 +666,13 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
Order of operations: Order of operations:
1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors). 1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors).
2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files). 2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files).
3. Unload plugin from runtime if loaded (rollback + 500 if this raises). 3. Remove plugin files (rollback + 500 if this returns False or raises).
4. Remove plugin files (rollback + 500 if this returns False or raises). 4. Finish (remove state, invalidate caches).
5. Finish (remove state, invalidate caches).
Rollback restores the config snapshot and, if the plugin had been Rollback restores the config snapshot. Nothing is unloaded here: the web
loaded before unload, calls load_plugin to restore runtime state. process never loaded the plugin. The display unloads it when the removed
config section reaches its config watcher; plugin_catalog's
display_restart_required() covers the case where that doesn't happen.
Returns (True, None) on success or (False, error_message) on failure. Returns (True, None) on success or (False, error_message) on failure.
""" """
@@ -692,13 +694,7 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
if not preserve_config: if not preserve_config:
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True) api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
# Record whether the plugin was running before we touch anything. def _rollback():
was_loaded = (
api_v3.plugin_manager is not None
and plugin_id in api_v3.plugin_manager.plugins
)
def _rollback(reload_plugin):
if main_snapshot is not None: if main_snapshot is not None:
try: try:
api_v3.config_manager.save_raw_file_content('main', main_snapshot) api_v3.config_manager.save_raw_file_content('main', main_snapshot)
@@ -709,32 +705,19 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot) api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot)
except Exception as restore_err: except Exception as restore_err:
logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err) logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err)
if reload_plugin and api_v3.plugin_manager is not None:
try:
api_v3.plugin_manager.load_plugin(plugin_id)
except Exception as reload_err:
logger.error("Failed to reload plugin %s during rollback: %s", plugin_id, reload_err)
# --- Step 3: unload --- # --- Step 3: remove files ---
if was_loaded:
try:
api_v3.plugin_manager.unload_plugin(plugin_id)
except Exception as unload_err:
_rollback(reload_plugin=False) # unload failed — runtime state unchanged
return False, f"Failed to unload plugin {plugin_id}: {unload_err}"
# --- Step 4: remove files ---
try: try:
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id) success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
except Exception as remove_err: except Exception as remove_err:
_rollback(reload_plugin=was_loaded) _rollback()
return False, f"Failed to remove plugin {plugin_id}: {remove_err}" return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
if not success: if not success:
_rollback(reload_plugin=was_loaded) _rollback()
return False, f"Failed to uninstall plugin {plugin_id}" return False, f"Failed to uninstall plugin {plugin_id}"
# --- Step 5: finish --- # --- Step 4: finish ---
if api_v3.schema_manager: if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id) api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_state_manager: if api_v3.plugin_state_manager:
@@ -747,6 +730,43 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
except Exception as record_err: except Exception as record_err:
logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err) logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err)
return True, None return True, None
def _plugin_enabled_in_config(plugin_id: str) -> bool:
"""Whether config.json enables ``plugin_id``, by the display's rule.
Read this before an operation changes the config (uninstall removes the
section). A config that cannot be read counts as enabled, so the answer
errs towards asking for a restart.
"""
try:
section = (api_v3.config_manager.load_config() or {}).get(plugin_id)
except Exception:
logger.debug("Could not read config for %s", plugin_id, exc_info=True)
return True
return isinstance(section, dict) and bool(section.get('enabled', False))
_RESTART_MESSAGES = {
'install': 'Plugin installed — restart the display to start it',
'update': 'Plugin updated — restart the display to run the new version',
'uninstall': 'Plugin uninstalled — restart the display to stop it',
}
def _store_restart_fields(action: str, plugin_enabled: bool, **kwargs) -> Dict[str, Any]:
"""``restart_required`` (and the banner's wording) for a store response.
The rules are ``display_restart_required``'s: whether the display picks
the change up by itself or keeps running what it has until a restart.
The UI shows its restart banner when ``restart_required`` is true.
"""
from src.plugin_system.plugin_catalog import display_restart_required
required = display_restart_required(action, plugin_enabled, **kwargs)
fields: Dict[str, Any] = {'restart_required': required}
if required:
fields['restart_message'] = _RESTART_MESSAGES[action]
return fields
def deep_merge(base_dict, update_dict): def deep_merge(base_dict, update_dict):
""" """
Deep merge update_dict into base_dict. Deep merge update_dict into base_dict.
@@ -1346,24 +1366,6 @@ def _enhance_schema_with_core_properties(schema):
return with_core_plugin_properties(schema) return with_core_plugin_properties(schema)
def _prepared_plugin_config(plugin_id, raw_config):
"""A plugin's config section as the plugin runs with it, for on_config_change.
Loading a plugin reads legacy booleans as objects and fills in schema
defaults (PluginManager.prepare_plugin_config); a save's notification must
hand over the same shape. Falls back to the raw section.
"""
prepare = getattr(api_v3.plugin_manager, 'prepare_plugin_config', None)
if callable(prepare):
try:
prepared = prepare(plugin_id, raw_config)
if isinstance(prepared, dict):
return prepared
except Exception:
logger.debug("Could not prepare config for %s", plugin_id, exc_info=True)
return raw_config
def _filter_config_by_schema(config, schema, prefix=''): def _filter_config_by_schema(config, schema, prefix=''):
""" """
Filter config to only include fields defined in the schema. Filter config to only include fields defined in the schema.
@@ -1423,15 +1425,15 @@ _CALENDAR_LIST_MAX_PAGES = 10
def _plugin_directory(plugin_id: str) -> Optional[Path]: def _plugin_directory(plugin_id: str) -> Optional[Path]:
"""An installed plugin's directory, or None when it has none on disk. """An installed plugin's directory, or None when it has none on disk.
Only the plugin manager is asked, so no plugin manager means None. There Only the plugin catalog is asked, so no catalog means None. There is no
is no fallback to the legacy plugins/ directory: the loader never scans fallback to the legacy plugins/ directory: the loader never scans it, so
it, so a plugin found only there is one that never runs. a plugin found only there is one that never runs.
""" """
# getattr: the blueprint only has plugin_manager once the app has set it. # getattr: the blueprint only has plugin_catalog once the app has set it.
manager = getattr(api_v3, 'plugin_manager', None) catalog = getattr(api_v3, 'plugin_catalog', None)
if not manager: if not catalog:
return None return None
plugin_dir = manager.get_plugin_directory(plugin_id) plugin_dir = catalog.get_plugin_directory(plugin_id)
if not plugin_dir or not Path(plugin_dir).exists(): if not plugin_dir or not Path(plugin_dir).exists():
return None return None
return Path(plugin_dir) return Path(plugin_dir)
@@ -1533,10 +1535,22 @@ _STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json'
# A dedicated, never-replaced file to flock -- see _starlark_manifest_lock. # A dedicated, never-replaced file to flock -- see _starlark_manifest_lock.
_STARLARK_MANIFEST_LOCK_FILE = _STARLARK_APPS_DIR / 'manifest.json.lock' _STARLARK_MANIFEST_LOCK_FILE = _STARLARK_APPS_DIR / 'manifest.json.lock'
def _get_starlark_plugin() -> Optional[Any]: def _get_starlark_plugin() -> Optional[Any]:
"""Get the starlark-apps plugin instance, or None.""" """The starlark-apps plugin instance in this process: always None.
if not api_v3.plugin_manager:
return None The web process runs no plugin code (see PluginCatalog), so every
return api_v3.plugin_manager.get_plugin('starlark-apps') Starlark route takes its standalone path -- starlark-apps/manifest.json
and each app's files on disk, rendered through Pixlet directly -- and the
display's own starlark-apps plugin reads what they write. Before, this
returned a web-side copy only in the rare session that had just
installed or updated starlark-apps from the store, and that copy's
frames and state never reached the panel.
This is the one seam where a Starlark route would reach a plugin
instance. The instance branches behind it stay until the plugin
web-entry contract (docs/ARCHITECTURE.md) gives plugins an explicit way
to serve web requests; the tests drive them through this function.
"""
return None
def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]:
"""Find pixlet binary: explicit path → bundled binary → system PATH.""" """Find pixlet binary: explicit path → bundled binary → system PATH."""
import platform import platform
@@ -1647,70 +1661,66 @@ def _starlark_github_token() -> Optional[str]:
except Exception: except Exception:
logger.warning("[Starlark] Could not read config for a GitHub token", exc_info=True) logger.warning("[Starlark] Could not read config for a GitHub token", exc_info=True)
return None return None
def _get_tronbyte_repository_class() -> Type[Any]: def _import_plugin_code_in_web_process(module_name: str, module_path: Path,
"""Import TronbyteRepository from plugin-repos directory.""" reuse: bool = True) -> Any:
import importlib.util """Import a file of plugin code into the web process and return the module.
import importlib
The only place the web process executes plugin code. Plugins run in the
display process; the web process reads them as files (PluginCatalog) and
runs a web-UI action's script as a subprocess. Two features still need a
plugin's Python in-process, and both come through here:
- Starlark: the standalone routes use the starlark-apps plugin's
``tronbyte_repository`` (browsing the app repository) and
``pixlet_renderer`` (rendering an app) -- helper modules, never the
plugin class itself.
- A web-UI action with ``oauth_flow``: step 1 calls the action script's
``get_auth_url()`` (or the Spotify credential helpers).
Temporary: the plugin web-entry contract (docs/ARCHITECTURE.md, "Web and
display processes") replaces both with an explicit, declared entry point
for plugin web code.
``reuse`` returns the module already imported under ``module_name``
instead of executing the file again. A module that fails to execute is
removed from sys.modules, so one transient failure cannot leave a
half-initialised module cached for the rest of the process (it used to
surface as AttributeError, not ImportError).
"""
import importlib.util
if reuse and module_name in sys.modules:
return sys.modules[module_name]
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
if spec is None or spec.loader is None:
raise ImportError(f"Failed to create module spec for {module_name} at {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except BaseException:
sys.modules.pop(module_name, None)
raise
return module
def _get_tronbyte_repository_class() -> Type[Any]:
"""TronbyteRepository, from the installed starlark-apps plugin."""
module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py' module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py'
if not module_path.exists(): if not module_path.exists():
raise ImportError(f"TronbyteRepository module not found at {module_path}") raise ImportError(f"TronbyteRepository module not found at {module_path}")
return _import_plugin_code_in_web_process('tronbyte_repository', module_path).TronbyteRepository
# If already imported, return cached class
if "tronbyte_repository" in sys.modules:
return sys.modules["tronbyte_repository"].TronbyteRepository
spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path))
if spec is None:
raise ImportError(f"Failed to create module spec for tronbyte_repository at {module_path}")
module = importlib.util.module_from_spec(spec)
if module is None:
raise ImportError("Failed to create module from spec for tronbyte_repository")
sys.modules["tronbyte_repository"] = module
try:
spec.loader.exec_module(module)
except BaseException:
# A module that failed to execute must not stay in sys.modules: the
# cache branch above would hand back the half-initialised object for
# the rest of the process, so one transient failure would disable
# this path permanently and surface as AttributeError, not ImportError.
sys.modules.pop("tronbyte_repository", None)
raise
return module.TronbyteRepository
def _get_pixlet_renderer_class() -> Type[Any]: def _get_pixlet_renderer_class() -> Type[Any]:
"""Import PixletRenderer from plugin-repos directory.""" """PixletRenderer, from the installed starlark-apps plugin."""
import importlib.util
import importlib
module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'pixlet_renderer.py' module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'pixlet_renderer.py'
if not module_path.exists(): if not module_path.exists():
raise ImportError(f"PixletRenderer module not found at {module_path}") raise ImportError(f"PixletRenderer module not found at {module_path}")
return _import_plugin_code_in_web_process('pixlet_renderer', module_path).PixletRenderer
# If already imported, return cached class
if "pixlet_renderer" in sys.modules:
return sys.modules["pixlet_renderer"].PixletRenderer
spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path))
if spec is None:
raise ImportError(f"Failed to create module spec for pixlet_renderer at {module_path}")
module = importlib.util.module_from_spec(spec)
if module is None:
raise ImportError("Failed to create module from spec for pixlet_renderer")
sys.modules["pixlet_renderer"] = module
try:
spec.loader.exec_module(module)
except BaseException:
# A module that failed to execute must not stay in sys.modules: the
# cache branch above would hand back the half-initialised object for
# the rest of the process, so one transient failure would disable
# this path permanently and surface as AttributeError, not ImportError.
sys.modules.pop("pixlet_renderer", None)
raise
return module.PixletRenderer
def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
"""Validate and sanitize app_id to a safe slug.""" """Validate and sanitize app_id to a safe slug."""
if not app_id and fallback_source: if not app_id and fallback_source:
+9 -15
View File
@@ -1014,7 +1014,7 @@ def save_main_config():
plugin_manifests = _pkg._discovered_plugin_manifests() plugin_manifests = _pkg._discovered_plugin_manifests()
for key in data: for key in data:
# Check if this key is a plugin ID # Check if this key is a plugin ID
if api_v3.plugin_manager and key in plugin_manifests: if api_v3.plugin_catalog and key in plugin_manifests:
plugin_id = key plugin_id = key
submitted_config = data[key] submitted_config = data[key]
if not isinstance(submitted_config, dict): if not isinstance(submitted_config, dict):
@@ -1030,7 +1030,7 @@ def save_main_config():
# the schema load are far enough apart that a later edit could # the schema load are far enough apart that a later edit could
# separate them. Refuse rather than save without knowing which # separate them. Refuse rather than save without knowing which
# fields are secrets. # fields are secrets.
schema_path = resolve_under(api_v3.plugin_manager.plugins_dir, schema_path = resolve_under(api_v3.plugin_catalog.plugins_dir,
plugin_id, 'config_schema.json') plugin_id, 'config_schema.json')
if schema_path is None: if schema_path is None:
return error_response( return error_response(
@@ -1116,18 +1116,9 @@ def save_main_config():
invalidate_cache() invalidate_cache()
# Notify saved plugins of their new config (with secrets merged), now # Saved plugin sections reach the running plugins through the display
# that it is on disk. # process's config watcher (on_config_change there); nothing runs a
for plugin_id in plugin_keys_to_remove: # plugin in this process.
try:
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance and hasattr(plugin_instance, 'on_config_change'):
merged_config = api_v3.config_manager.load_config()
plugin_instance.on_config_change(_pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {})))
except Exception as hook_err:
# Don't fail the save if hook fails
logger.warning("on_config_change failed: %s", hook_err)
message = 'Configuration saved successfully' message = 'Configuration saved successfully'
# Switching automatic updates on finishes their setup, which needs # Switching automatic updates on finishes their setup, which needs
@@ -1139,7 +1130,10 @@ def save_main_config():
message = f'{message}. {note}' message = f'{message}. {note}'
except Exception: except Exception:
logger.warning("Automatic update setup could not be started", exc_info=True) logger.warning("Automatic update setup could not be started", exc_info=True)
return success_response(message=message) # Display hardware, rotation/durations and general settings take
# effect after a display restart; the UI shows its restart banner on
# this flag.
return success_response(message=message, extra={'restart_required': True})
except Exception as e: except Exception as e:
logger.error("Error saving config", exc_info=True) logger.error("Error saving config", exc_info=True)
return error_response( return error_response(
+9 -9
View File
@@ -77,19 +77,19 @@ def get_display_modes():
for the duration -- so they are reported with enabled: false for the duration -- so they are reported with enabled: false
rather than omitted. rather than omitted.
""" """
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
# Discovery is lazy and normally triggered by whichever endpoint runs # Discovery is lazy and normally triggered by whichever endpoint runs
# first, which is a person opening the dashboard. A caller that never # first, which is a person opening the dashboard. A caller that never
# visits it would otherwise see an empty list. # visits it would otherwise see an empty list.
api_v3.plugin_manager.discover_plugins() api_v3.plugin_catalog.discover_plugins()
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True') include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
modes = [] modes = []
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()): for plugin_id, manifest in sorted(api_v3.plugin_catalog.plugin_manifests.items()):
# A hand-edited or migrated config.json can hold a non-dict under a # A hand-edited or migrated config.json can hold a non-dict under a
# plugin id; DisplayController._reconcile guards the same shape, so # plugin id; DisplayController._reconcile guards the same shape, so
# it happens in practice. Without this, .get() raises AttributeError, # it happens in practice. Without this, .get() raises AttributeError,
@@ -107,7 +107,7 @@ def get_display_modes():
if not enabled and not include_disabled: if not enabled and not include_disabled:
continue continue
plugin_name = (manifest or {}).get('name') or plugin_id plugin_name = (manifest or {}).get('name') or plugin_id
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id] plugin_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) or [plugin_id]
for mode in plugin_modes: for mode in plugin_modes:
# A single-mode plugin's mode is the plugin, so its own name is # A single-mode plugin's mode is the plugin, so its own name is
# the readable label. Multi-mode plugins have no per-mode name # the readable label. Multi-mode plugins have no per-mode name
@@ -162,21 +162,21 @@ def start_on_demand_display():
resolved_plugin = plugin_id resolved_plugin = plugin_id
resolved_mode = mode resolved_mode = mode
if api_v3.plugin_manager: if api_v3.plugin_catalog:
if resolved_plugin and resolved_plugin not in _pkg._discovered_plugin_manifests(resolved_plugin): if resolved_plugin and resolved_plugin not in _pkg._discovered_plugin_manifests(resolved_plugin):
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404 return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
if resolved_plugin and not resolved_mode: if resolved_plugin and not resolved_mode:
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin) modes = api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin)
resolved_mode = modes[0] if modes else resolved_plugin resolved_mode = modes[0] if modes else resolved_plugin
elif resolved_mode and not resolved_plugin: elif resolved_mode and not resolved_plugin:
_pkg._discovered_plugin_manifests() _pkg._discovered_plugin_manifests()
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode) resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)
if not resolved_plugin: if not resolved_plugin:
# Not among what was discovered: the plugin that declares # Not among what was discovered: the plugin that declares
# it may have been installed since. Scan once more. # it may have been installed since. Scan once more.
_pkg._discovered_plugin_manifests(rescan=True) _pkg._discovered_plugin_manifests(rescan=True)
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode) resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)
if not resolved_plugin: if not resolved_plugin:
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
+1 -1
View File
@@ -76,7 +76,7 @@ def get_health():
# Check plugin system # Check plugin system
try: try:
if api_v3.plugin_manager: if api_v3.plugin_catalog:
plugin_count = len(_discovered_plugin_manifests()) plugin_count = len(_discovered_plugin_manifests())
health_status['checks']['plugin_system'] = { health_status['checks']['plugin_system'] = {
'status': 'operational', 'status': 'operational',
@@ -452,40 +452,9 @@ def save_plugin_config():
status_code=500 status_code=500
) )
# If the plugin is loaded, notify it of the config change with merged config # The running plugin hears about this from the display process: its
try: # config watcher calls on_config_change with the prepared section, and
if api_v3.plugin_manager: # loads or unloads the plugin if "enabled" changed.
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
# Reload merged config (includes secrets) and pass the plugin-specific section
merged_config = api_v3.config_manager.load_config()
plugin_full_config = _pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {}))
if hasattr(plugin_instance, 'on_config_change'):
plugin_instance.on_config_change(plugin_full_config)
# Update plugin state manager and call lifecycle methods based on enabled state
# This ensures the plugin state is synchronized with the config
enabled = plugin_full_config.get('enabled', plugin_instance.enabled)
# Update state manager if available
if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled)
# Call lifecycle methods to ensure plugin state matches config
try:
if enabled:
if hasattr(plugin_instance, 'on_enable'):
plugin_instance.on_enable()
else:
if hasattr(plugin_instance, 'on_disable'):
plugin_instance.on_disable()
except Exception as lifecycle_error:
# Log the error but don't fail the save - config is already saved
logger.warning("Lifecycle method error for %s: %s", plugin_id, lifecycle_error, exc_info=True)
except Exception as hook_err:
# Do not fail the save if hook fails; just log
logger.warning("on_config_change failed: %s", hook_err)
secret_count = len(secrets_config) secret_count = len(secrets_config)
message = f'Plugin {plugin_id} configuration saved successfully' message = f'Plugin {plugin_id} configuration saved successfully'
@@ -543,12 +512,7 @@ def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr
current_config = api_v3.config_manager.load_config() current_config = api_v3.config_manager.load_config()
if plugin_id in current_config and 'enabled' in current_config[plugin_id]: if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
plugin_config['enabled'] = current_config[plugin_id]['enabled'] plugin_config['enabled'] = current_config[plugin_id]['enabled']
elif api_v3.plugin_manager: # Fallback: default to True (matches BasePlugin default)
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
plugin_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in plugin_config: if 'enabled' not in plugin_config:
plugin_config['enabled'] = True plugin_config['enabled'] = True
except Exception as e: except Exception as e:
@@ -977,18 +941,8 @@ def reset_plugin_config():
if default_secrets or not preserve_secrets: if default_secrets or not preserve_secrets:
api_v3.config_manager.save_raw_file_content('secrets', current_secrets) api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
# Notify plugin of config change if loaded # The display's config watcher passes the reset config to the running
try: # plugin (on_config_change); nothing to notify in this process.
if api_v3.plugin_manager:
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
merged_config = api_v3.config_manager.load_config()
plugin_full_config = _pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {}))
if hasattr(plugin_instance, 'on_config_change'):
plugin_instance.on_config_change(plugin_full_config)
except Exception as hook_err:
logger.warning("on_config_change failed: %s", hook_err)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -1,7 +1,9 @@
"""Plugin health, resource metrics and resource limits. """Plugin health, resource metrics and resource limits.
These read and reset the web process's own trackers; the display service The display process records health and metrics to the shared on-disk cache;
keeps its own (see the route docstrings). these routes read (and reset) that published state through a tracker and a
monitor backed by the same cache (app.py sets api_v3.health_tracker and
api_v3.resource_monitor). See the route docstrings.
Routes decorate the shared `api_v3` Blueprint from the package `__init__`, Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names do not depend on which module they live in. so their endpoint names do not depend on which module they live in.
@@ -11,20 +13,30 @@ from web_interface.blueprints.api_v3 import (
) )
def _health_tracker():
"""The reader of the display's published plugin health, or None."""
return getattr(api_v3, 'health_tracker', None)
def _resource_monitor():
"""The reader of the display's published plugin metrics, or None."""
return getattr(api_v3, 'resource_monitor', None)
@api_v3.route('/plugins/health', methods=['GET']) @api_v3.route('/plugins/health', methods=['GET'])
def get_plugin_health(): def get_plugin_health():
"""Get health metrics for all plugins""" """Get health metrics for all plugins"""
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.health_tracker: if not _health_tracker():
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'data': {}, 'data': {},
'message': 'Health tracking not available' 'message': 'Health tracking not available'
}) })
tracker = api_v3.plugin_manager.health_tracker tracker = _health_tracker()
# Build per-plugin summaries by ID so persisted (cross-process) health # Build per-plugin summaries by ID so persisted (cross-process) health
# is included, then fold in any in-memory-only entries. # is included, then fold in any in-memory-only entries.
health_summaries = {} health_summaries = {}
@@ -51,17 +63,17 @@ def get_plugin_health():
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET']) @api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
def get_plugin_health_single(plugin_id): def get_plugin_health_single(plugin_id):
"""Get health metrics for a specific plugin""" """Get health metrics for a specific plugin"""
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.health_tracker: if not _health_tracker():
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Health tracking not available' 'message': 'Health tracking not available'
}), 503 }), 503
# force_reload for the same reason as the list route above. # force_reload for the same reason as the list route above.
health_summary = api_v3.plugin_manager.health_tracker.get_health_summary( health_summary = _health_tracker().get_health_summary(
plugin_id, force_reload=True) plugin_id, force_reload=True)
return jsonify({ return jsonify({
@@ -79,17 +91,17 @@ def reset_plugin_health(plugin_id):
in-memory state, so its next recorded success or failure can write that in-memory state, so its next recorded success or failure can write that
state back; restart the display service for a reset it will honour. state back; restart the display service for a reset it will honour.
""" """
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.health_tracker: if not _health_tracker():
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Health tracking not available' 'message': 'Health tracking not available'
}), 503 }), 503
# Reset health state # Reset health state
api_v3.plugin_manager.health_tracker.reset_health(plugin_id) _health_tracker().reset_health(plugin_id)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -100,17 +112,17 @@ def reset_plugin_health(plugin_id):
@api_v3.route('/plugins/metrics', methods=['GET']) @api_v3.route('/plugins/metrics', methods=['GET'])
def get_plugin_metrics(): def get_plugin_metrics():
"""Get resource metrics for all plugins""" """Get resource metrics for all plugins"""
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.resource_monitor: if not _resource_monitor():
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'data': {}, 'data': {},
'message': 'Resource monitoring not available' 'message': 'Resource monitoring not available'
}) })
monitor = api_v3.plugin_manager.resource_monitor monitor = _resource_monitor()
# Build per-plugin summaries by ID so persisted (cross-process) metrics # Build per-plugin summaries by ID so persisted (cross-process) metrics
# are included, then fold in any in-memory-only entries. # are included, then fold in any in-memory-only entries.
metrics_summaries = {} metrics_summaries = {}
@@ -136,17 +148,17 @@ def get_plugin_metrics():
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET']) @api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
def get_plugin_metrics_single(plugin_id): def get_plugin_metrics_single(plugin_id):
"""Get resource metrics for a specific plugin""" """Get resource metrics for a specific plugin"""
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.resource_monitor: if not _resource_monitor():
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Resource monitoring not available' 'message': 'Resource monitoring not available'
}), 503 }), 503
# force_reload for the same reason as the list route above. # force_reload for the same reason as the list route above.
metrics_summary = api_v3.plugin_manager.resource_monitor.get_metrics_summary( metrics_summary = _resource_monitor().get_metrics_summary(
plugin_id, force_reload=True) plugin_id, force_reload=True)
return jsonify({ return jsonify({
@@ -163,17 +175,17 @@ def reset_plugin_metrics(plugin_id):
display service keeps accumulating in its own process and republishes display service keeps accumulating in its own process and republishes
its totals on its next persist, so the reset does not stick while it runs. its totals on its next persist, so the reset does not stick while it runs.
""" """
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.resource_monitor: if not _resource_monitor():
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Resource monitoring not available' 'message': 'Resource monitoring not available'
}), 503 }), 503
# Reset metrics # Reset metrics
api_v3.plugin_manager.resource_monitor.reset_metrics(plugin_id) _resource_monitor().reset_metrics(plugin_id)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -190,10 +202,10 @@ def manage_plugin_limits(plugin_id):
plugin, so a change to existing limits takes effect there after the plugin, so a change to existing limits takes effect there after the
display service restarts. display service restarts.
""" """
if not api_v3.plugin_manager: if not api_v3.plugin_catalog:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
if not api_v3.plugin_manager.resource_monitor: if not _resource_monitor():
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Resource monitoring not available' 'message': 'Resource monitoring not available'
@@ -201,7 +213,7 @@ def manage_plugin_limits(plugin_id):
if request.method == 'GET': if request.method == 'GET':
# Get limits # Get limits
limits = api_v3.plugin_manager.resource_monitor.get_limits(plugin_id) limits = _resource_monitor().get_limits(plugin_id)
if limits: if limits:
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -234,7 +246,7 @@ def manage_plugin_limits(plugin_id):
'message': f'{bad} must be a non-negative number or null'}), 400 'message': f'{bad} must be a non-negative number or null'}), 400
limits = limits_from_dict(data) limits = limits_from_dict(data)
api_v3.plugin_manager.resource_monitor.set_limits(plugin_id, limits) _resource_monitor().set_limits(plugin_id, limits)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -120,10 +120,10 @@ def get_plugin_state():
def reconcile_plugin_state(): def reconcile_plugin_state():
"""Reconcile plugin state across all sources""" """Reconcile plugin state across all sources"""
try: try:
if not api_v3.plugin_state_manager or not api_v3.plugin_manager: if not api_v3.plugin_state_manager or not api_v3.plugin_catalog:
return error_response( return error_response(
ErrorCode.SYSTEM_ERROR, ErrorCode.SYSTEM_ERROR,
'State manager or plugin manager not initialized', 'State manager or plugin catalog not initialized',
status_code=500 status_code=500
) )
@@ -139,8 +139,8 @@ def reconcile_plugin_state():
reconciler = StateReconciliation( reconciler = StateReconciliation(
state_manager=api_v3.plugin_state_manager, state_manager=api_v3.plugin_state_manager,
config_manager=api_v3.config_manager, config_manager=api_v3.config_manager,
plugin_manager=api_v3.plugin_manager, plugin_manager=api_v3.plugin_catalog,
plugins_dir=Path(api_v3.plugin_manager.plugins_dir) plugins_dir=Path(api_v3.plugin_catalog.plugins_dir)
) )
result = reconciler.reconcile_state(force=force) result = reconciler.reconcile_state(force=force)
@@ -205,7 +205,7 @@ def _drop_stale_reconciliation_findings(unresolved):
) )
cm = api_v3.config_manager cm = api_v3.config_manager
plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None) plugins_dir = getattr(api_v3.plugin_catalog, 'plugins_dir', None)
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set() installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
config_keys = config_plugin_ids(cm.load_config() or {}, config_keys = config_plugin_ids(cm.load_config() or {},
ignored_config_keys(cm, installed)) ignored_config_keys(cm, installed))
+41 -22
View File
@@ -6,7 +6,8 @@ so their endpoint names do not depend on which module they live in.
""" """
from web_interface.blueprints.api_v3 import ( from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, Path, _do_transactional_uninstall, ErrorCode, OperationType, Path, _do_transactional_uninstall,
_non_plugin_id_error, _get_plugin_version, _plugin_directory, api_v3, _non_plugin_id_error, _get_plugin_version, _plugin_directory,
_plugin_enabled_in_config, _store_restart_fields, api_v3,
datetime, error_response, exception_error_response, json, jsonify, logger, datetime, error_response, exception_error_response, json, jsonify, logger,
request, success_response, validate_request_json, request, success_response, validate_request_json,
) )
@@ -228,11 +229,12 @@ def update_plugin():
if api_v3.schema_manager: if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id) api_v3.schema_manager.invalidate_cache(plugin_id)
# Rediscover plugins # Rediscover plugins. The web process runs no plugin code, so
if api_v3.plugin_manager: # there is nothing here to reload: the display keeps running the
api_v3.plugin_manager.discover_plugins() # version it loaded until it restarts, which restart_required
if plugin_id in api_v3.plugin_manager.plugins: # below asks for.
api_v3.plugin_manager.reload_plugin(plugin_id) if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
# Update state and history # Update state and history
if api_v3.plugin_state_manager: if api_v3.plugin_state_manager:
@@ -261,7 +263,10 @@ def update_plugin():
'commit': updated_commit, 'commit': updated_commit,
'update_status': update_status 'update_status': update_status
}, },
message=message message=message,
extra=_store_restart_fields(
'update', _plugin_enabled_in_config(plugin_id),
changed=update_status == 'updated'),
) )
else: else:
refusal = _compatibility_refusal(plugin_id) refusal = _compatibility_refusal(plugin_id)
@@ -341,6 +346,8 @@ def uninstall_plugin():
if api_v3.operation_queue: if api_v3.operation_queue:
def uninstall_callback(operation): def uninstall_callback(operation):
"""Callback to execute plugin uninstallation via transactional helper.""" """Callback to execute plugin uninstallation via transactional helper."""
# Read before the uninstall removes the config section.
was_enabled = _plugin_enabled_in_config(plugin_id)
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
if not success: if not success:
if api_v3.operation_history: if api_v3.operation_history:
@@ -358,7 +365,9 @@ def uninstall_plugin():
status="success", status="success",
details={"preserve_config": preserve_config} details={"preserve_config": preserve_config}
) )
return {'success': True, 'message': 'Plugin uninstalled successfully'} return {'success': True, 'message': 'Plugin uninstalled successfully',
**_store_restart_fields('uninstall', was_enabled,
preserve_config=preserve_config)}
# Enqueue operation # Enqueue operation
operation_id = api_v3.operation_queue.enqueue_operation( operation_id = api_v3.operation_queue.enqueue_operation(
@@ -373,6 +382,7 @@ def uninstall_plugin():
) )
else: else:
# Direct (non-queued) transactional uninstall # Direct (non-queued) transactional uninstall
was_enabled = _plugin_enabled_in_config(plugin_id)
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
if success: if success:
@@ -383,7 +393,10 @@ def uninstall_plugin():
status="success", status="success",
details={"preserve_config": preserve_config} details={"preserve_config": preserve_config}
) )
return success_response(message='Plugin uninstalled successfully') return success_response(
message='Plugin uninstalled successfully',
extra=_store_restart_fields('uninstall', was_enabled,
preserve_config=preserve_config))
else: else:
if api_v3.operation_history: if api_v3.operation_history:
api_v3.operation_history.record_operation( api_v3.operation_history.record_operation(
@@ -448,10 +461,11 @@ def install_plugin():
if api_v3.schema_manager: if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id) api_v3.schema_manager.invalidate_cache(plugin_id)
# Discover and load the new plugin # List the new plugin. The display loads it, from disk, when
if api_v3.plugin_manager: # it is enabled; see restart_required below for one that
api_v3.plugin_manager.discover_plugins() # already is.
api_v3.plugin_manager.load_plugin(plugin_id) if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
# Update state manager # Update state manager
if api_v3.plugin_state_manager: if api_v3.plugin_state_manager:
@@ -468,7 +482,9 @@ def install_plugin():
) )
branch_msg = f" (branch: {branch})" if branch else "" branch_msg = f" (branch: {branch})" if branch else ""
return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}'} return {'success': True,
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
else: else:
error_msg = f'Failed to install plugin {plugin_id}' error_msg = f'Failed to install plugin {plugin_id}'
if branch: if branch:
@@ -511,9 +527,8 @@ def install_plugin():
if success: if success:
if api_v3.schema_manager: if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id) api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_manager: if api_v3.plugin_catalog:
api_v3.plugin_manager.discover_plugins() api_v3.plugin_catalog.discover_plugins()
api_v3.plugin_manager.load_plugin(plugin_id)
if api_v3.plugin_state_manager: if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.set_plugin_installed(plugin_id) api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
if api_v3.operation_history: if api_v3.operation_history:
@@ -526,7 +541,9 @@ def install_plugin():
) )
branch_msg = f" (branch: {branch})" if branch else "" branch_msg = f" (branch: {branch})" if branch else ""
return success_response(message=f'Plugin installed successfully{branch_msg}') return success_response(
message=f'Plugin installed successfully{branch_msg}',
extra=_store_restart_fields('install', _plugin_enabled_in_config(plugin_id)))
else: else:
error_msg = f'Failed to install plugin {plugin_id}' error_msg = f'Failed to install plugin {plugin_id}'
if branch: if branch:
@@ -587,10 +604,9 @@ def install_plugin_from_url():
if api_v3.schema_manager and installed_plugin_id: if api_v3.schema_manager and installed_plugin_id:
api_v3.schema_manager.invalidate_cache(installed_plugin_id) api_v3.schema_manager.invalidate_cache(installed_plugin_id)
# Discover and load the new plugin # List the new plugin; the display loads it when it is enabled.
if api_v3.plugin_manager and installed_plugin_id: if api_v3.plugin_catalog and installed_plugin_id:
api_v3.plugin_manager.discover_plugins() api_v3.plugin_catalog.discover_plugins()
api_v3.plugin_manager.load_plugin(installed_plugin_id)
branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else "" branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else ""
response_data = { response_data = {
@@ -601,6 +617,9 @@ def install_plugin_from_url():
} }
if result.get('branch'): if result.get('branch'):
response_data['branch'] = result.get('branch') response_data['branch'] = result.get('branch')
if installed_plugin_id:
response_data.update(_store_restart_fields(
'install', _plugin_enabled_in_config(installed_plugin_id)))
return jsonify(response_data) return jsonify(response_data)
else: else:
return jsonify({ return jsonify({
+64 -80
View File
@@ -11,7 +11,7 @@ from web_interface.blueprints.api_v3 import (
) )
from src.common.path_safety import safe_path_component from src.common.path_safety import safe_path_component
from src.plugin_system.base_plugin import ( from src.plugin_system.base_plugin import (
configured_vegas_participation, resolve_vegas_participation, configured_vegas_participation, vegas_participation_value,
) )
import web_interface.blueprints.api_v3 as _pkg import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # Read through the module rather than bound by value: tests patch these
@@ -20,18 +20,48 @@ import web_interface.blueprints.api_v3 as _pkg
# package is the only patch point that covers every caller. # package is the only patch point that covers every caller.
def _vegas_participation(plugin_id, plugin_config, manifest):
"""What Vegas does with a plugin, as far as its files say, and from where.
The order the display resolves it in (resolve_vegas_participation), up
to where that needs the plugin's code: the user's ``vegas_participation`` setting
(``'config'``), then the manifest's declared ``vegas_participation``
(``'manifest'``). Past those the display asks the plugin itself -- a
get_vegas_participation() override or the legacy Vegas hooks -- which the
web process never runs, so the answer is ``(None, 'runtime')``: decided
at run time, not guessed here. A plugin that overrides
get_vegas_participation() can still differ from its manifest.
"""
configured = configured_vegas_participation(plugin_id, plugin_config)
if configured is not None:
return configured, 'config'
declared = vegas_participation_value(
manifest.get('vegas_participation') if isinstance(manifest, dict) else None)
if declared is not None:
return declared, 'manifest'
return None, 'runtime'
@api_v3.route('/plugins/installed', methods=['GET']) @api_v3.route('/plugins/installed', methods=['GET'])
def get_installed_plugins(): def get_installed_plugins():
"""Get installed plugins""" """Get installed plugins.
if not api_v3.plugin_manager or not api_v3.plugin_store_manager:
Metadata comes from the plugin catalog (manifests on disk), ``enabled``
from config.json. ``loaded``, ``state`` and ``error_info`` are always
null: they would describe the display process's plugin instances, and
the display does not publish which plugins it has loaded. What it does
publish -- health, metrics, errors -- is served by /plugins/health,
/plugins/metrics and /errors.
"""
if not api_v3.plugin_catalog or not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500
# Re-discover plugins to ensure we have the latest list # Re-discover plugins to ensure we have the latest list
# This handles cases where plugins are added/removed after app startup # This handles cases where plugins are added/removed after app startup
api_v3.plugin_manager.discover_plugins() api_v3.plugin_catalog.discover_plugins()
# Get all installed plugin info from the plugin manager # Get all installed plugin info from the catalog
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info() all_plugin_info = api_v3.plugin_catalog.get_all_plugin_info()
# Load config once before the loop (not per-plugin) # Load config once before the loop (not per-plugin)
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
@@ -45,18 +75,6 @@ def get_installed_plugins():
return None return None
def _build_plugin_entry_inner(plugin_info, plugin_id): def _build_plugin_entry_inner(plugin_info, plugin_id):
# Capture runtime state (state machine + error context) before the
# manifest merge below can shadow the 'state' key. get_all_plugin_info
# attaches this via PluginStateManager.get_state_info(); surfacing it
# lets the UI show *why* a plugin isn't running instead of just
# 'loaded: false'.
state_info = plugin_info.get('state')
plugin_state = None
plugin_error_info = None
if isinstance(state_info, dict):
plugin_state = state_info.get('state')
plugin_error_info = state_info.get('error_info')
# Re-read manifest from disk to ensure we have the latest metadata. # Re-read manifest from disk to ensure we have the latest metadata.
# Through the resolver, not plugins_dir/<id>: a plugin installed as # Through the resolver, not plugins_dir/<id>: a plugin installed as
# ledmatrix-<id> otherwise never had its manifest refreshed here. # ledmatrix-<id> otherwise never had its manifest refreshed here.
@@ -74,16 +92,13 @@ def get_installed_plugins():
except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e: except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e:
logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e) logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e)
# Enabled status: config is source of truth, fall back to instance # Enabled status: config.json, read by the display's rule -- it runs
enabled = None # a plugin only when its section says "enabled": true, so a missing
# flag is disabled here too.
plugin_config = full_config.get(plugin_id, {}) plugin_config = full_config.get(plugin_id, {})
if 'enabled' in plugin_config: if not isinstance(plugin_config, dict):
enabled = bool(plugin_config['enabled']) plugin_config = {}
enabled = bool(plugin_config.get('enabled', False))
# Single get_plugin() call shared for both enabled fallback and Vegas mode
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if enabled is None:
enabled = plugin_instance.enabled if plugin_instance else True
# Verified + latest published version from registry (no network call) # Verified + latest published version from registry (no network call)
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id) store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
@@ -113,32 +128,16 @@ def get_installed_plugins():
if store_info and not last_commit_message: if store_info and not last_commit_message:
last_commit_message = store_info.get('last_commit_message') last_commit_message = store_info.get('last_commit_message')
# Vegas mode from instance, overridden by explicit config value # Vegas mode as configured. What a plugin's code would choose on its
vegas_mode = None # own is only known to the display, which runs it.
vegas_mode = plugin_config.get('vegas_mode')
vegas_content_type = None vegas_content_type = None
if plugin_instance:
try:
if hasattr(plugin_instance, 'get_vegas_display_mode'):
mode = plugin_instance.get_vegas_display_mode()
vegas_mode = mode.value if hasattr(mode, 'value') else str(mode)
except (AttributeError, TypeError, ValueError) as e:
logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e)
try:
if hasattr(plugin_instance, 'get_vegas_content_type'):
vegas_content_type = plugin_instance.get_vegas_content_type()
except (AttributeError, TypeError, ValueError) as e:
logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e)
if 'vegas_mode' in plugin_config: # What Vegas does with it: 'scroll', 'pause' or 'exclude', or None
vegas_mode = plugin_config['vegas_mode'] # when only the plugin's code (run by the display) decides. The Vegas
# order list badges a None as its configured vegas_mode, else Scroll.
# What Vegas actually does with the plugin: 'scroll', 'pause' or vegas_participation, vegas_participation_source = _vegas_participation(
# 'exclude'. The same resolution the ticker uses; without a loaded plugin_id, plugin_config, plugin_info)
# instance only the user's own setting is known.
if plugin_instance is not None:
vegas_participation = resolve_vegas_participation(plugin_instance, plugin_id)
else:
vegas_participation = configured_vegas_participation(plugin_id, plugin_config)
return { return {
'id': plugin_id, 'id': plugin_id,
@@ -155,9 +154,10 @@ def get_installed_plugins():
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None, 'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
'enabled': enabled, 'enabled': enabled,
'verified': verified, 'verified': verified,
'loaded': plugin_info.get('loaded', False), # Not published by the display process; see the docstring.
'state': plugin_state, 'loaded': None,
'error_info': plugin_error_info, 'state': None,
'error_info': None,
'last_updated': last_updated, 'last_updated': last_updated,
'last_commit': last_commit, 'last_commit': last_commit,
'last_commit_message': last_commit_message, 'last_commit_message': last_commit_message,
@@ -166,6 +166,7 @@ def get_installed_plugins():
'vegas_mode': vegas_mode, 'vegas_mode': vegas_mode,
'vegas_content_type': vegas_content_type, 'vegas_content_type': vegas_content_type,
'vegas_participation': vegas_participation, 'vegas_participation': vegas_participation,
'vegas_participation_source': vegas_participation_source,
} }
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -183,7 +184,7 @@ def toggle_plugin():
plugin_id = None plugin_id = None
enabled = None enabled = None
try: try:
if not api_v3.plugin_manager or not api_v3.config_manager: if not api_v3.plugin_catalog or not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
# Support both JSON and form data (for HTMX submissions) # Support both JSON and form data (for HTMX submissions)
@@ -221,7 +222,7 @@ def toggle_plugin():
current_enabled = config.get(plugin_id, {}).get('enabled', False) current_enabled = config.get(plugin_id, {}).get('enabled', False)
enabled = not current_enabled enabled = not current_enabled
# A Starlark app is not a plugin in plugin_manager's sense -- it is an # A Starlark app is not a plugin in the catalog's sense -- it is an
# entry in starlark-apps' own manifest -- so its enable/disable is # entry in starlark-apps' own manifest -- so its enable/disable is
# handled here rather than falling through to the check below, which # handled here rather than falling through to the check below, which
# would answer "Plugin not found". # would answer "Plugin not found".
@@ -258,21 +259,9 @@ def toggle_plugin():
status="success" status="success"
) )
# If plugin is loaded, also call its lifecycle methods # No lifecycle hooks here: the display's config watcher sees the
# Wrap in try/except to prevent lifecycle errors from failing the toggle # enabled flag change and loads or unloads the plugin itself
plugin = api_v3.plugin_manager.get_plugin(plugin_id) # (DisplayController._reconcile_enabled_plugins).
if plugin:
try:
if enabled:
if hasattr(plugin, 'on_enable'):
plugin.on_enable()
else:
if hasattr(plugin, 'on_disable'):
plugin.on_disable()
except Exception as lifecycle_error:
# Log the error but don't fail the toggle - config is already saved
logger.warning("Lifecycle method error for %s: %s", plugin_id, lifecycle_error, exc_info=True)
return success_response( return success_response(
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully" message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
) )
@@ -511,16 +500,11 @@ sys.exit(proc.returncode)
# Step 1: Get initial data (like auth URL) # Step 1: Get initial data (like auth URL)
# For OAuth flows, we might need to import the script as a module # For OAuth flows, we might need to import the script as a module
if action_def.get('oauth_flow'): if action_def.get('oauth_flow'):
# Import script as module to get auth URL
import sys
import importlib.util
spec = importlib.util.spec_from_file_location("plugin_action", script_file)
action_module = importlib.util.module_from_spec(spec)
sys.modules["plugin_action"] = action_module
try: try:
spec.loader.exec_module(action_module) # Plugin code in the web process: see the
# function for why, and what replaces it.
action_module = _pkg._import_plugin_code_in_web_process(
"plugin_action", script_file, reuse=False)
# Try to get auth URL using common patterns # Try to get auth URL using common patterns
auth_url = None auth_url = None
+1 -1
View File
@@ -506,7 +506,7 @@ def execute_system_action():
'output': "\n".join(outputs) 'output': "\n".join(outputs)
}) })
elif action == 'install_plugin_requirements': elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_manager', None) active_pm = getattr(api_v3, 'plugin_catalog', None)
if active_pm: if active_pm:
plugins_dir = Path(active_pm.plugins_dir) plugins_dir = Path(active_pm.plugins_dir)
else: else:
+25 -26
View File
@@ -20,7 +20,8 @@ from web_interface import widget_bundle
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# The managers live on the blueprint object: app.py sets # The managers live on the blueprint object: app.py sets
# pages_v3.config_manager, pages_v3.plugin_manager and the rest. # pages_v3.config_manager, pages_v3.plugin_catalog and the rest. The catalog
# reads plugins as files; this process never runs plugin code.
pages_v3 = Blueprint('pages_v3', __name__) pages_v3 = Blueprint('pages_v3', __name__)
@@ -201,11 +202,11 @@ def settings_search_index():
] ]
try: try:
plugin_ids = [] plugin_ids = []
if pages_v3.plugin_manager: if pages_v3.plugin_catalog:
try: try:
pages_v3.plugin_manager.discover_plugins() pages_v3.plugin_catalog.discover_plugins()
plugin_ids = sorted( plugin_ids = sorted(
pi.get('id') for pi in pages_v3.plugin_manager.get_all_plugin_info() pi.get('id') for pi in pages_v3.plugin_catalog.get_all_plugin_info()
if pi.get('id') if pi.get('id')
) )
except Exception: except Exception:
@@ -220,7 +221,7 @@ def settings_search_index():
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label)) fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))
for pid in plugin_ids: for pid in plugin_ids:
info = pages_v3.plugin_manager.get_plugin_info(pid) or {} info = pages_v3.plugin_catalog.get_plugin_info(pid) or {}
label = info.get('name', pid) label = info.get('name', pid)
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid)) html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
fields.extend(_extract_settings_fields(html, pid, label)) fields.extend(_extract_settings_fields(html, pid, label))
@@ -264,8 +265,8 @@ def serve_plugin_web_ui(plugin_id, filename):
if not safe_id or not safe_fn: if not safe_id or not safe_fn:
return 'Invalid path component', 400, {'Content-Type': 'text/plain'} return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
if not pages_v3.plugin_manager: if not pages_v3.plugin_catalog:
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'} return 'Plugin catalog not available', 503, {'Content-Type': 'text/plain'}
try: try:
web_ui_path = resolve_under(_plugin_dir_for(safe_id) / 'web_ui', safe_fn) web_ui_path = resolve_under(_plugin_dir_for(safe_id) / 'web_ui', safe_fn)
@@ -329,7 +330,7 @@ def _resolved_plugin_dir(plugin_id):
its id is found there), and it refuses anything that is not one plain its id is found there), and it refuses anything that is not one plain
path segment. See src/plugin_system/plugin_dirs.py. path segment. See src/plugin_system/plugin_dirs.py.
""" """
found = pages_v3.plugin_manager.get_plugin_directory(plugin_id) found = pages_v3.plugin_catalog.get_plugin_directory(plugin_id)
if isinstance(found, (str, Path)) and Path(found).exists(): if isinstance(found, (str, Path)) and Path(found).exists():
return Path(found) return Path(found)
return None return None
@@ -347,7 +348,7 @@ def _plugin_dir_for(safe_id):
if resolved is not None: if resolved is not None:
return resolved return resolved
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve() plugins_base = Path(pages_v3.plugin_catalog.plugins_dir).resolve()
plugin_dir = resolve_under(plugins_base, safe_id) plugin_dir = resolve_under(plugins_base, safe_id)
if plugin_dir is None: if plugin_dir is None:
raise ValueError('plugin id escapes the plugins directory') raise ValueError('plugin id escapes the plugins directory')
@@ -416,8 +417,8 @@ def serve_plugin_widget(plugin_id, widget_name):
if not safe_id or not safe_widget: if not safe_id or not safe_widget:
return 'Invalid path component', 400, {'Content-Type': 'text/plain'} return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
if not pages_v3.plugin_manager: if not pages_v3.plugin_catalog:
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'} return 'Plugin catalog not available', 503, {'Content-Type': 'text/plain'}
try: try:
plugin_dir = _plugin_dir_for(safe_id) plugin_dir = _plugin_dir_for(safe_id)
@@ -543,17 +544,17 @@ def _load_durations_partial():
main_config = pages_v3.config_manager.load_config() main_config = pages_v3.config_manager.load_config()
duration_groups = [] duration_groups = []
covered_keys = set() covered_keys = set()
if pages_v3.plugin_manager: if pages_v3.plugin_catalog:
try: try:
pages_v3.plugin_manager.discover_plugins() pages_v3.plugin_catalog.discover_plugins()
saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {} saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {}
infos = sorted(pages_v3.plugin_manager.get_all_plugin_info(), infos = sorted(pages_v3.plugin_catalog.get_all_plugin_info(),
key=lambda i: (i.get('name') or i.get('id') or '').lower()) key=lambda i: (i.get('name') or i.get('id') or '').lower())
for info in infos: for info in infos:
pid = info.get('id') pid = info.get('id')
if not pid or not (main_config.get(pid, {}) or {}).get('enabled', False): if not pid or not (main_config.get(pid, {}) or {}).get('enabled', False):
continue continue
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid] modes = pages_v3.plugin_catalog.get_plugin_display_modes(pid) or [pid]
covered_keys.update(modes) covered_keys.update(modes)
default = _plugin_default_duration(pid, main_config.get(pid, {}) or {}) default = _plugin_default_duration(pid, main_config.get(pid, {}) or {})
duration_groups.append({ duration_groups.append({
@@ -691,7 +692,7 @@ def _load_plugin_config_partial(plugin_id):
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400 return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
try: try:
if not pages_v3.plugin_manager: if not pages_v3.plugin_catalog:
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500 return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
# Handle starlark app config (starlark:<app_id>) # Handle starlark app config (starlark:<app_id>)
@@ -699,18 +700,18 @@ def _load_plugin_config_partial(plugin_id):
return _load_starlark_config_partial(plugin_id[len('starlark:'):]) return _load_starlark_config_partial(plugin_id[len('starlark:'):])
# Resolve and validate all plugin paths against the plugins base directory # Resolve and validate all plugin paths against the plugins base directory
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve() _plugins_base = Path(pages_v3.plugin_catalog.plugins_dir).resolve()
_plugin_dir = resolve_under(_plugins_base, plugin_id) _plugin_dir = resolve_under(_plugins_base, plugin_id)
if _plugin_dir is None: if _plugin_dir is None:
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400 return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
# Try to get plugin info first # Try to get plugin info first
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)
# If not found, re-discover plugins (handles plugins added after startup) # If not found, re-discover plugins (handles plugins added after startup)
if not plugin_info: if not plugin_info:
pages_v3.plugin_manager.discover_plugins() pages_v3.plugin_catalog.discover_plugins()
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)
if not plugin_info: if not plugin_info:
return '<div class="text-red-500 p-4">Plugin not found</div>', 404 return '<div class="text-red-500 p-4">Plugin not found</div>', 404
@@ -720,9 +721,6 @@ def _load_plugin_config_partial(plugin_id):
# for one installed as ledmatrix-<id> is not plugins_dir/<id>. # for one installed as ledmatrix-<id> is not plugins_dir/<id>.
_plugin_dir = _resolved_plugin_dir(plugin_id) or _plugin_dir _plugin_dir = _resolved_plugin_dir(plugin_id) or _plugin_dir
# Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
# Get plugin configuration from config file # Get plugin configuration from config file
config = {} config = {}
if pages_v3.config_manager: if pages_v3.config_manager:
@@ -829,8 +827,6 @@ def _load_plugin_config_partial(plugin_id):
# Determine enabled status # Determine enabled status
enabled = config.get('enabled', True) enabled = config.get('enabled', True)
if plugin_instance:
enabled = plugin_instance.enabled
# Build plugin data for template # Build plugin data for template
plugin_data = { plugin_data = {
@@ -868,7 +864,10 @@ def _load_starlark_config_partial(app_id):
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400 return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
try: try:
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None # Always None: the web process runs no plugin code. See
# api_v3._get_starlark_plugin, the one seam for this.
from web_interface.blueprints.api_v3 import _get_starlark_plugin
starlark_plugin = _get_starlark_plugin()
if starlark_plugin and hasattr(starlark_plugin, 'apps'): if starlark_plugin and hasattr(starlark_plugin, 'apps'):
app = starlark_plugin.apps.get(app_id) app = starlark_plugin.apps.get(app_id)
+24 -11
View File
@@ -27,8 +27,9 @@
* the display" banner; the floating live preview; aria-current on the nav; * the display" banner; the floating live preview; aria-current on the nav;
* the mobile nav drawer's keyboard handling; header widget placement. * the mobile nav drawer's keyboard handling; header widget placement.
* *
* Globals: showSaveResult, showRestartPending, dismissRestartPending, * Globals: showSaveResult, showRestartPending, noteRestartRequired,
* restartPendingNow, toggleFloatingPreview, applyFloatingPreviewSize, * dismissRestartPending, restartPendingNow, toggleFloatingPreview,
* applyFloatingPreviewSize,
* cycleFloatingPreviewSize, updateFloatingPreviewVisibility, * cycleFloatingPreviewSize, updateFloatingPreviewVisibility,
* previewPluginNow, updateNavAriaCurrent, placeHeaderWidgets. * previewPluginNow, updateNavAriaCurrent, placeHeaderWidgets.
*/ */
@@ -72,19 +73,31 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} }
} }
// Main-config saves (display hardware, rotation/durations, general) only // A response that needs a display restart to take effect says so with
// take effect after a display-service restart — surface the reminder // restart_required (main-config saves, store operations the display
// banner. Plugin config saves apply live and are deliberately excluded. // cannot pick up live); surface the reminder banner for it.
try { try {
const cfg = event.detail.requestConfig; if (response && response.status >= 200 && response.status < 300 && response.responseText) {
if (cfg && cfg.verb === 'post' && window.noteRestartRequired(JSON.parse(response.responseText));
(cfg.path || '').includes('/api/v3/config/main') &&
response && response.status >= 200 && response.status < 300) {
window.showRestartPending();
} }
} catch { /* banner is best-effort */ } } catch { /* not JSON; the banner is best-effort */ }
}); });
/**
* Shows the restart-pending banner when an API response says the change
* needs a display restart (`restart_required: true`), with the response's
* `restart_message` as its wording when there is one. Every caller of an
* endpoint that can answer this way passes the parsed body here, so the
* server alone decides when the banner appears.
* @param {Object} data - a parsed JSON response body (or an operation result)
* @returns {boolean} whether the banner was shown
*/
window.noteRestartRequired = function(data) {
if (!data || data.restart_required !== true) return false;
window.showRestartPending(typeof data.restart_message === 'string' ? data.restart_message : undefined);
return true;
};
/** /**
* Shows the outcome of a settings form save as one notification. Used by the * Shows the outcome of a settings form save as one notification. Used by the
* hx-on:htmx:after-request of the Display, Rotation & Durations and General * hx-on:htmx:after-request of the Display, Rotation & Durations and General
+2
View File
@@ -1366,6 +1366,8 @@ function markPanelLoadFailed(event) {
const data = await response.json(); const data = await response.json();
showNotification(data.message, data.status); showNotification(data.message, data.status);
// The display keeps running the old code until it restarts.
window.noteRestartRequired(data);
if (data.status === 'success') { if (data.status === 'success') {
// Refresh the plugin list // Refresh the plugin list
@@ -69,10 +69,20 @@ const PluginInstallManager = {
if (onProgress) onProgress(i + 1, plugins.length, plugin.id); if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
// Each plugin gets its own pass over the backoff schedule. // Each plugin gets its own pass over the backoff schedule.
const pendingDelays = retryDelays.slice(); const pendingDelays = retryDelays.slice();
let lostAnswer = false;
for (;;) { for (;;) {
try { try {
const result = await window.PluginAPI.updatePlugin(plugin.id); const result = await window.PluginAPI.updatePlugin(plugin.id);
results.push({ pluginId: plugin.id, success: true, result }); const entry = { pluginId: plugin.id, success: true, result };
if (lostAnswer) {
// An earlier attempt got no answer, so it may have
// updated the plugin before the connection dropped,
// and this answer then says up_to_date. restartRequest()
// reads these two.
entry.afterLostAnswer = true;
entry.enabled = plugin.enabled === true;
}
results.push(entry);
break; break;
} catch (error) { } catch (error) {
// No HTTP answer at all (connection refused/reset, e.g. the // No HTTP answer at all (connection refused/reset, e.g. the
@@ -81,6 +91,7 @@ const PluginInstallManager = {
// back rather than skipping it. An HTTP error response is // back rather than skipping it. An HTTP error response is
// the server's answer and is not retried. // the server's answer and is not retried.
if (error && error.error_code === 'NETWORK_ERROR' && pendingDelays.length > 0) { if (error && error.error_code === 'NETWORK_ERROR' && pendingDelays.length > 0) {
lostAnswer = true;
await sleep(pendingDelays.shift()); await sleep(pendingDelays.shift());
continue; continue;
} }
@@ -90,9 +101,14 @@ const PluginInstallManager = {
} }
} }
// Reload plugin list once at the end // Reload plugin list once at the end. A failed refresh must not
// lose the results: they carry the restart flags.
if (window.PluginStateManager) { if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins(); try {
await window.PluginStateManager.loadInstalledPlugins();
} catch (error) {
console.warn('Could not refresh the installed plugin list after updating:', error);
}
} }
return results; return results;
@@ -148,6 +164,42 @@ const PluginInstallManager = {
text: parts.join(', '), text: parts.join(', '),
type type
}; };
},
/**
* The first update answer that says the display needs a restart, or null.
*
* The display keeps running the code it loaded until it restarts, so an
* update of a plugin it runs answers `restart_required: true` (with the
* banner's wording in `restart_message`). One restart covers every
* plugin in the run, so one answer is enough; pass it to
* window.noteRestartRequired.
*
* Failing that, an enabled plugin whose first request got no answer and
* whose re-sent one says up_to_date may have been updated by the lost
* request, which nothing reported: that asks for a restart too, since a
* needless restart is cheaper than the display running old code.
*
* @param {Array} results - updateAll()'s results
* @returns {Object|null}
*/
restartRequest(results) {
const entries = Array.isArray(results) ? results : [];
for (const entry of entries) {
const body = entry && entry.success ? entry.result : null;
if (body && body.restart_required === true) return body;
}
for (const entry of entries) {
if (entry && entry.success && entry.afterLostAnswer && entry.enabled
&& this.updateOutcome(entry) === 'up_to_date') {
return {
restart_required: true,
restart_message: `Plugin ${entry.pluginId} may have been updated before the `
+ 'connection dropped — restart the display to be sure it runs the new version',
};
}
}
return null;
} }
}; };
@@ -453,6 +453,7 @@ window.handleGitHubPluginInstall = function() {
urlInput.value = ''; urlInput.value = '';
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success'); showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
window.noteRestartRequired(data);
setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000); setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000);
} else { } else {
@@ -1548,6 +1549,9 @@ function runUpdateAllPlugins() {
// a no-op update is "already up to date", not "updated". // a no-op update is "already up to date", not "updated".
const summary = window.PluginInstallManager.summarizeUpdateResults(results); const summary = window.PluginInstallManager.summarizeUpdateResults(results);
showNotification(summary.text, summary.type); showNotification(summary.text, summary.type);
// An updated plugin the display is running keeps its old code
// until the display restarts.
window.noteRestartRequired(window.PluginInstallManager.restartRequest(results));
}) })
.catch(error => { .catch(error => {
console.error('Error updating all plugins:', error); console.error('Error updating all plugins:', error);
@@ -2062,6 +2066,7 @@ window.uninstallPlugin = function(pluginId) {
pollOperationStatus(operationId, pluginId, pluginName); pollOperationStatus(operationId, pluginId, pluginName);
} else if (data.status === 'success') { } else if (data.status === 'success') {
// Direct uninstall completed immediately // Direct uninstall completed immediately
window.noteRestartRequired(data);
handleUninstallSuccess(pluginId); handleUninstallSuccess(pluginId);
} else { } else {
// Error response // Error response
@@ -2104,6 +2109,9 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
const status = operation.status; const status = operation.status;
if (status === 'completed') { if (status === 'completed') {
// The operation's result says whether the display picks
// the change up by itself or needs a restart.
window.noteRestartRequired(operation.result);
onComplete(); onComplete();
} else if (status === 'failed') { } else if (status === 'failed') {
onFailed(operation.error || operation.message); onFailed(operation.error || operation.message);
@@ -2549,6 +2557,7 @@ window.installPlugin = function(pluginId, branch = null) {
}); });
} else { } else {
// No operation queue configured - install already completed synchronously. // No operation queue configured - install already completed synchronously.
window.noteRestartRequired(data);
enableAfterInstall(); enableAfterInstall();
} }
}) })
@@ -2579,6 +2588,7 @@ window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, b
.then(data => { .then(data => {
if (data.status === 'success') { if (data.status === 'success') {
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success'); showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
window.noteRestartRequired(data);
// Refresh installed plugins and re-render custom registry // Refresh installed plugins and re-render custom registry
loadInstalledPlugins().catch(() => {}); loadInstalledPlugins().catch(() => {});
// Re-render custom registry to update install buttons // Re-render custom registry to update install buttons
@@ -2771,6 +2781,7 @@ function attachInstallButtonHandler() {
pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${escapeHtml(data.plugin_id)}</span>`; pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${escapeHtml(data.plugin_id)}</span>`;
} }
pluginUrlInput.value = ''; pluginUrlInput.value = '';
window.noteRestartRequired(data);
// Refresh installed plugins list // Refresh installed plugins list
setTimeout(() => { setTimeout(() => {
+5 -4
View File
@@ -456,10 +456,11 @@
</div> </div>
</div> </div>
<!-- Restart-pending banner: shown after a main-config save (display <!-- Restart-pending banner: shown when a response says restart_required
hardware, rotation order, durations, general settings) — those only -- a main-config save (display hardware, rotation order, durations,
take effect after the display service restarts. Plugin config saves general settings), or a plugin install, update or uninstall the
apply live and don't trigger this. Wired in app.js. --> running display cannot pick up live. Plugin config saves apply live
and don't trigger this. Wired in app.js (noteRestartRequired). -->
<div id="restart-pending-banner" style="display:none" <div id="restart-pending-banner" style="display:none"
class="update-banner border-b transition-all duration-300 ease-in-out"> class="update-banner border-b transition-all duration-300 ease-in-out">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%"> <div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">