Kurt McKee

lessons learned in production

Archive

Hey there! This article was written in 2019.

It might not have aged well for any number of reasons, so keep that in mind when reading (or clicking outgoing links!).

Self-documenting code

Posted 26 December 2019 in programming and python

The problem

I maintain code and narrative documentation at work to help ensure that there's a good understanding of the environmental requirements in which the software runs. However, this increases the maintenance burden: every time I want to make a change to the code I have to separately update the documentation.

For example, if the software must connect to several servers in a specific order, that's a requirement that has to be documented. If the list of servers changes for any reason, I have to update the code and the documentation. What a waste of time.

It would be far better if I could update the code or documentation and have the other piece automatically update, too. Although it's possible to parse the documentation at runtime into usable code, I think it's easier to generate the documentation using up-to-date code.

To do this, I'm going to modify the module's docstring at import so the Sphinx autodoc extension can extract the docstring.

The current situation

Let's say I have a module I can import named server.py. It currently looks like this:

# server.py
# ---------

servers = (
    ('Boston', '192.168.1.10'),
    ('Liverpool', '192.168.2.20'),
    ('Moscow', '192.168.3.30'),
    ('Seoul', '192.168.4.40'),
)

# A bunch of additional code connects to the servers,
# collects information, and logs it.

The process is documented in a ReST file named process.rst. It currently looks like this:

..  process.rst
..  -----------

The following data centers will be accessed, in this order:

Data center     IP address
--------------- ------------
Boston          192.168.1.10
Liverpool       192.168.2.20
Moscow          192.168.3.30
Seoul           192.168.4.40
--------------- ------------

This is inefficient and can be fixed by writing a little more code.

The solution

First, I'm going to create a docstring in server.py. The code below will automatically run when Python imports the module:

# server.py
# ---------

"""The following data centers will be accessed, in this order:

#SERVERMARKER

"""

servers = (
    ('Boston', '192.168.1.10'),
    ('Liverpool', '192.168.2.20'),
    ('Moscow', '192.168.3.30'),
    ('Seoul', '192.168.4.40'),
)

# Adjust the docstring.
lines = []
for line in __doc__.splitlines():
    if not line.startswith('#SERVERMARKER'):
        lines.append(line)
        continue

    lines.append('..  csv-table::')
    lines.append('    :header: "Data center", "IP address"')
    lines.append('')
    for location, ip_address in servers:
        lines.append(f'    "{location}", "{ip_address}"')

__doc__ = '\n'.join(lines)

# The `del` keyword could be used to remove the additional variables
# (like `lines` and `location`) from the module namespace.

When this code runs, server.__doc__ will contain the following text:

The following data centers will be accessed, in this order:

..  csv-table::
    :header: "Data center", "IP address"

    "Boston", "192.168.1.10"
    "Liverpool", "192.168.2.20"
    "Moscow", "192.168.3.30"
    "Seoul", "192.168.4.40"

Then, I can update process.rst with this autodoc directive:

..  process.rst
..  -----------

..  automodule:: server
    :no-members:

This results in the same narrative documentation but eliminates the need to update the documentation when the code changes.

☕ Like my work? I accept tips!