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. .. contents:: :local: :depth: 2 ---- 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: .. code-block:: text 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 :class:`~wolfhece._menu_companion_abc.AbstractCompanion`. Manifest schema ~~~~~~~~~~~~~~~ .. code-block:: toml [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 :class:`~wolfhece._plugin_loader.PluginManifest`. 3. Imports ``companion.py`` under the internal module name ``_wolfhece_plugin_`` (idempotent — re-uses cached module on reload). 4. Retrieves ``entry_class`` from the module and verifies it is a subclass of :class:`~wolfhece._menu_companion_abc.AbstractCompanion`. 5. Returns a :class:`~wolfhece._plugin_loader.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. .. code-block:: python 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 ~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 15 85 * - 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 ~~~~~~~~~~~~~~~~ :func:`~wolfhece._plugin_trust.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. .. code-block:: python 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 ~~~~~~~~~~~ :class:`~wolfhece._plugin_trust.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. .. code-block:: python 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 :func:`~wolfhece._plugin_trust.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: .. list-table:: :header-rows: 1 :widths: 10 20 70 * - 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 :class:`~wolfhece._menu_companion_abc.AbstractCompanion` (defined in ``wolfhece/_menu_companion_abc.py``). .. code-block:: python 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`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 40 60 * - Helper - Description * - ``_action_id(local_id)`` - Returns ``"."`` — 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: .. list-table:: :header-rows: 1 :widths: 35 65 * - 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``. .. code-block:: python 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//`` 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//``. 2. Write ``plugin.toml`` (use the schema above as a guide). 3. Write ``companion.py`` — subclass :class:`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 ~~~~~~~~~~~ :class:`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: .. code-block:: python 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: .. code-block:: python 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: .. code-block:: python 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``: .. code-block:: python 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``: .. code-block:: python 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``: .. code-block:: 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//``. 2. Add an empty ``__init__.py`` inside it. 3. Add the sub-package to ``[tool.setuptools.packages.find] include`` in ``pyproject.toml``: .. code-block:: toml include = [ ... "wolfhece._builtin_plugins", "wolfhece._builtin_plugins.", ] 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 :attr:`~wolfhece._plugin_trust.TrustStatus.BUILTIN` automatically for any plugin whose resolved path is inside ``wolfhece/_builtin_plugins/``. API reference ------------- Loader (``wolfhece/_plugin_loader.py``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 45 55 * - 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``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 45 55 * - 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).