pluggy
¶
The pytest plugin system
What is it?¶
pluggy
is the crystallized core of plugin management and hook
calling for pytest.
It enables 500+ plugins to extend and customize pytest
’s default
behaviour. Even pytest
itself is composed as a set of pluggy
plugins
which are invoked in sequence according to a well defined set of protocols.
It gives users the ability to extend or modify the behaviour of a
host program
by installing a plugin
for that program.
The plugin code will run as part of normal program execution, changing or
enhancing certain aspects of it.
In essence, pluggy
enables function hooking so you can build
“pluggable” systems.
Why is it useful?¶
There are some established mechanisms for modifying the behavior of other
programs/libraries in Python like
method overriding
(e.g. Jinja2) or
monkey patching (e.g. gevent
or for writing tests).
These strategies become problematic though when several parties want to
participate in the modification of the same program. Therefore pluggy
does not rely on these mechanisms to enable a more structured approach and
avoid unnecessary exposure of state and behaviour. This leads to a more
loosely coupled relationship
between host
and plugins
.
The pluggy
approach puts the burden on the designer of the
host program
to think carefully about which objects are really
needed in a hook implementation. This gives plugin
creators a clear
framework for how to extend the host
via a well defined set of functions
and objects to work with.
How does it work?¶
Let us start with a short overview of what is involved:
host
orhost program
: the program offering extensibility by specifyinghook functions
and invoking their implementation(s) as part of program executionplugin
: the program implementing (a subset of) the specified hooks and participating in program execution when the implementations are invoked by thehost
pluggy
: connectshost
andplugins
by using …- the hook specifications defining call signatures
provided by the
host
(a.k.ahookspecs
- see Marking hooks) - the hook implementations provided by registered
plugins
(a.k.ahookimpl
- see callbacks) - the hook caller - a call loop triggered at appropriate
program positions in the
host
invoking the implementations and collecting the results
… where for each registered hook specification, a hook call will invoke up to
N
registered hook implementations.- the hook specifications defining call signatures
provided by the
user
: the person who installed thehost program
and wants to extend its functionality withplugins
. In the simplest case they install theplugin
in the same environment as thehost
and the magic will happen when thehost program
is run the next time. Depending on theplugin
, there might be other things they need to do. For example, they might have to call the host with an additional commandline parameter to the host that theplugin
added.
A toy example¶
Let us demonstrate the core functionality in one module and show how you can start experimenting with pluggy functionality.
import pluggy
hookspec = pluggy.HookspecMarker("myproject")
hookimpl = pluggy.HookimplMarker("myproject")
class MySpec:
"""A hook specification namespace."""
@hookspec
def myhook(self, arg1, arg2):
"""My special little hook that you can customize."""
class Plugin_1:
"""A hook implementation namespace."""
@hookimpl
def myhook(self, arg1, arg2):
print("inside Plugin_1.myhook()")
return arg1 + arg2
class Plugin_2:
"""A 2nd hook implementation namespace."""
@hookimpl
def myhook(self, arg1, arg2):
print("inside Plugin_2.myhook()")
return arg1 - arg2
# create a manager and add the spec
pm = pluggy.PluginManager("myproject")
pm.add_hookspecs(MySpec)
# register plugins
pm.register(Plugin_1())
pm.register(Plugin_2())
# call our `myhook` hook
results = pm.hook.myhook(arg1=1, arg2=2)
print(results)
Running this directly gets us:
$ python docs/examples/toy-example.py
inside Plugin_2.myhook()
inside Plugin_1.myhook()
[-1, 3]
A complete example¶
Now let us demonstrate how this plays together in a vaguely real world scenario.
Let’s assume our host program
is called eggsample where some eggs will
be prepared and served with a tray containing condiments. As everybody knows:
the more cooks are involved the better the food, so let us make the process
pluggable and write a plugin that improves the meal with some spam and replaces
the steak sauce (nobody likes that anyway) with spam sauce (it’s a thing - trust me).
Note
naming markers: HookSpecMarker
and HookImplMarker
must be
initialized with the name of the host
project (the name
parameter in setup()
) - so eggsample in our case.
naming plugin projects: they should be named in the form of
<host>-<plugin>
(e.g. pytest-xdist
), therefore we call our
plugin eggsample-spam.
The host¶
eggsample/eggsample/__init__.py
import pluggy
hookimpl = pluggy.HookimplMarker("eggsample")
"""Marker to be imported and used in plugins (and for own implementations)"""
eggsample/eggsample/hookspecs.py
import pluggy
hookspec = pluggy.HookspecMarker("eggsample")
@hookspec
def eggsample_add_ingredients(ingredients: tuple):
"""Have a look at the ingredients and offer your own.
:param ingredients: the ingredients, don't touch them!
:return: a list of ingredients
"""
@hookspec
def eggsample_prep_condiments(condiments: dict):
"""Reorganize the condiments tray to your heart's content.
:param condiments: some sauces and stuff
:return: a witty comment about your activity
"""
eggsample/eggsample/lib.py
import eggsample
@eggsample.hookimpl
def eggsample_add_ingredients():
spices = ["salt", "pepper"]
you_can_never_have_enough_eggs = ["egg", "egg"]
ingredients = spices + you_can_never_have_enough_eggs
return ingredients
@eggsample.hookimpl
def eggsample_prep_condiments(condiments):
condiments["mint sauce"] = 1
eggsample/eggsample/host.py
import itertools
import random
import pluggy
from eggsample import hookspecs, lib
condiments_tray = {"pickled walnuts": 13, "steak sauce": 4, "mushy peas": 2}
def main():
pm = get_plugin_manager()
cook = EggsellentCook(pm.hook)
cook.add_ingredients()
cook.prepare_the_food()
cook.serve_the_food()
def get_plugin_manager():
pm = pluggy.PluginManager("eggsample")
pm.add_hookspecs(hookspecs)
pm.load_setuptools_entrypoints("eggsample")
pm.register(lib)
return pm
class EggsellentCook:
FAVORITE_INGREDIENTS = ("egg", "egg", "egg")
def __init__(self, hook):
self.hook = hook
self.ingredients = None
def add_ingredients(self):
results = self.hook.eggsample_add_ingredients(
ingredients=self.FAVORITE_INGREDIENTS
)
my_ingredients = list(self.FAVORITE_INGREDIENTS)
# Each hook returns a list - so we chain this list of lists
other_ingredients = list(itertools.chain(*results))
self.ingredients = my_ingredients + other_ingredients
def prepare_the_food(self):
random.shuffle(self.ingredients)
def serve_the_food(self):
condiment_comments = self.hook.eggsample_prep_condiments(
condiments=condiments_tray
)
print(f"Your food. Enjoy some {', '.join(self.ingredients)}")
print(f"Some condiments? We have {', '.join(condiments_tray.keys())}")
if any(condiment_comments):
print("\n".join(condiment_comments))
if __name__ == "__main__":
main()
eggsample/setup.py
from setuptools import setup, find_packages
setup(
name="eggsample",
install_requires="pluggy>=0.3,<1.0",
entry_points={"console_scripts": ["eggsample=eggsample.host:main"]},
packages=find_packages(),
)
Let’s get cooking - we install the host and see what a program run looks like:
$ pip install --editable pluggy/docs/examples/eggsample
$ eggsample
Your food. Enjoy some egg, egg, salt, egg, egg, pepper, egg
Some condiments? We have pickled walnuts, steak sauce, mushy peas, mint sauce
The plugin¶
eggsample-spam/eggsample_spam.py
import eggsample
@eggsample.hookimpl
def eggsample_add_ingredients(ingredients):
"""Here the caller expects us to return a list."""
if "egg" in ingredients:
spam = ["lovely spam", "wonderous spam"]
else:
spam = ["splendiferous spam", "magnificent spam"]
return spam
@eggsample.hookimpl
def eggsample_prep_condiments(condiments):
"""Here the caller passes a mutable object, so we mess with it directly."""
try:
del condiments["steak sauce"]
except KeyError:
pass
condiments["spam sauce"] = 42
return "Now this is what I call a condiments tray!"
eggsample-spam/setup.py
from setuptools import setup
setup(
name="eggsample-spam",
install_requires="eggsample",
entry_points={"eggsample": ["spam = eggsample_spam"]},
py_modules=["eggsample_spam"],
)
Let’s get cooking with more cooks - we install the plugin and and see what we get:
$ pip install --editable pluggy/docs/examples/eggsample-spam
$ eggsample
Your food. Enjoy some egg, lovely spam, salt, egg, egg, egg, wonderous spam, egg, pepper
Some condiments? We have pickled walnuts, mushy peas, mint sauce, spam sauce
Now this is what I call a condiments tray!
Define and collect hooks¶
A plugin is a namespace type (currently one of a
class
or module) which defines a set of hook functions.
As mentioned in The Plugin registry, all plugins which specify hooks
are managed by an instance of a pluggy.PluginManager
which
defines the primary pluggy
API.
In order for a PluginManager
to detect functions in a namespace
intended to be hooks, they must be decorated using special pluggy
marks.
Marking hooks¶
The HookspecMarker
and HookimplMarker
decorators are used to mark functions for detection by a
PluginManager
:
from pluggy import HookspecMarker, HookimplMarker
hookspec = HookspecMarker("project_name")
hookimpl = HookimplMarker("project_name")
Each decorator type takes a single project_name
string as its
lone argument the value of which is used to mark hooks for detection by
a similarly configured PluginManager
instance.
That is, a mark type called with project_name
returns an object which
can be used to decorate functions which will then be detected by a
PluginManager
which was instantiated with the same
project_name
value.
Furthermore, each hookimpl or hookspec decorator can configure the underlying call-time behavior of each hook object by providing special options passed as keyword arguments.
Note
The following sections correspond to similar documentation in
pytest
for Writing hook functions and can be used as
a supplementary resource.
Implementations¶
A hook implementation (hookimpl) is just a (callback) function which has been appropriately marked.
hookimpls are loaded from a plugin using the
register()
method:
import sys
from pluggy import PluginManager, HookimplMarker
hookimpl = HookimplMarker("myproject")
@hookimpl
def setup_project(config, args):
"""This hook is used to process the initial config
and possibly input arguments.
"""
if args:
config.process_args(args)
return config
pm = PluginManager("myproject")
# load all hookimpls from the local module's namespace
plugin_name = pm.register(sys.modules[__name__])
Optional validation¶
Normally each hookimpl should be validated against a corresponding
hook specification. If you want to make an exception
then the hookimpl should be marked with the "optionalhook"
option:
@hookimpl(optionalhook=True)
def setup_project(config, args):
"""This hook is used to process the initial config
and possibly input arguments.
"""
if args:
config.process_args(args)
return config
Hookspec name matching¶
During plugin registration, pluggy attempts to match each
hook implementation declared by the plugin to a hook
specification in the host program with the same name as
the function being decorated by @hookimpl
(e.g. setup_project
in the
example above). Note: there is no strict requirement that each hookimpl
has a corresponding hookspec (see
enforcing spec validation).
new in version 0.13.2:
To override the default behavior, a hookimpl may also be matched to a
hookspec in the host program with a non-matching function name by using
the specname
option. Continuing the example above, the hookimpl function
does not need to be named setup_project
, but if the argument
specname="setup_project"
is provided to the hookimpl
decorator, it will
be matched and checked against the setup_project
hookspec:
@hookimpl(specname="setup_project")
def any_plugin_function(config, args):
"""This hook is used to process the initial config
and possibly input arguments.
"""
if args:
config.process_args(args)
return config
Call time order¶
By default hooks are called in LIFO registered order, however,
a hookimpl can influence its call-time invocation position using special
attributes. If marked with a "tryfirst"
or "trylast"
option it
will be executed first or last respectively in the hook call loop:
import sys
from pluggy import PluginManager, HookimplMarker
hookimpl = HookimplMarker("myproject")
@hookimpl(trylast=True)
def setup_project(config, args):
"""Default implementation."""
if args:
config.process_args(args)
return config
class SomeOtherPlugin:
"""Some other plugin defining the same hook."""
@hookimpl(tryfirst=True)
def setup_project(self, config, args):
"""Report what args were passed before calling
downstream hooks.
"""
if args:
print("Got args: {}".format(args))
return config
pm = PluginManager("myproject")
# load from the local module's namespace
pm.register(sys.modules[__name__])
# load a plugin defined on a class
pm.register(SomeOtherPlugin())
For another example see the Hook function ordering / call example section of the
pytest
docs.
Note
tryfirst
and trylast
hooks are still invoked in LIFO order within
each category.
Wrappers¶
A hookimpl can be marked with a "hookwrapper"
option which indicates that
the function will be called to wrap (or surround) all other normal hookimpl
calls. A hookwrapper can thus execute some code ahead and after the execution
of all corresponding non-wrappper hookimpls.
Much in the same way as a @contextlib.contextmanager
, hookwrappers must
be implemented as generator function with a single yield
in its body:
@hookimpl(hookwrapper=True)
def setup_project(config, args):
"""Wrap calls to ``setup_project()`` implementations which
should return json encoded config options.
"""
if config.debug:
print("Pre-hook config is {}".format(config.tojson()))
# get initial default config
defaults = config.tojson()
# all corresponding hookimpls are invoked here
outcome = yield
for item in outcome.get_result():
print("JSON config override is {}".format(item))
if config.debug:
print("Post-hook config is {}".format(config.tojson()))
if config.use_defaults:
outcome.force_result(defaults)
The generator is sent
a pluggy._callers._Result
object which can
be assigned in the yield
expression and used to override or inspect
the final result(s) returned back to the caller using the
force_result()
or
get_result()
methods.
Note
Hook wrappers can not return results (as per generator function
semantics); they can only modify them using the _Result
API.
Also see the hookwrapper: executing around other hooks section in the pytest
docs.
Specifications¶
A hook specification (hookspec) is a definition used to validate each hookimpl ensuring that an extension writer has correctly defined their callback function implementation .
hookspecs are defined using similarly marked functions however only the function signature (its name and names of all its arguments) is analyzed and stored. As such, often you will see a hookspec defined with only a docstring in its body.
hookspecs are loaded using the
add_hookspecs()
method and normally
should be added before registering corresponding hookimpls:
import sys
from pluggy import PluginManager, HookspecMarker
hookspec = HookspecMarker("myproject")
@hookspec
def setup_project(config, args):
"""This hook is used to process the initial config and input
arguments.
"""
pm = PluginManager("myproject")
# load from the local module's namespace
pm.add_hookspecs(sys.modules[__name__])
Registering a hookimpl which does not meet the constraints of its corresponding hookspec will result in an error.
A hookspec can also be added after some hookimpls have been registered however this is not normally recommended as it results in delayed hook validation.
Note
The term hookspec can sometimes refer to the plugin-namespace
which defines hookspec
decorated functions as in the case of
pytest
’s hookspec module
Enforcing spec validation¶
By default there is no strict requirement that each hookimpl has
a corresponding hookspec. However, if you’d like you enforce this
behavior you can run a check with the
check_pending()
method. If you’d like
to enforce requisite hookspecs but with certain exceptions for some hooks
then make sure to mark those hooks as optional.
Opt-in arguments¶
To allow for hookspecs to evolve over the lifetime of a project, hookimpls can accept less arguments then defined in the spec. This allows for extending hook arguments (and thus semantics) without breaking existing hookimpls.
In other words this is ok:
@hookspec
def myhook(config, args):
pass
@hookimpl
def myhook(args):
print(args)
whereas this is not:
@hookspec
def myhook(config, args):
pass
@hookimpl
def myhook(config, args, extra_arg):
print(args)
First result only¶
A hookspec can be marked such that when the hook is called the call loop
will only invoke up to the first hookimpl which returns a result other
then None
.
@hookspec(firstresult=True)
def myhook(config, args):
pass
This can be useful for optimizing a call loop for which you are only
interested in a single core hookimpl. An example is the
pytest_cmdline_main()
central routine of pytest
.
Note that all hookwrappers
are still invoked with the first result.
Also see the firstresult: stop at first non-None result section in the pytest
docs.
Historic hooks¶
You can mark a hookspec as being historic meaning that the hook
can be called with call_historic()
before
having been registered:
@hookspec(historic=True)
def myhook(config, args):
pass
The implication is that late registered hookimpls will be called back immediately at register time and can not return a result to the caller.
This turns out to be particularly useful when dealing with lazy or dynamically loaded plugins.
For more info see Exception handling.
Warnings on hook implementation¶
As projects evolve new hooks may be introduced and/or deprecated.
if a hookspec specifies a warn_on_impl
, pluggy will trigger it for any plugin implementing the hook.
@hookspec(
warn_on_impl=DeprecationWarning("oldhook is deprecated and will be removed soon")
)
def oldhook():
pass
The Plugin registry¶
pluggy
manages plugins using instances of the
pluggy.PluginManager
.
A PluginManager
is instantiated with a single
str
argument, the project_name
:
import pluggy
pm = pluggy.PluginManager("my_project_name")
The project_name
value is used when a PluginManager
scans for hook functions defined on a plugin.
This allows for multiple plugin managers from multiple projects
to define hooks alongside each other.
Registration¶
Each PluginManager
maintains a plugin registry where each plugin
contains a set of hookimpl definitions. Loading hookimpl and hookspec
definitions to populate the registry is described in detail in the section on
Define and collect hooks.
In summary, you pass a plugin namespace object to the
register()
and
add_hookspecs()
methods to collect
hook implementations and specifications from plugin namespaces respectively.
You can unregister any plugin’s hooks using
unregister()
and check if a plugin is
registered by passing its name to the
is_registered()
method.
Loading setuptools
entry points¶
You can automatically load plugins registered through
setuptools entry points
with the load_setuptools_entrypoints()
method.
An example use of this is the pytest entry point.
Blocking¶
You can block any plugin from being registered using
set_blocked()
and check if a given
plugin is blocked by name using is_blocked()
.
Inspection¶
You can use a variety of methods to inspect both the registry and particular plugins in it:
list_name_plugin()
- return a list of name-plugin pairsget_plugins()
- retrieve all pluginsget_canonical_name()
- get a plugin’s canonical name (the name it was registered with)get_plugin()
- retrieve a plugin by its canonical name
Parsing mark options¶
You can retrieve the options applied to a particular
hookspec or hookimpl as per Marking hooks using the
parse_hookspec_opts()
and
parse_hookimpl_opts()
respectively.
Calling hooks¶
The core functionality of pluggy
enables an extension provider
to override function calls made at certain points throughout a program.
A particular hook is invoked by calling an instance of
a pluggy._hooks._HookCaller
which in turn loops through the
1:N
registered hookimpls and calls them in sequence.
Every PluginManager
has a hook
attribute
which is an instance of this pluggy._hooks._HookRelay
.
The _HookRelay
itself contains references
(by hook name) to each registered hookimpl’s _HookCaller
instance.
More practically you call a hook like so:
import sys
import pluggy
import mypluginspec
import myplugin
from configuration import config
pm = pluggy.PluginManager("myproject")
pm.add_hookspecs(mypluginspec)
pm.register(myplugin)
# we invoke the _HookCaller and thus all underlying hookimpls
result_list = pm.hook.myhook(config=config, args=sys.argv)
Note that you must call hooks using keyword argument syntax!
Hook implementations are called in LIFO registered order: the last registered plugin’s hooks are called first. As an example, the below assertion should not error:
from pluggy import PluginManager, HookimplMarker
hookimpl = HookimplMarker("myproject")
class Plugin1:
@hookimpl
def myhook(self, args):
"""Default implementation."""
return 1
class Plugin2:
@hookimpl
def myhook(self, args):
"""Default implementation."""
return 2
class Plugin3:
@hookimpl
def myhook(self, args):
"""Default implementation."""
return 3
pm = PluginManager("myproject")
pm.register(Plugin1())
pm.register(Plugin2())
pm.register(Plugin3())
assert pm.hook.myhook(args=()) == [3, 2, 1]
Collecting results¶
By default calling a hook results in all underlying hookimpls functions to be invoked in sequence via a loop. Any function
which returns a value other then a None
result will have that result
appended to a list
which is returned by the call.
The only exception to this behaviour is if the hook has been marked to return
its first result only in which case only the first
single value (which is not None
) will be returned.
Exception handling¶
If any hookimpl errors with an exception no further callbacks are invoked and the exception is packaged up and delivered to any wrappers before being re-raised at the hook invocation point:
from pluggy import PluginManager, HookimplMarker
hookimpl = HookimplMarker("myproject")
class Plugin1:
@hookimpl
def myhook(self, args):
return 1
class Plugin2:
@hookimpl
def myhook(self, args):
raise RuntimeError
class Plugin3:
@hookimpl
def myhook(self, args):
return 3
@hookimpl(hookwrapper=True)
def myhook(self, args):
outcome = yield
try:
outcome.get_result()
except RuntimeError:
# log the error details
print(outcome.excinfo)
pm = PluginManager("myproject")
# register plugins
pm.register(Plugin1())
pm.register(Plugin2())
pm.register(Plugin3())
# register wrapper
pm.register(sys.modules[__name__])
# this raises RuntimeError due to Plugin2
pm.hook.myhook(args=())
Historic calls¶
A historic call allows for all newly registered functions to receive all hook calls that happened before their registration. The implication is that this is only useful if you expect that some hookimpls may be registered after the hook is initially invoked.
Historic hooks must be specially marked and called
using the call_historic()
method:
def callback(result):
print("historic call result is {result}".format(result=result))
# call with history; no results returned
pm.hook.myhook.call_historic(
kwargs={"config": config, "args": sys.argv}, result_callback=callback
)
# ... more of our program ...
# late loading of some plugin
import mylateplugin
# historic callback is invoked here
pm.register(mylateplugin)
Note that if you call_historic()
the _HookCaller
(and thus your calling code)
can not receive results back from the underlying hookimpl functions.
Instead you can provide a callback for processing results (like the
callback
function above) which will be called as each new plugin
is registered.
Note
historic calls are incompatible with First result only marked hooks since only the first registered plugin’s hook(s) would ever be called.
Calling with extras¶
You can call a hook with temporarily participating implementation functions
(that aren’t in the registry) using the
pluggy._hooks._HookCaller.call_extra()
method.
Calling with a subset of registered plugins¶
You can make a call using a subset of plugins by asking the
PluginManager
first for a
_HookCaller
with those plugins removed
using the pluggy.PluginManager.subset_hook_caller()
method.
You then can use that _HookCaller
to make normal, call_historic()
, or
call_extra()
calls as necessary.
Built-in tracing¶
pluggy
comes with some batteries included hook tracing for your
debugging needs.
Call tracing¶
To enable tracing use the
pluggy.PluginManager.enable_tracing()
method which returns an
undo function to disable the behaviour.
pm = PluginManager("myproject")
# magic line to set a writer function
pm.trace.root.setwriter(print)
undo = pm.enable_tracing()
Call monitoring¶
Instead of using the built-in tracing mechanism you can also add your
own before
and after
monitoring functions using
pluggy.PluginManager.add_hookcall_monitoring()
.
The expected signature and default implementations for these functions is:
def before(hook_name, methods, kwargs):
pass
def after(outcome, hook_name, methods, kwargs):
pass
Public API¶
Please see the Api Reference.
Development¶
Great care must taken when hacking on pluggy
since multiple mature
projects rely on it. Our Github integrated CI process runs the full
tox test suite on each commit so be sure your changes can run on
all required Python interpreters and pytest
versions.
For development, we suggest to create a virtual environment and install pluggy
in
editable mode and dev
dependencies:
$ python3 -m venv .env
$ source .env/bin/activate
$ pip install -e .[dev]
To make sure you follow the code style used in the project, install pre-commit which will run style checks before each commit:
$ pre-commit install
Release Policy¶
Pluggy uses Semantic Versioning. Breaking changes are only foreseen for
Major releases (incremented X in “X.Y.Z”). If you want to use pluggy
in your project you should thus use a dependency restriction like
"pluggy>=0.1.0,<1.0"
to avoid surprises.
Table of contents¶
- Api Reference
- Changelog
- pluggy 1.0.0 (2021-08-25)
- pluggy 0.13.1 (2019-11-21)
- pluggy 0.13.0 (2019-09-10)
- pluggy 0.12.0 (2019-05-27)
- pluggy 0.11.0 (2019-05-07)
- pluggy 0.10.0 (2019-05-07)
- pluggy 0.9.0 (2019-02-21)
- pluggy 0.8.1 (2018-11-09)
- pluggy 0.8.0 (2018-10-15)
- pluggy 0.7.1 (2018-07-28)
- pluggy 0.7.0 (Unreleased)
- pluggy 0.6.0 (2017-11-24)
- pluggy 0.5.2 (2017-09-06)
- pluggy 0.5.1 (2017-08-29)
- pluggy 0.5.0 (2017-08-28)
- pluggy 0.4.0 (2016-09-25)
- pluggy 0.3.1 (2015-09-17)
- pluggy 0.3.0 (2015-05-07)