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( """