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

registry.io

Readers, writers, filetype associations

registry.processing

Processors, unit contexts, dtype handlers

registry.visualization

Visualizers

registry.extensions

Generic extensions: analyses, simulations, fit models, domain schemas

registry.metadata

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.extensions under 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.extensions under 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.extensions under 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

PluginCapability.READER

"reader"

PluginCapability.WRITER

"writer"

PluginCapability.PROCESSOR

"processor"

PluginCapability.VISUALIZER

"visualizer"

PluginCapability.ANALYSIS

"analysis"

PluginCapability.SIMULATION

"simulation"

PluginCapability.ACCESSOR

"accessor"

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

from spectrochempy.api.plugins import

from spectrochempy.plugins import

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

SpectroChemPyPlugin

Base class for plugins

PluginCapability

Enum: READER, WRITER, PROCESSOR, VISUALIZER

ReaderContribution

Dataclass for reader contributions

WriterContribution

Dataclass for writer contributions

ProcessorContribution

Dataclass for processor contributions

VisualizerContribution

Dataclass for visualizer contributions

AnalysisContribution

Dataclass for analysis contributions

SimulationContribution

Dataclass for simulation contributions

analysis_from_dict

Convert dict to AnalysisContribution

simulation_from_dict

Convert dict to SimulationContribution

check_plugin_requires

Check optional dependency availability

reader_from_dict

Convert dict to ReaderContribution

writer_from_dict

Convert dict to WriterContribution

processor_from_dict

Convert dict to ProcessorContribution

visualizer_from_dict

Convert dict to VisualizerContribution

PluginState

Enum: DISCOVERED, LOADED, ACTIVE, FAILED, DISABLED

PluginDescriptor

Dataclass for plugin state snapshot

MissingPluginError

Import error with install hint

PluginVersionError

Version incompatibility error

CORE_PLUGIN_API_VERSION

Current API version string ("1.0")

hookspec

Decorator for hook specifications

hookimpl

Decorator for hook implementations

validate_plugin_compatibility

Compatibility check (returns (bool, list[str]))

check_plugin_metadata

Metadata completeness check

check_plugin_contributions

Contribution structure validation

check_plugin_compatibility

Full compatibility check (all issues)

Plugin lifecycle

Every plugin managed by PluginManager passes through explicit lifecycle states:

State

Meaning

DISCOVERED

Entry point found via importlib.metadata

LOADED

Instantiated and validated, registered in pluggy

ACTIVE

All contributions registered in the registry

FAILED

Error during load, validation, or registration

DISABLED

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 if description is 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 PluginManager during registration. Returns a boolean and a list of error messages.

check_plugin_requires(plugin) -> list[str]

Checks that optional dependencies declared via the requires class 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().