Creating Plugins

After a lot of trial and error, the easiest way I have found to define an API is to follow these steps:

  1. Use the abc module to create a base abstract class to define the behaviors required of plugins of the API. Developers don’t have to subclass from the base class, but it provides a convenient way to document the API, and using an abstract base class keeps you honest.

  2. Create plugins by subclassing the base class and implementing the required methods.

  3. Define a unique namespace for each API by combining the name of the application (or library) and a name of the API. Keep it shallow. For example, “cliff.formatters” or “ceilometer.pollsters.compute”.

Example Plugin Set

The example program in this tutorial will create a plugin set with several data formatters, like what might be used by a command line program to prepare data to be printed to the console. Each formatter will take as input a dictionary with string keys and built-in data types as values. It will return as output an iterator that produces the string with the data structure formatted based on the rules of the specific formatter being used. The formatter’s constructor lets the caller specify the maximum width the output should have.

A Plugin Base Class

Step 1 above is to define an abstract base class for the API that needs to be implemented by each plugin.

# stevedore/example/base.py
# -*- coding: utf-8 -*-
# Copyright (C) 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import abc


class FormatterBase(metaclass=abc.ABCMeta):
    """Base class for example plugin used in the tutorial.
    """

    def __init__(self, max_width=60):
        self.max_width = max_width

    @abc.abstractmethod
    def format(self, data):
        """Format the data and return unicode text.

        :param data: A dictionary with string keys and simple types as
                     values.
        :type data: dict(str:?)
        :returns: Iterable producing the formatted text.
        """

The constructor is a concrete method because subclasses do not need to override it, but the format() method does not do anything useful because there is no “default” implementation available.

Concrete Plugins

The next step is to create a couple of plugin classes with concrete implementations of format(). A simple example formatter produces output with each variable name and value on a single line.

# stevedore/example/simple.py
# Copyright (C) 2020 Red Hat, Inc.
#
#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.
from stevedore.example import base


class Simple(base.FormatterBase):
    """A very basic formatter."""

    def format(self, data):
        """Format the data and return unicode text.

        :param data: A dictionary with string keys and simple types as
                     values.
        :type data: dict(str:?)
        """
        for name, value in sorted(data.items()):
            line = '{name} = {value}\n'.format(
                name=name,
                value=value,
            )
            yield line

There are plenty of other formatting options, but this example will give us enough to work with to demonstrate registering and using plugins.

Registering the Plugins

To use setuptools entry points, you must package your application or library using setuptools. The build and packaging process generates metadata which is available after installation to find the plugins provided by each python distribution.

The entry points must be declared as belonging to a specific namespace, so we need to pick one before going any further. These plugins are formatters from the stevedore examples, so I will use the namespace “stevedore.example.formatter”. Now it is possible to provide all of the necessary information in the packaging instructions:

# stevedore/example/setup.py
# Copyright (C) 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from setuptools import find_packages
from setuptools import setup

setup(
    name='stevedore-examples',
    version='1.0',

    description='Demonstration package for stevedore',

    author='Doug Hellmann',
    author_email='doug@doughellmann.com',

    url='http://opendev.org/openstack/stevedore',

    classifiers=['Development Status :: 3 - Alpha',
                 'License :: OSI Approved :: Apache Software License',
                 'Programming Language :: Python',
                 'Programming Language :: Python :: 2',
                 'Programming Language :: Python :: 2.7',
                 'Programming Language :: Python :: 3',
                 'Programming Language :: Python :: 3.5',
                 'Intended Audience :: Developers',
                 'Environment :: Console',
                 ],

    platforms=['Any'],

    scripts=[],

    provides=['stevedore.examples',
              ],

    packages=find_packages(),
    include_package_data=True,

    entry_points={
        'stevedore.example.formatter': [
            'simple = stevedore.example.simple:Simple',
            'plain = stevedore.example.simple:Simple',
        ],
    },

    zip_safe=False,
)

