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

plugin.py

Class Plugin(PluginBase) — metadata, initialize / cleanup, commands

widget.py

Optional Qt UI (tab in Plugin Studio)

README / icon

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__.py

  • plugin.py — Plugin(PluginBase) wired to a widget

  • widget.py — minimal QWidget holding self.api

  • README_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) in initialize; emit with self.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 api facade.

  • 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.dependencies is 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.