Plugins#

Added in version 0.3.0: Plugins were released in 0.3.0.

While fastapi.APIRouter can get you started building new endpoints for datasets quickly, the real extendability of Xpublish comes from it’s plugin system.

By using a plugin system, Xpublish becomes incredibly adaptable, and hopefully easier to develop for also. Individual plugins and their functionality can evolve independently, there are clear boundaries between types of functionality, which allows easier reasoning about code.

There are a few main varieties of plugins that Xpublish supports, but those provide a lot of flexibility, and can enable whole new categories of plugins and functionality.

Plugins work by implementing specific methods to support a variety of usage, and marking the implementations with a decorator. A plugin can also implement methods for multiple varieties, which may be useful for things like dynamic data providers.

Warning

Plugins are new to Xpublish, so we’re learning how everything works best together.

If you have any questions, please ask in Github Discussions (and feel free to tag @abkfenris for help with the plugin system).

Functionality#

Plugins are built as Pydantic models and descend from xpublish.plugins.hooks.Plugin. This allows there to be a common way of configuring plugins and their functionality.

from xpublish import Plugin


class HelloWorldPlugin(Plugin):
    name: str = "hello_world"

At the minimum, a plugin needs to specify a name attribute with a type annotation. For example, name: str = my_plugin_name.

Marking implementation methods#

We’ll go deeper into the specific methods below, what they have in common is that any method that a plugin is hoping to expose to the rest of Xpublish needs to be marked with a @hookimpl decorator.

from xpublish import Plugin, hookimpl
from fastapi import APIRouter

class HelloWorldPlugin(Plugin):
    name: str = "hello_world"

    @hookimpl
    def app_router(self):
        router = APIRouter()

        @router.get("/hello")
        def get_hello():
            return "world"

        return router

For the plugin system, Xpublish is using pluggy. Pluggy was developed to support pytest, but it now is used by several other projects including Tox, Datasette, and Conda, among others.

Pluggy implements plugins as a system of hooks, each one is a distinct way for Xpublish to communicate with plugins. Each hook has both reference specifications, and plugin provided implementations.

Most of the specifications are provided by Xpublish and are methods on xpublish.plugins.hooks.PluginSpec that are marked with @hookspec.

Plugins can then re-implement these methods with all or a subset of the arguments, which are then marked with @hookimpl to tell Pluggy to make them accessible to Xpublish (and other plugins).

Note

Over time Xpublish will most likely end up expanding the number of arugments passed into most hook methods.

Currently we’re starting with a minimum set of arguments as we can always expand, but currently it is much harder to reduce the number of arguments.

If there is a new argument that you would like your plugin hooks to have, please raise an issue to discuss including it in a future version.

In the specification, Xpublish defines if it’s supposed to get responses from all implementations (xpublish.plugins.hooks.PluginSpec.get_dataset_ids()), or the first non-None response (xpublish.plugins.hooks.PluginSpec.get_dataset()).

Pluggy also provides a lot more advanced functionality that we aren’t going to go into at this point, but could allow for creative things like dataset middleware.

Loading Local Plugins#

For plugins that you are not distributing, they can either be loaded directly via the xpublish.Rest initializer, or they can use xpublish.Rest.register_plugin() to load afterwards.

from xpublish import Rest

rest = Rest(datasets, plugins={"hello-world": HelloWorldPlugin()})
from xpublish import Rest

rest = Rest(datasets)
rest.register_plugin(HelloWorldPlugin())

Caution

When plugins are provided directly to the xpublish.Rest initializer as keyword arguments, it prevents Xpublish from automatically loading other plugins that are installed.

For more details of the automatic plugin loading system, see [entry points] below.

Entry Points#

When you install a plugin library, the library takes advantage of the entry point system.

This allows xpublish.Rest to automatically find and use plugins. It only does this if plugins are not provided as an keyword argument.

xpublish.Rest uses plugins.manage.load_default_plugins() to load plugins from entry points. It can be used directly and be set to disable specific plugins from being loaded, or plugins.manage.find_default_plugins() and plugins.manage.configure_plugins(), can be used to further tweak loading plugins from entrypoints.

To completely disable loading of plugins from entry points pass an empty dictionary to xpublish.Rest(datasets, plugins={}).

Example Entry Point#

Using xpublish-edr as an example.

The plugin is named CfEdrPlugin and is located in xpublish_edr/plugin.py.

In pyproject.toml that then is added to the [project.entry-points."xpublish.plugin"] table.

[project.entry-points."xpublish.plugin"]
cf_edr = "xpublish_edr.plugin:CfEdrPlugin"

