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