9.5.2 #4

Merged
Lerking merged 2 commits from 9.5.2 into main 2026-07-27 20:57:17 +02:00
5 changed files with 152 additions and 26 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# 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.
+3
View File
@@ -37,3 +37,6 @@ class ExamplePlugin(XtendRBase):
def pre_load(self, callback):
time.sleep(5) # Indicate long running pre-load.
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(
name="XtendR",
version="0.5.1",
version="0.5.2",
packages=find_packages(),
install_requires=[],
author="Jan Lerking",
+31 -2
View File
@@ -2,19 +2,43 @@ from abc import ABC, abstractmethod
class XtendRBase(ABC):
"""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:
>>> class TestPlugin(XtendRBase):
... def run(self):
... print("Running TestPlugin")
... def stop(self):
... print("Stopping TestPlugin")
... def unload(self):
... print("Unloading TestPlugin")
>>> plugin = TestPlugin()
>>> plugin.run()
Running TestPlugin
>>> plugin.stop()
Stopping TestPlugin
>>> plugin.unload()
Unloading TestPlugin
"""
@abstractmethod
def run(self, *args, **kwargs):
@@ -27,4 +51,9 @@ class XtendRBase(ABC):
@abstractmethod
def pre_load(self, *args):
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 import signing as xsign
__version__ = "0.5.0"
__version__ = "0.5.1"
logger = logging.getLogger("xtendr")
@@ -37,7 +37,7 @@ class XtendRSystem:
Example:
>>> system = XtendRSystem()
>>> system.version()
XtendR v0.5.0
XtendR v0.5.1
>>> system.attach("example_plugin", lambda: None)
>>> system.run("example_plugin")
ExamplePlugin is running!
@@ -45,11 +45,23 @@ class XtendRSystem:
ExamplePlugin has stopped!
>>> system.detach("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.plugins = {}
self.protected = protected
self._lock = threading.RLock()
# -- signature verification setup ---------------------------------
@@ -208,6 +220,7 @@ class XtendRSystem:
"module_key": None,
"disabled": True,
"signature": sig,
"pre_load_thread": None,
}
return
@@ -244,6 +257,7 @@ class XtendRSystem:
"module_key": qualified_name,
"disabled": False,
"signature": sig,
"pre_load_thread": None,
}
logger.info("Attached plugin '%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)
thread = threading.Thread(target=_pre_load_worker, daemon=True)
self.plugins[name]["pre_load_thread"] = thread
thread.start()
def run(self, name: str, *args, **kwargs):
"""Run the plugin's 'run' method if available."""
with self._lock:
entry = self.plugins.get(name)
if entry is None:
logger.error("Plugin '%s' not found or has no 'run' method.", name)
return
if entry.get("disabled"):
logger.error("Plugin '%s' is disabled (failed signature verification) and cannot run.", name)
return
entry["running"] = True
if entry is None:
logger.error("Plugin '%s' not found or has no 'run' method.", name)
return
if entry.get("disabled"):
logger.error("Plugin '%s' is disabled (failed signature verification) and cannot run.", name)
return
entry["running"] = True
instance = entry["instance"]
try:
return entry["instance"].run(*args, **kwargs)
return instance.run(*args, **kwargs)
except Exception: # noqa: BLE001
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:
"""Stop the plugin if it's running."""
with self._lock:
entry = self.plugins.get(name)
if entry is None or not entry["running"]:
logger.info("Plugin '%s' is not running.", name)
return
entry["running"] = False
if entry is None or not entry["running"]:
logger.info("Plugin '%s' is not running.", name)
return
entry["running"] = False
instance = entry["instance"]
try:
entry["instance"].stop()
instance.stop()
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during stop.", name, exc_info=True)
def detach(self, name: str) -> None:
"""Unload a plugin."""
def detach(self, name: str, *, timeout: float = 5.0, force: bool = False) -> None:
"""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:
entry = self.plugins.pop(name, None)
if entry is None:
logger.info("Plugin '%s' is not attached.", name)
return
if entry.get("module_key"):
sys.modules.pop(entry["module_key"], None)
logger.info("Detached plugin '%s'.", name)
was_running = entry["running"]
entry["running"] = False
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)