diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index 9d9e873b..6f03357d 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -18,9 +18,10 @@ "HookspecMarker", "HookimplMarker", "Result", + "force_not_a_hook", ] -from ._manager import PluginManager, PluginValidationError +from ._manager import PluginManager, PluginValidationError, force_not_a_hook from ._result import HookCallError, Result from ._hooks import ( HookspecMarker, diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 84717e6e..8eea0b41 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -11,6 +11,7 @@ from typing import Iterable from typing import Mapping from typing import Sequence +from typing import TypeVar from . import _tracing from ._callers import _multicall @@ -72,6 +73,32 @@ def __dir__(self) -> list[str]: return sorted(dir(self._dist) + ["_dist", "project_name"]) +_T = TypeVar("_T") + + +_pluggy_hide_attr_name = "_pluggy_hide_mark" + + +def force_not_a_hook(obj: _T) -> _T: + """ + Use this to mark a function or method as *hidden* from discovery as a plugin hook. + + This is useful in rare cases. Use it where hooks are discovered by prefix name but some objects exist with + matching names which are _not_ to be considered as hook implementations. + + e.g. When using _pytest_, use this marker to mark a function that has a name starting with 'pytest_' but which + is not intended to be picked up as a hook implementation. + + >>> @force_not_a_hook + ... def pytest_some_function(arg1): + ... # For some reason, we needed to name this function with `pytest_` prefix, but we don't want it treated as a + ... # hook + """ + assert not hasattr(obj, _pluggy_hide_attr_name) + setattr(obj, _pluggy_hide_attr_name, True) + return obj + + class PluginManager: """Core class which manages registration of plugin objects and 1:N hook calling. @@ -148,7 +175,7 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: self._name2plugin[plugin_name] = plugin # register matching hook implementations of the plugin - for name in dir(plugin): + for name in self._find_plugin_attrs(plugin): hookimpl_opts = self.parse_hookimpl_opts(plugin, name) if hookimpl_opts is not None: normalize_hookimpl_opts(hookimpl_opts) @@ -165,6 +192,24 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: hook._add_hookimpl(hookimpl) return plugin_name + def _find_plugin_attrs(self, plugin: _Namespace) -> Iterable[str]: + """ + Override this method to customize the way we select the attribute names from an object to inspect as potential + hook implementations. + + The results from this method will run through `parse_hookimpl_opts` which may do additional filtering. + """ + for name in dir(plugin): # , method in inspect.getmembers(plugin): + try: + method = getattr(plugin, name, None) + pluggy_hide_mark = getattr(method, _pluggy_hide_attr_name, None) + except Exception: + # Ignore all kinds of exceptions. Pluggy has to be very exception-tolerant during plugin registration + continue + + if pluggy_hide_mark is not True: + yield name + def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None: """Try to obtain a hook implementation from an item with the given name in the given plugin which is being searched for hook impls. diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 81b86b65..3e28dc49 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -7,6 +7,7 @@ import pytest +from pluggy import force_not_a_hook from pluggy import HookCallError from pluggy import HookimplMarker from pluggy import HookspecMarker @@ -211,6 +212,26 @@ def he_method1(self, arg): assert len(hookcallers) == 1 +def test_register_force_not_hook(pm: PluginManager) -> None: + class Hooks: + @hookspec + def he_method1(self): + pass + + pm.add_hookspecs(Hooks) + + class Plugin: + @force_not_a_hook + @hookimpl + def he_method1(self): + return 1 + + pm.register(Plugin()) + hc = pm.hook + out = hc.he_method1() + assert out == [] + + def test_register_historic(pm: PluginManager) -> None: class Hooks: @hookspec(historic=True)