Search code examples
pythonpython-decoratorscode-documentation

How to make Python help() function work well with decorators?


Python has the nice help() built-in that displays the doc string of an object. When I use it in the REPL passing a function in my module it nicely displays:

>>> help(mymodule.myfunction)
Help on function myfunction in module mymodule.main:

myfunction(parameter=False) -> Dict[str, str]
    Doc string of myfunction

But if I use a decorator like functools.@lru_cache the help function is somewhat confusing:

>>> help(mymodule.myfunction)
Help on _lru_cache_wrapper in module mymodule.main:

myfunction(parameter=False) -> Dict[str, str]
    Doc string of myfunction

The doc string is displayed, but the first line of the message is confusing for my users who aren't experienced Python programmers.

Note that I didn't create the decorator, it is from the functools module in stdlib. It looks like the solution of using functools.wraps won't work for me.

Can I do something to force the display of the first message even if the function has a decorator?


Solution

  • The following wrapper makes the cached function look more like the original.

    from functools import wraps
    
    def restore(func):
        @wraps(func.__wrapped__)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
    
        return wrapper
    

    It creates yet another wrapper around your decorated function that restores its type as a function while preserving the docstring.

    For example, if you have a function like this:

    @restore
    @lru_cache
    def func_dup(x: int):
        """my doc"""
        return x
    

    Then, run help(func_dup)

    Help on function func_dup in module __main__:
    
    func_dup(x: int)
        my doc
    

    Why the Difference

    I will be using CPython 3.10, which is the latest version as of the time I wrote this answer.

    The help callable is actually implemented in pydoc as a Helper object. The magic method Helper.__call__ is defined to call Helper.help. It then calls doc, which calls render_doc. The render_doc function makes up the string that gets printed. Inside this function, it calls pydoc.describe for a descriptive name for your function.

    Your original mymodule.myfunction is a function, so describe returns in this branch.

    if inspect.isfunction(thing):
            return 'function ' + thing.__name__
    

    This gives "function myfunction".

    However, after you decorate your function with @lru_cache, it becomes an instance of the built-in/extension type functools._lru_cache_wrapper. I am not sure why it is implemented this way, but the decorated function is not of type types.FunctionType anymore. So the describe(mymodule.myfunction) function returns on the last line after being decorated.

    return type(thing).__name__
    

    This returns "_lru_cache_wrapper".

    The functools.update_wrapper function attempts to

    Update a wrapper function to look like the wrapped function

    It doesn't restore the wrapper as an instance of types.FunctionType. It does, however, reference the original function in the __wrapped__ attribute. Hence, we can use that to wrap your original function yet again.

    Reference

    There is a Python Issue bpo-46761 that may or may not relate to this issue.

    when using functools.partial() to pre-supply arguments to a function, if you then call functools.update_wrapper() to update that partial object, inspect.signature() returns the original function's signature, not the wrapped function's signature.

    It is primarily on functools.partial, which doesn't even preserve the wrapped function's signature.