Saving an enabled plugin threw away hot_reload_plugin's (ok, message), so a plugin that failed to load live read "Enabled, not loaded" with nothing more. Now: - a live-load failure is recorded with its reason, as a boot failure is, and logged; a later success clears it; - any save of an enabled plugin that isn't running tries to start it, not only the off-to-on toggle; - enabling one already running is a success, not a failure; - the plugin's page offers "Restart to load" while it is enabled and not running. Quart never marks the app as having served its first request, so register_blueprint after startup is allowed: hot-loading is not refused wholesale, as #6087 suspected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 lines
2.4 KiB
Python
65 lines
2.4 KiB
Python
"""A plugin enabled while Steward runs is loaded live; one that can't be is
|
|
recorded with its reason, so its settings page says why and offers a restart
|
|
instead of a bare "Enabled, not loaded" (#6087)."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from steward.core import plugin_manager as pm
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_registries(monkeypatch):
|
|
monkeypatch.setattr(pm, "_LOADED_PLUGINS", set())
|
|
monkeypatch.setattr(pm, "_FAILED_PLUGINS", {})
|
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
|
monkeypatch.setattr(pm, "_PLUGIN_MODULES", {})
|
|
yield
|
|
for key in [k for k in sys.modules if k.startswith("_steward_plugin_hl_")]:
|
|
sys.modules.pop(key)
|
|
|
|
|
|
def _app(root):
|
|
return SimpleNamespace(config={"PLUGIN_DIRS": [str(root)], "PLUGINS": {}},
|
|
_task_registry=[], mcp_server=None)
|
|
|
|
|
|
def _plugin(root, name, *, yaml_name=None, setup_body="pass"):
|
|
pdir = root / name
|
|
pdir.mkdir(parents=True)
|
|
(pdir / "plugin.yaml").write_text(f"name: {yaml_name or name}\nversion: 1.0.0\n")
|
|
(pdir / "__init__.py").write_text(
|
|
f"def setup(app):\n {setup_body}\n\n"
|
|
"def get_scheduled_tasks():\n return []\n")
|
|
|
|
|
|
def test_a_plugin_that_cannot_load_live_is_recorded_with_its_reason(tmp_path):
|
|
_plugin(tmp_path, "hl_broken", setup_body="raise RuntimeError('no controller')")
|
|
ok, message = pm.hot_reload_plugin(_app(tmp_path), "hl_broken")
|
|
assert not ok
|
|
assert pm.get_plugin_failures() == {"hl_broken": "setup() raised: no controller"}
|
|
assert "hl_broken" not in pm._LOADED_PLUGINS
|
|
|
|
|
|
def test_an_early_refusal_is_recorded_too(tmp_path):
|
|
_plugin(tmp_path, "hl_misnamed", yaml_name="other")
|
|
ok, _ = pm.hot_reload_plugin(_app(tmp_path), "hl_misnamed")
|
|
assert not ok and "name mismatch" in pm.get_plugin_failures()["hl_misnamed"]
|
|
|
|
|
|
def test_a_plugin_that_loads_clears_an_earlier_failure(tmp_path):
|
|
_plugin(tmp_path, "hl_good")
|
|
pm._FAILED_PLUGINS["hl_good"] = "setup() raised: earlier"
|
|
ok, _ = pm.hot_reload_plugin(_app(tmp_path), "hl_good")
|
|
assert ok
|
|
assert "hl_good" in pm._LOADED_PLUGINS and pm.get_plugin_failures() == {}
|
|
|
|
|
|
def test_enabling_one_already_running_is_a_success_not_a_failure(tmp_path):
|
|
pm._LOADED_PLUGINS.add("hl_running")
|
|
ok, _ = pm.hot_reload_plugin(_app(tmp_path), "hl_running")
|
|
assert ok and pm.get_plugin_failures() == {}
|