Skip to content

Commit af49da6

Browse files
authored
BUG: Rewrite inspect.unwrap() to respect classes (#464)
* TST: Add tests covering the `inspect.unwrap()` problem (#463) * BUG: Rewrite `inspect.unwrap()` to respect classes Fixes #463 Post-review changes: * Remove argument `stop` from `pdoc._unwrap_object()` * Fix flake8 errors
1 parent 9f2d530 commit af49da6

File tree

7 files changed

+172
-8
lines changed

7 files changed

+172
-8
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,46 @@ def test__pdoc__dict(self):
659659
self.assertEqual(cm, [])
660660
self.assertNotIn('downloaded_modules', mod.doc)
661661

662+
def test_class_wrappers(self):
663+
"""
664+
Check that decorated classes are unwrapped properly.
665+
Details: https://github.com/pdoc3/pdoc/issues/463
666+
"""
667+
668+
module_name = f'{EXAMPLE_MODULE}._test_classwrap'
669+
670+
root_module = pdoc.Module(module_name, context=pdoc.Context())
671+
root_wrapped_cls_parent = root_module.doc['DecoratedClassParent']
672+
root_wrapped_cls_child = root_module.doc['DecoratedClassChild']
673+
674+
module_classdef = root_module.doc['class_definition']
675+
module_classdef_cls_parent = module_classdef.doc['DecoratedClassParent']
676+
module_classdef_cls_child = module_classdef.doc['DecoratedClassChild']
677+
678+
module_util = root_module.doc['util']
679+
module_util_decorator = module_util.doc['decorate_class']
680+
681+
self.assertEqual(root_module.qualname, module_name)
682+
self.assertEqual(root_wrapped_cls_parent.qualname, 'DecoratedClassParent')
683+
self.assertEqual(root_wrapped_cls_parent.docstring,
684+
"""This is `DecoratedClassParent` class.""")
685+
self.assertEqual(root_wrapped_cls_child.qualname, 'DecoratedClassChild')
686+
self.assertEqual(root_wrapped_cls_child.docstring,
687+
"""This is an `DecoratedClassParent`'s implementation that always returns 1.""") # noqa: E501 [LineTooLong]
688+
689+
self.assertEqual(module_classdef.qualname, f'{module_name}.class_definition')
690+
self.assertEqual(module_classdef_cls_parent.qualname, 'DecoratedClassParent')
691+
self.assertEqual(module_classdef_cls_parent.docstring,
692+
"""This is `DecoratedClassParent` class.""")
693+
self.assertEqual(module_classdef_cls_child.qualname, 'DecoratedClassChild')
694+
self.assertEqual(module_classdef_cls_child.docstring,
695+
"""This is an `DecoratedClassParent`'s implementation that always returns 1.""") # noqa: E501 [LineTooLong]
696+
697+
self.assertEqual(module_util.qualname, f'{module_name}.util')
698+
self.assertEqual(module_util_decorator.qualname, 'decorate_class')
699+
700+
pdoc.link_inheritance(root_module._context)
701+
662702
@ignore_warnings
663703
def test_dont_touch__pdoc__blacklisted(self):
664704
class Bomb:
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
This is the root module.
3+
4+
This re-exports `DecoratedClassParent` and `DecoratedClassChild`.
5+
See `pdoc.test.example_pkg._test_classwrap.class_definition.DecoratedClassParent`
6+
and `pdoc.test.example_pkg._test_classwrap.class_definition.DecoratedClassChild` for more details.
7+
"""
8+
9+
10+
from .class_definition import DecoratedClassParent, DecoratedClassChild
11+
12+
13+
__all__ = [
14+
'DecoratedClassParent',
15+
'DecoratedClassChild',
16+
]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
This module exports the following classes:
3+
4+
* `DecoratedClassParent`
5+
* `DecoratedClassChild`
6+
"""
7+
8+
from .util import decorate_class
9+
from abc import ABC, abstractmethod
10+
11+
12+
@decorate_class
13+
class DecoratedClassParent(ABC):
14+
""" This is `DecoratedClassParent` class. """
15+
16+
@abstractmethod
17+
def __value__(self) -> int:
18+
""" An `DecoratedClassParent`'s value implementation, abstract method. """
19+
raise NotImplementedError
20+
21+
@property
22+
def value(self) -> int:
23+
""" This is `DecoratedClassParent`'s property. """
24+
return self.__value__()
25+
26+
27+
@decorate_class
28+
class DecoratedClassChild(DecoratedClassParent):
29+
""" This is an `DecoratedClassParent`'s implementation that always returns 1. """
30+
31+
def __value__(self) -> int:
32+
return 1
33+
34+
35+
__all__ = [
36+
'DecoratedClassParent',
37+
'DecoratedClassChild',
38+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import functools
2+
import types
3+
from typing import Type, TypeVar, cast
4+
5+
6+
C = TypeVar('C')
7+
8+
9+
def wrap_first(cls: Type[C]) -> Type[C]:
10+
wrapped = types.new_class(cls.__name__, (cls, ), {})
11+
wrapped = functools.update_wrapper(wrapped, cls, updated=())
12+
wrapped = cast(Type[C], wrapped)
13+
14+
return wrapped
15+
16+
17+
def wrap_second(cls: Type[C]) -> Type[C]:
18+
wrapped = type(cls.__name__, cls.__mro__, dict(cls.__dict__))
19+
wrapped = functools.update_wrapper(wrapped, cls, updated=())
20+
wrapped = cast(Type[C], wrapped)
21+
22+
return wrapped
23+
24+
25+
def decorate_class(cls: Type[C]) -> Type[C]:
26+
""" Creates a two-step class decoration. """
27+
28+
wrapped = wrap_first(cls)
29+
wrapped_again = wrap_second(wrapped)
30+
wrapped_again.__decorated__ = True
31+
32+
return wrapped_again
33+
34+
35+
__all__ = [
36+
'decorate_class',
37+
]

0 commit comments

Comments
 (0)