Writing a Plugin
This document explains how to write a plugin for SpectroChemPy.
How plugins work
Plugins are external Python packages that register readers, writers, and
processors with SpectroChemPy. They are discovered automatically via
Python entry points (spectrochempy.plugins group) at import time.
A plugin declares its contributions by implementing declarative hooks — simple methods that return data instead of mutating shared state:
class MyPlugin:
def register_readers(self) -> list[dict]:
return [{"name": "myfmt", "func": read_myfmt, ...}]
The PluginManager collects these
contributions and registers them into a
PluginRegistry.
The registry is decomposed into specialised sub-registries, each owning a single domain:
Sub-registry |
Responsibilities |
|---|---|
|
Readers, writers, filetype associations |
|
Processors, unit contexts, dtype handlers |
|
Visualizers |
|
Generic extensions: analyses, simulations, fit models, domain schemas |
|
Plugin descriptors |
Legacy code using top-level methods (registry.register_reader(...))
continues to work via forwarding. New code can target sub-registries
directly:
registry.io.register_reader(...)
registry.processing.register_processor(...)
registry.metadata.register_plugin(...)
Minimal plugin example
Here is the smallest possible plugin:
# myplugin/src/myplugin/__init__.py
from spectrochempy.api.plugins import SpectroChemPyPlugin
class MyPlugin(SpectroChemPyPlugin):
name = "myplugin"
version = "0.1.0"
def register_readers(self) -> list[dict]:
return [
{
"name": "myformat",
"func": read_myformat,
"description": "Read MyFormat files",
"extensions": [".myf", ".myformat"],
},
]
And the corresponding pyproject.toml entry point:
# myplugin/pyproject.toml
[project.entry-points."spectrochempy.plugins"]
myplugin = "myplugin:MyPlugin"
That is all. Once installed, import spectrochempy will discover
MyPlugin, validate it, and register read_myformat as a reader.
Plugin API policy
Plugin APIs are exposed in two places, depending on whether they create data or operate on an existing object.
Package-level plugin namespaces are used for I/O, object creation, and standalone workflows:
scp.nmr.read_topspin(...)
scp.iris.batch_iris(...)
Dataset accessors are used only for operations on an existing
NDDataset. The accessor holds a reference to the parent dataset and
passes it as the first argument to the registered callable:
dataset.iris.kernel_matrix(...)
Do not add a dataset accessor just to make a plugin function look like a method. The callable should use the parent dataset as a real input.
Readers are not exposed under dataset accessors. For example, use
scp.nmr.read_topspin(...) rather than
dataset.nmr.read_topspin(...) because reading a file creates a
dataset rather than operating on one. The same rule applies to core
readers such as scp.read_omnic(...) and scp.read_csv(...):
dataset.read_omnic(...) and dataset.read_csv(...) are not part
of the supported API.
Legacy package-level reader aliases such as scp.read_topspin(...)
are kept during transitions when they already exist publicly. They are
thin dispatches through the reader registry.
Namespaced dataset accessors can keep old flat names by declaring
legacy_names:
def register_accessors(self) -> list[dict]:
return [
{
"namespace": "iris",
"name": "kernel_matrix",
"legacy_names": ["iris_kernel_matrix"],
"func": _ndd_build_kernel,
"description": "Build an IrisKernel from the dataset",
},
]
This exposes both dataset.iris.kernel_matrix(...) and the legacy
dataset.iris_kernel_matrix(...).
The current accessor mechanism stores namespaced accessors as strings such
as "iris.kernel_matrix" in the plugin registry, then exposes them as
dataset.iris.kernel_matrix(...) at runtime. This is intentionally a
minimal bridge. A future implementation may migrate mature domains to
dedicated accessor classes, but plugin authors should keep the public rule
the same: I/O and object creation belong at scp.<plugin>.*; operations
on an existing dataset belong at dataset.<plugin>.*.
Available hooks
A plugin can implement any combination of the following methods:
register_readers() -> list[dict]Declare file readers. Each dict must contain
"name"and"func". Optional keys:"description"(str),"extensions"(list[str]).def register_readers(self) -> list[dict]: return [ { "name": "myformat", "func": read_myformat, "description": "Read MyFormat files", "extensions": [".myf"], }, ]
register_writers() -> list[dict]Declare file writers. Each dict must contain
"name"and"func". Optional key:"description"(str).def register_writers(self) -> list[dict]: return [ { "name": "myformat", "func": write_myformat, "description": "Write MyFormat files", }, ]
register_processors() -> list[dict]Declare data processors. Each dict must contain
"name"and"func". Optional key:"description"(str).def register_processors(self) -> list[dict]: return [ { "name": "smooth", "func": smooth_data, "description": "Smooth a signal", }, ]
register_analyses() -> list[dict]Declare high-level analysis workflows (decomposition, multivariate analysis, curve fitting, kinetic modelling). Routed to
registry.extensionsunder the"analysis"category.def register_analyses(self) -> list[dict]: return [ { "name": "pca", "func": perform_pca, "description": "Principal Component Analysis", }, ]
register_simulations() -> list[dict]Declare simulation engines (thermodynamics, reactor modelling, kinetic solvers). Routed to
registry.extensionsunder the"simulation"category.def register_simulations(self) -> list[dict]: return [ { "name": "equilibrium", "func": compute_equilibrium, "description": "Chemical equilibrium calculation", }, ]
register_accessors() -> list[dict]Declare dataset accessor methods attached to NDDataset. Routed to
registry.extensionsunder the"accessor"category. Namespaced accessors should set"namespace"and use"name"for the method name inside that namespace.def register_accessors(self) -> list[dict]: return [ { "namespace": "myplugin", "name": "analysis", "legacy_names": ["my_analysis"], "func": _ndd_analysis, "description": "Perform analysis on dataset", }, ]
register_handlers() -> dict[str, Callable]Declare named extension-point handlers. Handlers let core code delegate plugin-owned behavior without importing plugin formats, metadata conventions, or numeric types.
def register_handlers(self) -> dict: return { "importer.resolve_directory_target": resolve_directory_target, "importer.infer_filetype_key": infer_filetype_key, "ndmath.execution_branch": execution_branch, "ndmath.execute": execute, }
Importer handlers are the right place for directory layouts and extensionless filenames owned by a plugin format. Numeric handlers are the right place for plugin-owned array backends such as quaternion data.
Returning an empty list (or None) from a hook is treated as “no
contribution” and is silently ignored.
Standard capability names
You can attach a capabilities class attribute to advertise what
your plugin provides (purely informational):
from spectrochempy.api.plugins import PluginCapability
class MyPlugin(SpectroChemPyPlugin):
capabilities = [PluginCapability.READER, PluginCapability.WRITER]
Available capability values:
Enum member |
Value |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Plugin metadata
SpectroChemPy validates every plugin at registration time. The following class attributes are required:
name(str)Unique plugin identifier. Used as the entry point name.
version(str)Plugin version (semver recommended).
The following are optional but recommended:
description(str)Human-readable description of what the plugin does.
spectrochempy_min_version(str)Minimum SpectroChemPy version required (e.g.
"1.0").PLUGIN_API_VERSION(str)Plugin API version. Defaults to
"1.0". Only the major version is checked for compatibility.
If you need to provide dynamic metadata, override plugin_info():
def plugin_info(self) -> dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"plugin_api_version": self.PLUGIN_API_VERSION,
"spectrochempy_min_version": self.spectrochempy_min_version,
"description": self.description,
}
Full example: NMR plugin
The reference external plugin is spectrochempy-nmr. It currently
provides the Bruker TopSpin reader, and can grow additional NMR readers
and tools over time:
# spectrochempy-nmr/src/spectrochempy_nmr/__init__.py
from spectrochempy.api.plugins import SpectroChemPyPlugin
from .read_topspin import read_topspin
class NMRPlugin(SpectroChemPyPlugin):
name = "nmr"
version = "0.1.0"
def register_readers(self) -> list[dict]:
return [
{
"name": "topspin",
"func": read_topspin,
"description": "Bruker TOPSPIN fid, series, or processed data",
"extensions": [".fid", ".ser", "1r", "1i"],
},
]
Entry point declaration in pyproject.toml:
[project.entry-points."spectrochempy.plugins"]
nmr = "spectrochempy_nmr:NMRPlugin"
Import guidance
Import from the stable public API namespace:
✅ Recommended |
❌ Avoid |
|---|---|
|
|
The public API (spectrochempy.api) is stable across releases.
Internal modules (spectrochempy.plugins) may change without notice.
All symbols available from spectrochempy.api.plugins:
Symbol |
Description |
|---|---|
|
Base class for plugins |
|
Enum: |
|
Dataclass for reader contributions |
|
Dataclass for writer contributions |
|
Dataclass for processor contributions |
|
Dataclass for visualizer contributions |
|
Dataclass for analysis contributions |
|
Dataclass for simulation contributions |
|
Convert dict to |
|
Convert dict to |
|
Check optional dependency availability |
|
Convert dict to |
|
Convert dict to |
|
Convert dict to |
|
Convert dict to |
|
Enum: |
|
Dataclass for plugin state snapshot |
|
Import error with install hint |
|
Version incompatibility error |
|
Current API version string ( |
|
Decorator for hook specifications |
|
Decorator for hook implementations |
|
Compatibility check (returns |
|
Metadata completeness check |
|
Contribution structure validation |
|
Full compatibility check (all issues) |
Plugin lifecycle
Every plugin managed by PluginManager passes through explicit
lifecycle states:
State |
Meaning |
|---|---|
|
Entry point found via |
|
Instantiated and validated, registered in pluggy |
|
All contributions registered in the registry |
|
Error during load, validation, or registration |
|
Explicitly deactivated by the user |
Inspect plugin states:
manager.get_plugin_state("nmr") # PluginState.ACTIVE
manager.get_active_plugins() # ["nmr", ...]
manager.get_failed_plugins() # {"broken": "error msg"}
manager.get_plugin_descriptor("nmr") # PluginDescriptor snapshot
Activation and deactivation:
manager.deactivate_plugin("nmr") # → marks DISABLED
manager.activate_plugin("nmr") # → marks ACTIVE
Deactivation is a lightweight state flag — no unloading or reimport happens. A disabled plugin is skipped if its entry point is encountered again during discovery.
Lazy loading and optional dependencies
Plugins should defer heavy imports to avoid slowing down SpectroChemPy startup:
# ❌ Avoid: top-level import of a heavy library
import numpy as np # fine
import torch # ❌ heavy, loaded at startup
# ✅ Prefer: deferred import inside operational methods
class MyPlugin(SpectroChemPyPlugin):
name = "mynet"
version = "0.1.0"
def register_readers(self) -> list[dict]:
return [
{
"name": "myformat",
"func": self._read_myformat,
},
]
def _read_myformat(self, path):
from mynet import load_model # deferred import
...
When a plugin fails to load (missing optional dependency,
ImportError in constructor, etc.), the manager catches the
exception, marks the plugin as FAILED, and continues. Other
plugins and SpectroChemPy itself are unaffected:
manager.get_failed_plugins()
# → {"mynet": "No module named 'mynet'"}
This lets plugins declare optional dependencies freely without risking startup crashes.
Test isolation
Use PluginTestHarness for
isolated tests that don’t touch the global registry:
from spectrochempy.testing.plugins import PluginTestHarness
def test_my_plugin():
harness = PluginTestHarness()
harness.register(MyPlugin())
reader = harness.get_reader("myformat")
assert reader is not None
It also works as a context manager:
def test_with_context():
with PluginTestHarness() as harness:
harness.register(MyPlugin())
...
See Testing a Plugin for the full testing guide.
Plugin validation
SpectroChemPy provides several validation helpers to check your plugin before registration:
check_plugin_metadata(plugin) -> list[str]Checks that required metadata fields (
name,version,plugin_api_version) are present and non-empty. Also warns ifdescriptionis missing.check_plugin_contributions(plugin) -> list[str]Calls each declarative hook (
register_readers,register_writers,register_processors,register_visualizers) and validates that the returned data has the correct structure (list of dicts with"name"and"func"keys).check_plugin_compatibility(plugin) -> list[str]Runs all checks at once: metadata, contributions, API version compatibility, and minimum SpectroChemPy version.
validate_plugin_compatibility(plugin) -> tuple[bool, list[str]]Legacy check used by
PluginManagerduring registration. Returns a boolean and a list of error messages.check_plugin_requires(plugin) -> list[str]Checks that optional dependencies declared via the
requiresclass attribute are importable. If any dependency is missing, its name is returned in the issue list.class MyPlugin(SpectroChemPyPlugin): requires = ["cantera>=3.0"]
Plugin managers also check requires automatically during
registration: missing dependencies cause the plugin to be marked
FAILED with a clear message, without affecting other plugins.
Usage:
from spectrochempy.api.plugins import check_plugin_compatibility
plugin = MyPlugin()
issues = check_plugin_compatibility(plugin)
if issues:
print("Compatibility issues found:")
for issue in issues:
print(f" - {issue}")
else:
print("Plugin is fully compatible.")
These helpers are designed to give plugin authors clear diagnostics
during development. They are also used internally by
PluginManager.register().