The hook-based plugin architecture of py.test

Floris Bruynooghe

flub@devork.be

Welcome

  • py.test:
    • mature testing tool
    • ~150 plugins
  • pluggy:
    • stand-alone py.test plugin system
    • < 1.0

This Talk

  • Intro to how plugins in pluggy work
  • Features and benefits
  • "everything is a plugin" design

The Shape of a Plugin

  • extend an application
  • dynamicly loaded
  • plugin implements hook functions
  • applications call hooks
  • 1:N - multiple impls/hook
    • 1:1 possible too

Pluggy

Implementing a plugin using pluggy

A Simple CURL

# curl.py
import requests

def main(url):
    session = requests.Session()
    request = requests.Request('GET', url)
    prepped = request.prepare()
    response = session.send(prepped)
    print(response.text)

if __name__ == '__main__':
    main('http://localhost:8000/curl.py')

The Hook Specification

# hookspec.py
import pluggy

hookspec = pluggy.HookspecMarker('curl')

@hookspec
def curl_prepare_headers(headers, session):
    """Prepare the HTTP headers.

    :param headers: HTTP headers structure to modify in-place.
    :type headers: dict
    :param session: HTTP session object
    :type session: requests.Session
    """
    pass
  • Hook specifications have no code
  • curl_-prefix is convention

The Plugin

# plugin.py
import curl

@curl.hookimpl
def curl_prepare_headers(headers):
    headers['X-Spam'] = 'eggs'
  • Arguments are optional

The Plugin Manager

# curl.py
import requests, pluggy, hookspec, importlib

hookimpl = pluggy.HookimplMarker('curl')

def main(url):
    pm = pluggy.PluginManager('curl')
    pm.add_hookspecs(hookspec)
    plugin = importlib.import_module('plugin')
    pm.register(plugin)

    session = requests.Session()
    request = requests.Request('GET', url)
    prepped = request.prepare()

    pm.hook.curl_prepare_headers(headers=prepped.headers, session=session)

    response = session.send(prepped)
    print(response.text)

if __name__ == '__main__':
    main('http://localhost:8000/curl.py')

Return Values

  • 1:N calls
  • Results as a list
  • None is swallowed

The Hookspec

# hookspec.py
import pluggy

hookspec = pluggy.HookspecMarker('curl')

@hookspec
def curl_prepare_headers(headers, session):
    pass

@hookspec
def curl_filter_request(request):
    """Filter a request.

    Return False to stop the request.
    """

The Plugin

# plugin.py
import curl

@curl.hookimpl
def curl_prepare_headers(headers):
    headers['X-Spam'] = 'eggs'

@curl.hookimpl
def curl_filter_request(request):
    return True

Calling the Hook

# curl.py
import requests, pluggy, hookspec, importlib

hookimpl = pluggy.HookimplMarker('curl')

def main(url):
    pm = pluggy.PluginManager('curl')
    pm.add_hookspecs(hookspec)
    plugin = importlib.import_module('plugin')
    pm.register(plugin)

    session = requests.Session()
    request = requests.Request('GET', url)
    prepped = request.prepare()

    pm.hook.curl_prepare_headers(headers=prepped.headers)
    filters = pm.hook.curl_filter_request(request=prepped)
    if all(filters):
        response = session.send(prepped)
        print(response.text)
    else:
        print('E: request not allowed')

if __name__ == '__main__':
    main('http://localhost:8000/curl.py')

Pluggy Recap

  • Application defines hook specifications
    • Function signatures
  • Plugins implement hook functions
    • Arguments are optional
  • 1:N calling, returns a list
  • Skipped more advanced features
    • setuptools entrypoints
    • hook ordering
    • hookwrapping
    • plugins defining new hooks

Benefits of Pluggy's Approach

  • Hooks can evolve
    • New arguments do not affect plugins
  • No state or behaviour
    • Plugins are free to store state

