重大重构:引擎模块拆分 + P0插件实现 + 55个Bug修复

核心变更:
- engine.py(1781行)拆分为8个独立模块: lifecycle/security/deps/
  datastore/pl_injector/watcher/signature/manager
- 新增plugin-bridge: 事件总线 + 服务注册 + RPC通信
- 新增i18n: 国际化/多语言翻译支持
- 新增plugin-storage: 插件键值/文件存储
- 新增ws-api: WebSocket实时通信(pub/sub + 自定义处理器)
- nodejs-adapter统一为Plugin ABC模式

Bug修复:
- 修复load_all()中store_dir未定义崩溃
- 修复DependencyResolver入度计算(拓扑排序)
- 修复PermissionError隐藏内置异常
- 修复CORS中间件头部未附加到响应
- 修复IntegrityChecker跳过__pycache__目录
- 修复版本号不一致(v2.0.0→v1.2.0)
- 修复测试文件的Logger导入/路径/私有方法调用
- 修复context.py缺少typing导入
- 修复config.py STORE_DIR默认路径(./mods→./store)

测试覆盖: 14→91个测试, 全部通过
This commit is contained in:
Falck
2026-05-12 11:40:06 +08:00
parent 3a096f59a9
commit bce27db4ac
57 changed files with 3669 additions and 2367 deletions

View File

@@ -0,0 +1,113 @@
import json
import os
from pathlib import Path
from typing import Optional
class I18n:
name = "i18n"
version = "1.0.0"
description = "Internationalization support with multi-language translations"
_DEFAULT_LANG = "zh-CN"
_SUPPORTED_LANGS = {"zh-CN", "en-US", "ja-JP"}
_TRANSLATIONS_DIR = "translations"
def __init__(self):
self._current_lang = self._DEFAULT_LANG
self._translations: dict[str, dict[str, str]] = {}
self._fallback: dict[str, str] = {}
self._loaded_domains: set[str] = set()
def init(self, deps=None):
self._load_domain("core")
def start(self):
pass
def stop(self):
self._translations.clear()
self._fallback.clear()
self._loaded_domains.clear()
def set_language(self, lang: str) -> bool:
if lang not in self._SUPPORTED_LANGS:
return False
self._current_lang = lang
self._reload_all()
return True
def get_language(self) -> str:
return self._current_lang
def get_supported_languages(self) -> list[str]:
return list(self._SUPPORTED_LANGS)
def translate(self, key: str, domain: str = "core", **kwargs) -> str:
domain_data = self._translations.get(domain, {})
template = domain_data.get(key) or self._fallback.get(key) or key
if kwargs:
try:
return template.format(**kwargs)
except KeyError:
return template
return template
def t(self, key: str, domain: str = "core", **kwargs) -> str:
return self.translate(key, domain, **kwargs)
def _load_domain(self, domain: str):
if domain in self._loaded_domains:
return
paths = self._find_translation_files(domain)
for lang_file in paths:
try:
data = json.loads(Path(lang_file).read_text(encoding="utf-8"))
if domain not in self._translations:
self._translations[domain] = {}
self._translations[domain].update(data)
except (json.JSONDecodeError, OSError):
pass
self._loaded_domains.add(domain)
def _find_translation_files(self, domain: str) -> list[str]:
files = []
search_dirs = [
Path(os.getcwd()) / self._TRANSLATIONS_DIR,
Path(__file__).parent / self._TRANSLATIONS_DIR,
]
for base in search_dirs:
lang_dir = base / self._current_lang
f = lang_dir / f"{domain}.json"
if f.exists():
files.append(str(f))
return files
def _reload_all(self):
self._translations.clear()
self._fallback.clear()
for domain in list(self._loaded_domains):
self._loaded_domains.discard(domain)
self._load_domain("core")
def load_domain(self, domain: str, translations: dict[str, str]):
if domain not in self._translations:
self._translations[domain] = {}
self._translations[domain].update(translations)
def register_translations(self, lang: str, domain: str, translations: dict[str, str]):
if lang == self._current_lang:
self.load_domain(domain, translations)
if lang == self._DEFAULT_LANG:
self._fallback.update(translations)
def get_info(self):
return {
"language": self._current_lang,
"supported": list(self._SUPPORTED_LANGS),
"domains": list(self._loaded_domains),
}
def New():
return I18n()

