Wiring a host library#

A host library is one that defines a public scientific API and wants to let backends optionally accelerate it.

1. Instantiate a BackendDispatcher#

# example_host/_backends/__init__.py
from scverse_backends import BackendDispatcher

_dispatcher = BackendDispatcher(
    entrypoint_group="example_host.backends",
    host_name="example_host",
    trusted_backends={
        "example_accel": {
            "aliases": ["example", "accelerated"],
            "package": "example-host-accel",
        },
    },
    reserved_backends={
        "generic": "Use a concrete backend alias instead.",
    },
)

backend_dispatch = _dispatcher.backend_dispatch
settings = _dispatcher.settings
get_backend = _dispatcher.get_backend
available_backend_names = _dispatcher.available_backend_names

BackendDispatcher is fully instance-scoped — every host owns its own registry, settings, and ContextVar. Multiple hosts can dispatch independently in the same process.

Constructor parameters#

entrypoint_group

The Python entrypoint group your backends register against. Convention is "<host_name>.backends".

host_name

Used in error messages and as the prefix of the settings ContextVar.

trusted_backends (optional)

Mapping of canonical backend name to {"aliases": [...], "package": "<pip-name>"}. Backends listed here skip the “untrusted” warning on first use, and the host’s error messages can suggest a pip install for known-but-not-installed backends. Any package can still plug in without being listed.

Trusted aliases are reserved. If accelerated is listed as an alias for example_accel, an unrelated backend cannot claim name="accelerated" or aliases=["accelerated"].

Trusted canonical names are also provider-verified during entrypoint discovery. By default, package is treated as the expected Python distribution name. If the same trusted backend can be shipped by multiple distributions, use distributions:

trusted_backends={
    "example_accel": {
        "aliases": ["example", "accelerated"],
        "package": "example-host-accel",
        "distributions": [
            "example-host-accel",
            "example-host-accel-cu12",
        ],
        "entrypoints": ["example_accel"],
        "module_prefixes": ["example_host_accel"],
    },
}

This prevents another installed package from registering an entrypoint whose adapter claims name="example_accel" unless the entrypoint is provided by one of the configured distributions. entrypoints, object_refs, and module_prefixes are optional extra checks for hosts that want a narrower handshake.

reserved_backends (optional)

Mapping of host-reserved backend names or aliases to human-readable reasons. No backend may claim these names. Use this for names that are too generic or intentionally left undefined by the host.

2. Decorate your public functions#

Use @backend_dispatch on module-level public functions. Instance methods and class methods are outside the dispatch contract because their self/cls binding does not map cleanly onto backend adapter callables.

# example_host/analysis.py
from example_host._backends import backend_dispatch

@backend_dispatch
def compute_score(data, *, method="fast", n_jobs=None, copy=False):
    """Compute a score.

    Parameters
    ----------
    data
        Input data.
    method
        Scoring method.
    n_jobs
        Number of CPU jobs (host-only — silently dropped on backends).
    copy
        Return a copy instead of writing to ``adata``.
    """
    ...

@backend_dispatch injects a backend=None keyword and (after backends are discovered) merges any backend-specific parameters into the function’s signature and numpydoc. None means “use the active setting”; backend="cpu" explicitly forces the host implementation for that call. The injected backend selector is documented with the host-owned Parameters, while backend-specific parameters are placed under Other Parameters and marked on the parameter line with the backend that provided them. That keeps host-owned knobs visually separate from optional backend package knobs.

For example, a backend-only parameter from example_accel is rendered as solver (example_accel) under Other Parameters.

3. Re-export settings for users#

# example_host/__init__.py
from example_host._backends import settings  # noqa: F401

4. Trigger eager discovery where signatures matter#

Discovery is lazy by default — backend entrypoints aren’t loaded until something actually queries the dispatcher (settings setter, get_backend, a non-CPU dispatched call). That keeps host import fast.

But help(my_func), IDE tooltips, and Sphinx autodoc introspect the function statically — they never trigger lazy discovery. Without an explicit nudge, those tools see only the host’s CPU-only signature.

BackendDispatcher.discover() is the explicit nudge:

# docs/conf.py
import example_host._backends as _b

_b._dispatcher.discover()  # so autodoc sees merged signatures
# in IDE startup, test setup, or anywhere else that introspects:
from example_host._backends import _dispatcher
_dispatcher.discover()

discover() is idempotent and safe to call repeatedly — internally it reuses the cached “original docstring” of every decorated function so re-running the merge never double-injects backend params.

Discovery is intentionally one-shot for installed entry points. It is not a runtime plugin reload mechanism; if a backend package is installed into an already-running Python process, restart the process before expecting the host to discover it. Functions decorated after discovery still get merged against the already-discovered backends.

Don’t call discover() from your top-level __init__.py. That forces every backend’s heavy imports (CUDA runtimes, JAX, …) to load on every host import — wiping out the lazy-discovery benefit. Save it for the few places that actually need merged signatures.

That’s all the host needs to do. Backend authors take it from here.