Plugin System
This document describes the full lifecycle of a companion plugin in wolfhece: from directory layout and metadata, through the trust / approval system, to writing well-tested companions.
Overview
A companion plugin is a self-contained directory that extends the
WolfMapViewer with new interactive behaviour (mouse picking, drawing
overlays, custom menus, …). The plugin system has three tiers:
wolfhece/_builtin_plugins/ ← ships with the package, always trusted
├── dot_picker/ ← example built-in plugin
│ ├── __init__.py
│ ├── plugin.toml
│ ├── companion.py
│ └── tests/
│ ├── conftest.py
│ └── test_companion.py
└── _template/ ← scaffolding source (starts with _)
├── __init__.py
├── plugin.toml
├── companion.py
└── tests/
└── test_companion.py
wolfhece/data/plugins/ ← user runtime directory (not distributed)
└── my_feature/
├── plugin.toml
└── companion.py
Built-in plugins are distributed inside the wolfhece package. User plugins
live in wolfhece/data/plugins/ which is created automatically at first run.
The two directories are merged at discovery time; built-ins are always loaded
first and their names take precedence over user plugins with the same slug.
Note
Plugins whose directory name starts with _ or . are silently
ignored — they serve as templates or drafts.
Runtime architecture (viewer ↔ manager ↔ plugin ↔ proxy)
The runtime wiring after discovery is intentionally layered so that plugin code stays independent from wx and direct viewer internals.
+---------------------+
| WolfMapViewer |
| (UI + render loop) |
+----------+----------+
^
| owns / drives
|
+----------+----------+
| PluginManagerCompanion |
| - discovers plugins |
| - enables/disables |
| - owns active[name] |
+----------+-------------+
|
| instantiate + attach + build
v
+----------+-------------------+
| Concrete Companion |
| (subclass of AbstractUICompanion)
| - menu_spec / actions_spec |
| - start / stop / destroy |
+----------+-------------------+
|
| delegates technical calls
v
+----------+-------------------+
| ViewerProxy |
| - action id namespacing |
| - action/menu registration |
| - dialog/status helpers |
| - teardown tracking |
+----------+------------------+
|
+-----> back to WolfMapViewer APIs
Mermaid view (same architecture)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: mermaid
flowchart LR
V["WolfMapViewer\n(UI + render loop)"]
M["PluginManagerCompanion\n(discovery + lifecycle)"]
C["Concrete Companion\n(AbstractUICompanion subclass)"]
P["ViewerProxy\n(namespacing + registration + dialogs)"]
V -->|hosts| M
M -->|instantiate + attach + build| C
C -->|delegates technical calls| P
P -->|uses viewer APIs| V
M -. enable(name) .-> C
M -. disable(name) .-> C
C -. destroy() .-> P
Activation flow (simplified)
manager.enable(name)
-> manager._activate(info)
-> companion = info.companion_class()
-> viewer.attach_companion(companion)
-> companion.configure()
-> companion.proxy.attach(viewer)
-> companion.build()
-> proxy.register_actions(...)
-> proxy.register_action(...)
-> viewer.register_action(...)
-> manager.active[name] = companion
Deactivation flow (simplified)
manager.disable(name)
-> companion = manager.active.pop(name)
-> companion.destroy()
-> proxy unregisters actions + removes menu
Why this matters
The companion keeps business behaviour and interaction logic.
The proxy centralises technical viewer/wx plumbing.
The manager orchestrates lifecycle and plugin state.
The viewer remains the rendering/event host, not a plugin state container.
Plugin layout
Every plugin directory (regardless of tier) must contain exactly two files:
plugin.tomlMachine-readable metadata. See Manifest schema below.
companion.pyPython module that defines the companion class. The class must subclass
Manifest schema
[plugin]
name = "my_feature" # unique slug (required)
display_name = "My Feature" # human-readable label (defaults to name)
version = "1.0.0" # semver string (required)
author = "Jane Doe" # (required)
email = "jane@example.com" # optional
description = "Short description." # optional
entry_class = "MyFeatureCompanion" # companion class name (required)
created = "2026-01-15" # optional ISO date
updated = "2026-05-22" # optional ISO date
[compatibility]
requires_wolfhece = ">=2.2" # optional, informational only
min_python = "3.11" # optional "major.minor" constraint
name must be a slug: only letters, digits, hyphens and underscores.
The loader rejects manifests whose name is not a valid Python identifier
when - is replaced by _.
Loading pipeline
load_plugin(plugin_dir)Validates the directory (
validate_plugin_dir).
Parses
plugin.tomlinto aPluginManifest.
Imports
companion.pyunder the internal module name_wolfhece_plugin_<name>(idempotent — re-uses cached module on reload).Retrieves
entry_classfrom the module and verifies it is a subclass
Returns a
PluginInfo.
discover_plugins(directory)Calls
load_pluginfor every non-underscore subdirectory. Failures are caught, logged and returned asPluginInfoobjects withload_errorset (companion_class = None). The viewer therefore keeps running even if one plugin is broken.discover_all_plugins(user_directory)Merges built-in and user plugins. Built-ins are listed first; any user plugin whose name shadows a built-in is skipped with a warning.
from wolfhece.plugins.loader import discover_all_plugins
plugins = discover_all_plugins() # built-ins + ~/.../data/plugins
for info in plugins:
print(info.name, '—', info.manifest.display_name, info.manifest.version)
Trust system
Loading a plugin executes arbitrary Python code. To prevent silent execution of modified or unknown files, every non-built-in plugin goes through an approval gate.
Trust levels
Status |
Meaning |
|---|---|
|
Plugin lives inside |
|
User has approved the plugin. The SHA-256 of |
|
Plugin was approved before, but at least one of the two tracked files has changed since. Re-approval is required. |
|
Plugin has never been seen; the user has not yet been asked. |
Hash computation
compute_plugin_hash() processes
plugin.toml and companion.py in that order. Both file names are
fed into the SHA-256 digest so that renaming a file without changing its
content still invalidates the hash.
from wolfhece.plugins.trust import compute_plugin_hash
from pathlib import Path
digest = compute_plugin_hash(Path('wolfhece/data/plugins/my_feature'))
# → 64-character lowercase hex string
Trust store
TrustStore persists approval records in a
human-readable JSON file:
Windows —
%APPDATA%\wolfhece\trusted_plugins.jsonLinux / macOS —
$XDG_CONFIG_HOME/wolfhece/trusted_plugins.json(falls back to~/.config/wolfhece/)
Each record stores the hash at the time of approval, a UTC timestamp, the display name and the version.
from wolfhece.plugins.trust import get_default_store, TrustStatus
store = get_default_store()
status = store.get_status(info) # → TrustStatus
store.approve(info) # record current hash
store.revoke('my_feature') # remove approval
ts = store.get_approved_at('my_feature') # → ISO-8601 string or None
For unit tests, use reset_default_store() with
a temporary file so tests never touch the real user config:
from wolfhece.plugins.trust import reset_default_store
import tempfile, pathlib
tmp = pathlib.Path(tempfile.mkdtemp()) / 'trust.json'
store = reset_default_store(trust_file=tmp)
UI integration
The plugin list in the Settings → Plugins tab shows a badge in front of every plugin name:
Badge |
Trust status |
Meaning |
|---|---|---|
★ |
BUILTIN |
Ships with wolfhece — always trusted. |
✓ |
APPROVED |
User-approved; files unchanged since last approval. |
⚠ |
CHANGED |
Approved before, but files have changed — must re-approve. |
? |
UNKNOWN |
Never approved; user will be prompted on first enable. |
A second badge indicates whether the companion has a custom menu:
☰ —
menu_spec()returns at least one entry (shown under the shared viewer menuCompanionsby default).⚡ — direct / API-only companion (no menu).
When the user tries to enable an UNKNOWN or CHANGED plugin, a confirmation
dialog is shown. Clicking Yes calls store.approve(info) and enables the
plugin; clicking No unchecks it.
AbstractUICompanion ABC
Every companion class must inherit from
AbstractUICompanion (defined in
wolfhece/plugins/abc.py).
from wolfhece.plugins.abc import AbstractUICompanion, ActionSpec, MenuItem
from wolfhece._viewer_plugin_handlers import MouseContext, KeyboardSnapshot
_WXK_ESCAPE = 27
class MyFeatureCompanion(AbstractUICompanion):
def get_namespace(self) -> str:
return 'my_feature'
def menu_host(self) -> str:
# Default is already 'companions_root'; override only if needed.
return 'companions_root'
def menu_spec(self):
"""Build the companion submenu entry. Omit if no menu is needed."""
return ('My Feature', [
MenuItem('Run…', self._on_run, 'Start interactive action'),
])
def actions_spec(self):
return [
ActionSpec(
'run',
rdown=self._rdown,
key=self._key,
paint=self._paint,
),
]
def start(self) -> None:
"""Called by the plugin manager to activate the companion."""
self.proxy.start_action(
'run',
'Right-click to interact — Esc to stop',
)
# -- event handlers ---------------------------------------------------
def _on_run(self, _ctx) -> None:
self.start()
def _rdown(self, ctx: MouseContext) -> None:
... # handle right-click at (ctx.x, ctx.y)
self.proxy._viewer.Refresh()
def _key(self, kb: KeyboardSnapshot) -> bool:
if kb.key_code == _WXK_ESCAPE:
self.stop() # → proxy.end_action() → viewer.end_action()
return True
return False
def _paint(self) -> None:
... # OpenGL overlay drawing
Key helpers inherited from AbstractUICompanion
Helper |
Description |
|---|---|
|
Returns |
|
Registers handlers in the viewer and tracks the resolved id for cleanup
in |
|
Forwards to |
|
Shortcut for |
|
Calls |
|
Default implementation calls |
|
Writes msg to the viewer’s status bar. |
|
Requests a viewer repaint. |
|
Useful for scaling overlay sizes to the current zoom level. |
Action registration in the viewer (current mechanics)
ViewerProxy.register_action(...) now mirrors the viewer registration API
while keeping plugin handlers concise and safe:
Automatic id resolution: local ids such as
'run'are resolved to'<namespace>.run'before registration. Already-qualified ids are kept as-is.Flexible handler signatures:
mouse handlers:
(viewer, ctx)or(ctx)key handlers:
(viewer, kb)or(kb)paint handlers:
(viewer)or()
The proxy adapts these forms before calling
viewer.register_action(...).Safe overload mode: when
overload=True, the viewer stores displaced handlers and restores them onunregister_action.Deterministic teardown: proxy-tracked ids are unregistered in
destroy(); repeateddestroy()calls stay safe.Idempotent notebook re-runs:
proxy.attach(viewer)replaces any previous proxy instance with the same identity and tears it down first.
Note
build() imports wx internally when a menu exists. Do not call
it in unit tests; use proxy.register_action(...) directly instead
(see Testing plugins).
By default, build() hosts companion menus under the shared top-level
Companions viewer menu. Return 'top_level' from menu_host() only
for companions that must expose their own top-level entry.
Multi-step action scope
Use MultiStepSpec inside actions_spec()
when an interaction spans multiple clicks/keys and needs explicit step hints.
Each StepSpec carries its own hint and
handlers; the proxy drives step progression automatically based on the
StepTransition value returned by each handler.
Important
The proxy’s multi-step state machine is a step orchestrator only.
It tracks step index, updates step hints, and drives
start_action / end_action through the proxy.
It does not own domain data (picked points, geometry, business state)
and does not implement mouse/keyboard logic. Keep that logic in
companion handlers (for example _ldown_* / _key_*), and keep
domain state in your companion fields or a CompanionState subclass.
Ownership map:
MultiStepSpec / proxy state machine
- owns: step index, step hints, state machine transitions
- does not own: picked data, geometry, business rules, input handlers
Companion class
- owns: domain state, finalization logic, _ldown_* / _key_* handlers
- returns: StepTransition.NEXT / FINISH / CANCEL from step handlers
Proxy
- owns: viewer integration (start_action/end_action, id resolution)
Ready-made factories
wolfhece/plugins/factory.py provides pre-built companions and factory
functions for the most common interaction patterns:
Class / factory |
Behaviour |
|---|---|
|
Collect 2-D points (right-click adds, Ctrl+Z undoes, Esc stops). |
|
Instantiate and |
|
Collect a single open polyline. |
|
Factory for |
|
Collect a closed polygon. |
|
Factory for |
|
Collect multiple polylines, each stored as a |
|
Factory for |
from wolfhece.plugins.factory import point_picker, multi_polyline_zones
# Quick one-liner — returns the active companion
picker = point_picker(viewer)
# Zones variant
comp = multi_polyline_zones(viewer, zones_id='my_zones', auto_attach=True)
Creating a new plugin
The quickest way is to use the built-in wizard: in the Settings → Plugins
tab, click New plugin from template…. Fill in the slug, display name,
author and description; the wizard copies wolfhece/_builtin_plugins/_template/
to wolfhece/data/plugins/<slug>/ with all MyPlugin occurrences replaced
by your new class name. A tests/ subdirectory with a pre-filled test
module is included automatically.
Manual setup
Create the directory
wolfhece/data/plugins/<slug>/.Write
plugin.toml(use the schema above as a guide).Write
companion.py— subclassAbstractUICompanionand implementstart()at minimum.Create
tests/test_companion.pyfollowing the Testing plugins pattern.Restart the viewer (or call
discover_all_plugins()again).
Testing plugins
Companion tests must work headlessly — without a wx.App, without an OpenGL context, and without a real display. Two mocking techniques make this possible.
Viewer mock
unittest.mock.MagicMock satisfies every attribute access and method
call without raising. Set xmin / xmax explicitly so that
(proxy._viewer.xmax - proxy._viewer.xmin) * fraction returns a
sensible value instead of zero:
from unittest.mock import MagicMock
@pytest.fixture
def viewer():
v = MagicMock()
v.xmin = 0.0
v.xmax = 1000.0
return v
OpenGL mock
Inject a fake OpenGL.GL module into sys.modules before any
_paint call. Use monkeypatch so the injection is automatically
reversed after each test:
import sys, types
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def gl_mock(monkeypatch):
gl = types.ModuleType('OpenGL.GL')
for attr in ('glBegin', 'glEnd', 'glVertex2f', 'glColor4f',
'glLineWidth', 'glPointSize'):
setattr(gl, attr, MagicMock())
gl.GL_LINES = 1; gl.GL_LINE_STRIP = 3; gl.GL_POINTS = 0
pkg = types.ModuleType('OpenGL')
pkg.GL = gl
monkeypatch.setitem(sys.modules, 'OpenGL', pkg)
monkeypatch.setitem(sys.modules, 'OpenGL.GL', gl)
return gl
Importing the companion
When multiple plugins run in the same pytest session, a plain
from companion import MyClass may resolve to the wrong file if both
plugin directories are on sys.path. Use importlib to load by
absolute path instead:
import importlib.util
from pathlib import Path
_COMPANION = Path(__file__).parent.parent / 'companion.py'
_spec = importlib.util.spec_from_file_location('_my_plugin_companion', _COMPANION)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
MyFeatureCompanion = _mod.MyFeatureCompanion
Registering actions without wx
build() creates wx.Menu objects when menu_spec() provides entries
(top-level or under Companions) — that requires a live wx.App.
In tests, register handlers directly with
proxy.register_action(...):
def test_start_registers_action(viewer):
c = MyFeatureCompanion()
c.proxy.attach(viewer)
c.proxy.register_action('run', rdown=c._rdown)
assert any('run' in aid for aid in c.proxy._registered_action_ids)
Asserting on Esc
stop() calls proxy.end_action() which calls viewer.end_action()
directly (not start_action). Assert on end_action:
def test_esc_stops(viewer):
c = MyFeatureCompanion()
c.proxy.attach(viewer)
c.start()
consumed = c._key(KeyboardSnapshot(key_code=27, ctrl=False))
assert consumed is True
viewer.end_action.assert_called()
Plugin tests are picked up automatically via the testpaths setting in
pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests", "wolfhece/_builtin_plugins"]
addopts = "--import-mode=importlib"
Run only the plugin tests:
pytest wolfhece/_builtin_plugins/ -v
Run the full suite:
pytest
Adding a plugin to the built-in tier
If you want your plugin to ship with wolfhece and be always trusted:
Copy the plugin directory to
wolfhece/_builtin_plugins/<slug>/.Add an empty
__init__.pyinside it.Add the sub-package to
[tool.setuptools.packages.find] includeinpyproject.toml:include = [ ... "wolfhece._builtin_plugins", "wolfhece._builtin_plugins.<slug>", ]
The
_builtin_plugins/**/*.tomland_builtin_plugins/**/*.pyglobs in[tool.setuptools.package-data]already cover any new plugin’s data files.Bump the package version in
pyproject.toml.
No trust store entry is needed — TrustStore.get_status returns
BUILTIN automatically for any
plugin whose resolved path is inside wolfhece/_builtin_plugins/.
API reference
Loader (wolfhece/plugins/loader.py)
Symbol |
Description |
|---|---|
|
|
|
|
|
Dataclass with all |
|
Dataclass: |
|
Returns a list of error strings (empty = OK). |
|
Load one plugin; raises on validation / import errors. |
|
Discover all valid plugins in a directory (failures included). |
|
Built-ins first, then user plugins; deduplicates by name. |
Trust (wolfhece/plugins/trust.py)
Symbol |
Description |
|---|---|
|
Enum: |
|
SHA-256 of |
|
Persistent approval store. Key methods: |
|
Lazy singleton for the real user config. |
|
Replace the singleton (for tests). |