Dependencies#

To allow plugins to be more adaptable, they should use xpublish.Dependencies.dataset() rather than directly importing xpublish.dependencies.get_dataset().

To facilitate this, xpublish.Dependencies is passed into router hook methods.

from fastapi import APIRouter, Depends
from xpublish import Plugin, Dependencies, hookimpl


class DatasetAttrs(Plugin):
    name: str = "dataset-attrs"

    @hookimpl
    def dataset_router(self, deps: Dependencies):
        router = APIRouter()

        @router.get("/attrs")
        def get_attrs(ds=Depends(deps.dataset)):
            return ds.attrs

        return router

xpublish.Dependencies has several other types of dependency functions that it includes.

Dataset Router Plugins#

Dataset router plugins are the next step from passing routers into xpublish.Rest.

By implementing xpublish.plugins.hooks.PluginSpec.dataset_router() a developer can add new routes that respond below /datasets/<dataset_id>/.

Most dataset routers will have a prefix on their paths, and apply tags. To make this reasonably standard, those should be specified as dataset_router_prefix and dataset_router_tags on the plugin allowing them to be reasonably overridden.

Adapted from xpublish/plugins/included/dataset_info.py

from fastapi import APIRouter, Depends
from xpublish import Plugin, Dependencies, hookimpl


class DatasetInfoPlugin(Plugin):
    name: str = "dataset-info"

    dataset_router_prefix = "/info"
    dataset_router_tags = ["info"]

    @hookimpl
    def dataset_router(self, deps: Dependencies):
        router = APIRouter(
            prefix=self.dataset_router_prefix, tags=self.dataset_router_tags
        )

        @router.get("/keys")
        def list_keys(dataset=Depends(deps.dataset)):
            return dataset.variables

        return router

This plugin will respond to /datasets/<dataset_id>/info/keys with a list of the keys in the dataset.

App Router Plugins#

App routers allow new top level routes to be provided by implementing xpublish.plugins.hooks.PluginSpec.app_router().

Similar to dataset routers, these should have a prefix (app_router_prefix) and tags (app_router_tags) that can be user overridable.

from fastapi import APIRouter, Depends
from xpublish import Plugin, Dependencies, hookimpl


class PluginInfo(Plugin):
    name: str = "plugin_info"

    app_router_prefix = "/info"
    app_router_tags = ["info"]

    @hookimpl
    def app_router(self, deps: Dependencies):
        router = APIRouter(prefix=self.app_router_prefix, tags=self.app_router_tags)

        @router.get("/plugins")
        def plugins(plugins: Dict[str, Plugin] = Depends(deps.plugins)):
            return {name: type(plugin) for name, plugin in plugins.items}

        return router

This will return a dictionary of plugin names, and types at /info/plugins.

Dataset Provider Plugins#

While Xpublish can have datasets passed in to xpublish.Rest on intialization, plugins can provide datasets (and they actually have priority over those passed in directly).

In order for a plugin to provide datasets it needs to implemenent xpublish.plugins.hooks.PluginSpec.get_datasets() and xpublish.plugins.hooks.PluginSpec.get_dataset() methods.

The first should return a list of all datasets that a plugin knows about.

The second is provided a dataset_id. The plugin should return a dataset if it knows about the dataset corresponding to the id, otherwise it should return None, so that Xpublish knows to continue looking to the next plugin or the passed in dictionary of datasets.

Warning

When creating a dataset provider, you need to set a unique _xpublish_id attribute (use DATASET_ID_ATTR_KEY from xpublish.utils.api) on each dataset for routers to manage caching appropriately. See the assign_attrs call below for an example.

We suggest including the plugin name as part of the attribute to help uniqueness.

A plugin that provides the Xarray tutorial air_temperature dataset.

from xpublish import Plugin, hookimpl
from xpublish.utils.api import DATASET_ID_ATTR_KEY


class TutorialDataset(Plugin):
    name: str = "xarray-tutorial-dataset"

    @hookimpl
    def get_datasets(self):
        return ["air"]

    @hookimpl
    def get_dataset(self, dataset_id: str):
        if dataset_id == "air":
            return xr.tutorial.open_dataset("air_temperature").assign_attrs(
                {DATASET_ID_ATTR_KEY: f"{self.name}_air"}
            )

        return None

Hook Spec Plugins#

Plugins can also provide new hook specifications that other plugins can then implement. This allows Xpublish to support things that we haven’t even thought of yet.

These return a class of hookspecs from xpublish.plugins.hooks.PluginSpec.register_hookspec().