From 24895d0ddc6154f02e711daa6dd480f40546464c Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 26 Sep 2024 20:39:13 -0700 Subject: [PATCH 1/9] Avoid triggering property methods when inspecting plugin attribute signatures --- src/pluggy/_manager.py | 14 ++++++++++++++ testing/test_pluginmanager.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index d778334b..a52044f0 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -181,6 +181,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`. """ + + # IMPORTANT: @property methods can have side effects, and are never hookimpl + # if attr is a property, skip it in advance + plugin_class = plugin if inspect.isclass(plugin) else type(plugin) + if isinstance(getattr(plugin_class, name, None), property): + return None + + # pydantic model fields are like attrs and also can never be hookimpls + plugin_is_pydantic_obj = hasattr(plugin, "__pydantic_core_schema__") + if plugin_is_pydantic_obj and name in getattr(plugin, "model_fields", {}): + # pydantic models mess with the class and attr __signature__ + # so inspect.isroutine(...) throws exceptions and cant be used + return None + method: object = getattr(plugin, name) if not inspect.isroutine(method): return None diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index c4ce08f3..67ea6789 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -4,6 +4,7 @@ import importlib.metadata from typing import Any +from typing import Dict from typing import List import pytest @@ -123,6 +124,36 @@ class A: assert pm.register(A(), "somename") +def test_register_skips_properties(he_pm: PluginManager) -> None: + class ClassWithProperties: + property_was_executed: bool = False + + @property + def some_func(self): + self.property_was_executed = True + return None + + test_plugin = ClassWithProperties() + he_pm.register(test_plugin) + assert not test_plugin.property_was_executed + + +def test_register_skips_pydantic_fields(he_pm: PluginManager) -> None: + class PydanticModelClass: + # stub to make object look like a pydantic model + model_fields: Dict[str, bool] = {"some_attr": True} + + def __pydantic_core_schema__(self): ... + + @hookimpl + def some_attr(self): ... + + test_plugin = PydanticModelClass() + he_pm.register(test_plugin) + with pytest.raises(AttributeError): + he_pm.hook.some_attr.get_hookimpls() + + def test_register_mismatch_method(he_pm: PluginManager) -> None: class hello: @hookimpl From aae3799a8efa9a8d5f24e74b3a69a807f16c41a3 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 26 Sep 2024 23:01:53 -0700 Subject: [PATCH 2/9] Add exception handler to deal with proxy object attrs as well --- src/pluggy/_manager.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index a52044f0..05407cfb 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -191,11 +191,16 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None # pydantic model fields are like attrs and also can never be hookimpls plugin_is_pydantic_obj = hasattr(plugin, "__pydantic_core_schema__") if plugin_is_pydantic_obj and name in getattr(plugin, "model_fields", {}): - # pydantic models mess with the class and attr __signature__ - # so inspect.isroutine(...) throws exceptions and cant be used return None - method: object = getattr(plugin, name) + method: object + try: + method = getattr(plugin, name) + except AttributeError: + # AttributeError: '__signature__' attribute of 'Plugin' is class-only + # can happen for some special objects (e.g. proxies, pydantic, etc.) + method = getattr(type(plugin), name) # use class sig instead + if not inspect.isroutine(method): return None try: From 90d9f80d74658d16cffee86c9f2a9e3651eb72db Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 1 Oct 2024 13:17:55 -0700 Subject: [PATCH 3/9] remove pydantic-specific logic and support hookspecs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.7 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.7...v0.6.8) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .pre-commit-config.yaml | 2 +- src/pluggy/_manager.py | 38 ++++++++++++++++++++++++----------- testing/test_pluginmanager.py | 23 +++------------------ 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40f6141e..05c80cfc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.7" + rev: "v0.6.8" hooks: - id: ruff args: ["--fix"] diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 05407cfb..e9905ccd 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: Any, 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. @@ -182,23 +193,16 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None options for items decorated with :class:`HookimplMarker`. """ - # IMPORTANT: @property methods can have side effects, and are never hookimpl - # if attr is a property, skip it in advance - plugin_class = plugin if inspect.isclass(plugin) else type(plugin) - if isinstance(getattr(plugin_class, name, None), property): - return None - - # pydantic model fields are like attrs and also can never be hookimpls - plugin_is_pydantic_obj = hasattr(plugin, "__pydantic_core_schema__") - if plugin_is_pydantic_obj and name in getattr(plugin, "model_fields", {}): + 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 happen for some special objects (e.g. proxies, pydantic, etc.) + # AttributeError: '__signature__' attribute of 'plugin' is class-only + # can happen if plugin is a proxy object wrapping a class/module method = getattr(type(plugin), name) # use class sig instead if not inspect.isroutine(method): @@ -305,7 +309,17 @@ 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 happen if module_or_class is a proxy obj wrapping a class/module + method = getattr(type(module_or_class), name) # use class sig instead 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 67ea6789..d412f7a5 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -4,7 +4,6 @@ import importlib.metadata from typing import Any -from typing import Dict from typing import List import pytest @@ -124,36 +123,20 @@ class A: assert pm.register(A(), "somename") -def test_register_skips_properties(he_pm: PluginManager) -> None: +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 - return None + self.property_was_executed = True # pragma: no cover + return None # pragma: no cover test_plugin = ClassWithProperties() he_pm.register(test_plugin) assert not test_plugin.property_was_executed -def test_register_skips_pydantic_fields(he_pm: PluginManager) -> None: - class PydanticModelClass: - # stub to make object look like a pydantic model - model_fields: Dict[str, bool] = {"some_attr": True} - - def __pydantic_core_schema__(self): ... - - @hookimpl - def some_attr(self): ... - - test_plugin = PydanticModelClass() - he_pm.register(test_plugin) - with pytest.raises(AttributeError): - he_pm.hook.some_attr.get_hookimpls() - - def test_register_mismatch_method(he_pm: PluginManager) -> None: class hello: @hookimpl From 2536c621ffb91a8499d6cb3cb8f83995aee2a86b Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 1 Oct 2024 13:41:35 -0700 Subject: [PATCH 4/9] explicitly test that properties are ignored on both classes and instances --- testing/test_pluginmanager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index d412f7a5..8e21ccbe 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -132,6 +132,9 @@ 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 From c0789a6b8e4d9f0f97dd55d0fba34c80476bd5e5 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 3 Oct 2024 10:49:43 -0400 Subject: [PATCH 5/9] swap _attr_is_property arg Any type to object type Co-authored-by: Ran Benita --- src/pluggy/_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index e9905ccd..9064b521 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -46,7 +46,7 @@ def _warn_for_function(warning: Warning, function: Callable[..., object]) -> Non ) -def _attr_is_property(obj: Any, name: str) -> bool: +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 From bfe9f915d89000f25bdc6640b3c3dd877f784c32 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 24 Oct 2024 17:44:31 -0400 Subject: [PATCH 6/9] Return None instead of falling back to getattr(type(plugin)) --- src/pluggy/_manager.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 9064b521..b6d6bdc1 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -202,8 +202,9 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None method = getattr(plugin, name) except AttributeError: # AttributeError: '__signature__' attribute of 'plugin' is class-only - # can happen if plugin is a proxy object wrapping a class/module - method = getattr(type(plugin), name) # use class sig instead + # 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 @@ -318,8 +319,9 @@ def parse_hookspec_opts( method = getattr(module_or_class, name) except AttributeError: # AttributeError: '__signature__' attribute of is class-only - # can happen if module_or_class is a proxy obj wrapping a class/module - method = getattr(type(module_or_class), name) # use class sig instead + # 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 From 5f5498179c365eb7e916813727a83155d9723439 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 21 Jul 2026 09:59:09 +0200 Subject: [PATCH 7/9] Address review: soften AttributeError comments and add descriptor test Document the generic descriptor/proxy AttributeError skip and cover it with a minimal raising-descriptor plugin so the path is tested without pydantic. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/pluggy/_manager.py | 8 ++------ testing/test_pluginmanager.py | 27 +++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index d168160f..e6361d20 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -204,9 +204,7 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None 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 + # May be raised when trying to access some descriptor/proxied fields. return None if not inspect.isroutine(method): @@ -321,9 +319,7 @@ def parse_hookspec_opts( 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 + # May be raised when trying to access some descriptor/proxied fields. 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 2f70d7b6..b38a87c8 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -133,14 +133,37 @@ def some_func(self): self.property_was_executed = True # pragma: no cover return None # pragma: no cover - # test registering it as a class + # Registering the class is harmless (getattr returns the property object). he_pm.register(ClassWithProperties) - # test registering it as an instance + # Registering an instance must not evaluate the property getter. test_plugin = ClassWithProperties() he_pm.register(test_plugin) assert not test_plugin.property_was_executed +def test_register_ignores_raising_descriptors(he_pm: PluginManager) -> None: + """Names in dir() whose getattr raises AttributeError are skipped.""" + + class RaisingDescriptor: + def __get__(self, obj: object, owner: type | None = None) -> object: + raise AttributeError("descriptor access failed") + + class PluginWithRaisingDescriptor: + weird_attr = RaisingDescriptor() + + @hookimpl + def he_method1(self, arg: object) -> list[object]: + return [arg] + + plugin = PluginWithRaisingDescriptor() + assert "weird_attr" in dir(plugin) + with pytest.raises(AttributeError): + getattr(plugin, "weird_attr") + + he_pm.register(plugin) + assert he_pm.hook.he_method1(arg=1) == [[1]] + + def test_register_mismatch_method(he_pm: PluginManager) -> None: class hello: @hookimpl From 014e2d734de4fedc69ec7cc38bf59f6f25f2e4f4 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 21 Jul 2026 10:26:43 +0200 Subject: [PATCH 8/9] Discover hooks via inspect.getattr_static instead of getattr Only treat functions, classmethods, staticmethods, and bound methods as hookable, so properties and other descriptors are never executed during registration. Also discovers @hookimpl applied above classmethod/staticmethod. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/pluggy/_manager.py | 117 +++++++++++++++++++++------------- testing/test_pluginmanager.py | 45 ++++++++++++- 2 files changed, 117 insertions(+), 45 deletions(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index e6361d20..9d188192 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -15,7 +15,6 @@ from . import _tracing from ._callers import _multicall -from ._hooks import _HookImplFunction from ._hooks import _Namespace from ._hooks import _Plugin from ._hooks import _SubsetHookCaller @@ -49,15 +48,58 @@ 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 +def _get_hookable(obj: object, name: str) -> Callable[..., object] | None: + """Return a hookable callable for ``name`` without triggering descriptors. - base_class = obj if inspect.isclass(obj) else type(obj) - if isinstance(getattr(base_class, name, None), property): - return True - return False + Only plain functions, :class:`classmethod`, :class:`staticmethod`, and + already-bound :class:`~types.MethodType` objects are supported. Properties, + ``cached_property``, and other descriptors are intentionally skipped -- + hooks provided via such descriptors (or only via ``__getattr__``) are not + supported. + """ + static: object = inspect.getattr_static(obj, name, None) + if isinstance(static, staticmethod): + return static.__func__ + if isinstance(static, classmethod): + owner = obj if inspect.isclass(obj) else type(obj) + return cast(Callable[..., object], static.__get__(owner, owner)) + if isinstance(static, types.MethodType): + return static + if inspect.isfunction(static): + if inspect.isclass(obj) or inspect.ismodule(obj): + return static + return static.__get__(obj, type(obj)) + return None + + +def _hook_marker_holder(obj: object, name: str) -> object | None: + """Return the object that may carry hookimpl/hookspec marker options. + + Marker attributes may live on a ``classmethod``/``staticmethod`` wrapper + (``@hookimpl`` applied above them) or on the underlying function + (``@hookimpl`` applied below them). + """ + static: object = inspect.getattr_static(obj, name, None) + if isinstance(static, (classmethod, staticmethod)): + return static + if isinstance(static, types.MethodType): + return static.__func__ + if inspect.isfunction(static): + return static + return None + + +def _get_marker_opts(holder: object, attrname: str) -> dict[str, Any] | None: + """Read marker options from ``holder``, falling back to ``__func__``.""" + opts = getattr(holder, attrname, None) + if opts is not None: + return opts if isinstance(opts, dict) else None + func = getattr(holder, "__func__", None) + if func is not None: + opts = getattr(func, attrname, None) + if opts is not None: + return opts if isinstance(opts, dict) else None + return None class PluginValidationError(Exception): @@ -171,7 +213,8 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: hookimpl_opts = self.parse_hookimpl_opts(plugin, name) if hookimpl_opts is not None: normalize_hookimpl_opts(hookimpl_opts) - method: _HookImplFunction[object] = getattr(plugin, name) + method = _get_hookable(plugin, name) + assert method is not None hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) name = hookimpl_opts.get("specname") or name hook: HookCaller | None = getattr(self.hook, name, None) @@ -194,31 +237,18 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None This method can be overridden by ``PluginManager`` subclasses to customize how hook implementation are picked up. By default, returns the options for items decorated with :class:`HookimplMarker`. - """ - - 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: - # May be raised when trying to access some descriptor/proxied fields. - return None - if not inspect.isroutine(method): + Discovery uses :func:`inspect.getattr_static` so properties and other + descriptors are not executed. Only functions, classmethods, + staticmethods, and bound methods are considered. + """ + holder = _hook_marker_holder(plugin, name) + if holder is None: return None - try: - res: HookimplOpts | None = getattr( - method, self.project_name + "_impl", None - ) - except Exception: # pragma: no cover - res = {} # type: ignore[assignment] #pragma: no cover - if res is not None and not isinstance(res, dict): - # false positive - res = None # type:ignore[unreachable] #pragma: no cover - return res + return cast( + HookimplOpts | None, + _get_marker_opts(holder, self.project_name + "_impl"), + ) def unregister( self, plugin: _Plugin | None = None, name: str | None = None @@ -310,19 +340,18 @@ def parse_hookspec_opts( This method can be overridden by ``PluginManager`` subclasses to customize how hook specifications are picked up. By default, returns the options for items decorated with :class:`HookspecMarker`. - """ - 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: - # May be raised when trying to access some descriptor/proxied fields. + Discovery uses :func:`inspect.getattr_static` so properties and other + descriptors are not executed. Only functions, classmethods, + staticmethods, and bound methods are considered. + """ + holder = _hook_marker_holder(module_or_class, name) + if holder is None: return None - opts: HookspecOpts | None = getattr(method, self.project_name + "_spec", None) - return opts + return cast( + HookspecOpts | None, + _get_marker_opts(holder, self.project_name + "_spec"), + ) def get_plugins(self) -> set[Any]: """Return a set of all registered plugin objects.""" diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index b38a87c8..3b488df7 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -141,8 +141,25 @@ def some_func(self): assert not test_plugin.property_was_executed +def test_register_ignores_cached_property(he_pm: PluginManager) -> None: + from functools import cached_property + + class ClassWithCachedProperty: + cached_was_executed: bool = False + + @cached_property + def some_func(self) -> None: + self.cached_was_executed = True # pragma: no cover + return None # pragma: no cover + + test_plugin = ClassWithCachedProperty() + he_pm.register(test_plugin) + assert not test_plugin.cached_was_executed + assert "some_func" not in test_plugin.__dict__ + + def test_register_ignores_raising_descriptors(he_pm: PluginManager) -> None: - """Names in dir() whose getattr raises AttributeError are skipped.""" + """Descriptor attrs are skipped without accessing them via getattr.""" class RaisingDescriptor: def __get__(self, obj: object, owner: type | None = None) -> object: @@ -164,6 +181,32 @@ def he_method1(self, arg: object) -> list[object]: assert he_pm.hook.he_method1(arg=1) == [[1]] +def test_register_hookimpl_above_classmethod(he_pm: PluginManager) -> None: + """@hookimpl applied above @classmethod is discoverable via static lookup.""" + + class Plugin: + @hookimpl + @classmethod + def he_method1(cls, arg: object) -> list[object]: + return [arg] + + he_pm.register(Plugin()) + assert he_pm.hook.he_method1(arg=1) == [[1]] + + +def test_register_hookimpl_above_staticmethod(he_pm: PluginManager) -> None: + """@hookimpl applied above @staticmethod is discoverable via static lookup.""" + + class Plugin: + @hookimpl + @staticmethod + def he_method1(arg: object) -> list[object]: + return [arg] + + he_pm.register(Plugin()) + assert he_pm.hook.he_method1(arg=1) == [[1]] + + def test_register_mismatch_method(he_pm: PluginManager) -> None: class hello: @hookimpl From c494f9548f45de433fb7937f59b854c36da6db1b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 21 Jul 2026 10:31:29 +0200 Subject: [PATCH 9/9] Add changelog fragment for getattr_static hook discovery Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- changelog/701.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/701.bugfix.rst diff --git a/changelog/701.bugfix.rst b/changelog/701.bugfix.rst new file mode 100644 index 00000000..d2adb05e --- /dev/null +++ b/changelog/701.bugfix.rst @@ -0,0 +1 @@ +Discover hookimpls/hookspecs via :func:`inspect.getattr_static` so properties and other descriptors are not executed during plugin registration.