From f0192b976f93824891f5b73ec5488278adfe7834 Mon Sep 17 00:00:00 2001 From: kartikdp <73169017+kartikdp@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:49:03 +0530 Subject: [PATCH 1/6] warn on ambiguous -p plugin module usage --- changelog/14135.bugfix.rst | 1 + src/_pytest/config/__init__.py | 42 ++++++++++++++++++++++++++++++- testing/test_config.py | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 changelog/14135.bugfix.rst diff --git a/changelog/14135.bugfix.rst b/changelog/14135.bugfix.rst new file mode 100644 index 00000000000..fd4a4551884 --- /dev/null +++ b/changelog/14135.bugfix.rst @@ -0,0 +1 @@ +Pytest now warns when ``-p`` loads a module with no pytest hooks but a ``pytest11`` entry-point exists in one of its submodules, helping catch wrong plugin names when plugin autoloading is disabled. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 21dc35219d8..3ad7913613f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -878,8 +878,9 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No importspec = "_pytest." + modname if modname in builtin_plugins else modname self.rewrite_hook.mark_rewrite(importspec) + loaded = False if consider_entry_points: - loaded = self.load_setuptools_entrypoints("pytest11", name=modname) + loaded = bool(self.load_setuptools_entrypoints("pytest11", name=modname)) if loaded: return @@ -900,6 +901,45 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No self.skipped_plugins.append((modname, e.msg or "")) else: self.register(mod, modname) + if consider_entry_points and not loaded: + self._warn_about_submodule_entrypoint_plugin(modname, mod) + + def _warn_about_submodule_entrypoint_plugin( + self, modname: str, mod: _PluggyPlugin + ) -> None: + if self._plugin_has_pytest_hooks(mod): + return + + modname_prefix = f"{modname}." + suggested = { + ep.name + for dist in importlib.metadata.distributions() + for ep in dist.entry_points + if ep.group == "pytest11" + and ep.name != modname + and isinstance(getattr(ep, "value", None), str) + and getattr(ep, "value").split(":", 1)[0].startswith(modname_prefix) + } + if not suggested: + return + + suggestion = ( + f"-p {sorted(suggested)[0]}" + if len(suggested) == 1 + else "one of: " + ", ".join(f"-p {name}" for name in sorted(suggested)) + ) + warnings.warn( + PytestConfigWarning( + f'Plugin "{modname}" contains no pytest hooks. ' + f"Did you mean to use {suggestion}?" + ), + stacklevel=3, + ) + + def _plugin_has_pytest_hooks(self, plugin: _PluggyPlugin) -> bool: + return any( + self.parse_hookimpl_opts(plugin, attr) is not None for attr in dir(plugin) + ) def _get_plugin_specs_as_list( diff --git a/testing/test_config.py b/testing/test_config.py index de11e3fa13a..c9e267fce1b 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -10,6 +10,7 @@ import re import sys import textwrap +import types from typing import Any import _pytest._code @@ -30,6 +31,7 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import absolutepath from _pytest.pytester import Pytester +from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import PytestDeprecationWarning import pytest @@ -1690,6 +1692,50 @@ def distributions(): # __spec__ is present when testing locally on pypy, but not in CI ???? +def test_disable_plugin_autoload_warns_for_submodule_entrypoint( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + class DummyEntryPoint: + project_name = "pytest-recording" + name = "recording" + group = "pytest11" + version = "1.0" + value = "pytest_recording.plugin" + + def load(self): + return sys.modules[self.value] + + class Distribution: + metadata = {"name": "pytest-recording"} + entry_points = (DummyEntryPoint(),) + files = () + + def distributions(): + return (Distribution(),) + + top_level_plugin = types.ModuleType("pytest_recording") + submodule_plugin = types.ModuleType("pytest_recording.plugin") + + def pytest_addoption(parser): + parser.addoption("--block-network") + + setattr(submodule_plugin, "pytest_addoption", pytest_addoption) + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + monkeypatch.setitem(sys.modules, "pytest_recording", top_level_plugin) + monkeypatch.setitem(sys.modules, "pytest_recording.plugin", submodule_plugin) + + with pytest.warns( + PytestConfigWarning, + match=r'Plugin "pytest_recording" contains no pytest hooks\. Did you mean to use -p recording\?', + ): + config = pytester.parseconfig( + "--disable-plugin-autoload", "-p", "pytest_recording" + ) + + assert config.pluginmanager.get_plugin("pytest_recording") is not None + + def test_plugin_loading_order(pytester: Pytester) -> None: """Test order of plugin loading with `-p`.""" p1 = pytester.makepyfile( From 6d288ced87098b561c50be39f36816582fff60c5 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 7 Mar 2026 01:00:55 +0530 Subject: [PATCH 2/6] tests: cover plugin warning branches for -p --- testing/test_config.py | 133 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/testing/test_config.py b/testing/test_config.py index c9e267fce1b..02c56802cb7 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -12,6 +12,7 @@ import textwrap import types from typing import Any +import warnings import _pytest._code from _pytest.config import _get_plugin_specs_as_list @@ -1736,6 +1737,138 @@ def pytest_addoption(parser): assert config.pluginmanager.get_plugin("pytest_recording") is not None +def test_disable_plugin_autoload_does_not_warn_when_module_has_hooks( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + class DummyEntryPoint: + project_name = "pytest-recording" + name = "recording" + group = "pytest11" + version = "1.0" + value = "pytest_recording.plugin" + + def load(self): + return sys.modules[self.value] + + class Distribution: + metadata = {"name": "pytest-recording"} + entry_points = (DummyEntryPoint(),) + files = () + + def distributions(): + return (Distribution(),) + + plugin_with_hooks = types.ModuleType("pytest_recording") + + def pytest_addoption(parser): + parser.addoption("--block-network") + + setattr(plugin_with_hooks, "pytest_addoption", pytest_addoption) + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + monkeypatch.setitem(sys.modules, "pytest_recording", plugin_with_hooks) + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + config = pytester.parseconfig( + "--disable-plugin-autoload", "-p", "pytest_recording" + ) + + assert config.pluginmanager.get_plugin("pytest_recording") is not None + assert not [ + w + for w in captured + if isinstance(w.message, PytestConfigWarning) + and "contains no pytest hooks" in str(w.message) + ] + + +def test_disable_plugin_autoload_does_not_warn_when_no_submodule_entrypoint( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + class DummyEntryPoint: + project_name = "pytest-recording" + name = "recording" + group = "pytest11" + version = "1.0" + value = "other_plugin.plugin" + + def load(self): + return sys.modules[self.value] + + class Distribution: + metadata = {"name": "pytest-recording"} + entry_points = (DummyEntryPoint(),) + files = () + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + monkeypatch.setitem( + sys.modules, "pytest_recording", types.ModuleType("pytest_recording") + ) + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + config = pytester.parseconfig( + "--disable-plugin-autoload", "-p", "pytest_recording" + ) + + assert config.pluginmanager.get_plugin("pytest_recording") is not None + assert not [ + w + for w in captured + if isinstance(w.message, PytestConfigWarning) + and "contains no pytest hooks" in str(w.message) + ] + + +def test_disable_plugin_autoload_warns_for_multiple_submodule_entrypoints( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + class DummyEntryPoint: + project_name = "pytest-recording" + group = "pytest11" + version = "1.0" + + def __init__(self, name: str, value: str) -> None: + self.name = name + self.value = value + + def load(self): + return sys.modules[self.value] + + class Distribution: + metadata = {"name": "pytest-recording"} + entry_points = ( + DummyEntryPoint("recording", "pytest_recording.plugin"), + DummyEntryPoint("recording_alt", "pytest_recording.alt"), + ) + files = () + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + monkeypatch.setitem( + sys.modules, "pytest_recording", types.ModuleType("pytest_recording") + ) + + with pytest.warns( + PytestConfigWarning, + match=( + r'Plugin "pytest_recording" contains no pytest hooks\. ' + r"Did you mean to use one of: -p recording, -p recording_alt\?" + ), + ): + config = pytester.parseconfig( + "--disable-plugin-autoload", "-p", "pytest_recording" + ) + + assert config.pluginmanager.get_plugin("pytest_recording") is not None + + def test_plugin_loading_order(pytester: Pytester) -> None: """Test order of plugin loading with `-p`.""" p1 = pytester.makepyfile( From 9fb25c82d9b1515a2d594a3c1e32f89569032703 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 7 Mar 2026 01:20:12 +0530 Subject: [PATCH 3/6] tests: exercise direct plugin warning helper --- testing/test_config.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/testing/test_config.py b/testing/test_config.py index 02c56802cb7..8c159327a2f 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1869,6 +1869,36 @@ def distributions(): assert config.pluginmanager.get_plugin("pytest_recording") is not None +def test_warn_about_submodule_entrypoint_plugin_direct(pytester: Pytester, monkeypatch): + class DummyEntryPoint: + group = "pytest11" + version = "1.0" + + def __init__(self, name: str, value: str): + self.name = name + self.value = value + + class Distribution: + metadata = {"name": "pytest-recording"} + entry_points = (DummyEntryPoint("recording", "pytest_recording.plugin"),) + files = () + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + config = pytester.parseconfig() + plugin = types.ModuleType("pytest_recording") + + with pytest.warns( + PytestConfigWarning, + match=r'Plugin "pytest_recording" contains no pytest hooks\. Did you mean to use -p recording\?', + ): + config.pluginmanager._warn_about_submodule_entrypoint_plugin( + "pytest_recording", plugin + ) + + def test_plugin_loading_order(pytester: Pytester) -> None: """Test order of plugin loading with `-p`.""" p1 = pytester.makepyfile( From c92d06798de76b190645b8060d09428dc4336821 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 7 Mar 2026 01:32:05 +0530 Subject: [PATCH 4/6] tests: avoid uncovered dummy entry-point methods --- testing/test_config.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/testing/test_config.py b/testing/test_config.py index 8c159327a2f..4b0903e105d 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1703,9 +1703,6 @@ class DummyEntryPoint: version = "1.0" value = "pytest_recording.plugin" - def load(self): - return sys.modules[self.value] - class Distribution: metadata = {"name": "pytest-recording"} entry_points = (DummyEntryPoint(),) @@ -1747,9 +1744,6 @@ class DummyEntryPoint: version = "1.0" value = "pytest_recording.plugin" - def load(self): - return sys.modules[self.value] - class Distribution: metadata = {"name": "pytest-recording"} entry_points = (DummyEntryPoint(),) @@ -1793,9 +1787,6 @@ class DummyEntryPoint: version = "1.0" value = "other_plugin.plugin" - def load(self): - return sys.modules[self.value] - class Distribution: metadata = {"name": "pytest-recording"} entry_points = (DummyEntryPoint(),) @@ -1836,9 +1827,6 @@ def __init__(self, name: str, value: str) -> None: self.name = name self.value = value - def load(self): - return sys.modules[self.value] - class Distribution: metadata = {"name": "pytest-recording"} entry_points = ( From c19bda1b3eec509e122424d37dee938557d304c9 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 7 Mar 2026 01:44:38 +0530 Subject: [PATCH 5/6] config: keep plugin warning on one executable line --- src/_pytest/config/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 3ad7913613f..1c63f7fba6a 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -930,8 +930,7 @@ def _warn_about_submodule_entrypoint_plugin( ) warnings.warn( PytestConfigWarning( - f'Plugin "{modname}" contains no pytest hooks. ' - f"Did you mean to use {suggestion}?" + f'Plugin "{modname}" contains no pytest hooks. Did you mean to use {suggestion}?' ), stacklevel=3, ) From 7dd52bf5c79e04c94958e48dbf9f85732b35e703 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 7 Mar 2026 01:57:54 +0530 Subject: [PATCH 6/6] tests: avoid uncovered noop hook body --- testing/test_config.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/testing/test_config.py b/testing/test_config.py index 4b0903e105d..294a7c61abb 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1713,11 +1713,7 @@ def distributions(): top_level_plugin = types.ModuleType("pytest_recording") submodule_plugin = types.ModuleType("pytest_recording.plugin") - - def pytest_addoption(parser): - parser.addoption("--block-network") - - setattr(submodule_plugin, "pytest_addoption", pytest_addoption) + setattr(submodule_plugin, "pytest_addoption", lambda parser: None) monkeypatch.setattr(importlib.metadata, "distributions", distributions) monkeypatch.setitem(sys.modules, "pytest_recording", top_level_plugin)