Skip to content

Plugin Authoring Guide

How to build a MineProductivity plugin, using SitePack as the worked example. The framework is frozen at v2.0.0: a plugin never edits mineproductivity; it implements the framework's interface-only ABCs and registers itself through entry points.

1. Anatomy of a plugin

A plugin is an ordinary installable Python distribution:

your-plugin/
├── pyproject.toml            # metadata + entry points + optional extras
├── your_package/
│   ├── __init__.py           # version gate (fail-fast)
│   ├── _compat.py            # supported-framework range
│   └── <backend modules>     # concrete ABC implementations that self-register
└── tests/

2. Declare a dependency on the framework's public API

Depend on the framework with a major-version-bounded range. The public API is stable within a major version; do not depend on anything under a private (_-prefixed) module.

# pyproject.toml
[project]
name = "mineproductivity-sitepack"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["mineproductivity>=2.0.0,<4"]   # widen only across majors you have verified

[project.optional-dependencies]
pdf = ["reportlab>=4,<5"]   # heavy/optional deps go in extras, never core

Rule (ADR-0012): reporting/solver dependencies (matplotlib, reportlab, etc.) belong to plugins or the enterprise extra - never to the framework core. Keep them optional and import them lazily.

3. Implement a backend by subclassing an interface ABC

Every extension point is an ABC that ships zero concrete implementations. Subclass it, set a meta descriptor with a unique code, and implement the one abstract method.

# your_package/optimization.py
from mineproductivity.optimization import (
    MixedIntegerProgrammingModel, OptimizationCategory, OptimizationMetadata,
    OptimizationProblem, OptimizationContext, OptimizationResult, register,
)

@register                                   # <- automatic registration
class DispatchAllocationModel(MixedIntegerProgrammingModel):
    meta = OptimizationMetadata(
        code="MIP.SitePackDispatchAllocation",   # globally unique
        category=OptimizationCategory.MIXED_INTEGER_PROGRAMMING,
        description="Demand-proportional fleet-to-route allocation.",
        version="0.1.0",
    )
    def _solve_mip(self, problem, *, context) -> OptimizationResult:
        ...

Renderers work the same way with visualization.Renderer + @register_renderer and a RendererMetadata(code=...).

The code is the public handle used to look the backend up in the registry (REGISTRY.get(code)), so namespace it (<CATEGORY>.<Vendor><Purpose>) to avoid collisions with other plugins.

4. Wire up entry points (this is the discovery contract)

Point each entry-point group at the module that performs registration. EntryPointDiscovery.discover() imports that module, and the @register decorator fires as an import side effect - there is no separate "register" call.

[project.entry-points."mineproductivity.optimization"]
sitepack = "mineproductivity_sitepack.optimization"

[project.entry-points."mineproductivity.visualization.renderers"]
sitepack = "mineproductivity_sitepack.visualization"
Extension point Entry-point group
Optimization models mineproductivity.optimization
Visualization renderers mineproductivity.visualization.renderers
Analytics models (forecasting, anomaly, …) mineproductivity.analytics
Agent tools mineproductivity.agents.tools
Agents mineproductivity.agents

Analytics models use analytics.register + an AnalyticsMetadata(code=...); tools use agents.register_tool + ToolMetadata(code=...); agents use agents.register + AgentMetadata(code=..., category=...). An agent composes other backends by looking them up from the registries (analytics.REGISTRY.get(code), agents.TOOLS.get(code)) rather than importing the concrete classes — that is what keeps discovery, not hard-wiring, in charge.

5. Fail fast on version incompatibility

Gate the framework version at import so an incompatible plugin raises (and is skipped by discovery) instead of failing deep inside a solve.

# your_package/_compat.py
import mineproductivity
from mineproductivity.registry import VersionRange, VersionCompatibility

SUPPORTED_FRAMEWORK = VersionRange(min_version="2.0.0", max_version_exclusive="3.0.0")

def check_framework_compatibility(core_version: str | None = None) -> None:
    version = core_version if core_version is not None else mineproductivity.__version__
    VersionCompatibility.check_or_raise(SUPPORTED_FRAMEWORK, version)

Call it from __init__.py so any import of the package enforces the gate. VersionCompatibility.check_or_raise raises VersionIncompatibleError; EntryPointDiscovery catches import-time failures and omits the plugin from its result, so a version-mismatched plugin never destabilises the host (plugin isolation).

6. Keep execution deterministic

Enterprise solutions require reproducible artifacts. Avoid wall-clock, random without a fixed seed, and set/dict iteration that leaks insertion order into output. SitePack's optimizer uses largest-remainder apportionment with id-tiebreaks, and its HTML renderer emits sorted, escaped fields - identical inputs yield byte-identical output. (PDF via ReportLab is content-deterministic only; it embeds a per-build timestamp.)

7. Test against the installed entry points

Keep plugin tests outside the framework's testpaths and run them against the installed distribution so discovery is exercised for real:

pip install -e ".[pdf]"
python -m pytest tests -q

See tests/test_plugin.py for discovery, registration, version-gate (accept + reject), deterministic solve through OptimizationExecutor, and HTML/PDF rendering assertions.

8. Checklist

  • dependencies = ["mineproductivity>=X,<X+1"]; heavy deps in extras.
  • One code per backend, namespaced and unique.
  • Backends subclass the framework ABC and @register / @register_renderer.
  • Entry points point at the self-registering modules.
  • Import-time version gate via VersionCompatibility.check_or_raise.
  • Deterministic outputs (or the non-determinism is documented).
  • Tests run against the installed package and assert discovery.