Skip to content

BUG: Rewrite inspect.unwrap() to respect classes #464

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions pdoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pdoc/html_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\
Expand Down
41 changes: 41 additions & 0 deletions pdoc/test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions pdoc/test/example_pkg/_test_classwrap/__init__.py
Original file line number Diff line number Diff line change
@@ -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',
]
38 changes: 38 additions & 0 deletions pdoc/test/example_pkg/_test_classwrap/class_definition.py
Original file line number Diff line number Diff line change
@@ -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',
]
37 changes: 37 additions & 0 deletions pdoc/test/example_pkg/_test_classwrap/util.py
Original file line number Diff line number Diff line change
@@ -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',
]
Loading