The important lines are near the bottom where the entry_points argument to setup() is set. The value is a dictionary mapping the namespace for the plugins to a list of their definitions. Each item in the list should be a string with name = module:importable where name is the user-visible name for the plugin, module is the Python import reference for the module, and importable is the name of something that can be imported from inside the module.

                 'Environment :: Console',
                 ],

    platforms=['Any'],

    scripts=[],

In this case, there are two plugins registered. The “simple” plugin defined above, and a “plain” plugin, which is just an alias for the simple plugin.

setuptools Metadata

During the build, setuptools copies entry point definitions to a file in the “.egg-info” directory for the package. For example, the file for stevedore is located in stevedore.egg-info/entry_points.txt:

[stevedore.example.formatter]
simple = stevedore.example.simple:Simple
plain = stevedore.example.simple:Simple

[stevedore.test.extension]
t2 = stevedore.tests.test_extension:FauxExtension
t1 = stevedore.tests.test_extension:FauxExtension

importlib.metadata uses the entry_points.txt file from all of the installed packages on the import path to find plugins. You should not modify these files, except by changing the list of entry points in setup.py.

Adding Plugins in Other Packages

Part of the appeal of using entry points for plugins is that they can be distributed independently of an application. The namespace setuptools uses to find the plugins is different from the Python source code namespace. It is common to use a plugin namespace prefixed with the name of the application or library that loads the plugins, to ensure it is unique, but that name has no bearing on what Python package the code for the plugin should live in.

For example, we can add an alternate implementation of a formatter plugin that produces a reStructuredText field list.

# stevedore/example2/fields.py
# -*- coding: utf-8 -*-
# Copyright (C) 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import textwrap

from stevedore.example import base


class FieldList(base.FormatterBase):
    """Format values as a reStructuredText field list.

    For example::

      : name1 : value
      : name2 : value
      : name3 : a long value
          will be wrapped with
          a hanging indent
    """

    def format(self, data):
        """Format the data and return unicode text.

        :param data: A dictionary with string keys and simple types as
                     values.
        :type data: dict(str:?)
        """
        for name, value in sorted(data.items()):
            full_text = ': {name} : {value}'.format(
                name=name,
                value=value,
            )
            wrapped_text = textwrap.fill(
                full_text,
                initial_indent='',
                subsequent_indent='    ',
                width=self.max_width,
            )
            yield wrapped_text + '\n'

The new plugin can then be packaged using a setup.py containing

# stevedore/example2/setup.py
# Copyright (C) 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from setuptools import find_packages
from setuptools import setup

setup(
    name='stevedore-examples2',
    version='1.0',

    description='Demonstration package for stevedore',

    author='Doug Hellmann',
    author_email='doug@doughellmann.com',

    url='http://opendev.org/openstack/stevedore',

    classifiers=['Development Status :: 3 - Alpha',
                 'License :: OSI Approved :: Apache Software License',
                 'Programming Language :: Python',
                 'Programming Language :: Python :: 2',
                 'Programming Language :: Python :: 2.7',
                 'Programming Language :: Python :: 3',
                 'Programming Language :: Python :: 3.5',
                 'Intended Audience :: Developers',
                 'Environment :: Console',
                 ],

    platforms=['Any'],

    scripts=[],

    provides=['stevedore.examples2',
              ],

    packages=find_packages(),
    include_package_data=True,

    entry_points={
        'stevedore.example.formatter': [
            'field = stevedore.example2.fields:FieldList',
        ],
    },

    zip_safe=False,
)

The new plugin is in a separate stevedore-examples2 package.

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.

However, the plugin is registered as part of the stevedore.example.formatter namespace.

                 'Intended Audience :: Developers',
                 'Environment :: Console',
                 ],

    platforms=['Any'],

When the plugin namespace is scanned, all packages on the current PYTHONPATH are examined and the entry point from the second package is found and can be loaded without the application having to know where the plugin is actually installed.