A Python package that implements a plugin hosting capability with comprehensive resource management, inspired by operating system concepts.
📚 View Full Documentation | Quick Start | Examples
pkl treats plugins like processes in an operating system. Each plugin has its own resources, and the system keeps track of everything. When a plugin is disabled, all of its resources are automatically cleaned up.
- Resource Management: Automatic tracking and cleanup of plugin resources
- Lifecycle Events: Plugins can register handlers for
on_disableandon_unloadevents - Context Preservation: Functions can maintain plugin context across calls using
@syscall - Event System: Plugin events and host events with automatic context detection
- Protected and public events with before/after hooks
- Host events for system-wide notifications
- Automatic subscription cleanup when plugins disable
- Async Support: Full async/await support with context variable propagation
- Child Plugins: Plugins can load and manage child plugins
- Custom Resources: Define your own resource types (routes, workers, etc.)
- Type Safety: Full typing support with strict type checking
- Hooks: System-wide hooks for monitoring plugin lifecycle and context switches
This project uses uv for fast and reliable Python package management.
# Install uv first (if not already installed)
pip install uv
# Install pkl
uv pip install pklFor development:
git clone https://github.com/xpodev/pkl.git
cd pkl
uv pip install -e .plugin.json (metadata):
{
"name": "my_plugin",
"version": "1.0.0",
"entrypoint": "plugin",
"requires": []
}plugin.py (entrypoint):
from pkl import event, log
@event()
def user_login(username: str):
log.info(f"User {username} logging in")
yield # Handlers run here
log.info(f"User {username} logged in")init.py (optional, for API):
from pkl import syscall, get_current_plugin
@syscall
def get_user_count() -> int:
"""This runs in the plugin's context, not the caller's."""
plugin = get_current_plugin()
print(f"Called from plugin: {plugin.name}")
return 42import pkl
from pathlib import Path
# Create a host
host = pkl.PluginHost()
pkl.set_default_host(host)
# Load and enable a plugin
plugin = pkl.load_plugin(path=Path("./my_plugin"))
plugin.enable()
# Use the plugin's API
from pkl.plugins import my_plugin
count = my_plugin.get_user_count()
# Trigger events
plugin.module.user_login("alice")
# Disable when done
plugin.disable()Resources are objects created by plugins that need cleanup. The system tracks:
- Event handlers
- Timers (set_timeout, set_interval)
- Loggers
- Custom resources you define
All resources are automatically cleaned up when their plugin is disabled.
from pkl import Resource, get_current_plugin
class DatabaseConnection(Resource):
def __init__(self, plugin, connection_string):
super().__init__(plugin)
self.conn = connect(connection_string)
def _cleanup(self):
self.conn.close()
# Register it
conn = DatabaseConnection(get_current_plugin(), "localhost:5432")
get_current_plugin().host.resource_manager.register(conn)The @syscall decorator makes functions run in their defining plugin's context:
from pkl import syscall, get_current_plugin
@syscall
def my_api():
# Always runs as the plugin that defined this function
print(get_current_plugin().name) # Always this plugin
def normal_function():
# Runs as whichever plugin called it
print(get_current_plugin().name) # Could be any pluginEvents provide a way for plugins to notify subscribers about actions. Event handlers are automatically cleaned up when the subscribing plugin is disabled.
The @event decorator automatically detects context:
- Inside plugin code: Creates a plugin event (owned by that plugin)
- Outside plugin context: Creates a host event (system-wide, no owner)
Important: Only the plugin that owns an event can invoke it. For host events with protected=True, only code running outside plugin context can invoke them.
from pkl import event, syscall
# Event with before/after behavior (generator function)
@event()
def user_login(username: str):
print(f"User {username} logging in")
yield # Handlers run here
print(f"User {username} logged in")
# Simple event (regular function) - just calls handlers
@event()
def config_changed():
pass # No before/after code
# Protected event - only this plugin can subscribe
@event(protected=True)
def internal_event():
print("Internal plugin event")
yield
# API to allow other plugins to trigger events
@syscall
def login_user(username: str):
"""Public API to trigger the login event."""
user_login(username) # Must use @syscall to run in this plugin's contextHost events are system-wide events defined outside plugin context. Any plugin can subscribe to them.
host_events.py:
import pkl
# Define host events before plugins load
# (outside plugin context, so these become host events)
@pkl.event()
def system_message(message: str):
"""System-wide message broadcast."""
print(f"[SYSTEM] {message}")
yield
@pkl.event(protected=True)
def admin_notification(data: str):
"""Protected - only host code can invoke."""
print(f"[ADMIN] {data}")
yieldUsing host events in plugins:
# In your plugin code
from host_events import system_message
@system_message.on
def on_message(msg: str):
print(f"Plugin received: {msg}")
# Host code can invoke
system_message("Server starting...") # OK
# Plugin code CANNOT invoke protected host events
# admin_notification("test") # RuntimeError!# Other plugins subscribe to events
from pkl.plugins import auth
@auth.user_login.on
def on_login(username: str):
print(f"Handling login for {username}")
# Or using += operator
# auth.user_login += on_login
# To trigger the event, call the API function
auth.login_user("alice")- Unloaded: Not yet loaded
- Loaded: Module loaded, not yet enabled
- Enabled: Running and active
- Disabled: Stopped and cleaned up
- Error: Failed to load/enable
plugin = pkl.load_plugin(path=plugin_path) # -> LOADED
plugin.enable() # -> ENABLED
plugin.disable() # -> DISABLEDPlugins can register handlers for their own lifecycle events:
from pkl import get_current_plugin
# Register cleanup handler using .on decorator
@get_current_plugin().on_disable.on
def cleanup():
print("Cleaning up plugin!")
# Or using += operator
# get_current_plugin().on_disable += cleanup
# on_unload is also available (called when plugin is unloaded)
@get_current_plugin().on_unload.on
def unload_handler():
print("Unloading!")Important: Lifecycle events can only be subscribed to by the plugin itself. This ensures that cleanup handlers are always associated with the correct plugin and run at the appropriate time.
Plugins can load other plugins as children:
from pkl import load_plugin, get_current_plugin
this = get_current_plugin()
child = load_plugin(path=this.path / "subplugins" / "child")
child.enable() # Disabled when parent is disabled
# Or detach it
child.enable(detach=True) # Independent of parentfrom pkl import set_timeout, set_interval
# One-time delay
def delayed():
print("Called after 5 seconds")
timer = set_timeout(delayed, 5.0)
timer.cancel() # Cancel if needed
# Repeating
def periodic():
print("Called every 10 seconds")
interval = set_interval(periodic, 10.0)
interval.cancel() # Stop itfrom pkl import log, get_logger
# Use the convenience proxy
log.info("Starting up") # Automatically includes plugin name
# Or create a named logger
logger = get_logger("database")
logger.error("Connection failed")from pkl import PluginLoader, Plugin
class CustomLoader:
def load(self, plugin: Plugin):
# Your custom loading logic
return loaded_module
host = PluginHost(plugin_loader=CustomLoader())from pkl import MetadataLoader, PluginMetadata
from pathlib import Path
class CustomMetadataLoader:
def load(self, location: Path) -> PluginMetadata:
# Your custom metadata loading
return PluginMetadata({"name": "...", "version": "..."})
host = PluginHost(metadata_loader=CustomMetadataLoader())host = pkl.PluginHost()
# Context switch hook
def on_context_switch(old_plugin, new_plugin):
print(f"Switching from {old_plugin} to {new_plugin}")
host.add_context_switch_hook(on_context_switch)
# Lifecycle hooks
host.add_enable_hook(lambda p: print(f"Enabled: {p.name}"))
host.add_disable_hook(lambda p: print(f"Disabled: {p.name}"))
# Resource hooks
host.resource_manager.add_register_hook(lambda r: print(f"Resource created: {r}"))
host.resource_manager.add_cleanup_hook(lambda r: print(f"Resource cleaned: {r}"))The system uses context variables for async compatibility:
from pkl import syscall, get_current_plugin
@syscall
async def async_api():
# Context preserved across async boundaries
await asyncio.sleep(1)
print(get_current_plugin().name) # Still correct
async def main():
await async_api()See the examples/ directory for complete working examples:
examples/plugins/a/- Plugin with events and APIexamples/plugins/b/- Plugin that uses plugin Aexamples/host_events.py- Host-level event definitionsexamples/example.py- Full demonstration script
Run the example:
uv run examples/example.py- PluginHost: Main system coordinator
- Plugin: Represents a loaded plugin
- ResourceManager: Tracks and manages resources
- MetadataLoader: Loads plugin metadata from manifests
- PluginLoader: Loads plugin code (default uses importlib)
- Context System: Tracks current plugin using context variables
- Event System: Protected/public events with generators
- Plugin Importer: Virtual
pkl.pluginsmodule for imports
- Automatic Cleanup: Everything is tracked and cleaned up
- Context Awareness: Always know which plugin is running
- Type Safety: Full typing support throughout
- Extensibility: Custom loaders, resources, and hooks
- Isolation: Plugins can't interfere with each other (unless explicitly shared)
- Flexibility: Works with sync and async code
uv pip install -e ".[dev]"
pytest tests/mypy pkl/black pkl/- Python 3.9+
- No required dependencies (uses stdlib only)
- Optional:
tomlifor TOML support (Python <3.11) - Optional:
pyyamlfor YAML manifest support
MIT License - see LICENSE file for details
Contributions welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure mypy and tests pass
- Submit a pull request
- Plugin discovery by name (registry)
- Version constraint checking
- Resource quotas and limits
- Plugin sandboxing for security
- Hot reloading support
- Plugin dependency graph visualization
- Performance profiling hooks
- Web UI for plugin management
Created by xpodev Inspired by operating system process management concepts