mineproductivity.registry¶
Purpose¶
mineproductivity.registry is the plugin-first backbone of MineProductivity: the single, generic discovery-and-lookup mechanism that lets KPIs, connectors, ontology entity types, and analytics models be added to the platform as separate, installable packages, with zero change to the core. It is the direct implementation of the root README's plugin-first principle.
This package implements the Registry Framework Design Specification exactly. Where this README and that specification disagree, the specification governs. Note the specification covers two packages together: registry (this package, the pure mechanism) and plugins (the lifecycle layer built on it).
Scope¶
What belongs here:
- The generic
Registry[TKey, TItem]mechanism: register, lookup, get, list, metadata. EntryPointDiscovery/EntryPointSpec— scanning Python entry-points viaimportlib.metadata.registered_in()— the generic@registerdecorator factory every domain package's own decorator is built from.VersionRange/VersionCompatibility— plugin-to-core version gating.DiscoveryCache— scan-once-per-process memoization of discovery results.
What must never belong here:
- What gets registered. A
KPIMetadatainstance, a connector subclass, anEquipmentTypesubclass — their shapes are defined bykpis,connectors,ontologyrespectively;registryonly defines the container. - Plugin lifecycle concerns (activation state, manifests, inter-plugin dependencies) — see
plugins. - Any domain package import (
ontology,events,kpis,connectors,analytics,optimization,simulation,decision,digital_twin,agents) — the mechanism cannot play favorites among the things it discovers, because it cannot even see them.
Architecture¶
registry is a cross-cutting package per the root README's dependency rules: may be depended upon by any layer, but must never depend on a domain layer.
Registry[TKey, TItem] is a deliberate structural echo of core.BaseRepository: a registry is, conceptually, a repository whose entities are types/classes/callables instead of domain entities. It does not subclass BaseRepository (its keys are typically strings/codes, not BaseEntity ids), and every domain-specific registry (kpis.REGISTRY, connectors.CONNECTORS, ontology's internal entity-type registry) is a type alias over this one class, never a subclass with new behavior (design spec AD-RG-01) — composition over inheritance applied at the plugin-architecture level.
Discovery is scan-once, cache-forever within a process (DiscoveryCache): importlib.metadata entry-point scanning touches installed-package metadata on disk and is not repeated on every Registry.get() call. Lookup itself is O(1) regardless of how many plugins are installed.
See the design specification's §10 for the full object model, sequence diagrams, and class diagrams.
Package Structure¶
registry/
├── __init__.py # public API surface (__all__)
├── registry.py # Registry[TKey, TItem]
├── entry_point.py # EntryPointSpec, EntryPointDiscovery
├── decorators.py # registered_in() -- the generic @register decorator factory
├── version_compat.py # VersionRange, VersionCompatibility
├── caching.py # DiscoveryCache
├── exceptions.py # the registry exception hierarchy
└── README.md # this file
Dependency Rules¶
registrydepends on:coreonly. No other package.registryis depended on by:plugins,kpis, andconnectorsdirectly, and byanalytics,decision,digital_twin,simulation,optimization,agents, andvisualizationper their own locked design specifications' Registry Framework specialization (§21/equivalent of each spec) — every package that exposes an extension point.ontologydoes not currently depend onregistry: its internal entity-type registry remains a separate, self-contained mechanism (seeontology/README.md's Future Work);eventshas no extension point of its own and does not depend onregistryeither.- Forbidden:
registrymust never importplugins,ontology,events,connectors,kpis,analytics,optimization,simulation,decision,digital_twin,agents, orvisualization. This is mechanically checked bytests/unit/registry/test_public_api.py::TestNoForbiddenDependencies.
Public API¶
from mineproductivity.registry import (
Registry, EntryPointDiscovery, EntryPointSpec,
VersionCompatibility, VersionRange, DiscoveryCache,
registered_in,
RegistrationError, DuplicateRegistrationError,
UnregisteredLookupError, VersionIncompatibleError,
)
Extension Guide¶
Building a domain registry. Instantiate Registry[TKey, TItem], expose it as REGISTRY (or a domain-appropriate name), and build a thin @register decorator. When registration needs nothing beyond "derive a key, reject a duplicate," build it directly from registered_in() — connectors.register_connector does exactly this:
# src/mineproductivity/connectors/_registry.py
from mineproductivity.registry import Registry, registered_in
CONNECTORS: Registry[str, type[FMSConnector]] = Registry(name="connectors")
register_connector = registered_in(CONNECTORS, key_of=lambda cls: cls.name)
When a domain package needs additional registration-time validation, it implements its own @register calling Registry.register() directly instead of wrapping registered_in() — kpis.register does this to also raise KPICircularDependencyError immediately if the new KPI would complete a dependency cycle, never deferred to first use (see kpis/_registry.py). Every domain package's @register still shares the same shape — decorator, typed Registry, raising lookup — even when it isn't literally built from this one function; that shape, not this specific helper, is the actual consistency guarantee. (ontology.register_equipment is a different case again: ontology sits below registry in the dependency stack and cannot import it at all, so its entity-type registry predates and is independent of this package.)
# In a THIRD-PARTY plugin package's pyproject.toml
[project.entry-points."mineproductivity.kpis"]
haulmetrics = "mineproductivity_haulmetrics.kpis"
Discovering installed plugins at startup:
from mineproductivity.registry import EntryPointDiscovery, EntryPointSpec
discovery = EntryPointDiscovery()
result = discovery.discover(EntryPointSpec(group="mineproductivity.kpis", target_registry="kpis"))
if result.is_err:
log.warning("KPI plugin discovery scan failed: %s", result.error)
An exception raised while importing any one entry-point's target module is caught, logged, and skipped by EntryPointDiscovery itself — it never aborts discovery of the remaining entry-points in the same group (the isolation rule).
Gating a plugin by core version. Declare a VersionRange and check it before activation (plugins.PluginLifecycle does this automatically as part of activate(); call it directly for a standalone check):
from mineproductivity.registry import VersionCompatibility, VersionRange
VersionCompatibility.check_or_raise(VersionRange(min_version="0.5.0", max_version_exclusive="1.0.0"), "0.5.0")
Examples¶
Runnable, narrated scripts live in examples/registry/:
| Script | Demonstrates |
|---|---|
01_register_and_discover.py |
The full register → discover → lookup cycle, including duplicate-key rejection and a real, on-disk module discovered through EntryPointDiscovery. |
02_version_compatibility.py |
A compatible and an incompatible plugin activated side by side, proving the incompatible one's failure never blocks the compatible one. |
Design Rationale¶
- Why one generic
Registry[TKey, TItem]class, specialized by type alias per domain, rather than a base class subclassed per domain? A domain registry needs no behavior beyond the generic mechanism, only a type — subclassing would add ceremony without adding capability (design spec AD-RG-01). - Why does
Registry.register()return aResultinstead of raising on a duplicate key? Duplicate registration during plugin discovery is an expected, recoverable condition (two plugins claiming the same code), not an exceptional one —Resultlets a discovery loop continue processing the remaining entry-points after logging the conflict, mirroringEntryPointDiscovery's own isolation philosophy. - Why is registration add-only, with no update path? A registered key is a public contract (a KPI code, a connector name, an entity type code); silently overwriting it on re-registration is exactly the "our numbers don't match" failure mode the whole registry design exists to prevent (design spec AD-RG-04). A genuine new version of what a code means is a versioning event the owning domain package governs, never a mechanism-level silent overwrite.
- Why does
Registrystore types/classes by default, not instances? Matches every worked example in the Cookbook (REGISTRY.get("PROD.TPH")()— instantiated at point of use) and keeps registered-item memory footprint independent of how many times an item is used (design spec AD-RG-05). - Why is
DiscoveryCachea separate class fromEntryPointDiscoveryrather than caching being built intodiscover()itself? Single Responsibility:EntryPointDiscoveryknows how to scan and import;DiscoveryCacheknows when not to re-scan. Separating them also makes the "never invalidated implicitly" contract auditable in one small class instead of buried inside a larger one. - Why does
registered_in()takekey_of/metadata_ofcallables instead of requiring every registered item to implement a common protocol? Domain packages register very different kinds of things (KPI classes, connector classes, entity types) with different metadata shapes; deriving the key/metadata via a caller-supplied function keepsregistryitself free of any assumption about what shape a registered item takes.
Anti-Patterns¶
- ❌ A domain package implementing its own ad hoc
dict-based registry instead of instantiatingRegistry[T]. Every domain registry must be aRegistryinstance so tooling, discovery, and version-compatibility checks work uniformly platform-wide. - ❌ Silently overwriting a registration on key collision. Always reject and log; see Design Rationale above.
- ❌ Eagerly importing every possible plugin module at
registryimport time. Discovery is driven by installed entry-points, never byregistrymaintaining a hard-coded list of "known" plugins. - ❌ Letting one plugin's import-time exception crash application startup.
EntryPointDiscoveryalready isolates this — do not wrapdiscover()calls in code that re-raises on the first entry-point failure. - ❌ A
Registryinstance stored as a bare module-level global mutated from arbitrary call sites outside its owning package. Every registry has exactly one owner package that constructs and exposes it. - ❌ Reaching into
Registry._itemsorRegistry._metadatadirectly. Use the public methods; the underscore-prefixed attributes are implementation detail.
Testing & Quality¶
tests/unit/registry/— onetest_*.pyper source module — 100% line coverage.tests/integration/test_registry_plugin_discovery.py— discovery and isolation proven against two real, independently pip-installed fixture plugin packages (tests/fixtures/plugins/), not mocks.mypy --strictandruffare clean onsrc/mineproductivity/registry/,tests/unit/registry/, andexamples/registry/.- Concurrent
DiscoveryCache.get_or_discover()calls for the same spec are covered by a dedicated thread-based stress test, not just incidental coverage.
Contents¶
See Package Structure above for the full file layout.
Dependencies¶
Depends on: core only.
Depended on by: plugins, kpis, connectors directly; analytics, decision, digital_twin, and simulation per their own locked specs' Registry Framework specialization. Not ontology or events — see Dependency Rules above.
Future Work¶
ontology's internal_EntityTypeRegistrymigrating to aRegistry[str, type[BaseEntityType]]instance once this package existed to delegate to (documented as a forward-compatible seam inontology/README.md).- A registry introspection/discovery CLI surface (
mineprod plugins list), built onRegistry.list()/PluginManifest, requiring no changes to this package. - Remote/marketplace plugin discovery as an additional
EntryPointDiscovery-like source beyond localimportlib.metadata.
References¶
- Master Architecture Handbook v1.0
- Reference Implementation Blueprint v1.0
docs/architecture/03_Registry_Framework_Design_Specification.mddocs/design/03_Registry_Implementation_Checklist.md- Developer & Cookbook Guide Part I, Chapter 3 (layered configuration) and Chapter 9 ("one discovery pattern for the whole ecosystem")