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

Machine-readable metadata. See Manifest schema below.

companion.py

Python module that defines the companion class. The class must subclass

AbstractUICompanion.

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

  1. Parses plugin.toml into a PluginManifest.

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

  2. Retrieves entry_class from the module and verifies it is a subclass

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

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.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.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.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 menu Companions by 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

proxy.action_id(local_id)

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

proxy.register_action(local_or_global_id, *, rdown, ldown, motion, key, paint)

Registers handlers in the viewer and tracks the resolved id for cleanup in destroy(). Local ids are auto-namespaced when needed.

proxy.register_action(..., overload=True)

Forwards to viewer.register_action(..., overload=True) so previous handlers are saved and restored when unregistered.

proxy.start_action(local_or_global_id, message)

Shortcut for viewer.start_action(action_id, message).

proxy.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 proxy.end_action()viewer.end_action(). Override to add post-processing.

proxy.set_status(msg)

Writes msg to the viewer’s status bar.

proxy._viewer.Refresh()

Requests a viewer repaint.

(proxy._viewer.xmax - proxy._viewer.xmin) * fraction

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 on unregister_action.

  • Deterministic teardown: proxy-tracked ids are unregistered in destroy(); repeated destroy() 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

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

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

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

  3. Write companion.py — subclass AbstractUICompanion 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 (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:

  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/plugins/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/plugins/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).