Skip to content

Commit 3db7827

Browse files
committed
BUG: Rewrite inspect.unwrap() to respect classes
Fixes #463 Post-review changes: * Remove argument `stop` from `pdoc._unwrap_object()` * Fix flake8 errors
1 parent 8e4f120 commit 3db7827

File tree

4 files changed

+43
-11
lines changed

4 files changed

+43
-11
lines changed

pdoc/__init__.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,39 @@ def _unwrap_descriptor(dobj):
449449
return getattr(obj, '__get__', obj)
450450

451451

452+
def _unwrap_object(obj: T) -> T:
453+
"""
454+
This is a modified version of `inspect.unwrap()` that properly handles classes.
455+
456+
Follows the chains of `__wrapped__` attributes, until either:
457+
1. `obj.__wrapped__` is missing or None
458+
2. `obj` is a class and `obj.__wrapped__` has a different name or module
459+
"""
460+
461+
orig = obj # remember the original func for error reporting
462+
# Memoise by id to tolerate non-hashable objects, but store objects to
463+
# ensure they aren't destroyed, which would allow their IDs to be reused.
464+
memo = {id(orig): orig}
465+
recursion_limit = sys.getrecursionlimit()
466+
while hasattr(obj, '__wrapped__'):
467+
candidate = obj.__wrapped__
468+
if candidate is None:
469+
break
470+
471+
if isinstance(candidate, type) and isinstance(orig, type):
472+
if not (candidate.__name__ == orig.__name__
473+
and candidate.__module__ == orig.__module__):
474+
break
475+
476+
obj = typing.cast(T, candidate)
477+
id_func = id(obj)
478+
if (id_func in memo) or (len(memo) >= recursion_limit):
479+
raise ValueError('wrapper loop when unwrapping {!r}'.format(orig))
480+
memo[id_func] = obj
481+
482+
return obj
483+
484+
452485
def _filter_type(type: Type[T],
453486
values: Union[Iterable['Doc'], Mapping[str, 'Doc']]) -> List[T]:
454487
"""
@@ -712,11 +745,11 @@ def __init__(self, module: Union[ModuleType, str], *,
712745
"exported in `__all__`")
713746
else:
714747
if not _is_blacklisted(name, self):
715-
obj = inspect.unwrap(obj)
748+
obj = _unwrap_object(obj)
716749
public_objs.append((name, obj))
717750
else:
718751
def is_from_this_module(obj):
719-
mod = inspect.getmodule(inspect.unwrap(obj))
752+
mod = inspect.getmodule(_unwrap_object(obj))
720753
return mod is None or mod.__name__ == self.obj.__name__
721754

722755
for name, obj in inspect.getmembers(self.obj):
@@ -730,7 +763,7 @@ def is_from_this_module(obj):
730763
self._context.blacklisted.add(f'{self.refname}.{name}')
731764
continue
732765

733-
obj = inspect.unwrap(obj)
766+
obj = _unwrap_object(obj)
734767
public_objs.append((name, obj))
735768

736769
index = list(self.obj.__dict__).index
@@ -1066,7 +1099,7 @@ def __init__(self, name: str, module: Module, obj, *, docstring: Optional[str] =
10661099
self.module._context.blacklisted.add(f'{self.refname}.{_name}')
10671100
continue
10681101

1069-
obj = inspect.unwrap(obj)
1102+
obj = _unwrap_object(obj)
10701103
public_objs.append((_name, obj))
10711104

10721105
def definition_order_index(
@@ -1428,7 +1461,7 @@ def _is_async(self):
14281461
try:
14291462
# Both of these are required because coroutines aren't classified as async
14301463
# generators and vice versa.
1431-
obj = inspect.unwrap(self.obj)
1464+
obj = _unwrap_object(self.obj)
14321465
return (inspect.iscoroutinefunction(obj) or
14331466
inspect.isasyncgenfunction(obj))
14341467
except AttributeError:

pdoc/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ def docfilter(obj, _filters=args.filter.strip().split(',')):
574574
_print_pdf(modules, **template_config)
575575
import textwrap
576576
PANDOC_CMD = textwrap.indent(_PANDOC_COMMAND, ' ')
577-
print(f"""
577+
help_msg = f"""
578578
PDF-ready markdown written to standard output.
579579
^^^^^^^^^^^^^^^
580580
Convert this file to PDF using e.g. Pandoc:
@@ -600,7 +600,7 @@ def docfilter(obj, _filters=args.filter.strip().split(',')):
600600
wkhtmltopdf --encoding utf8 -s A4 --print-media-type pdf.html pdf.pdf
601601
602602
or similar, at your own discretion.""",
603-
file=sys.stderr)
603+
print(help_msg, file=sys.stderr)
604604
sys.exit(0)
605605

606606
for module in modules:

pdoc/html_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ def format_git_link(template: str, dobj: pdoc.Doc):
565565
if 'commit' in _str_template_fields(template):
566566
commit = _git_head_commit()
567567
obj = pdoc._unwrap_descriptor(dobj)
568-
abs_path = inspect.getfile(inspect.unwrap(obj))
568+
abs_path = inspect.getfile(pdoc._unwrap_object(obj))
569569
path = _project_relative_path(abs_path)
570570

571571
# Urls should always use / instead of \\

pdoc/test/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,6 @@ def test__pdoc__dict(self):
659659
self.assertEqual(cm, [])
660660
self.assertNotIn('downloaded_modules', mod.doc)
661661

662-
# flake8: noqa: E501 line too long
663662
def test_class_wrappers(self):
664663
"""
665664
Check that decorated classes are unwrapped properly.
@@ -685,15 +684,15 @@ def test_class_wrappers(self):
685684
"""This is `DecoratedClassParent` class.""")
686685
self.assertEqual(root_wrapped_cls_child.qualname, 'DecoratedClassChild')
687686
self.assertEqual(root_wrapped_cls_child.docstring,
688-
"""This is an `DecoratedClassParent`'s implementation that always returns 1.""")
687+
"""This is an `DecoratedClassParent`'s implementation that always returns 1.""") # noqa: E501 [LineTooLong]
689688

690689
self.assertEqual(module_classdef.qualname, f'{module_name}.class_definition')
691690
self.assertEqual(module_classdef_cls_parent.qualname, 'DecoratedClassParent')
692691
self.assertEqual(module_classdef_cls_parent.docstring,
693692
"""This is `DecoratedClassParent` class.""")
694693
self.assertEqual(module_classdef_cls_child.qualname, 'DecoratedClassChild')
695694
self.assertEqual(module_classdef_cls_child.docstring,
696-
"""This is an `DecoratedClassParent`'s implementation that always returns 1.""")
695+
"""This is an `DecoratedClassParent`'s implementation that always returns 1.""") # noqa: E501 [LineTooLong]
697696

698697
self.assertEqual(module_util.qualname, f'{module_name}.util')
699698
self.assertEqual(module_util_decorator.qualname, 'decorate_class')

0 commit comments

Comments
 (0)