17 Commits

Author SHA1 Message Date
Lerking 984458d8f3 Hardened XtendR. /JL 2026-07-03 08:39:45 +02:00
Lerking be1af3ad05 Merge pull request 'Updated project description. /JL' (#35) from 0.3.3 into main
Reviewed-on: https://gitea.com/Lerking/XtendR/pulls/35
2025-03-29 13:59:42 +00:00
Lerking 0c8108d4d8 Updated project description. /JL 2025-03-29 14:58:49 +01:00
Lerking bcc9b864d1 Merge pull request 'README updated. /JL' (#34) from update_readme into main
Reviewed-on: https://gitea.com/Lerking/XtendR/pulls/34
2025-03-28 17:29:35 +00:00
Lerking a4c552d6f5 README updated. /JL 2025-03-28 18:28:58 +01:00
Lerking 34c7927601 Merge pull request '0.3.0' (#33) from 0.3.0 into main
Reviewed-on: https://gitea.com/Lerking/XtendR/pulls/33
2025-03-28 17:23:38 +00:00
Lerking ebf31756b5 #31 Done. /JL 2025-03-28 18:22:16 +01:00
Lerking 406c1b77ed #31 - Creating thread with callback, on pre_load(). /JL 2025-03-28 17:26:58 +01:00
Lerking 1be429c5cd Update xtendr/xtendrsystem.py 2025-03-27 20:33:19 +00:00
Lerking a093b3f56c Update plugins/example_plugin/example_plugin.py 2025-03-27 20:31:12 +00:00
Lerking 1dc0c81d84 Update plugins/example_plugin/example_plugin.py 2025-03-27 20:30:23 +00:00
Lerking 3e38cabf81 Update xtendr/xtendrsystem.py 2025-03-27 20:25:12 +00:00
Lerking 63f649af25 #31 Added **self.use_pre_load:bool = False** and pre_load() method. 2025-03-27 19:30:41 +00:00
Lerking b6efe1b1df Update xtendr/xtendrbase.py
#31 Added pre_load() method
2025-03-27 19:23:59 +00:00
Lerking c5ee073d65 Update setup.py 2025-03-27 19:20:30 +00:00
Lerking 3bcf01dc26 Merge pull request '0.2.1 Updated to include keyword arguments in run() method. /JL' (#30) from 0.2.1 into main
Reviewed-on: https://gitea.com/Lerking/XtendR/pulls/30
2025-03-27 09:47:53 +00:00
Jan Lerking dd5904c273 0.2.1 Updated to include keyword arguments in run() method. /JL 2025-03-27 10:46:05 +01:00
7 changed files with 211 additions and 74 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
# XtendR # XtendR
A very basic Python 3.12 plugin system based on the K.I.S.S principle. A very basic Python 3.12 friendly plugin system based on the K.I.S.S principle.
I was in need of a new plugin system, which should meet these requirements: I was in need of a new plugin system, which should meet these requirements:
:heavy_plus_sign: Simple to use :heavy_plus_sign: Simple to use
@@ -16,7 +16,8 @@ I didn't find anything that suited my needs, so I decided to make my own plugin
It simply contains 2 classes, one for the plugin system and one abstraction base class for the plugins themselves. It simply contains 2 classes, one for the plugin system and one abstraction base class for the plugins themselves.
At the moment only 4 functions are available: At the moment only 4 functions are available:
- Attach
- Attach - including call to pre-load data in plugin.
- Run - Run
- Stop - Stop
- Detach - Detach
@@ -27,4 +28,4 @@ The Run and Stop functions are mandatory in the plugin modules.
The system expects a folder called 'plugins', placed at the root, along side your main python file. The system expects a folder called 'plugins', placed at the root, along side your main python file.
Each plugin should be placed in subfolders, named as the plugin, inside the 'plugins' folder. Each plugin should be placed in subfolders, named as the plugin, inside the 'plugins' folder.
The example.py along with the plugins/example_plugin/example_plugin.py and plugins/example_plugin/example_plugin.json shows the workings of this plugin system. The example.py along with the plugins/example_plugin/example_plugin.py and plugins/example_plugin/example_plugin.json shows the workings of this plugin system.
+9 -2
View File
@@ -1,5 +1,9 @@
import time
from xtendr.xtendrsystem import XtendRSystem from xtendr.xtendrsystem import XtendRSystem
def my_callback():
print("'example_plugin is finished pre-loading")
if __name__ == "__main__": if __name__ == "__main__":
"""Example usage of the PluginSystem. """Example usage of the PluginSystem.
@@ -15,8 +19,11 @@ if __name__ == "__main__":
Detached plugin 'example_plugin'. Detached plugin 'example_plugin'.
""" """
system = XtendRSystem() system = XtendRSystem()
system.attach("example_plugin") # Assuming 'example_plugin/plugin_info.json' exists system.attach("example_plugin", my_callback) # Assuming 'example_plugin/plugin_info.json' exists
system.run("example_plugin", "Hello!") for i in range(3):
print(f"Main program is running iteration {i+1}...")
time.sleep(2)
system.run("example_plugin", test="Hello!")
system.stop("example_plugin") system.stop("example_plugin")
system.run("example_plugin", 25) system.run("example_plugin", 25)
system.stop("example_plugin") system.stop("example_plugin")
+19 -4
View File
@@ -1,3 +1,5 @@
import threading
import time
from xtendr.xtendrbase import XtendRBase from xtendr.xtendrbase import XtendRBase
class ExamplePlugin(XtendRBase): class ExamplePlugin(XtendRBase):
@@ -13,12 +15,25 @@ class ExamplePlugin(XtendRBase):
>>> plugin.stop() >>> plugin.stop()
ExamplePlugin has stopped! ExamplePlugin has stopped!
""" """
def run(self, *args): def run(self, *args, **kwargs):
print(f"Passed arguments {len(args)}:") arglen = len(args)
for idx, a in enumerate(args): keylen = len(kwargs)
print(f"Argument {idx}: {a}") if arglen > 0:
print(f"Passed arguments {arglen}:")
for idx, a in enumerate(args):
print(f"Argument {idx}: {a}")
if keylen > 0:
print(f"Keyword arguments passed {keylen}")
if not "test" in kwargs:
raise ValueError("Didn't get expected 'test' keyword!")
for kw in kwargs:
print(f"Argument {kw}: {kwargs[kw]}")
print("ExamplePlugin is running!") print("ExamplePlugin is running!")
def stop(self): def stop(self):
print("ExamplePlugin has stopped!") print("ExamplePlugin has stopped!")
def pre_load(self, callback):
time.sleep(5) # Indicate long running pre-load.
callback()
+1
View File
@@ -0,0 +1 @@
setuptools==68.2.2
+7 -2
View File
@@ -1,9 +1,12 @@
if __name__ == "__main__": if __name__ == "__main__":
from setuptools import setup, find_packages from setuptools import setup, find_packages
from pathlib import Path
this_directory = Path(__file__).parent
long_description = (this_directory / "README.md").read_text()
setup( setup(
name="XtendR", name="XtendR",
version="0.2.0", version="0.4.0",
packages=find_packages(), packages=find_packages(),
install_requires=[], install_requires=[],
author="Jan Lerking", author="Jan Lerking",
@@ -16,4 +19,6 @@ if __name__ == "__main__":
"Operating System :: OS Independent", "Operating System :: OS Independent",
], ],
python_requires='>=3.11', python_requires='>=3.11',
long_description=long_description,
long_description_content_type='text/markdown'
) )
+6 -1
View File
@@ -17,9 +17,14 @@ class XtendRBase(ABC):
Stopping TestPlugin Stopping TestPlugin
""" """
@abstractmethod @abstractmethod
def run(self, *args): def run(self, *args, **kwargs):
pass pass
@abstractmethod @abstractmethod
def stop(self): def stop(self):
pass pass
@abstractmethod
def pre_load(self, *args):
pass
+165 -62
View File
@@ -1,19 +1,37 @@
import importlib import importlib.util
import sys import sys
import os import os
import re
import json import json
import stat
import logging
import threading
from xtendr.xtendrbase import XtendRBase from xtendr.xtendrbase import XtendRBase
__version__ = "0.1.3" __version__ = "0.4.0"
logger = logging.getLogger("xtendr")
# Plugin (folder) names and module names are restricted to a safe identifier
# pattern. This blocks path traversal (e.g. "../../etc") and stray characters
# that have no business in a plugin name.
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_MODULE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class XtendRSystem: class XtendRSystem:
"""Plugin system to manage plugins. """Plugin system to manage plugins.
SECURITY NOTE: plugins are arbitrary Python code that runs with the full
privileges of the host process. Only attach plugins from sources you
trust. This class validates names/paths and isolates module loading, but
it cannot make untrusted plugin code safe to run.
Example: Example:
>>> system = XtendRSystem() >>> system = XtendRSystem()
>>> system.version() >>> system.version()
XtendR v0.1.3 XtendR v0.4.0
>>> system.attach("example_plugin") # Assuming 'example_plugin/example_plugin.json' exists >>> system.attach("example_plugin", lambda: None)
>>> system.run("example_plugin") >>> system.run("example_plugin")
ExamplePlugin is running! ExamplePlugin is running!
>>> system.stop("example_plugin") >>> system.stop("example_plugin")
@@ -21,75 +39,160 @@ class XtendRSystem:
>>> system.detach("example_plugin") >>> system.detach("example_plugin")
Detached plugin 'example_plugin'. Detached plugin 'example_plugin'.
""" """
def __init__(self, pluginpath = "plugins"):
def __init__(self, pluginpath="plugins"):
self.pluginspath = pluginpath self.pluginspath = pluginpath
self.plugins = {} self.plugins = {}
self._lock = threading.RLock()
def version(self) -> str: def version(self) -> str:
return "XtendR v" + __version__ return "XtendR v" + __version__
def attach(self, name: str) -> None: def _validate_name(self, name: str) -> bool:
"""Dynamically load a plugin from its folder.""" if not isinstance(name, str) or not _NAME_RE.match(name):
if name in self.plugins: logger.error("Rejected plugin name %r: must match %s", name, _NAME_RE.pattern)
print(f"Plugin '{name}' is already attached.") return False
return return True
plugin_path = os.path.join(os.getcwd(), self.pluginspath, name) def _check_permissions(self, path: str) -> None:
info_path = os.path.join(plugin_path, name + ".json") """Warn (don't block) if a plugin file is group/world-writable."""
print(plugin_path + "\n" + info_path)
if not os.path.isdir(plugin_path) or not os.path.isfile(info_path):
print(f"Failed to attach plugin '{name}', folder or info file not found.")
return
try: try:
with open(info_path, "r", encoding="utf-8") as f: st = os.stat(path)
plugin_info = json.load(f) if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
module_name = plugin_info.get("module") logger.warning(
class_name = plugin_info.get("class") "Plugin file '%s' is group/world-writable; this is a "
if not module_name or not class_name: "security risk on shared systems.",
print(f"Plugin '{name}' info file is missing 'module' or 'class' key.") path,
return )
except OSError:
sys.path.insert(0, plugin_path) pass
module = importlib.import_module(module_name)
def attach(self, name: str, callback=None) -> None:
"""Dynamically load a plugin from its folder."""
with self._lock:
if name in self.plugins:
logger.info("Plugin '%s' is already attached.", name)
return
if not self._validate_name(name):
return
plugin_path = os.path.join(os.getcwd(), self.pluginspath, name)
info_path = os.path.join(plugin_path, name + ".json")
# Defense in depth: even with a validated name, make sure the
# resolved path is actually inside the plugins directory.
plugins_root = os.path.realpath(os.path.join(os.getcwd(), self.pluginspath))
if os.path.commonpath([plugins_root, os.path.realpath(plugin_path)]) != plugins_root:
logger.error("Refusing to attach '%s': resolves outside plugins directory.", name)
return
if not os.path.isdir(plugin_path) or not os.path.isfile(info_path):
logger.error("Failed to attach plugin '%s': folder or info file not found.", name)
return
self._check_permissions(info_path)
try:
with open(info_path, "r", encoding="utf-8") as f:
plugin_info = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error("Failed to read info file for plugin '%s': %s", name, e)
return
module_name = plugin_info.get("module")
class_name = plugin_info.get("class")
if not module_name or not class_name:
logger.error("Plugin '%s' info file is missing 'module' or 'class' key.", name)
return
if not _MODULE_RE.match(module_name) or not _MODULE_RE.match(class_name):
logger.error("Plugin '%s' has an invalid module/class identifier.", name)
return
module_file = os.path.join(plugin_path, module_name + ".py")
if not os.path.isfile(module_file):
logger.error("Plugin '%s' module file '%s' not found.", name, module_file)
return
self._check_permissions(module_file)
# Load the module directly from its file path instead of
# mutating sys.path. This prevents a plugin from shadowing
# stdlib or third-party modules for the rest of the process.
qualified_name = f"xtendr_plugin_{name}_{module_name}"
try:
spec = importlib.util.spec_from_file_location(qualified_name, module_file)
if spec is None or spec.loader is None:
raise ImportError(f"Could not create import spec for '{module_file}'")
module = importlib.util.module_from_spec(spec)
sys.modules[qualified_name] = module
spec.loader.exec_module(module)
plugin_class = getattr(module, class_name) plugin_class = getattr(module, class_name)
instance = plugin_class() instance = plugin_class()
if not isinstance(instance, XtendRBase): if not isinstance(instance, XtendRBase):
print(f"Plugin '{name}' does not inherit from PluginBase.") logger.error("Plugin '%s' does not inherit from XtendRBase.", name)
sys.modules.pop(qualified_name, None)
return return
self.plugins[name] = { except Exception as e: # noqa: BLE001 - plugin code is untrusted, isolate all failures
'instance': instance, logger.error("Failed to attach plugin '%s': %s", name, e, exc_info=True)
'running': False, sys.modules.pop(qualified_name, None)
'info': plugin_info, return
'autorun': False
} self.plugins[name] = {
print(f"Attached plugin '{name}'.") "instance": instance,
except (ModuleNotFoundError, json.JSONDecodeError, AttributeError) as e: "running": False,
print(f"Failed to attach plugin '{name}': {e}") "info": plugin_info,
"autorun": False,
"module_key": qualified_name,
}
logger.info("Attached plugin '%s'.", name)
logger.info("Running pre-load on '%s'.", name)
def _pre_load_worker():
try:
instance.pre_load(callback)
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during pre_load.", name, exc_info=True)
thread = threading.Thread(target=_pre_load_worker, daemon=True)
thread.start()
def run(self, name: str, *args, **kwargs): def run(self, name: str, *args, **kwargs):
"""Run the plugin's 'run' method if available.""" """Run the plugin's 'run' method if available."""
if name in self.plugins: with self._lock:
self.plugins[name]['running'] = True entry = self.plugins.get(name)
return self.plugins[name]['instance'].run(*args, **kwargs) if entry is None:
print(f"Plugin '{name}' not found or has no 'run' method.") logger.error("Plugin '%s' not found or has no 'run' method.", name)
return
entry["running"] = True
try:
return entry["instance"].run(*args, **kwargs)
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during run.", name, exc_info=True)
entry["running"] = False
def stop(self, name: str) -> None: def stop(self, name: str) -> None:
"""Stop the plugin if it's running.""" """Stop the plugin if it's running."""
if name in self.plugins and self.plugins[name]['running']: with self._lock:
self.plugins[name]['running'] = False entry = self.plugins.get(name)
self.plugins[name]['instance'].stop() if entry is None or not entry["running"]:
else: logger.info("Plugin '%s' is not running.", name)
print(f"Plugin '{name}' is not running.") return
entry["running"] = False
try:
entry["instance"].stop()
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during stop.", name, exc_info=True)
def detach(self, name: str) -> None: def detach(self, name: str) -> None:
"""Unload a plugin.""" """Unload a plugin."""
if name in self.plugins: with self._lock:
del self.plugins[name] entry = self.plugins.pop(name, None)
sys.modules.pop(name, None) if entry is None:
print(f"Detached plugin '{name}'.") logger.info("Plugin '%s' is not attached.", name)
else: return
print(f"Plugin '{name}' is not attached.") sys.modules.pop(entry["module_key"], None)
logger.info("Detached plugin '%s'.", name)