From 76141c56043e3e715585c81b9d3e38a27a59bfb0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 18:23:23 +0200 Subject: [PATCH] config: add plugin hookspecs at registration, not at Config.parse() pytest_addhooks was only ever driven from Config.parse(), so a PytestPluginManager used outside a full pytest run - for instance one obtained from get_plugin_manager(), which is documented for integration with other tools - never called it at all. A plugin registered on such a manager never got to contribute its own hookspecs, and its matching hook implementations were then rejected by check_pending() as unknown hooks. Move the historic call into PytestPluginManager.__init__, so that hookspec contribution belongs to plugin registration rather than to command line parsing. It has to move rather than be duplicated: calling it twice replays the history twice, and add_hookspecs() rejects the second pass. Fixes #2720. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/2720.bugfix.rst | 8 ++++++++ src/_pytest/config/__init__.py | 8 ++++---- testing/test_pluginmanager.py | 25 +++++++++++++++++++++---- 3 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 changelog/2720.bugfix.rst diff --git a/changelog/2720.bugfix.rst b/changelog/2720.bugfix.rst new file mode 100644 index 00000000000..ac947c74600 --- /dev/null +++ b/changelog/2720.bugfix.rst @@ -0,0 +1,8 @@ +Plugins now contribute their own hookspecs via :hook:`pytest_addhooks` as soon as they are +registered, instead of only when a :class:`~pytest.Config` parses a command line. + +Previously a :class:`~pytest.PytestPluginManager` obtained outside a full pytest run - for +example from ``_pytest.config.get_plugin_manager()``, which is documented for integration +with other tools - never called :hook:`pytest_addhooks` on the plugins registered with it. +Such a plugin's own hook implementations were then rejected with +``PluginValidationError: unknown hook ...``. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c7bd3e1afab..951e7098558 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -529,6 +529,10 @@ def __init__(self) -> None: self.add_hookspecs(_pytest.hookspec) self.register(self) + # Historic, so that plugins registered later - which is all of them - + # get to contribute their own hookspecs as they are registered, rather + # than only if and when a Config gets around to parsing a command line. + self.hook.pytest_addhooks.call_historic(kwargs=dict(pluginmanager=self)) if os.environ.get("PYTEST_DEBUG"): err: IO[str] = sys.stderr encoding: str = getattr(err, "encoding", "utf8") @@ -1634,10 +1638,6 @@ def parse(self, args: list[str], addopts: bool = True) -> None: "can only parse cmdline args at most once per Config object" ) - self.hook.pytest_addhooks.call_historic( - kwargs=dict(pluginmanager=self.pluginmanager) - ) - if addopts: env_addopts = os.environ.get("PYTEST_ADDOPTS", "") if len(env_addopts): diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 03887de61d9..9e2759a84bf 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -46,10 +46,6 @@ def pytest_myhook(xyz): """ ) config = _config_for_test - pm = config.pluginmanager - pm.hook.pytest_addhooks.call_historic( - kwargs=dict(pluginmanager=config.pluginmanager) - ) config.pluginmanager._importconftest( conf, importmode="prepend", @@ -60,6 +56,27 @@ def pytest_myhook(xyz): res = config.hook.pytest_myhook(xyz=10) assert res == [11] + def test_addhooks_on_registration(self, pytestpm: PytestPluginManager) -> None: + """A plugin contributes its hookspecs when it is registered, not when + some Config gets around to parsing a command line (#2720).""" + + class NewHooks: + def pytest_myhook(self, xyz): + """New hook""" + + class Plugin: + def pytest_addhooks(self, pluginmanager): + pluginmanager.add_hookspecs(NewHooks) + + def pytest_myhook(self, xyz): + return xyz + 1 + + pytestpm.register(Plugin()) + + assert pytestpm.hook.pytest_myhook.has_spec() + pytestpm.check_pending() + assert pytestpm.hook.pytest_myhook(xyz=10) == [11] + def test_addhooks_nohooks(self, pytester: Pytester) -> None: pytester.makeconftest( """