Skip to content

Instantly share code, notes, and snippets.

@pirate
Last active October 1, 2024 20:52
Show Gist options
  • Save pirate/66f12beac594c99c697cd5543a1cb77b to your computer and use it in GitHub Desktop.
Save pirate/66f12beac594c99c697cd5543a1cb77b to your computer and use it in GitHub Desktop.
Exhaustive test for a patch to Pluggy to fix errors when attempting to register certain special object types.
#!/usr/bin/env python3
# Exhaustive test case demonstrating a patch to Pluggy's PluginManager.
# https://github.com/pytest-dev/pluggy/pull/536
# Fixes registration of pluggy namespaces that are modules/classes/instances with special attrs:
# - @property
# - @computed_field
# - @classproperty
# - @classmethod
# - @staticmethod
# - Meta classes (e.g. django models)
# - pydantic model fields, computed fields, validators, etc.
# - ClassVar attrs, _dunder private attrs, etc.
# - normal methods, normal variables, etc.
# This allows you to register many more types of object as Pluggy Plugins, hookspecs, hookimpls, etc.
# without AttributeError: '__signature__' attribute of <module/class/instance> is class-only errors.
import inspect
import unittest
from typing import ClassVar, Any
import pluggy
from pydantic import BaseModel, Field, computed_field, ConfigDict
from django.utils.functional import classproperty # very simple, see source code: https://github.com/django/django/blob/stable/5.1.x/django/utils/functional.py#L51 (many projects this implementation or one like it)
hookspec = pluggy.HookspecMarker("test")
hookimpl = pluggy.HookimplMarker("test")
class ComplexNamespace(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True, ignored_types=(classproperty,))
test_field_as_attr: str = Field(default_factory=lambda: "default_value")
test_field_as_classvar: ClassVar[str] = "default_value"
_test_private_field: str = 'abc'
class Meta:
test_meta_attr: str = "default_value"
@computed_field
def test_field_as_computed_prop_old_style(self) -> str:
return "default_value"
@computed_field
@property
def test_field_as_computed_prop(self) -> str:
return "default_value"
@classproperty
def test_field_as_classproperty(cls) -> str:
return "default_value"
@classmethod
def test_field_as_classmethod(cls) -> str:
return "default_value"
@staticmethod
def test_field_as_staticmethod() -> str:
return "default_value"
def test_field_as_normal_method(self) -> str:
return "default_value"
@staticmethod
@hookimpl
@hookspec
def test_hook_as_staticmethod() -> str:
return "default_value"
@classmethod
@hookimpl
@hookspec
def test_hook_as_classmethod(cls) -> str:
return "default_value"
@hookimpl
@hookspec
def test_hook_as_normal_method(self=None) -> str:
return "default_value"
######################################### EXISTING PLUGGY IMPLEMENTATION TEST #########################################
class CurrentPluggyImplementation(unittest.TestCase):
def test_class_as_namespace(self):
pm = pluggy.PluginManager("test")
pm.add_hookspecs(ComplexNamespace)
pm.register(ComplexNamespace)
print(pm.hook.test_hook_as_staticmethod())
print(pm.hook.test_hook_as_classmethod())
print(pm.hook.test_hook_as_normal_method())
# ['default_value']
# ['default_value']
# ['default_value']
def test_instance_as_namespace(self):
"""this test is expected to fail, it demonstrates the error with the old implementation"""
pm = pluggy.PluginManager("test")
pm.add_hookspecs(ComplexNamespace())
# Traceback (most recent call last):
# File "~/Desktop/pluggy-tests/pluggy_test.py", line 70, in <module>
# pm.add_hookspecs(ComplexNamespace())
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pluggy/_manager.py", line 257, in add_hookspecs
# spec_opts = self.parse_hookspec_opts(module_or_class, name)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pluggy/_manager.py", line 289, in parse_hookspec_opts
# method = getattr(module_or_class, name)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pydantic/main.py", line 853, in __getattr__
# return super().__getattribute__(item) # Raises AttributeError if appropriate
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 297, in __get__
# raise AttributeError(f'{self.name!r} attribute of {owner.__name__!r} is class-only')
# AttributeError: '__signature__' attribute of 'ComplexNamespace' is class-only
pm.register(ComplexNamespace())
# Traceback (most recent call last):
# File "~/Desktop/pluggy-tests/pluggy_test.py", line 87, in <module>
# pm.register(ComplexNamespace())
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pluggy/_manager.py", line 157, in register
# hookimpl_opts = self.parse_hookimpl_opts(plugin, name)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pluggy/_manager.py", line 184, in parse_hookimpl_opts
# method: object = getattr(plugin, name)
# ^^^^^^^^^^^^^^^^^^^^^
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pydantic/main.py", line 853, in __getattr__
# return super().__getattribute__(item) # Raises AttributeError if appropriate
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# File "~/Desktop/pluggy-tests/.venv/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 297, in __get__
# raise AttributeError(f'{self.name!r} attribute of {owner.__name__!r} is class-only')
# AttributeError: '__signature__' attribute of 'ComplexNamespace' is class-only
print(pm.hook.test_hook_as_staticmethod())
print(pm.hook.test_hook_as_classmethod())
print(pm.hook.test_hook_as_normal_method())
######################################### PLUGGY IMPLEMENTATION CHANGE #########################################
# See: https://github.com/pytest-dev/pluggy/pull/536
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
base_class = obj if inspect.isclass(obj) else type(obj)
if isinstance(getattr(base_class, name, None), property):
return True
return False
class FixedPluginManager(pluggy.PluginManager):
"""
Patch to fix pluggy's PluginManager to work with pydantic models.
See: https://github.com/pytest-dev/pluggy/pull/536
"""
def parse_hookspec_opts(self, module_or_class, name: str) -> pluggy.HookspecOpts | None:
if _attr_is_property(module_or_class, name):
return None
method: object
try:
method = getattr(module_or_class, name)
except AttributeError:
# AttributeError: '__signature__' attribute of <m_or_c> is class-only
# can happen for proxy objects that wrap modules/classes (e.g. pydantic)
method = getattr(type(module_or_class), name) # use class sig instead
opts: pluggy.HookspecOpts | None = getattr(method, self.project_name + "_spec", None)
return opts
def parse_hookimpl_opts(self, plugin, name: str) -> pluggy.HookimplOpts | None:
if _attr_is_property(plugin, name):
return None
method: object
try:
method = getattr(plugin, name)
except AttributeError:
# AttributeError: '__signature__' attribute of <plugin> is class-only
# can happen for proxy objects that wrap modules/classes (e.g. pydantic)
method = getattr(type(plugin), name) # use class sig instead
if not inspect.isroutine(method):
return None
try:
res: pluggy.HookimplOpts | None = getattr(
method, self.project_name + "_impl", None
)
except Exception:
res = {} # type: ignore[assignment]
if res is not None and not isinstance(res, dict):
# false positive
res = None # type:ignore[unreachable]
return res
######################################### NEW IMPLEMENTATION TEST #########################################
class NewPluggyImplementation(unittest.TestCase):
def test_class_as_namespace(self):
"""This test should pass, it demonstrates the fixed PluginManager correctly handling a class as a namespace even when it has special descriptors/attrs"""
pm = FixedPluginManager("test")
pm.add_hookspecs(ComplexNamespace)
pm.register(ComplexNamespace)
print(pm.hook.test_hook_as_staticmethod())
print(pm.hook.test_hook_as_classmethod())
print(pm.hook.test_hook_as_normal_method())
# ['default_value']
# ['default_value']
# ['default_value']
def test_instance_as_namespace(self):
"""This test should pass, it demonstrates the fixed PluginManager correctly handling an instance as a namespace even when it has special descriptors/attrs"""
pm = FixedPluginManager("test")
pm.add_hookspecs(ComplexNamespace())
pm.register(ComplexNamespace())
print(pm.hook.test_hook_as_staticmethod())
print(pm.hook.test_hook_as_classmethod())
print(pm.hook.test_hook_as_normal_method())
# ['default_value']
# ['default_value']
# ['default_value']
if __name__ == '__main__':
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment