Last active
October 1, 2024 20:52
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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