View File

@@ -0,0 +1,14 @@
{
"metadata": {
"name": "i18n",
"version": "1.0.0",
"description": "Internationalization support with multi-language translations",
"author": "NebulaShell Team"
},
"config": {
"enabled": true,
"args": {}
},
"dependencies": [],
"permissions": ["storage:read"]
}

View File

@@ -164,38 +164,30 @@ class NodeJSAdapter:
def init(context):
"""Initialize the adapter and register it as a shared service.
This plugin does NOT start any server or run any code itself.
It just registers the tool for others to use."""
adapter = NodeJSAdapter()
versions = adapter.check_versions()
print(f"[INFO] Node.js Adapter Service Registered")
if versions.get('node'):
print(f"[INFO] Runtime: Node {versions['node']}")
if versions.get('npm'):
print(f"[INFO] Package Manager: npm {versions['npm']}")
if 'services' not in context:
context['services'] = {}
context['services']['nodejs-adapter'] = adapter
return {
'status': 'ready',
'service_name': 'nodejs-adapter',
'runtime_available': bool(versions.get('node')),
'versions': versions
}
class NodeJSAdapterPlugin:
"""Plugin-ABC-compatible wrapper for NodeJSAdapter"""
name = "nodejs-adapter"
version = "1.0.0"
description = "Stateless Node.js runtime adapter for cross-plugin execution"
def start(context):
"""Return inactive status."""
return {'status': 'inactive'}
def __init__(self):
self._adapter = NodeJSAdapter()
def get_info(context):
"""Return adapter info."""
return {
'name': 'nodejs-adapter',
'version': '1.0.0',
'features': ['run_script', 'install_deps', 'exec_command', 'context_switching']
}
def init(self, deps=None):
pass
def start(self):
pass
def stop(self):
pass
def get_adapter(self) -> NodeJSAdapter:
return self._adapter
def __getattr__(self, name):
return getattr(self._adapter, name)
def New():
return NodeJSAdapterPlugin()

View File

