Files
PyPost/lua/plugin_manager.py

304 lines
12 KiB
Python

import os
import re
import time
from pathlib import Path
from lupa import LuaRuntime, LuaError
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from log.Logger import Logger
from .PluginFSHandler import PluginFSHandler
import json
from .luarails import guardrails_code
PLUGINS_DIR = Path(__file__).parent / "plugins"
HTML_DIR = Path(__file__).parent / "../html"
MARKDOWN_DIR = Path(__file__).parent / ".." / "markdown"
class PluginManager:
def __init__(self):
self.lua = LuaRuntime(unpack_returned_tuples=True)
self.plugins = {} # name -> dict{path, lua_module, hooks, routes}
self.routes = {
"GET": {}, # path -> list of (plugin_name, lua_fn, priority)
"POST": {} # path -> list of (plugin_name, lua_fn, priority)
}
self.hooks = {} # hook_name -> list of (plugin_name, lua_fn, priority)
# Create directories
PLUGINS_DIR.mkdir(exist_ok=True)
HTML_DIR.mkdir(exist_ok=True)
MARKDOWN_DIR.mkdir(exist_ok=True)
# Expose helpers to Lua
self._setup_lua_globals()
self._start_watcher()
def _setup_lua_globals(self):
"""Set up all Lua global functions and guardrails"""
from .Actions import Actions
g = self.lua.globals()
self.actions = Actions()
# Route and hook registration with priority support
g.add_route = self._expose_add_route
g.add_get_route = lambda path, fn, priority=50: self._expose_add_route_method(path, fn, "GET", priority)
g.add_post_route = lambda path, fn, priority=50: self._expose_add_route_method(path, fn, "POST", priority)
g.register_hook = self._expose_register_hook
# Logging
g.log = lambda msg: Logger.log_lua_info(f"PLUGIN => {msg}")
g.log_warn = lambda msg: Logger.log_lua_warning(f"PLUGIN => {msg}")
g.log_error = lambda msg: Logger.log_lua_error(f"PLUGIN => {msg}")
# File operations
g.read_file = self.actions._safe_read_file
g.write_file = self.actions._safe_write_file
g.file_exists = self.actions._file_exists
g.file_size = self.actions._file_size
# Convert Python list to Lua table
g.list_directory = lambda path: self.lua.table_from(self.actions._list_directory(path))
# HTML operations
g.read_html = lambda fn: self.actions._read_content(self.actions.html_dir, fn)
g.write_html = lambda fn, c: self.actions._write_content(self.actions.html_dir, fn, c)
# Convert Python list to Lua table so # operator works
g.list_html_files = lambda: self.lua.table_from(self.actions._list_files(self.actions.html_dir, ".html"))
# Markdown operations
g.read_markdown = lambda fn: self.actions._read_content(self.actions.markdown_dir, fn)
g.write_markdown = lambda fn, c: self.actions._write_content(self.actions.markdown_dir, fn, c)
# Convert Python list to Lua table so # operator works
g.list_markdown_files = lambda: self.lua.table_from(self.actions._list_files(self.actions.markdown_dir, ".md"))
# HTML manipulation helpers
g.html_find_tag = self.actions._html_find_tag
g.html_replace_tag = self.actions._html_replace_tag
g.html_insert_before = self.actions._html_insert_before
g.html_insert_after = self.actions._html_insert_after
g.html_wrap_content = self.actions._html_wrap_content
g.html_remove_tag = self.actions._html_remove_tag
g.html_add_class = self.actions._html_add_class
g.html_add_attribute = self.actions._html_add_attribute
# Markdown manipulation helpers
g.md_add_header = self.actions._md_add_header
g.md_replace_section = self.actions._md_replace_section
g.md_append_content = self.actions._md_append_content
g.md_prepend_content = self.actions._md_prepend_content
g.md_insert_at_position = self.actions._md_insert_at_position
g.md_find_section = self.actions._md_find_section
g.md_remove_section = self.actions._md_remove_section
g.md_add_list_item = self.actions._md_add_list_item
g.md_wrap_code_block = self.actions._md_wrap_code_block
# JSON helpers
g.table_to_json = self.actions._table_to_json
g.json_to_table = self.actions._json_to_table
g.json_parse = self.actions._json_parse
g.json_stringify = self.actions._json_stringify
# String utilities - also convert list results to Lua tables
g.string_split = lambda text, delim: self.lua.table_from(self.actions._string_split(text, delim))
g.string_join = self.actions._string_join
g.string_replace = self.actions._string_replace
g.string_match = self.actions._string_match
g.string_match_all = lambda text, pattern: self.lua.table_from(self.actions._string_match_all(text, pattern))
# Guardrails - predefined safe patterns
self._setup_lua_guardrails()
def _setup_lua_guardrails(self):
try:
self.lua.execute(guardrails_code)
except LuaError as e:
Logger.log_lua_error(f"Failed to initialize Lua guardrails: {e}")
""" Lifecycle of Plugin """
def load_all(self):
for p in PLUGINS_DIR.glob("*.lua"):
self.load_plugin(p)
def load_plugin(self, path: Path):
name = path.name
try:
code = path.read_text(encoding="utf-8")
self._current_plugin = name
lua_module = self.lua.execute(code)
if lua_module is None:
lua_module = {}
self.plugins[name] = {"path": path, "module": lua_module}
Logger.log_lua_info(f"Loaded plugin: {name}")
except LuaError as e:
Logger.log_lua_error(f"Lua error while loading {name}: {e}")
except Exception as e:
Logger.log_lua_error(f"Error loading plugin {name}: {e}")
finally:
self._current_plugin = None
def reload_plugin(self, path: Path):
name = path.name
self.unload_plugin(name)
time.sleep(0.05)
if path.exists():
self.load_plugin(path)
else:
Logger.log_lua_warning(f"Tried to reload {name}, but file no longer exists")
def unload_plugin(self, name: str):
# Remove routes from this plugin (both GET and POST)
for method in ["GET", "POST"]:
for route_path in list(self.routes[method].keys()):
self.routes[method][route_path] = [
x for x in self.routes[method][route_path] if x[0] != name
]
if not self.routes[method][route_path]:
del self.routes[method][route_path]
# Remove hooks from this plugin
for hook_name in list(self.hooks.keys()):
self.hooks[hook_name] = [x for x in self.hooks[hook_name] if x[0] != name]
if not self.hooks[hook_name]:
del self.hooks[hook_name]
if name in self.plugins:
del self.plugins[name]
Logger.log_lua_info(f"Unloaded plugin {name}")
else:
Logger.log_lua_warning(f"Tried to unload {name}, but it was not loaded")
""" Expose API routes """
def _expose_add_route(self, path, lua_fn, priority=50):
"""Legacy: Called from Lua as add_route(path, function(req) ... end) - defaults to GET"""
return self._expose_add_route_method(path, lua_fn, "GET", priority)
def _expose_add_route_method(self, path, lua_fn, method="GET", priority=50):
"""
Register a route with a specific HTTP method and priority.
Lower priority numbers run first (0 = highest priority).
"""
p = str(path)
m = str(method).upper()
plugin_name = self._current_loading_plugin_name() or "<unknown>"
if m not in self.routes:
self.routes[m] = {}
if p not in self.routes[m]:
self.routes[m][p] = []
# Add route with priority
self.routes[m][p].append((plugin_name, lua_fn, int(priority)))
# Sort by priority (lower number = higher priority)
self.routes[m][p].sort(key=lambda x: x[2])
Logger.log_lua_info(f"Plugin {plugin_name} registered {m} route {p} (priority: {priority})")
def _expose_register_hook(self, hook_name, lua_fn, priority=50):
"""
Register a hook with priority support.
Hooks are chained - each hook receives the output of the previous one.
Lower priority numbers run first (0 = highest priority).
"""
hook = str(hook_name)
plugin_name = self._current_loading_plugin_name() or "<unknown>"
if hook not in self.hooks:
self.hooks[hook] = []
self.hooks[hook].append((plugin_name, lua_fn, int(priority)))
# Sort by priority (lower number = higher priority)
self.hooks[hook].sort(key=lambda x: x[2])
Logger.log_lua_info(f"Plugin {plugin_name} registered hook {hook} (priority: {priority})")
def _current_loading_plugin_name(self):
return getattr(self, "_current_plugin", None)
""" Running hooks & handling routes """
def handle_request(self, path, request_info=None, method="GET"):
"""
Handle HTTP requests by calling registered plugin routes.
First matching route that returns a response wins.
"""
method = method.upper()
if method not in self.routes:
return None
if path not in self.routes[method]:
return None
# Try each registered handler in priority order
for plugin_name, lua_fn, priority in self.routes[method][path]:
try:
lua_req = request_info or {}
res = lua_fn(lua_req)
# If handler returns None, try next handler
if res is None:
continue
# Handle tuple response (status, headers, body)
if isinstance(res, tuple) and len(res) == 3:
return res
# Handle string response
return (200, {"Content-Type": "text/html"}, str(res))
except LuaError as e:
Logger.log_lua_error(f"Lua error in {method} route {path} from {plugin_name}: {e}")
# Continue to next handler instead of failing completely
continue
return None
def run_hook(self, hook_name: str, *args):
"""
Run all registered hook functions for hook_name in priority order.
Each hook receives the output of the previous hook (chain pattern).
This allows multiple plugins to modify content without overwriting each other.
Example:
Initial content -> Plugin A modifies -> Plugin B modifies -> Final content
"""
if hook_name not in self.hooks:
return None
# Start with the initial input (first arg for content hooks)
result = args[0] if args else None
for plugin_name, fn, priority in list(self.hooks[hook_name]):
try:
# Pass the current result as the first argument, plus any other args
hook_args = (result,) + args[1:] if len(args) > 1 else (result,)
output = fn(*hook_args)
# If hook returns something, use it as input for next hook
if output is not None:
result = output
Logger.log_lua_debug(f"Hook {hook_name} from {plugin_name} modified content")
except LuaError as e:
Logger.log_lua_error(f"Lua error in hook {hook_name} from {plugin_name}: {e}")
# Continue with current result even if this hook fails
continue
return result
""" File watcher """
def _start_watcher(self):
self.observer = Observer()
self.fs_handler = PluginFSHandler(self)
self.observer.schedule(self.fs_handler, str(PLUGINS_DIR), recursive=False)
self.observer.start()
Logger.log_lua_info("Started plugin folder watcher")
def stop(self):
self.observer.stop()
self.observer.join()