Plugins all the Way Down

Entire application composed of plugins

Plugins all the Way Down

  • All functionality implemented using plugins
  • One module bootstraps the PluginManager
  • Applicable to different kinds of applications:
    • command line tools (py.test, tox)
    • long running daemons
  • Ensures useful plugin API

Bootstrap the Plugins

# curl.py
import sys, pluggy, hookspec, core

CORE_PLUGINS = [core]

def main(argv):
    pm = pluggy.PluginManager('curl')
    pm.add_hookspecs(hookspec)
    for plugin in CORE_PLUGINS:
        pm.register(plugin)
    ret = pm.hook.curl_main(pluginmanager=pm, argv=argv)
    sys.exit(ret)

if __name__ == '__main__':
    main(['http://localhost:8000/curl.py'])

Plugins Drive the Application

# core.py
import requests, pluggy

hookimpl = pluggy.HookimplMarker('curl')

@hookimpl
def curl_main(pluginmanager, argv):
    pm = pluginmanager
    config = Config(argv, pm)
    pm.hook.curl_configure(config=config)
    cli_session = CliSession(config)
    pm.hook.curl_sessionstart(session=cli_session)

    pm.hook.curl_make_request(config=config, session=cli_session)

    pm.hook.curl_sessionfinish(session=cli_session)
    pm.hook.curl_unconfigure(config=config)
    return 0

Using Config/CliSession

# core.py
class Config:
    def __init__(self, argv, pm):
        self.url = argv[0]
        self.pm = pm

class CliSession:
    def __init__(self, config):
        self.config = config
        self.http_session = requests.Session()

@hookimpl
def curl_make_request(session, config):
    request = requests.Request('GET', config.url)
    prepped = request.prepare()
    config.pm.hook.curl_prepare_headers(headers=prepped.headers)
    response = session.http_session.send(prepped)
    print(response.text)

Handling Command Line Arguments

# core.py
import argparse

class Config:
    def __init__(self, argv, pm):
        self.pm = pm
        parser = argparse.ArgumentParser(prog='curl')
        parser.add_argument('--version', action='version', version='1.0')
        pm.hook.curl_addargument(parser=parser)
        self.args = parser.parse_args(argv)

@hookimpl
def curl_addargument(parser):
    parser.add_argument('url', action='store')

@hookimpl
def curl_make_request(session, config):
    request = requests.Request('GET', config.args.url)
    prepped = request.prepare()
    config.pm.hook.curl_prepare_headers(headers=prepped.headers)
    response = session.http_session.send(prepped)
    print(response.text)

py.test hooks

main()
 +- PyTestPluginManager()
 +- Config()
 +- import+register default built-in plugins
 |   +- pytest_plugin_registerd()
 +- pytest_namespace()
 +- pytest_addoption()
 +- pytest_cmdline_parse() 1:1
 +- pytest_cmdline_main() 1:1
     +- Session()
     +- pytest_configure()
     +- pytest_session_start()
     +- pytest_collection() 1:1
     |   +- pytest_collectreport() per item
     |   +- pytest_collection_modifyitems()
     |   +- pytest_collection_finish()
     +- pytest_runtestloop()
     |   +- pytest_runtest_protocol() per item
     |       +- pytest_runtest_logstart()
     |       +- pytest_runtest_setup()
     |       +- pytest_runtest_call()
     |       +- pytest_runtest_teardown()
     +- pytest_sessionfinish()
     +- pytest_unconfigure()

Summary

  • Unique hook calling behaviour
    • Eases evolving of APIs
  • No structure/behaviour imposed
    • Keeps small plugins simple
    • Plugins are free to keep state
  • Very few demands on application
    • Easy to integrate into existing app
  • Plugins all the way down
    • Can be interesting way to think

Questions?

Thanks for listening!

flub@devork.be

@flubdevork

cobe.png

And thanks to cobe.io for sponsoring my EuroPython attendance.