Plugin development¶
BrainDeer is extended through plugins. This page explains the model and walks you through a first plugin with copy-pasteable snippets taken from the real scaffold and bundled plugins.
Conventions¶
A plugin is a package under src/brain_deer/plugins/<name>/ with:
File |
Role |
|---|---|
|
Class |
|
Optional Qt UI (tab in Plugin Studio) |
|
Optional notes and catalog artwork |
The plugin manager (plugins_host/plugin_manager.py) discovers and loads
plugins at runtime. Discovery is limited to _BUNDLED_PLUGIN_ALLOWLIST so stray
folders do not appear in the UI. Loose coupling runs through the event bus
and the command registry.
See also
Stable types: brain_deer.plugin_api.v1 (PluginBase, PluginMetadata,
BrainDeerAppFacade). Full signatures: sidebar → API Reference, or
API map by layer.
Tutorial: your first plugin¶
1. Create the scaffold¶
From a repo install with the AI/scaffold extras available:
braindeer-new-plugin my_analysis_tool
# equivalent:
# python -m brain_deer.plugins.braindeer_ai.scaffold my_analysis_tool
This creates src/brain_deer/plugins/my_analysis_tool/ with:
__init__.pyplugin.py—Plugin(PluginBase)wired to a widgetwidget.py— minimalQWidgetholdingself.apiREADME_SCAFFOLD.txt— allowlist reminder
Register the plugin so Plugin Studio can see it: add the id to
_BUNDLED_PLUGIN_ALLOWLIST in src/brain_deer/plugins_host/plugin_manager.py,
then restart BrainDeer.
_BUNDLED_PLUGIN_ALLOWLIST = frozenset(
{
# … existing ids …
"my_analysis_tool",
}
)
Ids must be snake_case, start with a letter, length 2–49.
2. Write plugin.py¶
Subclass PluginBase, return metadata from get_metadata(), and build the
widget in initialize(). Metadata probing uses __new__ without __init__, so
get_metadata() must not touch self.api or self.logger.
from brain_deer.plugin_api.v1 import (
PluginBase,
PluginCategory,
PluginMetadata,
PluginPriority,
)
from brain_deer.plugins.my_analysis_tool.widget import MyAnalysisToolWidget
class Plugin(PluginBase):
def get_metadata(self) -> PluginMetadata:
return PluginMetadata(
name="My Analysis Tool",
version="0.1.0",
author="You",
description="Does one useful thing.",
category=PluginCategory.TOOL,
dependencies={},
icon="*",
icon_path=None,
keywords=["analysis"],
priority=PluginPriority.NORMAL,
)
def initialize(self) -> bool:
try:
self._widget = MyAnalysisToolWidget(api=self.api)
self.register_event_handler("subject_changed", self._on_subject_changed)
self.logger.info("Plugin %s initialized", self.metadata.name)
return True
except Exception as exc:
self.logger.error("Init failed: %s", exc)
return False
def cleanup(self) -> None:
w = getattr(self, "_widget", None)
if w is not None:
w.deleteLater()
self._widget = None
def get_widget(self):
return getattr(self, "_widget", None)
def _on_subject_changed(self, *args, **kwargs) -> None:
if self._widget is not None and hasattr(self._widget, "refresh"):
self._widget.refresh()
Reference implementation: plugins/roi_studio/plugin.py (same lifecycle,
production widget).
3. Write widget.py¶
Keep UI here; call into self.api (the running app facade) for viewer access.
Avoid importing private panel modules unless you are extending the host itself.
from __future__ import annotations
from typing import Any
from PyQt5.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
class MyAnalysisToolWidget(QWidget):
def __init__(self, api: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
lay = QVBoxLayout(self)
lay.addWidget(QLabel("<b>My Analysis Tool</b>"))
btn = QPushButton("Run")
btn.clicked.connect(self._run)
lay.addWidget(btn)
def _run(self) -> None:
# Prefer documented facade methods / commands over private widgets.
info = getattr(self.api, "get_current_data_info", None)
if callable(info):
self.findChild(QLabel).setText(str(info()))
def refresh(self) -> None:
"""Optional: called from plugin event handlers."""
4. Events and commands¶
Events:
self.register_event_handler(name, handler)ininitialize; emit withself.emit_event(name, data). Handlers are tied to the plugin name on the bus for cleanup.Commands: for shared user actions, add a definition under
commands/and register it in the command registry so the palette and other plugins can invoke the same action.
5. Test¶
Headless plugin tests set QT_QPA_PLATFORM=offscreen and avoid opening a real
window. Pattern from tests/test_warp_plugin.py:
import os
from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PyQt5")
def test_metadata_is_pure_on_uninitialised_instance():
from brain_deer.plugins.my_analysis_tool.plugin import Plugin
md = Plugin.__new__(Plugin).get_metadata()
assert md.name == "My Analysis Tool"
assert md.version.split(".")[0].isdigit()
def test_plugin_is_discoverable_and_allowlisted():
import brain_deer.plugins as plugins_pkg
from brain_deer.plugins_host import plugin_manager as pm_mod
from brain_deer.plugins_host.plugin_manager import PluginManager
assert "my_analysis_tool" in pm_mod._BUNDLED_PLUGIN_ALLOWLIST
pm = PluginManager(str(Path(plugins_pkg.__file__).parent))
assert "my_analysis_tool" in pm.discover_plugins()
Run: pytest tests/test_warp_plugin.py -q as a reference suite for discovery,
widget construction, and teardown patterns.
Best practices¶
No assumptions about the caller — behavior comes from parameters and the injected
apifacade.UI logic belongs in the widget; domain logic in the plugin class or
domain/ dedicated modules.Keep heavy dependencies optional and degrade cleanly when missing (
PluginMetadata.dependenciesis advisory logging today).Do not reach into another plugin’s private widgets; use events or commands.
Keep
get_metadata()pure for__new__probing by the plugin manager.