From 90e19c2d62cf7019603e9b75c8d7d4381fa1b469 Mon Sep 17 00:00:00 2001 From: starx710 <342042680+starx710@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:11:48 +0800 Subject: [PATCH] docs: add directory plugin discovery example --- changelog/258.doc.rst | 2 + docs/examples/directory_plugins/hookspecs.py | 9 +++ docs/examples/directory_plugins/host.py | 28 +++++++++ .../directory_plugins/plugins/__init__.py | 0 .../directory_plugins/plugins/english.py | 9 +++ .../directory_plugins/plugins/french.py | 9 +++ docs/index.rst | 62 +++++++++++++++++++ testing/test_directory_example.py | 50 +++++++++++++++ 8 files changed, 169 insertions(+) create mode 100644 changelog/258.doc.rst create mode 100644 docs/examples/directory_plugins/hookspecs.py create mode 100644 docs/examples/directory_plugins/host.py create mode 100644 docs/examples/directory_plugins/plugins/__init__.py create mode 100644 docs/examples/directory_plugins/plugins/english.py create mode 100644 docs/examples/directory_plugins/plugins/french.py create mode 100644 testing/test_directory_example.py diff --git a/changelog/258.doc.rst b/changelog/258.doc.rst new file mode 100644 index 00000000..78f31d4b --- /dev/null +++ b/changelog/258.doc.rst @@ -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. diff --git a/docs/examples/directory_plugins/hookspecs.py b/docs/examples/directory_plugins/hookspecs.py new file mode 100644 index 00000000..3a6575d4 --- /dev/null +++ b/docs/examples/directory_plugins/hookspecs.py @@ -0,0 +1,9 @@ +import pluggy + + +hookspec = pluggy.HookspecMarker("greetings") + + +@hookspec +def greeting(name: str) -> str: + """Return a greeting for the given name.""" diff --git a/docs/examples/directory_plugins/host.py b/docs/examples/directory_plugins/host.py new file mode 100644 index 00000000..67ef9f97 --- /dev/null +++ b/docs/examples/directory_plugins/host.py @@ -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() diff --git a/docs/examples/directory_plugins/plugins/__init__.py b/docs/examples/directory_plugins/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/examples/directory_plugins/plugins/english.py b/docs/examples/directory_plugins/plugins/english.py new file mode 100644 index 00000000..6316dc41 --- /dev/null +++ b/docs/examples/directory_plugins/plugins/english.py @@ -0,0 +1,9 @@ +import pluggy + + +hookimpl = pluggy.HookimplMarker("greetings") + + +@hookimpl +def greeting(name: str) -> str: + return f"Hello {name}!" diff --git a/docs/examples/directory_plugins/plugins/french.py b/docs/examples/directory_plugins/plugins/french.py new file mode 100644 index 00000000..ec262308 --- /dev/null +++ b/docs/examples/directory_plugins/plugins/french.py @@ -0,0 +1,9 @@ +import pluggy + + +hookimpl = pluggy.HookimplMarker("greetings") + + +@hookimpl +def greeting(name: str) -> str: + return f"Bonjour {name}!" diff --git a/docs/index.rst b/docs/index.rst index 6a67265e..a3efa20e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/testing/test_directory_example.py b/testing/test_directory_example.py new file mode 100644 index 00000000..a669a28d --- /dev/null +++ b/testing/test_directory_example.py @@ -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 == ""