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.toml

Machine-readable metadata. See Manifest schema below.

companion.py

Python 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)
  1. Validates the directory (validate_plugin_dir).

  2. Parses plugin.toml into a PluginManifest.

  3. Imports companion.py under the internal module name _wolfhece_plugin_<name> (idempotent — re-uses cached module on reload).

  4. Retrieves entry_class from the module and verifies it is a subclass of AbstractCompanion.

  5. Returns a PluginInfo.

discover_plugins(directory)

Calls load_plugin for every non-underscore subdirectory. Failures are caught, logged and returned as PluginInfo objects with load_error set (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

BUILTIN

Plugin lives inside wolfhece/_builtin_plugins/. Always trusted — no user action required.

APPROVED

User has approved the plugin. The SHA-256 of plugin.toml + companion.py matches the stored value.

CHANGED

Plugin was approved before, but at least one of the two tracked files has changed since. Re-approval is required.

UNKNOWN

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.json

  • Linux / 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

_action_id(local_id)

Returns "<namespace>.<local_id>" — avoids collisions between companions that chose the same short name.

_register_action(action_id, *, rdown, ldown, motion, key, paint)

Registers handlers in the viewer and tracks the id for cleanup in destroy().

_start_action(action_id, message)

Shortcut for viewer.start_action(action_id, message).

_end_action()

Calls viewer.end_action() directly. This resets action, active_vertex, active_cloud_vertex_id, fires the sculpt / assets end-action callbacks, and logs an End of action message.

stop()

Default implementation calls _end_action()viewer.end_action(). Override to add post-processing.

_set_status(msg)

Writes msg to the viewer’s status bar.

_force_redraw()

Requests a viewer repaint.

_viewport_fraction(fraction)

Returns (viewer.xmax - viewer.xmin) * fraction — useful for scaling overlay sizes to the current zoom level.

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

PointPickerCompanion

Collect 2-D points (right-click adds, Ctrl+Z undoes, Esc stops).

point_picker(viewer, **kwargs)

Instantiate and start() a PointPickerCompanion.

PolylineCompanion

Collect a single open polyline.

polyline(viewer, **kwargs)

Factory for PolylineCompanion.

PolygonCompanion

Collect a closed polygon.

polygon(viewer, **kwargs)

Factory for PolygonCompanion.

MultiPolylineZonesCompanion

Collect multiple polylines, each stored as a Zones object.

multi_polyline_zones(viewer, zones_id, auto_attach, **kwargs)

Factory for MultiPolylineZonesCompanion.

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

  1. Create the directory wolfhece/data/plugins/<slug>/.

  2. Write plugin.toml (use the schema above as a guide).

  3. Write companion.py — subclass AbstractCompanion and implement start() at minimum.

  4. Create tests/test_companion.py following the Testing plugins pattern.

  5. 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:

  1. Copy the plugin directory to wolfhece/_builtin_plugins/<slug>/.

  2. Add an empty __init__.py inside it.

  3. Add the sub-package to [tool.setuptools.packages.find] include in pyproject.toml:

    include = [
        ...
        "wolfhece._builtin_plugins",
        "wolfhece._builtin_plugins.<slug>",
    ]
    
  4. The _builtin_plugins/**/*.toml and _builtin_plugins/**/*.py globs in [tool.setuptools.package-data] already cover any new plugin’s data files.

  5. 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

PLUGINS_DIR

wolfhece/data/plugins/ — default user plugin directory.

BUILTIN_PLUGINS_DIR

wolfhece/_builtin_plugins/ — package-distributed plugins.

PluginManifest

Dataclass with all plugin.toml fields.

PluginInfo

Dataclass: manifest, path, companion_class, enabled, load_error.

validate_plugin_dir(path)

Returns a list of error strings (empty = OK).

load_plugin(path)

Load one plugin; raises on validation / import errors.

discover_plugins(directory)

Discover all valid plugins in a directory (failures included).

discover_all_plugins(user_directory)

Built-ins first, then user plugins; deduplicates by name.

Trust (wolfhece/_plugin_trust.py)

Symbol

Description

TrustStatus

Enum: BUILTIN, APPROVED, CHANGED, UNKNOWN.

compute_plugin_hash(plugin_dir)

SHA-256 of plugin.toml + companion.py.

TrustStore

Persistent approval store. Key methods: get_status, approve, revoke, get_approved_at.

get_default_store()

Lazy singleton for the real user config.

reset_default_store(trust_file)

Replace the singleton (for tests).