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.
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
AbstractCompanion.
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 ofAbstractCompanion.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._plugin_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._plugin_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._plugin_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._plugin_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_build()is overridden (has a menu entry in the viewer).⚡ — 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.
AbstractCompanion ABC
Every companion class must inherit from
AbstractCompanion (defined in
wolfhece/_menu_companion_abc.py).
from wolfhece._menu_companion_abc import AbstractCompanion, ActionItem
from wolfhece._viewer_plugin_handlers import MouseContext, KeyboardSnapshot
_WXK_ESCAPE = 27
class MyFeatureCompanion(AbstractCompanion):
def menu_build(self) -> None:
"""Build the viewer menu entry. Omit if no menu is needed."""
self._build_menu('My Feature', [
ActionItem('Run…', self._on_run, 'Start interactive action'),
])
self._register_action(
self._action_id('run'),
rdown=self._rdown,
key=self._key,
paint=self._paint,
)
def start(self) -> None:
"""Called by the plugin manager to activate the companion."""
self._start_action(
self._action_id('run'),
'Right-click to interact — Esc to stop',
)
# -- event handlers ---------------------------------------------------
def _on_run(self, event) -> None:
self.start()
def _rdown(self, viewer, ctx: MouseContext) -> None:
... # handle right-click at (ctx.x, ctx.y)
self._force_redraw()
def _key(self, viewer, kb: KeyboardSnapshot) -> bool:
if kb.key_code == _WXK_ESCAPE:
self.stop() # → _end_action() → viewer.end_action()
return True
return False
def _paint(self, viewer) -> None:
... # OpenGL overlay drawing
Key helpers inherited from AbstractCompanion
Helper |
Description |
|---|---|
|
Returns |
|
Registers handlers in the viewer and tracks the id for cleanup in
|
|
Shortcut for |
|
Calls |
|
Default implementation calls |
|
Writes msg to the viewer’s status bar. |
|
Requests a viewer repaint. |
|
Returns |
Note
menu_build() imports wx internally. Do not call it in unit
tests; use _register_action directly instead (see Testing plugins).
Ready-made factories
wolfhece/_companion_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._companion_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— subclassAbstractCompanionand 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
_viewport_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
menu_build() calls _build_menu() which internally creates a
wx.Menu — that requires a live wx.App. In tests, register handlers
directly with _register_action:
def test_start_registers_action(viewer):
c = MyFeatureCompanion(viewer)
c._register_action(c._action_id('run'), rdown=c._rdown)
assert any('run' in aid for aid in c._registered_action_ids)
Asserting on Esc
stop() calls _end_action() which calls viewer.end_action()
directly (not start_action). Assert on end_action:
def test_esc_stops(viewer):
c = MyFeatureCompanion(viewer)
c.start()
consumed = c._key(viewer, 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/_plugin_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/_plugin_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). |