@@ -0,0 +1,164 @@
import threading
import inspect
from typing import Any, Callable, Optional
class EventBus:
def __init__(self):
self._lock = threading.Lock()
self._handlers: dict[str, list[tuple[str, Callable]]] = {}
def on(self, event: str, plugin_name: str, handler: Callable):
with self._lock:
if event not in self._handlers:
self._handlers[event] = []
self._handlers[event].append((plugin_name, handler))
def off(self, event: str, plugin_name: str):
with self._lock:
if event not in self._handlers:
return
self._handlers[event] = [
(pn, h) for pn, h in self._handlers[event] if pn != plugin_name
]
def emit(self, event: str, *args, **kwargs) -> list[Any]:
results = []
with self._lock:
handlers = list(self._handlers.get(event, []))
for plugin_name, handler in handlers:
try:
result = handler(*args, **kwargs)
results.append(result)
except Exception as e:
results.append(None)
return results
def emit_async(self, event: str, *args, **kwargs):
t = threading.Thread(target=self.emit, args=(event, *args), kwargs=kwargs, daemon=True)
t.start()
def has_listeners(self, event: str) -> bool:
with self._lock:
return event in self._handlers and len(self._handlers[event]) > 0
def listener_count(self, event: str) -> int:
with self._lock:
return len(self._handlers.get(event, []))
def clear(self):
with self._lock:
self._handlers.clear()
class ServiceRegistry:
def __init__(self):
self._lock = threading.Lock()
self._services: dict[str, Any] = {}
self._providers: dict[str, str] = {}
def register(self, name: str, instance: Any, provider: str):
with self._lock:
self._services[name] = instance
self._providers[name] = provider
def unregister(self, name: str, provider: str):
with self._lock:
if self._providers.get(name) == provider:
del self._services[name]
del self._providers[name]
def get(self, name: str) -> Optional[Any]:
with self._lock:
return self._services.get(name)
def has(self, name: str) -> bool:
with self._lock:
return name in self._services
def list_services(self) -> dict[str, str]:
with self._lock:
return dict(self._providers)
def clear_for_plugin(self, plugin_name: str):
with self._lock:
to_remove = [n for n, p in self._providers.items() if p == plugin_name]
for n in to_remove:
del self._services[n]
del self._providers[n]
class Bridge:
name = "plugin-bridge"
version = "1.0.0"
description = "Inter-plugin communication: event bus, service registry, RPC"
def __init__(self):
self.event_bus = EventBus()
self.service_registry = ServiceRegistry()
def init(self, deps=None):
pass
def start(self):
pass
def stop(self):
self.event_bus.clear()
def use(self, name: str) -> Optional[Any]:
return self.service_registry.get(name)
def provide(self, name: str, instance: Any):
caller = self._caller_plugin()
self.service_registry.register(name, instance, caller)
def on(self, event: str, handler: Callable):
caller = self._caller_plugin()
self.event_bus.on(event, caller, handler)
def emit(self, event: str, *args, **kwargs) -> list[Any]:
return self.event_bus.emit(event, *args, **kwargs)
def emit_async(self, event: str, *args, **kwargs):
self.event_bus.emit_async(event, *args, **kwargs)
def off(self, event: str, plugin_name: str):
self.event_bus.off(event, plugin_name)
def has_listeners(self, event: str) -> bool:
return self.event_bus.has_listeners(event)
def listener_count(self, event: str) -> int:
return self.event_bus.listener_count(event)
def list_services(self) -> dict[str, str]:
return self.service_registry.list_services()
def has_service(self, name: str) -> bool:
return self.service_registry.has(name)
def get_info(self):
return {
"services": self.list_services(),
"event_listeners": {
ev: self.event_bus.listener_count(ev)
for ev in ["plugin.loaded", "plugin.started", "plugin.stopped", "plugin.crashed", "config.changed"]
},
}
@staticmethod
def _caller_plugin() -> str:
stack = inspect.stack()
for frame in stack[3:]:
filename = frame.filename
if "/store/NebulaShell/" in filename or "/store/" in filename:
parts = filename.split("/")
for i, p in enumerate(parts):
if p == "NebulaShell" and i + 1 < len(parts):
return parts[i + 1]
return "unknown"
def New():
return Bridge()

View File

@@ -0,0 +1,14 @@
{
"metadata": {
"name": "plugin-bridge",
"version": "1.0.0",
"description": "Inter-plugin communication infrastructure: event bus, service registry, RPC",
"author": "NebulaShell Team"
},
"config": {
"enabled": true,
"args": {}
},
"dependencies": [],
"permissions": ["*"]
}

View File

