Skip to main content
  1. Posts/

TIL you can refactor your documentation using decorators

·530 words·3 mins

I don’t mind writing documentation. I try to write and update docstrings as I’m working on the code. This is not always easy, and sometimes documentation is spread over different docstrings. Or if docstrings for similar functions are not consistent. This, I do mind. Especially if they are part of the public API.

Last week I found that you can copy docstrings using a decorator. This post is inspired by some code I found in matplotlib:

1
2
3
4
5
6
7
def copy(source):
    """Copy a docstring from another function."""
    def do_copy(target):
        if source.__doc__:
            target.__doc__ = source.__doc__
        return target
    return do_copy

It’s a decorator for a function, that copies the docstring from the source function to the decorated function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def roar():
    """Roar like a lion."""
    print('rawr')

@copy(roar)
def loud_roar():
    print('RAWR!')

help(roar)
help(loud_roar)
Help on function roar in module __main__:

roar()
    Roar like a lion.

Help on function loud_roar in module __main__:

loud_roar()
    Roar like a lion.

Cool! Not immediately useful for my own purposes. The good thing is that it can easily be extended using string formatting.

The decorator below takes a function, and tries to copy the docstring to the target function. Any keyword parameters are passed to string.formatting. A copy of the source docstring is kept on ._docstring.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from textwrap import dedent

def docstring(source=None, **params):
    """Copy a docstring from another function."""
    def do_copy(target):
        if source is None:
            docstring = dedent(target.__doc__)
        elif hasattr(source, '_docstring'):
            docstring = source._docstring
        elif source.__doc__:
            docstring = dedent(source.__doc__)

        target.__doc__ = docstring.format(**params)
        target._docstring = docstring

        return target

    return do_copy

Let’s see how it works:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@docstring(cry='Roar', animal='cow')
def roar():
    """{cry} like a {animal}."""
    print('RAWR!')

@docstring(roar, cry='Moo', animal='cow')
def moo():
    print('MOO!')

help(roar)

help(moo)
Help on function roar in module __main__:

roar()
    Roar like a cow.

Help on function moo in module __main__:

moo()
    Moo like a cow.

It also works with class methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from statistics import median

class MyList(list):
    @docstring(op='minimum')
    def min(self):
        """Return {op} value from the list."""
        return min(self)

    @docstring(min, op='maximum')
    def max(self):
        return max(self)

    @docstring(min, op='median')
    def mean(self):
        return median(self)


help(MyList.min)
help(MyList.max)
help(MyList.mean)
Help on function min in module __main__:

min(self)
    Return minimum value from the list.

Help on function max in module __main__:

max(self)
    Return maximum value from the list.

Help on function mean in module __main__:

mean(self)
    Return median value from the list.

Essentially anything with a docstring (__doc__ attribute) works, such as classes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@docstring(classname='A')
class A:
    """This is {classname}."""
    pass

@docstring(A, classname="B")
class B(A):
    pass

print(A.__doc__)
print(B.__doc__)
This is A.
This is B.

Nice! you can imagine that if you have large docstrings, that helps keeping the documentation consistent and concise.

As a bonus, this will also be picked up by Sphinx. If you are generating documentation for e.g. readthedocs, this will make it just a little bit nicer to read!