Annotate as you go

Saturday, 4 June 2022

Annotate as you go

There's a counter-constructive saying: a project is dead as soon as you add documentation (Aeschylus, I believe).

This could not be more incorrect. Whereas it is true that writing documentation on an evolving project will quickly result in the fresh documentation becoming quickly invalid, it is a planning truth that writing documentation once a project is finishing is impossible because there are a hundread and one more pressing issues. Therefore, adding docstrings to each function, method and class in Python as one goes along is by far more advantageous. Once this is done, however this information needs to be transmuted into documentation. Here is how once can set up ReadTheDocs without falling into a few traps, as the documentation generator Sphinx is ironically weirdly documented and should be done ideally early on, so one knows what mistakes one's making.

Note

I run an internal workshop on adding extras to a GitHub repository and one question in the feedback was if I could write out the steps to do ReadTheDocs properly. And this is it.

Motivation

Motivation is hard when it’s a big task. When the project is complete, the paper becomes the only focus and documentation falls on the sidelines. It does not help that reviewers rarely check code: in my experience, half of reviewers do not even check a web app. However, in the grand scheme of things it does matter. Therefore, one should not leave it to last. Every time I have left it as the last thing I have sorely regretted it. Furthermore, the sentence “I wrote this, but don’t remember what it does” does often arise when documentation is done late.

Invalid top-down documentation

The saying in the lead applies primarily to writing an overview. In an ideal world, the overview is written first and acts as a roadmap of how the module ought to work and by virtue of the excellent planning resulting from the thoroughly thought-out overview will be true even at the end of the project. However, projects evolve and more often than not they were not started with the idea of being evolvable —the comparison of an American city vs. a European medieval city is classic teaching example of the concept of planning and evolvability in CS. Nevertheless, I would still advocate to think what the end goals of a project are and sketch them out before starting. But majorly the most time-consuming part of writing documentation is the description of the parts, hence my insistence on writing them as one goes along.

Docstrings

In Python docstrings work really nicely —much nicer than doxigen documentation in C++. Docstrings are generally written in ReStructuredText (rst) within triple quotes within a function, method or class.

from typing import Any

def foo(bar: Any) -> int:
    """
    This is a docstring.

    :param bar: This is a parameter.
    :type bar: Any
    :return: This is a return value.
    :rtype: int
    """
    ...
    return bar

All these docstrings can be converted by Sphinx into a nice documentation page. Previously I wrote a blog post about converting docstrings to markdown documentation for GitHub, which is helpful in the case the project is not intended to be pip released, but for a proper project this is a bad idea and instead the correct course of action is to create ReadTheDocs documentation. The preferred format for GitHub is markdown as it's easier and the Sphinx autodoc extension is not applicable there. The preferred format for ReadTheDocs is ReStructuredText (rst).

The textbook example generation of the conf.py file is using Sphinx sphinx-quickstart command. This does not automatically tell it to convert docstrings out of the box, but you have to add them. The docstrings and module content is ”API” documentation and the command line tool sphinx-apidoc or sphinx-autogen do this. But it often requires some tweaks for the API documentation one wants.

At the base of the repo, we will create a .readthedocs.yml file for ReadTheDocs, but first lets make a .readthedocs folder (or any other name you want) will the documentation.

sphinx-apidoc -o .readthedocs . .readthedocs --full -A 'Your name here' -l 'en';
cd .readthedocs;

Running make html in that folder will generate the documentation in the html folder, for you to check out. Do this often as stuff breaks easily with Sphinx.

Some tweaks are a must.

In the folder there are two main files of interest, the conf.py file and the index.rst file. The former holds how the project is parsed the latter how is the main menu.

Automodule, autoclass, autobahn, automethod, autofunction

The index.rst file is the main menu. It will refer to a file, without the .rst extension, with the name of your module.

This will be a file in the folder along with all submodules, in the format module.submodule.rst. And will contain the following workhorse:

.. automodule:: module_name.submodule_name
   :members:
   :undoc-members:
   :show-inheritance:

There are a few directives like this that can be used to generate the documentation and are discussed in autodoc documentation, such as autoclass. When you add a new python file (submodule) to your project, Sphinx will not know about it. So be vigilant to add a new definition to the index.rst file.

The following parameters are worth noting:

  • :members: will include all the members of the module and the order can be changed with :member-order:.
  • This will not include private (_foo) or magic (or dunder) methods. :private-members: will include all, while :special-members: will include magic methods (called special by nobody except Sphinx).
  • :undoc-members: will include all members that are not documented.
  • :inherited-members: will include all members that are inherited from a parent class, which is rather key.