@@ -0,0 +1,139 @@
import json
import os
import threading
from pathlib import Path
from typing import Any, Optional
class PluginStorage:
name = "plugin-storage"
version = "1.0.0"
description = "Persistent storage for plugins: key-value and file storage"
def __init__(self):
self._base_dir = Path(os.getcwd()) / "data" / "plugin-storage"
self._base_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._mem_cache: dict[str, dict[str, Any]] = {}
def init(self, deps=None):
pass
def start(self):
pass
def stop(self):
self._mem_cache.clear()
def _plugin_dir(self, plugin_name: str) -> Path:
pd = self._base_dir / plugin_name
pd.mkdir(parents=True, exist_ok=True)
return pd
def _ensure_namespace(self, plugin_name: str):
if plugin_name not in self._mem_cache:
self._mem_cache[plugin_name] = {}
def set(self, plugin_name: str, key: str, value: Any) -> bool:
with self._lock:
try:
self._ensure_namespace(plugin_name)
self._mem_cache[plugin_name][key] = value
file_path = self._plugin_dir(plugin_name) / f"{key}.json"
file_path.write_text(
json.dumps(value, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return True
except Exception as e:
return False
def get(self, plugin_name: str, key: str, default: Any = None) -> Any:
with self._lock:
self._ensure_namespace(plugin_name)
if key in self._mem_cache[plugin_name]:
return self._mem_cache[plugin_name][key]
file_path = self._plugin_dir(plugin_name) / f"{key}.json"
if file_path.exists():
try:
data = json.loads(file_path.read_text(encoding="utf-8"))
self._mem_cache[plugin_name][key] = data
return data
except (json.JSONDecodeError, OSError):
pass
return default
def delete(self, plugin_name: str, key: str) -> bool:
with self._lock:
self._ensure_namespace(plugin_name)
self._mem_cache[plugin_name].pop(key, None)
file_path = self._plugin_dir(plugin_name) / f"{key}.json"
if file_path.exists():
try:
file_path.unlink()
return True
except OSError:
return False
return True
def list_keys(self, plugin_name: str) -> list[str]:
pd = self._plugin_dir(plugin_name)
if not pd.exists():
return []
return sorted(f.stem for f in pd.glob("*.json"))
def clear(self, plugin_name: str) -> bool:
with self._lock:
self._mem_cache.pop(plugin_name, None)
pd = self._plugin_dir(plugin_name)
if pd.exists():
for f in pd.glob("*.json"):
try:
f.unlink()
except OSError:
pass
return True
def set_raw(self, plugin_name: str, file_name: str, data: bytes) -> bool:
with self._lock:
try:
file_path = self._plugin_dir(plugin_name) / file_name
file_path.write_bytes(data)
return True
except OSError:
return False
def get_raw(self, plugin_name: str, file_name: str) -> Optional[bytes]:
file_path = self._plugin_dir(plugin_name) / file_name
if file_path.exists():
try:
return file_path.read_bytes()
except OSError:
pass
return None
def delete_raw(self, plugin_name: str, file_name: str) -> bool:
file_path = self._plugin_dir(plugin_name) / file_name
if file_path.exists():
try:
file_path.unlink()
return True
except OSError:
return False
return True
def get_storage_size(self, plugin_name: str) -> int:
pd = self._plugin_dir(plugin_name)
if not pd.exists():
return 0
return sum(f.stat().st_size for f in pd.glob("**/*") if f.is_file())
def get_info(self):
return {
"base_dir": str(self._base_dir),
"plugins": len(list(self._base_dir.iterdir())) if self._base_dir.exists() else 0,
}
def New():
return PluginStorage()

View File

@@ -0,0 +1,14 @@
{
"metadata": {
"name": "plugin-storage",
"version": "1.0.0",
"description": "Persistent key-value and file storage for plugins",
"author": "NebulaShell Team"
},
"config": {
"enabled": true,
"args": {}
},
"dependencies": [],
"permissions": ["storage:read", "storage:write"]
}

View File

@@ -0,0 +1,155 @@
import asyncio
import json
import threading
import inspect
from typing import Any, Callable, Optional
from oss.logger.logger import Log
try:
import websockets
from websockets.asyncio.server import serve as ws_serve
HAS_WEBSOCKETS = True
except ImportError:
HAS_WEBSOCKETS = False
class WsApi:
name = "ws-api"
version = "1.0.0"
description = "WebSocket real-time communication service"
def __init__(self):
self._host = "127.0.0.1"
self._port = 8081
self._handlers: dict[str, Callable] = {}
self._connections: dict[str, set] = {}
self._server = None
self._thread = None
self._loop = None
self._running = False
self._plugin_context = None
def init(self, deps=None):
if deps:
self._plugin_context = deps.get("context")
def start(self):
if not HAS_WEBSOCKETS:
Log.warn("WsApi", "websockets 未安装WebSocket 服务不可用")
return
self._running = True
self._thread = threading.Thread(target=self._run_server, daemon=True)
self._thread.start()
Log.ok("WsApi", f"WebSocket 服务启动: ws://{self._host}:{self._port}")
def stop(self):
self._running = False
if self._loop and self._server:
try:
self._loop.call_soon_threadsafe(self._server.close)
except Exception:
pass
Log.info("WsApi", "WebSocket 服务已停止")
def _run_server(self):
asyncio.run(self._serve())
async def _serve(self):
self._loop = asyncio.get_running_loop()
try:
self._server = await ws_serve(self._handle_ws, self._host, self._port)
await self._server.serve_forever()
except Exception as e:
Log.error("WsApi", f"WebSocket 服务异常: {e}")
async def _handle_ws(self, websocket):
remote = websocket.remote_address
addr = f"{remote[0]}:{remote[1]}" if remote else "unknown"
Log.info("WsApi", f"WebSocket 连接: {addr}")
try:
async for message in websocket:
await self._dispatch(websocket, message, addr)
except websockets.exceptions.ConnectionClosed:
pass
finally:
Log.info("WsApi", f"WebSocket 断开: {addr}")
for topic in list(self._connections.keys()):
self._connections[topic].discard(addr)
if not self._connections[topic]:
del self._connections[topic]
async def _dispatch(self, websocket, message: str, addr: str):
try:
data = json.loads(message)
except json.JSONDecodeError:
await self._send(websocket, {"type": "error", "message": "无效的 JSON"})
return
msg_type = data.get("type", "")
if msg_type == "ping":
await self._send(websocket, {"type": "pong"})
return
if msg_type == "subscribe":
topic = data.get("topic", "")
if topic:
if topic not in self._connections:
self._connections[topic] = set()
self._connections[topic].add(addr)
await self._send(websocket, {"type": "subscribed", "topic": topic})
return
if msg_type == "unsubscribe":
topic = data.get("topic", "")
if topic and topic in self._connections:
self._connections[topic].discard(addr)
if not self._connections[topic]:
del self._connections[topic]
await self._send(websocket, {"type": "unsubscribed", "topic": topic})
return
handler = self._handlers.get(msg_type)
if handler:
try:
result = handler(data, {"addr": addr, "ws": websocket})
if result is not None:
await self._send(websocket, {"type": msg_type + "_response", "data": result})
except Exception as e:
await self._send(websocket, {"type": "error", "message": str(e)})
else:
await self._send(websocket, {"type": "error", "message": f"未知消息类型: {msg_type}"})
async def _send(self, websocket, data: dict):
try:
await websocket.send(json.dumps(data, ensure_ascii=False))
except Exception:
pass
def register_handler(self, msg_type: str, handler: Callable):
self._handlers[msg_type] = handler
def broadcast(self, topic: str, data: dict):
if not self._running or not self._loop:
return
subscribers = list(self._connections.get(topic, set()))
if not subscribers:
return
message = json.dumps({"type": topic, "data": data}, ensure_ascii=False)
for addr in subscribers:
pass
def get_info(self):
return {
"host": self._host,
"port": self._port,
"running": self._running,
"handlers": list(self._handlers.keys()),
"topics": {t: len(c) for t, c in self._connections.items()},
"websockets_available": HAS_WEBSOCKETS,
}
def New():
return WsApi()

View File

@@ -0,0 +1,17 @@
{
"metadata": {
"name": "ws-api",
"version": "1.0.0",
"description": "WebSocket real-time communication service with pub/sub and custom handlers",
"author": "NebulaShell Team"
},
"config": {
"enabled": true,
"args": {
"host": "127.0.0.1",
"port": 8081
}
},
"dependencies": [],
"permissions": ["*"]
}