Extension Bundle
Multi-component integrations at runtime without a plugin API.
The Problem
Plugin systems are infrastructure. They introduce extension-point interfaces, plugin loaders, dependency resolvers, lifecycle hooks, and version negotiation – all before a single line of business logic runs. Frameworks like OSGi, Stevedore, and WordPress plugins each define their own vocabulary for “how to extend.”
The question is: if you already have a component kernel with hot-add, a bus for cross-component calls, and @subscribe for event fan-out, do you need any of that? The Extension Bundle pattern says no. Components are the plugin mechanism. A bundle is just a list of classes.
Architecture
The example models a core application that publishes domain events, and a Slack integration bundle that adds three components at runtime:
CoreApp SlackNotifier SlackCommandHandler
@runnable do_work @runnable notify @subscribe "core.work_completed"
| ^ |
| publish | rt.invoke |
| "core.work_completed" | "slack-notifier. |
+-----> bus ------+ | notify" |
| +---------------------+
v
SlackWebhookReceiver
@runnable receive
CoreApp publishes "core.work_completed" via the bus. It knows nothing about Slack. SlackNotifier exposes a notify runnable. SlackWebhookReceiver accepts inbound webhooks and publishes "slack.webhook_received" events. SlackCommandHandler is the glue – it @subscribes to core events and calls the notifier via self.rt.invoke(), bridging the two domains without either side knowing about the other.
All three are hot-added as a group and removed as a group.
How It Works
Step 1: Boot the core
The kernel starts with only the platform providers and CoreApp:
kernel = Kernel()
kernel.discover([ConfigProvider, LoggingProvider, CoreApp])
kernel.instantiate("config", properties={"defaults": {}})
await kernel.boot()At this point, kernel.bus.has_handler("slack-notifier.notify") is False. CoreApp can publish events, but nobody is listening.
Step 2: Hot-add the bundle
The bundle is a plain Python list:
SLACK_BUNDLE = [SlackNotifier, SlackWebhookReceiver, SlackCommandHandler]Installation is a loop:
for cls in SLACK_BUNDLE:
await kernel.hot_add(cls)Each hot_add() call does the full lifecycle: register the factory, instantiate, resolve dependencies, build the reactive runtime, activate, and register bus handlers. Order matters – SlackCommandHandler declares depends=["config", "slack-notifier"], so it must be added after SlackNotifier.
Step 3: @subscribe wires up automatically
SlackCommandHandler declares a subscription on the core.work_completed event. The kernel registers this subscription during activation – no manual wiring:
@component("slack-commands", version="1.0", depends=["config", "slack-notifier"])
@requires(config="IConfig")
class SlackCommandHandler:
@subscribe("core.work_completed",
description="Auto-notify Slack when core work completes")
async def on_work_completed(self, event_type, data):
self.handled.append(("work_completed", data))
# Cross-component call: notify Slack about the work
await self.rt.invoke("slack-notifier.notify", {
"channel": "ops",
"message": f"Work completed: {data.get('result', '?')}",
})This is the most interesting component in the bundle. It demonstrates two integration patterns at once:
- Event subscription (
@subscribe) – reacts to core domain events without CoreApp knowing it exists. - Cross-component invocation (
self.rt.invoke()) – calls the notifier through the bus, which means auth policies, audit trails, and any other bus-level concerns apply automatically.
When CoreApp.do_work() publishes "core.work_completed", the bus fans the event out to all subscribers. SlackCommandHandler.on_work_completed fires, which in turn calls slack-notifier.notify through the bus.
Step 4: Clean removal
Removal is the mirror of installation, in reverse dependency order:
for name in ["slack-commands", "slack-webhook", "slack-notifier"]:
await kernel.hot_remove(name)Each hot_remove() unregisters bus handlers, unprovides services, releases ref counts, deactivates the component, and disposes any reactive effects. After removal, kernel.bus.has_handler("slack-notifier.notify") is False again. CoreApp continues running, publishing events into the void.
Running It
PYTHONPATH=src python -m signalpy.examples.extension_bundleExpected output:
=== Core app running (no extensions) ===
No Slack handler registered yet.
=== Installing Slack bundle (3 components) ===
Handlers now: ['slack-notifier.notify', 'slack-webhook.receive', 'slack-commands.handle']
=== Core app does work -> auto-notifies Slack ===
[slack-notifier] #ops: Work completed: ok
Notifier sent: ['#ops: Work completed: ok']
Commands handled: [('work_completed', {'result': 'ok'})]
=== Direct Slack calls ===
[slack-notifier] #dev: Deploy successful
=== Removing Slack bundle ===
Slack handlers removed.
Extension bundle demo complete.
Tests: pytest src/signalpy/tests/test_examples.py::TestExtensionBundle -v
Production Considerations
Dependency ordering. SlackCommandHandler declares depends=["config", "slack-notifier"], so it must be added after SlackNotifier. In production, sort the bundle topologically before calling hot_add(). The kernel’s own _resolve_order() does this at boot – a bundle installer should reuse the same logic.
Partial install rollback. If a mid-bundle hot_add() fails, earlier components are already running. Wrap the loop with rollback:
installed = []
try:
for cls in SLACK_BUNDLE:
ci = await kernel.hot_add(cls)
installed.append(ci.name)
except Exception:
for name in reversed(installed):
await kernel.hot_remove(name)
raiseBundle manifests. SLACK_BUNDLE is a list literal. A production system would use a manifest (YAML, dataclass) declaring classes, install order, required kernel version, and config defaults. The kernel does not need to know about manifests – they are a convention on top of hot_add().
Version compatibility. Bundles that depend on specific runnables break if the target renames them. Use kernel.bus.has_handler() as a pre-check.
Key Takeaway
hot_add() + @subscribe + bus calls = a plugin system. No plugin API needed. Components are the extension mechanism. The same lifecycle, dependency resolution, bus routing, and reactive propagation that run at boot time also run at runtime. A bundle is just a list of component classes and a for loop.