Skip to content

Ambigous usage of inspect.unwrap() on classes #463

@USSX-Hares

Description

@USSX-Hares

obj = inspect.unwrap(obj)

PDoc uses inspect.unwrap() in multiple places. However, this function is designed to work on functions and is not able to work on classes properly.

3.12.1

Before 3.12.3, inspect.unwrap() would go on a class as long as __wrapper__ field exists, which is fine for functions, but for classes, it goes up to the parent class if you inherited from a decorated class. This results in PDoc rendering wrong docstrings for classes.

Class defition
PyDev console: starting.
Python 3.12.10 (tags/v3.12.10:0cc8128, Apr  8 2025, 12:21:36) [MSC v.1943 64 bit (AMD64)] on win32
from pdoc_issues.class_wrapper import AlwaysOne
AlwaysOne().value
1
AlwaysOne.__doc__
" This is an `AbstractValue`'s implementation that always returns 1. "

However, ...

D:\Projects\test\.venv.d\venv-3.12.1\Scripts\python.exe -m pdoc pdoc_issues.class_wrapper --html -o html 
html\pdoc_issues\class_wrapper\index.html
html\pdoc_issues\class_wrapper\class_definition.html
html\pdoc_issues\class_wrapper\util.html

Process finished with exit code 0
Image

Investigation/debugging result: obj is evaluated to be the parent class instead of the actual child, and thus stored in context on a child's name.

3.12.10

Starting from 3.12.3, this was changed. From now on, inspect.unwrap() just does not unwrap a class. Which is crucial for the PDoc's ability to extract docstrings and other metadata.

Image

https://docs.python.org/release/3.12.3/whatsnew/changelog.html#library
python/cpython#112006

Attempting to generate documentation just fails.

D:\Projects\test\.venv.d\venv-3.12.10\Scripts\python.exe -m pdoc pdoc_issues.class_wrapper --html -o html 
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "D:\Projects\test\.venv.d\venv-3.12.10\Lib\site-packages\pdoc\__main__.py", line 6, in <module>
    main()
  File "D:\Projects\test\.venv.d\venv-3.12.10\Lib\site-packages\pdoc\cli.py", line 559, in main
    pdoc.link_inheritance()
  File "D:\Projects\test\.venv.d\venv-3.12.10\Lib\site-packages\pdoc\__init__.py", line 498, in link_inheritance
    for cls in _toposort(graph):
               ^^^^^^^^^^^^^^^^
  File "D:\Projects\test\.venv.d\venv-3.12.10\Lib\site-packages\pdoc\__init__.py", line 480, in _toposort
    assert not graph, f"A cyclic dependency exists amongst {graph!r}"
           ^^^^^^^^^
AssertionError: A cyclic dependency exists amongst {<Class 'pdoc_issues.class_wrapper.class_definition.AbstractValue'>: {<Class 'pdoc_issues.class_wrapper.class_definition.AbstractValue'>}, <Class 'pdoc_issues.class_wrapper.class_definition.AlwaysOne'>: {<Class 'pdoc_issues.class_wrapper.class_definition.AlwaysOne'>, <Class 'pdoc_issues.class_wrapper.class_definition.AbstractValue'>}, <Class 'pdoc_issues.class_wrapper.AbstractValue'>: {<Class 'pdoc_issues.class_wrapper.class_definition.AbstractValue'>}, <Class 'pdoc_issues.class_wrapper.AlwaysOne'>: {<Class 'pdoc_issues.class_wrapper.class_definition.AlwaysOne'>, <Class 'pdoc_issues.class_wrapper.class_definition.AbstractValue'>}}

Process finished with exit code 1

Code to reproduce

File pdoc_issues/class_wrapper/class_definition.py

"""
This module exports the following classes:

* `AbstractValue`
* `AlwaysOne`
"""

from .util import decorate_class
from abc import ABC, abstractmethod

@decorate_class
class AbstractValue(ABC):
    """ This is `AbstractValue` class. """
    
    @abstractmethod
    def __value__(self) -> int:
        """ An `AbstractValue`'s value implementation, abstract method. """    
        raise NotImplemented
    
    @property
    def value(self) -> int:
        """ This is `AbstractValue`'s property. """
        return self.__value__()

@decorate_class
class AlwaysOne(AbstractValue):
    """ This is an `AbstractValue`'s implementation that always returns 1. """
    
    def __value__(self) -> int:
        return 1


__all__ = \
[
    'AbstractValue',
    'AlwaysOne',
]

File pdoc_issues/class_wrapper/util.py

import functools
import types
from typing import *

def wrap_first[C](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[C](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[C](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',
]

Potential Solution

Write your own inspect.unwrap(), which handles classes specifically:

  • For functions, the behaviour is unchanged
  • For classes, you should unwrap as long as __module__ and __name__ are preserved

P.S.

I know generating new classes at runtime during decoration is a bad practice, but it's not my code, I just found that while using a third-party library (dataclassabc)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions