feat(plugins): 插件支持热加载与启用/停用清单
- pugins/__init__.py 重写:load_plugins / discover / set_enabled / reload
- 热加载:把 study_tool.pugins.<名字> 的模块从 sys.modules 摘掉后重新 import;
重载前先 registry.unregister 旧 id,平台 id 改了也不会留残影
- 启用清单存设置项 plugins({"disabled": [...]}):未列出的默认启用,新插件自动生效;
停用即从注册表摘掉,/api/platforms 与账号平台下拉同步消失
- platforms/registry.py 新增 unregister(pid)
- 新接口:GET /api/plugins、POST /api/plugins/{name}/enable|disable、
POST /api/plugins/reload、POST /api/plugins/{name}/reload;失败返回 ok=false
- 同步 docs/pugin.md(新增「加载与启停」)、docs/architecture.md、docs/OPTIMIZATION.md、AGENTS.md
This commit is contained in:
@@ -9,6 +9,11 @@ def register(platform: Platform) -> Platform:
|
||||
return platform
|
||||
|
||||
|
||||
def unregister(pid):
|
||||
"""停用插件 / 重载前把实现从注册表摘掉;不在表里返回 None。"""
|
||||
return _PLATFORMS.pop(pid or "", None)
|
||||
|
||||
|
||||
def get(pid):
|
||||
"""按 course["platform"] 取平台实现。"""
|
||||
return _PLATFORMS.get(pid or "")
|
||||
|
||||
@@ -3,24 +3,131 @@
|
||||
pugin.json 注册信息:id / title / name(只写元数据,不写任何能力绑定)
|
||||
main.py `Platform` 子类,import 时用 `registry.register(...)` 注册自己
|
||||
|
||||
加载 = 扫目录逐个 import `main.py`。插件里不用声明「哪个函数绑定哪个槽位」,
|
||||
子类实现了哪些方法就具备哪些能力(映射见 `platforms/contract.py`)。
|
||||
要换加载方式,只改这里的 `load_plugins()`。
|
||||
对外四个函数(路由 `/api/plugins*` 直接用):
|
||||
|
||||
load_plugins() 启动时按启用清单加载全部插件
|
||||
discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码)
|
||||
set_enabled(name, bool) 启用 / 停用,立即生效并落库
|
||||
reload(name=None) 重新 import(改完插件代码不用重启服务)
|
||||
|
||||
启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用,
|
||||
新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from study_tool import runtime, store
|
||||
from study_tool.platforms import registry
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SETTING = "plugins"
|
||||
_LOADED = {} # 插件名 -> 注册时用的平台 id,停用 / 重载时据此摘下旧实现
|
||||
|
||||
|
||||
def _meta(name):
|
||||
meta = HERE / name / "pugin.json"
|
||||
if not meta.is_file():
|
||||
raise RuntimeError("没有这个插件:%s" % name)
|
||||
return json.loads(meta.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _disabled():
|
||||
return set((store.get_setting(SETTING) or {}).get("disabled") or [])
|
||||
|
||||
|
||||
def _save_disabled(names):
|
||||
store.set_setting(SETTING, {"disabled": sorted(names)})
|
||||
|
||||
|
||||
def _forget(name):
|
||||
"""把该插件的模块(含子模块)从 sys.modules 摘掉,下次 import 会重新执行。"""
|
||||
pkg = "%s.%s" % (__name__, name)
|
||||
for mod in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
|
||||
del sys.modules[mod]
|
||||
|
||||
|
||||
def _load(name):
|
||||
"""import 一个插件并确认它注册成功;重复调用就是重新加载。"""
|
||||
pid = _meta(name).get("id") or ""
|
||||
_unload(name) # 旧实现先下线,id 改了也不会留下残影
|
||||
importlib.import_module("%s.%s.main" % (__name__, name))
|
||||
platform = registry.get(pid)
|
||||
if platform is None:
|
||||
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, pid))
|
||||
_LOADED[name] = pid
|
||||
return platform
|
||||
|
||||
|
||||
def _unload(name):
|
||||
pid = _LOADED.pop(name, None)
|
||||
if pid:
|
||||
registry.unregister(pid)
|
||||
_forget(name)
|
||||
|
||||
|
||||
def discover():
|
||||
"""磁盘上的插件清单;不 import 插件代码。"""
|
||||
disabled = _disabled()
|
||||
out = []
|
||||
for meta in sorted(HERE.glob("*/pugin.json")):
|
||||
name = meta.parent.name
|
||||
info = json.loads(meta.read_text(encoding="utf-8"))
|
||||
platform = registry.get(info.get("id") or "")
|
||||
out.append({
|
||||
"name": name,
|
||||
"id": info.get("id") or "",
|
||||
"title": info.get("title") or "",
|
||||
"enabled": name not in disabled,
|
||||
"loaded": platform is not None,
|
||||
"label": platform.label if platform else "",
|
||||
"capabilities": platform.capabilities() if platform else [],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def load_plugins():
|
||||
"""导入 `pugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。"""
|
||||
for meta in sorted(HERE.glob("*/pugin.json")):
|
||||
info = json.loads(meta.read_text(encoding="utf-8"))
|
||||
name = meta.parent.name
|
||||
importlib.import_module("%s.%s.main" % (__name__, name))
|
||||
if not registry.get(info.get("id") or ""):
|
||||
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, info.get("id")))
|
||||
"""启动时加载启用清单里的全部插件。幂等。"""
|
||||
for item in discover():
|
||||
if not item["enabled"]:
|
||||
continue
|
||||
try:
|
||||
_load(item["name"])
|
||||
except Exception as e:
|
||||
runtime.log("system", "插件 %s 加载失败:%s" % (item["name"], e), level="warn")
|
||||
|
||||
|
||||
def set_enabled(name, enabled):
|
||||
"""启用 / 停用插件并立即生效;返回最新的插件清单。"""
|
||||
if enabled:
|
||||
platform = _load(name) # 先加载,失败了不写进启用清单
|
||||
disabled = _disabled()
|
||||
disabled.discard(name)
|
||||
_save_disabled(disabled)
|
||||
runtime.log("system", "插件已启用:%s(%s)" % (name, platform.label))
|
||||
else:
|
||||
disabled = _disabled()
|
||||
disabled.add(name)
|
||||
_save_disabled(disabled)
|
||||
_unload(name)
|
||||
runtime.log("system", "插件已停用:%s" % name)
|
||||
return discover()
|
||||
|
||||
|
||||
def reload(name=None):
|
||||
"""重新 import 插件;name 为空 = 重载所有已启用的插件。"""
|
||||
items = discover()
|
||||
if name:
|
||||
item = next((i for i in items if i["name"] == name), None)
|
||||
if item is None:
|
||||
raise RuntimeError("没有这个插件:%s" % name)
|
||||
if not item["enabled"]:
|
||||
raise RuntimeError("插件 %s 已停用,先启用再重载" % name)
|
||||
names = [name]
|
||||
else:
|
||||
names = [i["name"] for i in items if i["enabled"]]
|
||||
for n in names:
|
||||
_load(n)
|
||||
runtime.log("system", "插件已重载:%s" % n)
|
||||
return discover()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
|
||||
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from study_tool.webapp import app
|
||||
|
||||
from study_tool import store
|
||||
from study_tool import pugins, store
|
||||
from study_tool.capture import *
|
||||
from study_tool.config import *
|
||||
from study_tool.engine import study_worker
|
||||
@@ -25,6 +25,44 @@ def api_platforms():
|
||||
for p in registry.all()]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 插件管理
|
||||
# 插件目录 pugins/<名字>/,实现见 study_tool.pugins。
|
||||
|
||||
@app.get("/api/plugins")
|
||||
def api_plugins():
|
||||
"""磁盘上的插件清单:是否启用、是否已加载、加载后的 label 与 capabilities。"""
|
||||
return {"ok": True, "plugins": pugins.discover()}
|
||||
|
||||
|
||||
@app.post("/api/plugins/{name}/enable")
|
||||
def api_plugin_enable(name: str):
|
||||
return _plugin_call(lambda: pugins.set_enabled(name, True))
|
||||
|
||||
|
||||
@app.post("/api/plugins/{name}/disable")
|
||||
def api_plugin_disable(name: str):
|
||||
return _plugin_call(lambda: pugins.set_enabled(name, False))
|
||||
|
||||
|
||||
@app.post("/api/plugins/reload")
|
||||
def api_plugins_reload():
|
||||
"""重载全部已启用插件:改完插件代码不用重启服务。"""
|
||||
return _plugin_call(pugins.reload)
|
||||
|
||||
|
||||
@app.post("/api/plugins/{name}/reload")
|
||||
def api_plugin_reload(name: str):
|
||||
return _plugin_call(lambda: pugins.reload(name))
|
||||
|
||||
|
||||
def _plugin_call(fn):
|
||||
"""插件操作失败不抛 500,返回 ok=false 给前端提示。"""
|
||||
try:
|
||||
return {"ok": True, "plugins": fn()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "plugins": pugins.discover()}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 运行日志 / 快照
|
||||
|
||||
@app.get("/api/log/history")
|
||||
|
||||
Reference in New Issue
Block a user