2 Commits
Author SHA1 Message Date
Lerking 818cd44b3d Update README.md 2026-07-27 20:57:00 +02:00
Lerking c6b91303ad Updated. /JL 2026-07-27 20:54:00 +02:00
5 changed files with 152 additions and 26 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# XtendR # XtendR
![Latest Version](https://gitpot.lerk.ing/badges/badge/static?label=Latest+version&message=0.5.1&color=blue) ![Latest Version](https://gitpot.lerk.ing/badges/badge/static?label=Latest+version&message=0.5.2&color=blue)
A very basic Python 3.12 friendly 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.
+3
View File
@@ -37,3 +37,6 @@ class ExamplePlugin(XtendRBase):
def pre_load(self, callback): def pre_load(self, callback):
time.sleep(5) # Indicate long running pre-load. time.sleep(5) # Indicate long running pre-load.
callback() callback()
def unload(self):
print("ExamplePlugin has unloaded!") # release signal handlers, timers, widgets, etc. here
+1 -1
View File
@@ -6,7 +6,7 @@ if __name__ == "__main__":
setup( setup(
name="XtendR", name="XtendR",
version="0.5.1", version="0.5.2",
packages=find_packages(), packages=find_packages(),
install_requires=[], install_requires=[],
author="Jan Lerking", author="Jan Lerking",
+31 -2
View File
@@ -2,19 +2,43 @@ from abc import ABC, abstractmethod
class XtendRBase(ABC): class XtendRBase(ABC):
"""Abstract base class for all plugins. """Abstract base class for all plugins.
Lifecycle: pre_load() -> run() -> stop() -> unload(). XtendRSystem calls
unload() exactly once, right before it drops its own references to the
plugin instance and removes its module from sys.modules. This is the
plugin's only chance to release anything it holds that XtendRSystem
doesn't know about and can't clean up on its own, for example:
- GObject/GTK signal handler ids from .connect() (disconnect them, or
the GObject side keeps a reference to the bound method, which keeps
the plugin instance -- and everything it references -- alive).
- Widgets the plugin inserted into the app's widget tree (remove them
from their parent; a parented widget is kept alive by GTK regardless
of what Python does with its own references).
- GLib.timeout_add / idle_add source ids (GLib.source_remove them).
- Any threads it started that aren't daemon threads, or open files/
sockets/subprocesses.
unload() has a default no-op implementation so existing plugins that
don't hold any such resources keep working unchanged; override it only
when there's something to release.
Example: Example:
>>> class TestPlugin(XtendRBase): >>> class TestPlugin(XtendRBase):
... def run(self): ... def run(self):
... print("Running TestPlugin") ... print("Running TestPlugin")
... def stop(self): ... def stop(self):
... print("Stopping TestPlugin") ... print("Stopping TestPlugin")
... def unload(self):
... print("Unloading TestPlugin")
>>> plugin = TestPlugin() >>> plugin = TestPlugin()
>>> plugin.run() >>> plugin.run()
Running TestPlugin Running TestPlugin
>>> plugin.stop() >>> plugin.stop()
Stopping TestPlugin Stopping TestPlugin
>>> plugin.unload()
Unloading TestPlugin
""" """
@abstractmethod @abstractmethod
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
@@ -27,4 +51,9 @@ class XtendRBase(ABC):
@abstractmethod @abstractmethod
def pre_load(self, *args): def pre_load(self, *args):
pass pass
def unload(self):
"""Release any resources the plugin holds. Called once by
XtendRSystem.detach(), after stop(). Optional to override;
default is a no-op."""
pass
+116 -22
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from xtendr.xtendrbase import XtendRBase from xtendr.xtendrbase import XtendRBase
from xtendr import signing as xsign from xtendr import signing as xsign
__version__ = "0.5.0" __version__ = "0.5.1"
logger = logging.getLogger("xtendr") logger = logging.getLogger("xtendr")
@@ -37,7 +37,7 @@ class XtendRSystem:
Example: Example:
>>> system = XtendRSystem() >>> system = XtendRSystem()
>>> system.version() >>> system.version()
XtendR v0.5.0 XtendR v0.5.1
>>> system.attach("example_plugin", lambda: None) >>> system.attach("example_plugin", lambda: None)
>>> system.run("example_plugin") >>> system.run("example_plugin")
ExamplePlugin is running! ExamplePlugin is running!
@@ -45,11 +45,23 @@ class XtendRSystem:
ExamplePlugin has stopped! ExamplePlugin has stopped!
>>> system.detach("example_plugin") >>> system.detach("example_plugin")
Detached plugin 'example_plugin'. Detached plugin 'example_plugin'.
A system can be marked `protected=True` for plugins that must stay
attached for the lifetime of the process (e.g. an application's
built-ins). Protected systems still attach/run/stop normally; only
detach() is restricted, and only for ordinary callers -- detach(...,
force=True) and detach_all(..., force=True) remain available for the
host application's own shutdown path. There is deliberately no way to
force a single detach() without also opting in via the same keyword a
UI action would have to expose, so "not detachable by the user" is a
property of the call site, not of hidden state.
""" """
def __init__(self, pluginpath="plugins", public_key_path=None, whitelist_path=None, whitelist_passphrase=None): def __init__(self, pluginpath="plugins", public_key_path=None, whitelist_path=None,
whitelist_passphrase=None, protected=False):
self.pluginspath = pluginpath self.pluginspath = pluginpath
self.plugins = {} self.plugins = {}
self.protected = protected
self._lock = threading.RLock() self._lock = threading.RLock()
# -- signature verification setup --------------------------------- # -- signature verification setup ---------------------------------
@@ -208,6 +220,7 @@ class XtendRSystem:
"module_key": None, "module_key": None,
"disabled": True, "disabled": True,
"signature": sig, "signature": sig,
"pre_load_thread": None,
} }
return return
@@ -244,6 +257,7 @@ class XtendRSystem:
"module_key": qualified_name, "module_key": qualified_name,
"disabled": False, "disabled": False,
"signature": sig, "signature": sig,
"pre_load_thread": None,
} }
logger.info("Attached plugin '%s'.", name) logger.info("Attached plugin '%s'.", name)
logger.info("Running pre-load on '%s'.", name) logger.info("Running pre-load on '%s'.", name)
@@ -255,45 +269,125 @@ class XtendRSystem:
logger.error("Plugin '%s' raised during pre_load.", name, exc_info=True) logger.error("Plugin '%s' raised during pre_load.", name, exc_info=True)
thread = threading.Thread(target=_pre_load_worker, daemon=True) thread = threading.Thread(target=_pre_load_worker, daemon=True)
self.plugins[name]["pre_load_thread"] = thread
thread.start() 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."""
with self._lock: with self._lock:
entry = self.plugins.get(name) entry = self.plugins.get(name)
if entry is None: if entry is None:
logger.error("Plugin '%s' not found or has no 'run' method.", name) logger.error("Plugin '%s' not found or has no 'run' method.", name)
return return
if entry.get("disabled"): if entry.get("disabled"):
logger.error("Plugin '%s' is disabled (failed signature verification) and cannot run.", name) logger.error("Plugin '%s' is disabled (failed signature verification) and cannot run.", name)
return return
entry["running"] = True entry["running"] = True
instance = entry["instance"]
try: try:
return entry["instance"].run(*args, **kwargs) return instance.run(*args, **kwargs)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during run.", name, exc_info=True) logger.error("Plugin '%s' raised during run.", name, exc_info=True)
entry["running"] = False with self._lock:
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."""
with self._lock: with self._lock:
entry = self.plugins.get(name) entry = self.plugins.get(name)
if entry is None or not entry["running"]: if entry is None or not entry["running"]:
logger.info("Plugin '%s' is not running.", name) logger.info("Plugin '%s' is not running.", name)
return return
entry["running"] = False entry["running"] = False
instance = entry["instance"]
try: try:
entry["instance"].stop() instance.stop()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during stop.", name, exc_info=True) logger.error("Plugin '%s' raised during stop.", name, exc_info=True)
def detach(self, name: str) -> None: def detach(self, name: str, *, timeout: float = 5.0, force: bool = False) -> None:
"""Unload a plugin.""" """Unload a plugin: stop it if running, let it release its own
resources via unload(), then drop every reference XtendRSystem
holds to it (dict entry, sys.modules entry) so nothing outside the
plugin's own cleanup keeps it alive.
Protected systems (see __init__) refuse this unless force=True is
passed explicitly -- the caller has to opt in on purpose, so a
generic "detach" UI action wired up against this system can't
accidentally (or maliciously) unload a built-in.
"""
if self.protected and not force:
logger.error(
"Refusing to detach '%s': this plugin system is protected "
"and cannot be detached from without force=True.", name,
)
return
with self._lock: with self._lock:
entry = self.plugins.pop(name, None) entry = self.plugins.pop(name, None)
if entry is None: if entry is None:
logger.info("Plugin '%s' is not attached.", name) logger.info("Plugin '%s' is not attached.", name)
return return
if entry.get("module_key"): was_running = entry["running"]
sys.modules.pop(entry["module_key"], None) entry["running"] = False
logger.info("Detached plugin '%s'.", name) instance = entry["instance"]
thread = entry.get("pre_load_thread")
# pre_load() may still be running in its own thread (e.g. detach
# called right after attach). Give it a bounded chance to finish
# before we call stop()/unload(), so a plugin doesn't get stopped
# out from under itself mid pre_load. If it doesn't finish in
# time we proceed anyway -- it's a daemon thread and holds its
# own reference to the instance, so this is a correctness/race
# concern rather than a leak.
if thread is not None and thread.is_alive():
thread.join(timeout)
if thread.is_alive():
logger.warning(
"Plugin '%s' pre_load() did not finish within %.1fs; "
"detaching anyway.", name, timeout,
)
if instance is not None:
if was_running:
try:
instance.stop()
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during stop.", name, exc_info=True)
try:
instance.unload()
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during unload.", name, exc_info=True)
if entry.get("module_key"):
sys.modules.pop(entry["module_key"], None)
# Drop our own strong references explicitly rather than letting
# them idle until this frame unwinds -- entry/instance are the
# last references XtendRSystem holds, so this makes the plugin
# object (and anything it exclusively owns) collectible the
# moment its unload() has actually let go of its own resources.
entry["instance"] = None
entry["pre_load_thread"] = None
logger.info("Detached plugin '%s'.", name)
def detach_all(self, *, force: bool = False) -> None:
"""Detach every currently-attached plugin. Convenience for clean
shutdown; equivalent to calling detach() on every plugin name.
For a protected system this is a no-op unless force=True (see
detach()) -- shutdown code that genuinely needs to tear a
protected system down (e.g. the host application closing) passes
force=True explicitly; nothing else can.
"""
if self.protected and not force:
logger.error(
"Refusing detach_all(): this plugin system is protected "
"and cannot be detached from without force=True."
)
return
with self._lock:
names = list(self.plugins.keys())
for name in names:
self.detach(name, force=force)