16

I'd like to write a python function that has a dynamically created docstring. In essence for a function func() I want func.__doc__ to be a descriptor that calls a custom __get__ function create the docstring on request. Then help(func) should return the dynamically generated docstring.

The context here is to write a python package wrapping a large number of command line tools in an existing analysis package. Each tool becomes a similarly named module function (created via function factory and inserted into the module namespace), with the function documentation and interface arguments dynamically generated via the analysis package.

2
  • I doubt this is possible as you cannot even subclass function type, not to mention somehow making def produce objects of your own type. Commented Apr 22, 2010 at 19:47
  • I want to do this on the fly because within the analysis package one can set parameter values for different tools and these parameters get remembered. I want the function docstring to present the current parameter settings for that tool. So when you ask for 'help(wrapped_tool)' it needs to query the analysis package on the fly and create the docstring at that time. Commented Apr 22, 2010 at 20:14

4 Answers 4

16

You can't do what you're looking to do, in the way you want to do it.

From your description it seems like you could do something like this:

for tool in find_tools():
    def __tool(*arg):
        validate_args(tool, args)
        return execute_tool(tool, args)
    __tool.__name__ = tool.name
    __tool.__doc__ = compile_docstring(tool)
    setattr(module, tool.name, __tool)

i.e. create the documentation string dynamically up-front when you create the function. Is the a reason why the docstring has to be dynamic from one call to __doc__ to the next?

Assuming there is, you'll have to wrap your function up in a class, using __call__ to trigger the action.

But even then you've got a problem. When help() is called to find the docstring, it is called on the class, not the instance, so this kind of thing:

class ToolWrapper(object):
    def __init__(self, tool):
        self.tool = tool 
        self.__name__ = tool.name
    def _get_doc(self):
        return compile_docstring(self.tool)
    __doc__ = property(_get_doc)
    def __call__(self, *args):
        validate_args(args)
        return execute_tool(tool, args)

won't work, because properties are instance, not class attributes. You can make the doc property work by having it on a metaclass, rather than the class itself

for tool in find_tools():
    # Build a custom meta-class to provide __doc__.
    class _ToolMetaclass(type):
        def _get_doc(self):
            return create_docstring(tool)
        __doc__ = property(_get_doc)

    # Build a callable class to wrap the tool.
    class _ToolWrapper(object):
        __metaclass__ = _ToolMetaclass
        def _get_doc(self):
            return create_docstring(tool)
        __doc__ = property(_get_doc)
        def __call__(self, *args):
            validate_args(tool, args)
            execute_tool(tool, args)

    # Add the tool to the module.
    setattr(module, tool.name, _ToolWrapper())

Now you can do

help(my_tool_name)

and get the custom docstring, or

my_tool_name.__doc__

for the same thing. The __doc__ property is in the _ToolWrapper class is needed to trap the latter case.

Sign up to request clarification or add additional context in comments.

3 Comments

I can't comment on someone else's post yet (newbie, and all), but I want to say that overloading help() isn't a great solution, because you can't guarantee that anywhere help() will be called will use your version rather than the default version. Relying on that kind of monkey patching, in my experience, leads to difficult to debug problems at a later date when an unexpected use-case arrives (such as a new documentation tool, or a user with an 'enhanced' ipython, or a GUI help in an IDE, etc, etc).
Excellent, this did the trick. The first two code examples are just what I wrote before posting the question. I agree that overloading help is not a great idea.
Just say no to monkey patching.
4

(Python 3 solution)

You could make use of Python's duck typing to implement a dynamic string:

import time

def fn():
    pass

class mydoc( str ):
    def expandtabs( self, *args, **kwargs ):
        return "this is a dynamic strting created on {}".format( time.asctime() ).expandtabs( *args, **kwargs )

fn.__doc__ = mydoc()

help( fn )

Caveats: This assumes that the help function is calling .expandtabs to get the text from the __doc__ object, which works in Python 3.7. A more robust solution would implement the other str methods in order to have our duck continue acting like a duck even if the help method changes. Also note that our mydoc class derives from str, this is because help, somewhat atypically, enforces strong typing by asserting isinstance(thing.__doc__, str). Like all solutions this is a bit hacky, but whether this is a problem largely depends on the full project requirements.

Comments

0

Generating doc strings dynamically might indicate your code violates the single responsibility principle.

Still, a solution to this problem would be useful for documenting args/kwargs that are passed through to other functions without coupling your docstring to the underlying function's API.

One solution is the following: this code provides a decorator dynamic_helpstring which dynamically appends the arguments from one or more specified functions to the docstring of the decorated function.

import inspect
from typing import Callable, List, Union


def dynamic_helpstring(functions: Union[Callable, List[Callable]]):
    """
    A decorator that dynamically appends the arguments (wargs) from one or more specified
    functions to the docstring of the decorated function.

    Args:
        functions (Union[Callable, List[Callable]]): A function or a list of functions whose kwargs
                                                     will be appended to the docstring of the decorated function.

    Returns:
        Callable: The original function with an updated docstring.
    """
    if not isinstance(functions, list):
        functions = [functions]

    def decorator(func):
        # Generate the additional docstring with args from the specified functions
        additional_doc = "\n\nAvailable args from specified functions:\n"
        for function in functions:
            additional_doc += f"\nFrom {function.__module__}.{function.__name__}():\n"
            sig = inspect.signature(function)
            params = sig.parameters
            function_doc = "\n".join(
                _get_arg_docstring(name, param)
                for name, param in params.items()
            )
            additional_doc += function_doc + "\n"

        if func.__doc__ is None:
            func.__doc__ = ""
        func.__doc__ += additional_doc

        return func

    return decorator

def _get_arg_docstring(name: str, param: inspect.Parameter):
    annote = (
        f"{param.annotation.__module__}.{param.annotation.__name__}"
        if hasattr(param.annotation, "__name__") and hasattr(param.annotation, "__module__")
        else param.annotation
    )

    annote = annote.replace("builtins.","")

    if param.default is inspect.Parameter.empty:
        return f"\t{name} ({annote})"
    return f"\t{name} ({annote}): {param.default}"

Usage examples:

  1. Decorating a function with args from a single function:
   @dynamic_helpstring(bar)
   def foo(...):
       pass
  1. Decorating a function with args from multiple functions:
   @dynamic_helpstring([bar, bas])
   def foo(...):
       pass
  1. Complete example with args from multiple functions:
from typing import List, Union
from modeling.dynamic_helpstring import dynamic_helpstring

def foo(*, a: int, b: Union[int,str]):
    pass

def bar(c: list, d: str = None):
    pass

@dynamic_helpstring([foo,bar])
def bas(**kwargs):
    """default help string"""
    pass

help(bas)

Results in:

Help on function bas in module __main__:

bas(**kwargs)
    default help string
    Available args from specified functions:
    
    From __main__.foo():
            a (int)
            b (typing.Union)
    
    From __main__.bar():
            c (list)
            d (str): None

Comments

-4

Instead of messing with the function, why not write your own help function?

my_global=42

def help(func):
    print('%s: my_global=%s'%(func.func_name,my_global))        

def foo():
    pass

help(foo)

2 Comments

I agree with @Ian, the overloading of the help builtin is not a great idea, because you're never sure how it will be used elsewhere. However, there could be an improved implementation that would wrap a switch between a custom and the default implementation.
This is bad practise, don't do this.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.