This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
+75 -1
View File
@@ -82,6 +82,8 @@ def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[
"""形如 ``core.organization:DataCleaningOrg`` 的入口字符串解析。
``:`` 左边是相对插件根的模块路径(用 / 或 . 分隔均可),右边是类名。
会预先把插件根 + 入口模块所在子目录注册成虚拟 package,让相对导入
``from .db import ...``)能正常工作。
"""
if ":" not in entry:
raise ValueError(f"invalid entry {entry!r}: missing ':<ClassName>'")
@@ -91,11 +93,34 @@ def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[
if not file_path.exists():
raise FileNotFoundError(f"plugin {plugin_name} entry file not found: {file_path}")
module_name = f"data.plugin.{plugin_name}.{mod_path.replace('/', '.')}"
# 注册虚拟 root package(如 ``_kilostar_plugin_data_analytics``+ 入口所在子包
# (如 ``_kilostar_plugin_data_analytics.core``),这样 ``from .db import Base``
# 才能在 spec_from_file_location 加载的模块里正常解析。
import types as _types
root_pkg = f"_kilostar_plugin_{plugin_name}"
if root_pkg not in sys.modules:
root_mod = _types.ModuleType(root_pkg)
root_mod.__path__ = [str(plugin_dir)]
sys.modules[root_pkg] = root_mod
parts = mod_path.replace("/", ".").split(".")
cur_pkg = root_pkg
cur_dir = plugin_dir
for p in parts[:-1]:
cur_pkg = f"{cur_pkg}.{p}"
cur_dir = cur_dir / p
if cur_pkg not in sys.modules:
sub_mod = _types.ModuleType(cur_pkg)
sub_mod.__path__ = [str(cur_dir)]
sys.modules[cur_pkg] = sub_mod
module_name = f"{root_pkg}.{mod_path.replace('/', '.')}"
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load module {module_name}")
mod = importlib.util.module_from_spec(spec)
mod.__package__ = ".".join(module_name.split(".")[:-1])
sys.modules[module_name] = mod
spec.loader.exec_module(mod)
@@ -126,3 +151,52 @@ async def install_dependencies(deps_python: List[str]) -> None:
f"uv pip install failed (rc={proc.returncode}): {stderr.decode()}"
)
logger.info(f"installed deps: {deps_python}")
def discover_plugin_api(plugin_dir: Path, plugin_name: str) -> Any:
"""加载 ``<plugin_dir>/api.py``,返回模块的 ``router`` 属性(或 None)。
约定:插件如需暴露 HTTP 路由,在自己根目录写一个 ``api.py``,里面实例化
``router = APIRouter(...)`` 并按业务挂端点。主程序启动期统一以
``manifest.api_prefix`` 把它 include 到 FastAPI app。
"""
api_path = plugin_dir / "api.py"
if not api_path.exists():
return None
module_name = f"data.plugin.{plugin_name}.api"
spec = importlib.util.spec_from_file_location(module_name, str(api_path))
if spec is None or spec.loader is None:
logger.warning(f"plugin {plugin_name}: cannot load api.py at {api_path}")
return None
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
try:
spec.loader.exec_module(mod)
except Exception as e:
logger.warning(f"plugin {plugin_name}: api.py import failed: {e}")
return None
return getattr(mod, "router", None)
def collect_plugin_routers(plugin_root: Path) -> List[tuple]:
"""扫描所有插件,返回 ``[(api_prefix, router)]`` 列表。
用于 FastAPI 启动期统一挂载。纯文件扫描,不依赖任何 actor,避免启动顺序耦合。
无 ``api.py`` / 加载失败 / 缺 ``api_prefix`` 的插件被静默跳过。
"""
out: List[tuple] = []
for plugin_dir in discover_plugins(plugin_root):
try:
with open(plugin_dir / "manifest.json", "r", encoding="utf-8") as f:
manifest = OrgManifest.model_validate(json.load(f))
except Exception as e:
logger.warning(f"skip plugin {plugin_dir.name} (manifest invalid): {e}")
continue
if not manifest.api_prefix:
continue
router = discover_plugin_api(plugin_dir, manifest.name)
if router is None:
continue
out.append((manifest.api_prefix, router))
logger.info(f"discovered plugin router: {manifest.name} @ {manifest.api_prefix}")
return out