When a class gets too big, it should be split into multiple files, each with a single class in it that has a functional theme. These classes will form a chain of inheritance, leading up to the main class. Naming the split files with underscores will get them ignored. Consequently, it is an option to document only the main class which thanks to :inherited-members: will have everything.

But :inherited-members: is not always welcome. For example, when using typehinting (which is optional but actually a must), one does resort to typing.TypedDict (which allows you to specify the expected names of the dictionary keys and the type of its values) or typing.TypeVar (which is a wrapper for a type). The :inherited-members: on these will make a mess of pointlessness. Therefore it often gets easier to manually define how one wants things annotated via multiple autoclass rather than the autogenerated blanket automodule.

conf.py file

Ignore sys.path.insert

In the conf.py file, there's a commented out line with sys.path.insert. Leave it like so. In the .readthedocs.yml file, there will be

python:
   install:
     - method: pip
       path: .
     - requirements: .readthedocs/requirements.txt
     - requirements: requirements.txt

So the module to be documented will be installed anyway (path: .).

extensions

The conf.py file does not call a function like setup in a setup.py file, but just sets global variables for Sphinx. One is the list extensions which tells Sphinx which extensions to use. E.g.

extensions = [
    'readthedocs_ext.readthedocs',
    'sphinx.ext.viewcode',
    'sphinx.ext.todo',
    #'sphinx_toolbox.more_autodoc',
    'sphinx.ext.autodoc',
]

readthedocs_ext.readthedocs will be added by RTD, but it's nice for testing locally (need to be installed). sphinx.ext.viewcode shows the code snippets in the documentation. sphinx_toolbox.more_autodoc is a nice extension that adds more autodoc directives, but is hard to set up as it will crash one and a million corner cases —more so than mypy. But it is a good idea to check if it can in the first place —if something fails use the subsets that work. sphinx_toolbox.more_autodoc.typehints is the key one in my opinion as vanilla Sphinx does not do typehints. In the sphinx-quickstart command documentation there's a list of vanilla extensions that one can use.

It should be noted that the classic way to specify typehint only methods:

import typing
if typing.TYPE_CHECKING:
    from foo import Foo

needs to be altered to:

import typing
if typing.TYPE_CHECKING or 'sphinx' in sys.modules:
    from foo import Foo

Other variables

There is a variable html_static_path, which can be set to an empty list if there are no static files:

html_static_path = ['_static']

This is because you cannot git commit an empty folder so without a _static folder it will fail.

There is also a line html_theme = 'alabaster' which is the default theme for Sphinx. ReadTheDocs uses 'sphinx_rtd_theme'. Therefore to use the sphinx_rtd_theme locally you need to install it. So our installation list is looking like:

pip install sphinx-toolbox readthedocs-sphinx-ext sphinx-rtd-theme

Other variables worth adding for more_autodoc are:

always_document_param_types = True
typehints_defaults = 'braces'   # other styles are available

The root_doc variable is a good way to store the rst files in a folder to declutter. By default it is index as index.rst is the main page, so moving it to source/index.rst and setting root_doc='source/index'. Alternatively, one could have the conf.py in that folder, but not the make.

init.py

Counterintuitively, __init__ method docstrings are skipped, even if at first documentation of how to initialise a module would be expected in the __init__.py file. There are thre solutions:

One can add it manually on an autoclass directive via :special-members: __init__ in the rst definition.

One can globally override its skippage in the conf.py file one can add:

def skip(app, what, name, obj,would_skip, options):
    if name in ( '__init__',):
        return False
    return would_skip
def setup(app):
    app.connect('autodoc-skip-member', skip)

One can document class initialisation in the class docstring, which is often done, but one loses the typehints.

However, as codeclimate painfully reminds us, there should be ideally 4 or less attributes in a method, and class initialisation often has many arguments, so you may end up using packed keyword arguments annotated as a TypedDict. And to add insult to injury, the init may be overloaded:

from typing_extensions import Unpack, TypedDict  # this is a 3.10 feature
from typing import Dict, List
from singledispatchmethod import singledispatchmethod

class FooOptions(TypedDict):
    a: int
    b: str
    c: float
    d: bool
    e: Dict[str, int]

