From 8e4f12057304ade6ccc8a7b3da4ea281435bebde Mon Sep 17 00:00:00 2001 From: Peter Zaitcev / USSX-Hares Date: Thu, 24 Jul 2025 17:04:03 +0300 Subject: [PATCH 1/2] TST: Add tests covering the `inspect.unwrap()` problem (#463) --- pdoc/test/__init__.py | 41 +++++++++++++++++++ .../example_pkg/_test_classwrap/__init__.py | 16 ++++++++ .../_test_classwrap/class_definition.py | 38 +++++++++++++++++ pdoc/test/example_pkg/_test_classwrap/util.py | 37 +++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 pdoc/test/example_pkg/_test_classwrap/__init__.py create mode 100644 pdoc/test/example_pkg/_test_classwrap/class_definition.py create mode 100644 pdoc/test/example_pkg/_test_classwrap/util.py 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', +] From 3db78275399c771b9bcd846c2ec73139b8616877 Mon Sep 17 00:00:00 2001 From: Peter Zaitcev / USSX-Hares Date: Thu, 24 Jul 2025 17:04:52 +0300 Subject: [PATCH 2/2] BUG: Rewrite `inspect.unwrap()` to respect classes Fixes https://github.com/pdoc3/pdoc/issues/463 Post-review changes: * Remove argument `stop` from `pdoc._unwrap_object()` * Fix flake8 errors --- pdoc/__init__.py | 43 ++++++++++++++++++++++++++++++++++++++----- pdoc/cli.py | 4 ++-- pdoc/html_helpers.py | 2 +- pdoc/test/__init__.py | 5 ++--- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/pdoc/__init__.py b/pdoc/__init__.py index 926374da..3002ef4d 100644 --- a/pdoc/__init__.py +++ b/pdoc/__init__.py @@ -449,6 +449,39 @@ def _unwrap_descriptor(dobj): return getattr(obj, '__get__', obj) +def _unwrap_object(obj: T) -> 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 + """ + + 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__'): + 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 = typing.cast(T, 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 +745,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 +763,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 +1099,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 +1461,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/cli.py b/pdoc/cli.py index 26a056e6..b5d128af 100755 --- a/pdoc/cli.py +++ b/pdoc/cli.py @@ -574,7 +574,7 @@ def docfilter(obj, _filters=args.filter.strip().split(',')): _print_pdf(modules, **template_config) import textwrap PANDOC_CMD = textwrap.indent(_PANDOC_COMMAND, ' ') - print(f""" + help_msg = f""" PDF-ready markdown written to standard output. ^^^^^^^^^^^^^^^ Convert this file to PDF using e.g. Pandoc: @@ -600,7 +600,7 @@ def docfilter(obj, _filters=args.filter.strip().split(',')): wkhtmltopdf --encoding utf8 -s A4 --print-media-type pdf.html pdf.pdf or similar, at your own discretion.""", - file=sys.stderr) + print(help_msg, file=sys.stderr) sys.exit(0) for module in modules: 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 5ee03d26..bc4a3a6f 100644 --- a/pdoc/test/__init__.py +++ b/pdoc/test/__init__.py @@ -659,7 +659,6 @@ 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. @@ -685,7 +684,7 @@ def test_class_wrappers(self): """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.""") + """This is an `DecoratedClassParent`'s implementation that always returns 1.""") # noqa: E501 [LineTooLong] self.assertEqual(module_classdef.qualname, f'{module_name}.class_definition') self.assertEqual(module_classdef_cls_parent.qualname, 'DecoratedClassParent') @@ -693,7 +692,7 @@ def test_class_wrappers(self): """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.""") + """This is an `DecoratedClassParent`'s implementation that always returns 1.""") # noqa: E501 [LineTooLong] self.assertEqual(module_util.qualname, f'{module_name}.util') self.assertEqual(module_util_decorator.qualname, 'decorate_class')