diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index d778334b..b6d6bdc1 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -46,6 +46,17 @@ def _warn_for_function(warning: Warning, function: Callable[..., object]) -> Non ) +def _attr_is_property(obj: object, name: str) -> bool: + """Check if a given attr is a @property on a module, class, or object""" + if inspect.ismodule(obj): + return False # modules can never have @property methods + + base_class = obj if inspect.isclass(obj) else type(obj) + if isinstance(getattr(base_class, name, None), property): + return True + return False + + class PluginValidationError(Exception): """Plugin failed validation. @@ -181,7 +192,20 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None customize how hook implementation are picked up. By default, returns the options for items decorated with :class:`HookimplMarker`. """ - method: object = getattr(plugin, name) + + if _attr_is_property(plugin, name): + # @property methods can have side effects, and are never hookimpls + return None + + method: object + try: + method = getattr(plugin, name) + except AttributeError: + # AttributeError: '__signature__' attribute of 'plugin' is class-only + # can be raised when trying to access some descriptor/proxied fields + # https://github.com/pytest-dev/pluggy/pull/536#discussion_r1786431032 + return None + if not inspect.isroutine(method): return None try: @@ -286,7 +310,18 @@ def parse_hookspec_opts( customize how hook specifications are picked up. By default, returns the options for items decorated with :class:`HookspecMarker`. """ - method = getattr(module_or_class, name) + if _attr_is_property(module_or_class, name): + # @property methods can have side effects, and are never hookspecs + return None + + method: object + try: + method = getattr(module_or_class, name) + except AttributeError: + # AttributeError: '__signature__' attribute of is class-only + # can be raised when trying to access some descriptor/proxied fields + # https://github.com/pytest-dev/pluggy/pull/536#discussion_r1786431032 + return None opts: HookspecOpts | None = getattr(method, self.project_name + "_spec", None) return opts diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index c4ce08f3..8e21ccbe 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -123,6 +123,23 @@ class A: assert pm.register(A(), "somename") +def test_register_ignores_properties(he_pm: PluginManager) -> None: + class ClassWithProperties: + property_was_executed: bool = False + + @property + def some_func(self): + self.property_was_executed = True # pragma: no cover + return None # pragma: no cover + + # test registering it as a class + he_pm.register(ClassWithProperties) + # test registering it as an instance + test_plugin = ClassWithProperties() + he_pm.register(test_plugin) + assert not test_plugin.property_was_executed + + def test_register_mismatch_method(he_pm: PluginManager) -> None: class hello: @hookimpl