class Foo:
   """
   This class accepts a main arguments, either as a dictionary or as a list, 
   followed by various options as keyword arguments as specified in the `FooKwargs` class.
   """

    @singledispatchmethod
    def __init__(self, data: list, **options: Unpack[FooOptions]):
        """
        This docstring will be skipped. And also, are we talking of this dispatch or all?
        """
        self.data:List[int] = data
        self.a:int = options.get('a', -1)
        self.b:str = options.get('b', 'unknown')
        self.c:float = options.get('c', float('nan'))
        self.d:bool = options.get('d', False)
        self.e:Dict[str, int] = options.get('e', {})

    @__init__.register
    def _(self, data: dict, **kwargs: Unpack[FooOptions]):
        self.__init__(list(data.values()), **kwargs)

In this rather extreme case, annotating the class makes very much more sense. If this example seemed very alien, don't worry, but do make sure to read up on typehints as they make coding easier and less error-prone and as a bonus PyCharm will give better suggestions.

Mock

Often some module is required, but this requires a dark magic ritual to get running. As a result the Mock class from unittest is of great use. This is used to make a mock of a module, which pretends to be there, but does nothing. So in config.py one can add:

import sys
from unittest.mock import Mock, MagicMock
sys.modules['foo'] = MagicMock()

Additional caveats

Stick to ReStructuredText

One can write docstrings directly in markdown, but this is not a great idea as RST is specifically designed for code annotation as we will see in a later section.

Catch formatting errors early

PyCharm autofills docstrings for you if set to do so (search preferences for “Automatic documentation”), but a common mistake is to not add a blank space between the description and the parameters. Without this the first parameter will be interpreted as the description and not as a bullet point. Everyone makes this mistake, but if one started early to check the documentation was getting generated fine, then one would avoid this subsequently.

Browser hard refresh

In a browser it is critical to do a hard refresh of the pages (Shift+refresh button). Silly but I'd say 90% of issues come from this.

Tests are documentation

Tests are documentation. You should always write tests. I test new features generally in a Jupyter notebook, to see the outputs in full, but the key conclusions can be converted into a test. Future you or a user will likely check out the code in the tests, so do add docstrings to them too.

Check if possible

Sphinx has many extra formatting features over markdown and if you have a need for something that may be a common requirement, check the documentation and pick up the extra extensions or Sphinx formatting tricks as the need arises, for example: :ivar: or :cvar:, are worth adding to the documentation.

ReadTheDocs

So far I have gone through Sphinx, which only half of it. The next step, once we have a working Sphinx, is to use ReadTheDocs.

Yaml

Add a .readthedocs.yml file to the root of your project. For example I like to have:

version: 2

build:
  os: ubuntu-20.04
  tools:
    python: "3.8"

sphinx:
   configuration: .readthedocs/source/conf.py
   builder: html
   fail_on_warning: true

python:
   install:
     - method: pip
       path: .
     - requirements: requirements.txt
     - requirements: .readthedocs/requirements.txt

Namely, we install the module defined in the setup.py in the root with the method pip and the requirements with requirements.txt. But as mentioned there are a few requirements specific to Sphinx, which have nothing to do with the module's operations, hence the additional .readthedocs/requirements.txt file.

The fail_on_warning set to true is rather wishful thinking but at the debug stage this is helpful.

In the case of PyRosetta, we have a problem as it does not install like normal. Luckily one can have private environment variables in ReadTheDocs (set within the settings for the project on the ReadTheDocs website). In my package, pyrosetta-help is a command line tool that is added install_pyrosetta, which requires the presence of the PYROSETTA_USERNAME and PYROSETTA_PASSWORD env variables. This can be run by setting in the yaml file the following:

build:
  ...
  jobs:
    post_install:
      - install_pyrosetta

Likewise for other options the jobs directives can be used to better set up the environment.

Runtime

Once the yaml file is complete, head over to the ReadTheDocs website and link your GitHub account and create a new project from the reposition of interest.

Once the project build is kicked off, you can see what happens in the 'Builds' tab. Clicking on the top build, which give a badge (green hopefully), a printout and on two links in the right hand side saying view docs and view raw. The latter is crucial as it gives you the raw output.

Check for errors and warnings. If fail_on_warning is set to false, then if the documentation was partially generated, it would claim to be a success and only view raw would say otherwise.

And to reiterate, do make sure to do a hard refresh of the docs page.

Slack

In the settings on the site one can set up a webhook to a Slack channel to notify of build status.

No comments:

Post a Comment