diff --git a/pdoc/__init__.py b/pdoc/__init__.py index 926374da..0dde1980 100644 --- a/pdoc/__init__.py +++ b/pdoc/__init__.py @@ -449,6 +449,43 @@ def _unwrap_descriptor(dobj): return getattr(obj, '__get__', obj) +def _unwrap_object(obj: T, *, stop: Optional[Callable[[T], bool]] = None) -> T: + """ + This is a modified version of `inspect.unwrap()` that properly handles classes. + + Follows the chains of `__wrapped__` attributes, until either: + 1. `obj.__wrapped__` is missing or None + 2. `obj` is a class and `obj.__wrapped__` has a different name or module + 3. `stop` is given and `stop(obj)` is True + """ + + orig = obj # remember the original func for error reporting + # Memoise by id to tolerate non-hashable objects, but store objects to + # ensure they aren't destroyed, which would allow their IDs to be reused. + memo = {id(orig): orig} + recursion_limit = sys.getrecursionlimit() + while hasattr(obj, '__wrapped__'): + if stop is not None and stop(obj): + break + + candidate = obj.__wrapped__ + if candidate is None: + break + + if isinstance(candidate, type) and isinstance(orig, type): + if not (candidate.__name__ == orig.__name__ + and candidate.__module__ == orig.__module__): + break + + obj = candidate + id_func = id(obj) + if (id_func in memo) or (len(memo) >= recursion_limit): + raise ValueError('wrapper loop when unwrapping {!r}'.format(orig)) + memo[id_func] = obj + + return obj + + def _filter_type(type: Type[T], values: Union[Iterable['Doc'], Mapping[str, 'Doc']]) -> List[T]: """ @@ -712,11 +749,11 @@ def __init__(self, module: Union[ModuleType, str], *, "exported in `__all__`") else: if not _is_blacklisted(name, self): - obj = inspect.unwrap(obj) + obj = _unwrap_object(obj) public_objs.append((name, obj)) else: def is_from_this_module(obj): - mod = inspect.getmodule(inspect.unwrap(obj)) + mod = inspect.getmodule(_unwrap_object(obj)) return mod is None or mod.__name__ == self.obj.__name__ for name, obj in inspect.getmembers(self.obj): @@ -730,7 +767,7 @@ def is_from_this_module(obj): self._context.blacklisted.add(f'{self.refname}.{name}') continue - obj = inspect.unwrap(obj) + obj = _unwrap_object(obj) public_objs.append((name, obj)) index = list(self.obj.__dict__).index @@ -1066,7 +1103,7 @@ def __init__(self, name: str, module: Module, obj, *, docstring: Optional[str] = self.module._context.blacklisted.add(f'{self.refname}.{_name}') continue - obj = inspect.unwrap(obj) + obj = _unwrap_object(obj) public_objs.append((_name, obj)) def definition_order_index( @@ -1428,7 +1465,7 @@ def _is_async(self): try: # Both of these are required because coroutines aren't classified as async # generators and vice versa. - obj = inspect.unwrap(self.obj) + obj = _unwrap_object(self.obj) return (inspect.iscoroutinefunction(obj) or inspect.isasyncgenfunction(obj)) except AttributeError: diff --git a/pdoc/html_helpers.py b/pdoc/html_helpers.py index 041cba68..bfc1b701 100644 --- a/pdoc/html_helpers.py +++ b/pdoc/html_helpers.py @@ -565,7 +565,7 @@ def format_git_link(template: str, dobj: pdoc.Doc): if 'commit' in _str_template_fields(template): commit = _git_head_commit() obj = pdoc._unwrap_descriptor(dobj) - abs_path = inspect.getfile(inspect.unwrap(obj)) + abs_path = inspect.getfile(pdoc._unwrap_object(obj)) path = _project_relative_path(abs_path) # Urls should always use / instead of \\ diff --git a/pdoc/test/__init__.py b/pdoc/test/__init__.py index df6390f6..5ee03d26 100644 --- a/pdoc/test/__init__.py +++ b/pdoc/test/__init__.py @@ -659,6 +659,47 @@ def test__pdoc__dict(self): self.assertEqual(cm, []) self.assertNotIn('downloaded_modules', mod.doc) + # flake8: noqa: E501 line too long + def test_class_wrappers(self): + """ + Check that decorated classes are unwrapped properly. + Details: https://github.com/pdoc3/pdoc/issues/463 + """ + + module_name = f'{EXAMPLE_MODULE}._test_classwrap' + + root_module = pdoc.Module(module_name, context=pdoc.Context()) + root_wrapped_cls_parent = root_module.doc['DecoratedClassParent'] + root_wrapped_cls_child = root_module.doc['DecoratedClassChild'] + + module_classdef = root_module.doc['class_definition'] + module_classdef_cls_parent = module_classdef.doc['DecoratedClassParent'] + module_classdef_cls_child = module_classdef.doc['DecoratedClassChild'] + + module_util = root_module.doc['util'] + module_util_decorator = module_util.doc['decorate_class'] + + self.assertEqual(root_module.qualname, module_name) + self.assertEqual(root_wrapped_cls_parent.qualname, 'DecoratedClassParent') + self.assertEqual(root_wrapped_cls_parent.docstring, + """This is `DecoratedClassParent` class.""") + self.assertEqual(root_wrapped_cls_child.qualname, 'DecoratedClassChild') + self.assertEqual(root_wrapped_cls_child.docstring, + """This is an `DecoratedClassParent`'s implementation that always returns 1.""") + + self.assertEqual(module_classdef.qualname, f'{module_name}.class_definition') + self.assertEqual(module_classdef_cls_parent.qualname, 'DecoratedClassParent') + self.assertEqual(module_classdef_cls_parent.docstring, + """This is `DecoratedClassParent` class.""") + self.assertEqual(module_classdef_cls_child.qualname, 'DecoratedClassChild') + self.assertEqual(module_classdef_cls_child.docstring, + """This is an `DecoratedClassParent`'s implementation that always returns 1.""") + + self.assertEqual(module_util.qualname, f'{module_name}.util') + self.assertEqual(module_util_decorator.qualname, 'decorate_class') + + pdoc.link_inheritance(root_module._context) + @ignore_warnings def test_dont_touch__pdoc__blacklisted(self): class Bomb: diff --git a/pdoc/test/example_pkg/_test_classwrap/__init__.py b/pdoc/test/example_pkg/_test_classwrap/__init__.py new file mode 100644 index 00000000..61e74733 --- /dev/null +++ b/pdoc/test/example_pkg/_test_classwrap/__init__.py @@ -0,0 +1,16 @@ +""" +This is the root module. + +This re-exports `DecoratedClassParent` and `DecoratedClassChild`. +See `pdoc.test.example_pkg._test_classwrap.class_definition.DecoratedClassParent` +and `pdoc.test.example_pkg._test_classwrap.class_definition.DecoratedClassChild` for more details. +""" + + +from .class_definition import DecoratedClassParent, DecoratedClassChild + + +__all__ = [ + 'DecoratedClassParent', + 'DecoratedClassChild', +] diff --git a/pdoc/test/example_pkg/_test_classwrap/class_definition.py b/pdoc/test/example_pkg/_test_classwrap/class_definition.py new file mode 100644 index 00000000..b52c0640 --- /dev/null +++ b/pdoc/test/example_pkg/_test_classwrap/class_definition.py @@ -0,0 +1,38 @@ +""" +This module exports the following classes: + +* `DecoratedClassParent` +* `DecoratedClassChild` +""" + +from .util import decorate_class +from abc import ABC, abstractmethod + + +@decorate_class +class DecoratedClassParent(ABC): + """ This is `DecoratedClassParent` class. """ + + @abstractmethod + def __value__(self) -> int: + """ An `DecoratedClassParent`'s value implementation, abstract method. """ + raise NotImplementedError + + @property + def value(self) -> int: + """ This is `DecoratedClassParent`'s property. """ + return self.__value__() + + +@decorate_class +class DecoratedClassChild(DecoratedClassParent): + """ This is an `DecoratedClassParent`'s implementation that always returns 1. """ + + def __value__(self) -> int: + return 1 + + +__all__ = [ + 'DecoratedClassParent', + 'DecoratedClassChild', +] diff --git a/pdoc/test/example_pkg/_test_classwrap/util.py b/pdoc/test/example_pkg/_test_classwrap/util.py new file mode 100644 index 00000000..125dca3c --- /dev/null +++ b/pdoc/test/example_pkg/_test_classwrap/util.py @@ -0,0 +1,37 @@ +import functools +import types +from typing import Type, TypeVar, cast + + +C = TypeVar('C') + + +def wrap_first(cls: Type[C]) -> Type[C]: + wrapped = types.new_class(cls.__name__, (cls, ), {}) + wrapped = functools.update_wrapper(wrapped, cls, updated=()) + wrapped = cast(Type[C], wrapped) + + return wrapped + + +def wrap_second(cls: Type[C]) -> Type[C]: + wrapped = type(cls.__name__, cls.__mro__, dict(cls.__dict__)) + wrapped = functools.update_wrapper(wrapped, cls, updated=()) + wrapped = cast(Type[C], wrapped) + + return wrapped + + +def decorate_class(cls: Type[C]) -> Type[C]: + """ Creates a two-step class decoration. """ + + wrapped = wrap_first(cls) + wrapped_again = wrap_second(wrapped) + wrapped_again.__decorated__ = True + + return wrapped_again + + +__all__ = [ + 'decorate_class', +]