Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve docs: introduce roles and add another example #136

Merged
merged 16 commits into from
May 17, 2018
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
:orphan:

Api Reference
=============

Expand Down
16 changes: 16 additions & 0 deletions docs/examples/eggsample-spam/eggsample_spam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import eggsample

@eggsample.hookimpl
def eggsample_add_ingredients(ingredients):
if len([e for e in ingredients if e == "egg"]) > 2:
ingredients.remove("egg")
spam = ["lovely spam", "wonderous spam", "splendiferous spam"]
print(f"Add {spam}")
return spam

@eggsample.hookimpl
def eggsample_prep_condiments(condiments):
try:
del condiments["steak sauce"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

condiments.pop('steak sauce', None) is maybe a little more succinct?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You think so? I'd say you use del if you want to get rid of something and list.pop if you want to do something with the value. Here the steak sauce is just removed from the tray to vanish into thin air, so I'd use del here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's dict.pop but sure. I don't mind either way too much.

except KeyError:
pass
5 changes: 5 additions & 0 deletions docs/examples/eggsample-spam/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from setuptools import setup

setup(name="eggsample-spam", install_requires="eggsample",
entry_points={'eggsample': ['spam = eggsample_spam']},
py_modules=['eggsample_spam'])
4 changes: 4 additions & 0 deletions docs/examples/eggsample/eggsample/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import pluggy

hookimpl = pluggy.HookimplMarker("eggsample")
"""Marker to be imported and used in plugins (and for own implementations)"""
17 changes: 17 additions & 0 deletions docs/examples/eggsample/eggsample/hookspecs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import pluggy

hookspec = pluggy.HookspecMarker("eggsample")

@hookspec
def eggsample_add_ingredients(ingredients):
"""Change the used ingredients.

:param list ingredients: the ingredients to be modified
"""

@hookspec
def eggsample_prep_condiments(condiments):
"""Reorganize the condiments tray.

:param dict condiments: some sauces and stuff
"""
39 changes: 39 additions & 0 deletions docs/examples/eggsample/eggsample/host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import random

import pluggy

from eggsample import hookspecs, lib

condiments_tray = {"pickled walnuts": 13, "steak sauce": 4, "mushy peas": 2}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, gotta try me some pickled walnuts.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a Wikipedia approved british condiment, so it must be good!


class EggsellentCook:
def __init__(self, hook):
self.hook = hook
self.ingredients = []

def add_ingredients(self):
more_ingredients = self.hook.eggsample_add_ingredients(
ingredients=self.ingredients)
# each hook implementation returned a list of ingredients
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@obestwalter hmm so more_ingredients will now be a list of lists - I'm not sure self.ingredients will now be the same as before so you're examples in the docs might be wrong.

The easy way to fix this might be to do:

for ingredients in self.hook.eggsample_add_ingredients(ingredients=self.ingredients):
    self.ingredients.extend(ingredients)

self.ingredients.extend(more_ingredients)

def prepare_the_food(self):
random.shuffle(self.ingredients)

def serve_the_food(self):
self.hook.eggsample_prep_condiments(condiments=condiments_tray)
print(f"Your food: {', '.join(self.ingredients)}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will now join a bunch of list items with a , If I'm not mistaken since each hookimpl returns a list and you're extending self.ingredients with the list of lists.

print(f"Some condiments: {', '.join(condiments_tray.keys())}")

def main():
pluginmanager = pluggy.PluginManager("eggsample")
pluginmanager.add_hookspecs(hookspecs)
pluginmanager.load_setuptools_entrypoints("eggsample")
pluginmanager.register(lib)
cook = EggsellentCook(pluginmanager.hook)
cook.add_ingredients()
cook.prepare_the_food()
cook.serve_the_food()

if __name__ == '__main__':
main()
11 changes: 11 additions & 0 deletions docs/examples/eggsample/eggsample/lib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import eggsample

@eggsample.hookimpl
def eggsample_add_ingredients(ingredients):
basics = ["egg", "egg", "salt", "pepper"]
print(f"Add {basics}")
return basics

@eggsample.hookimpl
def eggsample_prep_condiments(condiments):
condiments["mint sauce"] = 1
5 changes: 5 additions & 0 deletions docs/examples/eggsample/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
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())
14 changes: 4 additions & 10 deletions docs/examples/firstexample.py → docs/examples/toy-example.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,22 @@


class MySpec(object):
"""A hook specification namespace.
"""
"""A hook specification namespace."""
@hookspec
def myhook(self, arg1, arg2):
"""My special little hook that you can customize.
"""
"""My special little hook that you can customize."""


class Plugin_1(object):
"""A hook implementation namespace.
"""
"""A hook implementation namespace."""
@hookimpl
def myhook(self, arg1, arg2):
print("inside Plugin_1.myhook()")
return arg1 + arg2


class Plugin_2(object):
"""A 2nd hook implementation namespace.
"""
"""A 2nd hook implementation namespace."""
@hookimpl
def myhook(self, arg1, arg2):
print("inside Plugin_2.myhook()")
Expand All @@ -34,11 +30,9 @@ def myhook(self, 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)
187 changes: 150 additions & 37 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,63 +1,176 @@
``pluggy``
==========
**The pytest plugin system**

The ``pytest`` plugin system
****************************
What is it?
***********
``pluggy`` is the crystallized core of `plugin management and hook
calling`_ for `pytest`_.
calling`_ for `pytest`_. It enables `200+ 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 <https://en.wikipedia.org/wiki/Method_overriding>`_
(e.g. Jinja2) or
`monkey patching <https://en.wikipedia.org/wiki/Monkey_patch>`_ (e.g. gevent
or for `writing tests <https://docs.pytest.org/en/latest/monkeypatch.html>`_).
These strategies become problematic though when several parties want to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bravo on this description; well done!

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 <https://en.wikipedia.org/wiki/Loose_coupling>`_ 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, which 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`` or ``host program``: the program offering extensibility
by specifying ``hook functions`` and invoking their implementation(s) as
part of program execution
* ``plugin``: the program implementing (a subset of) the specified hooks and
participating in program execution when the implementations are invoked
by the ``host``
* ``pluggy``: connects ``host`` and ``plugins`` by using ...

- the hook :ref:`specifications <specs>` defining call signatures
provided by the ``host`` (a.k.a ``hookspecs`` - see :ref:`marking_hooks`)
- the hook :ref:`implementations <impls>` provided by registered
``plugins`` (a.k.a ``hookimpl`` - see `callbacks`_)
- the hook :ref:`caller <calling>` - 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*.
* ``user``: the person who installed the ``host program`` and wants to
extend its functionality with ``plugins``. In the simplest case they install
the ``plugin`` in the same environment as the ``host`` and the magic will
happen when the ``host program`` is run the next time. Depending on
the ``plugin``, 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 the ``plugin`` added.

A toy example
-------------
Let us demonstrate the core functionality in one module and show how you can
start experimenting with pluggy functionality.

In fact, ``pytest`` is itself composed as a set of ``pluggy`` plugins
which are invoked in sequence according to a well defined set of protocols.
Some `200+ plugins`_ use ``pluggy`` to extend and customize ``pytest``'s default behaviour.
.. literalinclude:: examples/toy-example.py

In essence, ``pluggy`` enables function `hooking`_ so you can build "pluggable" systems.
Running this directly gets us::

How's it work?
--------------
A `plugin` is a `namespace`_ which defines hook functions.
$ python docs/examples/toy-example.py

``pluggy`` manages *plugins* by relying on:
inside Plugin_2.myhook()
inside Plugin_1.myhook()
[-1, 3]

- a hook *specification* - defines a call signature
- a set of hook *implementations* - aka `callbacks`_
- the hook *caller* - a call loop which collects results
A complete example
------------------
Now let us demonstrate how this plays together in a vaguely real world scenario.

where for each registered hook *specification*, a hook *call* will invoke up to ``N``
registered hook *implementations*.
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 removes
the steak sauce from the condiments tray (nobody likes that anyway).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lolz definitely not on eggs.


``pluggy`` accomplishes all this by implementing a `request-response pattern`_ using *function*
subscriptions and can be thought of and used as a rudimentary busless `publish-subscribe`_
event system.
.. note::

``pluggy``'s approach is meant to let a designer think carefully about which objects are
explicitly needed by an extension writer. This is in contrast to subclass-based extension
systems which may expose unnecessary state and behaviour or encourage `tight coupling`_
in overlying frameworks.
**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*.

A first example
---------------
The host
^^^^^^^^
``eggsample/eggsample/__init__.py``

.. literalinclude:: examples/firstexample.py
.. literalinclude:: examples/eggsample/eggsample/__init__.py

Running this directly gets us::
``eggsample/eggsample/hookspecs.py``

$ python docs/examples/firstexample.py
.. literalinclude:: examples/eggsample/eggsample/hookspecs.py

inside Plugin_2.myhook()
inside Plugin_1.myhook()
[-1, 3]
``eggsample/eggsample/lib.py``

.. literalinclude:: examples/eggsample/eggsample/lib.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally like including line numbers with :linenos:.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah - yes

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it ... and removed it again. Looks absoulutely horrific due to sphinx-doc/sphinx#2427 and does not really add too much utility IMO.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meh, not that big of a deal. Yeah just avoid the headache.


``eggsample/eggsample/host.py``

.. literalinclude:: examples/eggsample/eggsample/host.py

Let's get cooking - we install the host and see what a program run looks like::

$ pip install -e pluggy/docs/examples/eggsample
$ eggsample

Add ['egg', 'egg', 'salt', 'pepper']
Your food: egg, salt, pepper, egg
Some condiments: pickled walnuts, steak sauce, mushy peas, mint sauce

The plugin
^^^^^^^^^^
``eggsample-spam/eggsample_spam.py``

.. literalinclude:: examples/eggsample-spam/eggsample_spam.py

``eggsample-spam/setup.py``

.. literalinclude:: examples/eggsample-spam/setup.py

Let's get cooking with more cooks - we install the plugin and and see what
we get::

$ pip install -e pluggy/docs/examples/eggsample-spam
$ eggsample

Add ['egg', 'egg', 'salt', 'pepper']
add ['lovely spam', 'wonderous spam', 'splendiferous spam']
Your food: egg, lovely spam, egg, pepper, salt, wonderous spam, splendiferous spam
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah just double check this with a test to make sure either I'm wrong or this needs to be fixed.

Some condiments: pickled walnuts, mushy peas, mint sauce

More real world examples
------------------------
To see how ``pluggy`` is used in the real world, have a look at these projects
documentation and source code:

* `pytest <https://docs.pytest.org/en/latest/writing_plugins.html>`__
* `tox <https://tox.readthedocs.io/en/latest/plugins.html>`__
* `devpi <https://devpi.net/docs/devpi/devpi/stable/+d/devguide/index.html>`__

For more details and advanced usage please read on.

.. _define:

Defining and Collecting Hooks
*****************************
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 :ref:`manage`, all *plugins* which define *hooks*
As mentioned in :ref:`manage`, all *plugins* which specify *hooks*
are managed by an instance of a :py:class:`pluggy.PluginManager` which
defines the primary ``pluggy`` API.

Expand Down Expand Up @@ -404,7 +517,7 @@ For more info see :ref:`call_historic`.

.. _manage:

The Plugin Registry
The Plugin registry
*******************
``pluggy`` manages plugins using instances of the
:py:class:`pluggy.PluginManager`.
Expand Down Expand Up @@ -487,7 +600,7 @@ You can retrieve the *options* applied to a particular

.. _calling:

Calling Hooks
Calling hooks
*************
The core functionality of ``pluggy`` enables an extension provider
to override function calls made at certain points throughout a program.
Expand Down Expand Up @@ -674,7 +787,7 @@ in your project you should thus use a dependency restriction like

.. hyperlinks
.. _pytest:
http://pytest.org
https://pytest.org
.. _request-response pattern:
https://en.wikipedia.org/wiki/Request%E2%80%93response
.. _publish-subscribe:
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ deps =
sphinx
pygments
commands =
sphinx-build -b html {toxinidir}/docs {toxinidir}/build/html-docs
sphinx-build -W -b html {toxinidir}/docs {toxinidir}/build/html-docs

[pytest]
minversion=2.0
Expand Down