Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 changelog/258.doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added a runnable example showing how a host can discover and register plugin
modules from a directory, with separate hook specifications and implementations.
9 changes: 9 additions & 0 deletions docs/examples/directory_plugins/hookspecs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import pluggy


hookspec = pluggy.HookspecMarker("greetings")


@hookspec
def greeting(name: str) -> str:
"""Return a greeting for the given name."""
28 changes: 28 additions & 0 deletions docs/examples/directory_plugins/host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import importlib
import pkgutil

import hookspecs
import plugins

import pluggy


def main() -> None:
manager = pluggy.PluginManager("greetings")
manager.add_hookspecs(hookspecs)

module_names = sorted(
info.name
for info in pkgutil.iter_modules(plugins.__path__, plugins.__name__ + ".")
if not info.ispkg
)
for name in module_names:
manager.register(importlib.import_module(name), name=name)
manager.check_pending()

for greeting in manager.hook.greeting(name="Ada"):
print(greeting)


if __name__ == "__main__":
main()
Empty file.
9 changes: 9 additions & 0 deletions docs/examples/directory_plugins/plugins/english.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import pluggy


hookimpl = pluggy.HookimplMarker("greetings")


@hookimpl
def greeting(name: str) -> str:
return f"Hello {name}!"
9 changes: 9 additions & 0 deletions docs/examples/directory_plugins/plugins/french.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import pluggy


hookimpl = pluggy.HookimplMarker("greetings")


@hookimpl
def greeting(name: str) -> str:
return f"Bonjour {name}!"
62 changes: 62 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,68 @@ we get::
Some condiments? We have pickled walnuts, mushy peas, mint sauce, spam sauce
Now this is what I call a condiments tray!

Discovering plugins in a directory
----------------------------------
If your host ships with several plugin modules in one directory, it can discover
them using Python's import tools and register each module with pluggy. This is
useful when adding a plugin should not require editing a list of imports in the
host.

Here is a small host that collects greetings from plugins in a ``plugins``
package. The hook specification belongs to the host, while each plugin module
contains its own implementation::

docs/examples/directory_plugins/
host.py
hookspecs.py
plugins/
__init__.py
english.py
french.py

``plugins/__init__.py`` is empty; it makes the directory a regular Python
package. The host's specification defines the hook name and arguments:

.. literalinclude:: examples/directory_plugins/hookspecs.py

Each plugin uses the same project name, ``greetings``, for its hook marker:

.. literalinclude:: examples/directory_plugins/plugins/english.py

.. literalinclude:: examples/directory_plugins/plugins/french.py

The host discovers the package's modules with :func:`pkgutil.iter_modules`,
imports them with :func:`importlib.import_module`, and explicitly registers each
module with :meth:`~pluggy.PluginManager.register`:

.. literalinclude:: examples/directory_plugins/host.py

Run the host from the repository root, with pluggy installed in your Python
environment::

$ python docs/examples/directory_plugins/host.py
Bonjour Ada!
Hello Ada!

The script's directory is on Python's import path, so ``hookspecs`` and
``plugins`` are importable without changing the working directory. The example
visits only direct modules in ``plugins``; it skips subpackages and does not
search them recursively. Adding another plugin module to that directory makes
it available on the next run without changing ``host.py``.

Sorting the module names makes registration deterministic: ``english`` is
registered before ``french``. These ordinary hook implementations have no
ordering options, so pluggy calls them in reverse registration order, yielding
the French greeting first. See :ref:`callorder` for other ordering options.
After registration, :meth:`~pluggy.PluginManager.check_pending` checks that
plugins have not implemented unknown, non-optional hooks.

Discovery is the host's responsibility; pluggy handles registration and hook
calls. Importing a module executes its code, so choose a plugin directory whose
contents you trust. For plugins distributed as separately installed packages,
see :meth:`~pluggy.PluginManager.load_setuptools_entrypoints` and the complete
example above.

More real world examples
------------------------
To see how ``pluggy`` is used in the real world, have a look at these projects
Expand Down
50 changes: 50 additions & 0 deletions testing/test_directory_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from pathlib import Path
import shutil
import subprocess
import sys


EXAMPLE = Path(__file__).resolve().parents[1] / "docs/examples/directory_plugins"


def test_directory_example(tmp_path):
result = subprocess.run(
[sys.executable, str(EXAMPLE / "host.py")],
cwd=tmp_path,
capture_output=True,
text=True,
check=True,
timeout=10,
)
assert result.stdout == "Bonjour Ada!\nHello Ada!\n"
assert result.stderr == ""


def test_directory_example_discovers_new_modules_and_skips_packages(tmp_path):
example = tmp_path / "directory_plugins"
shutil.copytree(EXAMPLE, example)
(example / "plugins/spanish.py").write_text(
"import pluggy\n"
'hookimpl = pluggy.HookimplMarker("greetings")\n'
"@hookimpl\n"
"def greeting(name):\n"
' return f"Hola {name}!"\n',
encoding="utf-8",
)
subpackage = example / "plugins/subpackage"
subpackage.mkdir()
(subpackage / "__init__.py").write_text(
'raise RuntimeError("Subpackages should not be imported")\n',
encoding="utf-8",
)

result = subprocess.run(
[sys.executable, str(example / "host.py")],
cwd=tmp_path,
capture_output=True,
text=True,
check=True,
timeout=10,
)
assert result.stdout == "Hola Ada!\nBonjour Ada!\nHello Ada!\n"
assert result.stderr == ""