Files
connpy/connpy/services/plugin_service.py
T
2026-05-28 15:23:39 -03:00

360 lines
14 KiB
Python

from .base import BaseService
import yaml
import os
from .exceptions import InvalidConfigurationError, NodeNotFoundError
class PluginService(BaseService):
"""Business logic for enabling, disabling, and listing plugins."""
def _get_plugin_path(self, name, include_disabled=True):
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "user", True
if include_disabled:
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "user", False
# 2. Shared/Global directory
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "shared", True
if include_disabled:
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "shared", False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
p_file = os.path.join(core_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "core", True
return None, None, False
def list_plugins(self):
"""List all core and user-defined plugins with their status and hash."""
import os
import hashlib
all_plugin_info = {}
def get_hash(path):
try:
with open(path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
except Exception:
return ""
# 1. Scan core plugins (lowest priority)
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(core_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(user_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False}
return all_plugin_info
def add_plugin(self, name, source_file, update=False):
"""Add or update a plugin from a local file."""
import os
import shutil
from connpy.plugins import Plugins
if not name.isalpha() or not name.islower() or len(name) > 15:
raise InvalidConfigurationError("Plugin name should be lowercase letters up to 15 characters.")
p_manager = Plugins()
# Check for bad script
error = p_manager.verify_script(source_file)
if error:
raise InvalidConfigurationError(f"Invalid plugin script: {error}")
self._save_plugin_file(name, source_file, update, is_path=True)
def add_plugin_from_bytes(self, name, content, update=False):
"""Add or update a plugin from bytes (gRPC)."""
import tempfile
import os
if not name.isalpha() or not name.islower() or len(name) > 15:
raise InvalidConfigurationError("Plugin name should be lowercase letters up to 15 characters.")
# Write to temp file to verify script
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
from connpy.plugins import Plugins
p_manager = Plugins()
error = p_manager.verify_script(tmp_path)
if error:
raise InvalidConfigurationError(f"Invalid plugin script: {error}")
self._save_plugin_file(name, tmp_path, update, is_path=True)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def _save_plugin_file(self, name, source, update=False, is_path=True):
import os
import shutil
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
os.makedirs(plugin_dir, exist_ok=True)
target_file = os.path.join(plugin_dir, f"{name}.py")
backup_file = f"{target_file}.bkp"
if not update and (os.path.exists(target_file) or os.path.exists(backup_file)):
raise InvalidConfigurationError(f"Plugin '{name}' already exists.")
try:
if is_path:
shutil.copy2(source, target_file)
else:
with open(target_file, "wb") as f:
f.write(source)
except OSError as e:
raise InvalidConfigurationError(f"Failed to save plugin file: {e}")
def delete_plugin(self, name):
"""Remove a plugin file permanently."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
deleted = False
for f in [plugin_file, disabled_file]:
if os.path.exists(f):
try:
os.remove(f)
deleted = True
except OSError as e:
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
if not deleted:
# If not deleted from user directory, check if it's in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in ["shared", "core"]:
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def enable_plugin(self, name):
"""Activate a plugin by renaming its backup file."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
else:
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
if os.path.exists(plugin_file):
return False # Already enabled
# If it doesn't exist locally, check if it's already an active shared/core plugin
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
return False # Already active/enabled through inheritance
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def disable_plugin(self, name):
"""Deactivate a plugin by renaming it to a backup file."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
if os.path.exists(disabled_file):
return False # Already disabled
# Check if it exists in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
# Shadow disable it by creating an empty .py.bkp in user plugins dir
plugin_dir = os.path.dirname(plugin_file)
os.makedirs(plugin_dir, exist_ok=True)
try:
with open(disabled_file, "w") as f:
f.write("")
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
def get_plugin_source(self, name):
import os
from ..services.exceptions import InvalidConfigurationError
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
raise InvalidConfigurationError(f"Plugin '{name}' not found")
with open(path, "r") as f:
return f.read()
def invoke_plugin(self, name, args_dict):
import sys, io
from argparse import Namespace
from ..services.exceptions import InvalidConfigurationError
from connpy.plugins import Plugins
class MockApp:
is_mock = True
def __init__(self, config):
from ..core import node, nodes
from ..ai import ai
from ..services.provider import ServiceProvider
self.config = config
self.node = node
self.nodes = nodes
self.ai = ai
self.services = ServiceProvider(config, mode="local")
# Get settings for CLI behavior
settings = self.services.config_svc.get_settings()
self.case = settings.get("case", False)
self.fzf = settings.get("fzf", False)
try:
self.nodes_list = self.services.nodes.list_nodes()
self.folders = self.services.nodes.list_folders()
self.profiles = self.services.profiles.list_profiles()
except Exception:
self.nodes_list = []
self.folders = []
self.profiles = []
args = Namespace(**args_dict)
p_manager = Plugins()
import os
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
raise InvalidConfigurationError(f"Plugin '{name}' not found")
module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, "Parser") else None
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
args.func = getattr(module, args_dict["__func_name__"])
app = MockApp(self.config)
from .. import printer
from rich.console import Console
from rich.console import Console
import queue
import threading
q = queue.Queue()
class QueueIO(io.StringIO):
def write(self, s):
q.put(s)
return len(s)
def flush(self):
pass
buf = QueueIO()
old_console = printer._get_console()
old_err_console = printer._get_err_console()
def run_plugin():
printer.set_thread_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_err_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_stream(buf)
try:
if hasattr(module, "Entrypoint"):
module.Entrypoint(args, parser, app)
except BaseException as e:
if not isinstance(e, SystemExit):
import traceback
printer.err_console.print(traceback.format_exc())
finally:
printer.set_thread_console(old_console)
printer.set_thread_err_console(old_err_console)
printer.set_thread_stream(None)
q.put(None)
t = threading.Thread(target=run_plugin, daemon=True)
t.start()
while True:
item = q.get()
if item is None:
break
yield item