diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 23c63b5d..dc1ee284 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -1,3 +1,5 @@ +:orphan: + Api Reference ============= diff --git a/docs/examples/eggsample-spam/eggsample_spam.py b/docs/examples/eggsample-spam/eggsample_spam.py new file mode 100644 index 00000000..87daa06e --- /dev/null +++ b/docs/examples/eggsample-spam/eggsample_spam.py @@ -0,0 +1,20 @@ +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 f"Now this is what I call a condiments tray!" diff --git a/docs/examples/eggsample-spam/setup.py b/docs/examples/eggsample-spam/setup.py new file mode 100644 index 00000000..ce2db8ba --- /dev/null +++ b/docs/examples/eggsample-spam/setup.py @@ -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']) diff --git a/docs/examples/eggsample/eggsample/__init__.py b/docs/examples/eggsample/eggsample/__init__.py new file mode 100644 index 00000000..4dc4b36d --- /dev/null +++ b/docs/examples/eggsample/eggsample/__init__.py @@ -0,0 +1,4 @@ +import pluggy + +hookimpl = pluggy.HookimplMarker("eggsample") +"""Marker to be imported and used in plugins (and for own implementations)""" diff --git a/docs/examples/eggsample/eggsample/hookspecs.py b/docs/examples/eggsample/eggsample/hookspecs.py new file mode 100644 index 00000000..cde5177c --- /dev/null +++ b/docs/examples/eggsample/eggsample/hookspecs.py @@ -0,0 +1,19 @@ +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 + """ diff --git a/docs/examples/eggsample/eggsample/host.py b/docs/examples/eggsample/eggsample/host.py new file mode 100644 index 00000000..d095a5d2 --- /dev/null +++ b/docs/examples/eggsample/eggsample/host.py @@ -0,0 +1,51 @@ +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() diff --git a/docs/examples/eggsample/eggsample/lib.py b/docs/examples/eggsample/eggsample/lib.py new file mode 100644 index 00000000..1cf67add --- /dev/null +++ b/docs/examples/eggsample/eggsample/lib.py @@ -0,0 +1,12 @@ +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 diff --git a/docs/examples/eggsample/setup.py b/docs/examples/eggsample/setup.py new file mode 100644 index 00000000..f411c2ab --- /dev/null +++ b/docs/examples/eggsample/setup.py @@ -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()) diff --git a/docs/examples/firstexample.py b/docs/examples/toy-example.py similarity index 76% rename from docs/examples/firstexample.py rename to docs/examples/toy-example.py index 3cec7cd2..5bc43e3a 100644 --- a/docs/examples/firstexample.py +++ b/docs/examples/toy-example.py @@ -5,17 +5,14 @@ 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()") @@ -23,8 +20,7 @@ def myhook(self, 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()") @@ -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) diff --git a/docs/index.rst b/docs/index.rst index 264c18db..c9b540a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,63 +1,174 @@ ``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 `_ +(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`` 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 ` defining call signatures + provided by the ``host`` (a.k.a ``hookspecs`` - see :ref:`marking_hooks`) + - the hook :ref:`implementations ` provided by registered + ``plugins`` (a.k.a ``hookimpl`` - see `callbacks`_) + - the hook :ref:`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*. +* ``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 replaces +the steak sauce (nobody likes that anyway) with spam sauce (it's a thing - trust me). -``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 + ``-`` (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 + +``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 --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`` + +.. 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 --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! + +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 `__ +* `tox `__ +* `devpi `__ 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. @@ -404,7 +515,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`. @@ -487,7 +598,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. @@ -674,7 +785,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: diff --git a/tox.ini b/tox.ini index 89d44e35..18475eb1 100644 --- a/tox.ini +++ b/tox.ini @@ -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