Rendering docstrings

The previous section covered how to read and preview parsed docstrings. In this section, we’ll look at how to render a parsed docstring into a format that can be used in documentation, like markdown or HTML.

Setting up problem

Suppose that we wanted to take a function like get_object() and render a summary, with:

  • The number of parameters it takes.
  • The number of sections in its parsed docstring.

For get_object() it might look like the following:

## get_object
N PARAMETERS: 3
SECTIONS: A docstring with 4 pieces

Inspecting a function

As covered in the previous section, we can preview information about get_object().

from quartodoc import get_object, preview

f_obj = get_object("quartodoc", "get_object")

preview(f_obj, max_depth=3)
█─Alias
├─name = 'get_object'
├─annotation = ExprAttribute(values=[ExprName(name='dc', parent=M ...
├─parameters = █─Parameters
│              ├─0 = █─Parameter
│              │     ├─annotation = ExprName(name='str', parent=Module(PosixPath('/opt ...
│              │     ├─kind = <ParameterKind.positional_or_keyword: 'positional  ...
│              │     ├─name = 'path'
│              │     └─default = None
│              ├─1 = █─Parameter
│              │     ├─annotation = "'str | None'"
│              │     ├─kind = <ParameterKind.positional_or_keyword: 'positional  ...
│              │     ├─name = 'object_name'
│              │     └─default = 'None'
│              ├─2 = █─Parameter
│              │     ├─annotation = ExprName(name='str', parent=Module(PosixPath('/opt ...
│              │     ├─kind = <ParameterKind.positional_or_keyword: 'positional  ...
│              │     ├─name = 'parser'
│              │     └─default = "'numpy'"
│              ├─3 = █─Parameter
│              │     ├─annotation = None
│              │     ├─kind = <ParameterKind.positional_or_keyword: 'positional  ...
│              │     ├─name = 'load_aliases'
│              │     └─default = 'True'
│              ├─4 = █─Parameter
│              │     ├─annotation = None
│              │     ├─kind = <ParameterKind.positional_or_keyword: 'positional  ...
│              │     ├─name = 'dynamic'
│              │     └─default = 'False'
│              └─5 = █─Parameter
│                    ├─annotation = ExprBinOp(left='None', operator='|', right=ExprNam ...
│                    ├─kind = <ParameterKind.positional_or_keyword: 'positional  ...
│                    ├─name = 'loader'
│                    └─default = 'None'
└─docstring = █─Docstring
              ├─parser = <Parser.numpy: 'numpy'>
              └─parsed = █─list
                         ├─0 = █─DocstringSectionText ...
                         ├─1 = █─DocstringSectionParameters ...
                         ├─2 = █─DocstringSectionSeeAlso ...
                         ├─3 = █─DocstringSectionExamples ...
                         └─4 = █─DocstringSectionReturns ...

Note the following pieces:

  • preview() takes a max_depth argument, that limits how much information it shows.
  • get_object() takes 3 parameters.
  • get_object() has a docstring with 4 sections.

Importantly, the nodes () in the tree mention the name class of the python objects being previewed (e.g. Alias, Expression, Parameters). We’ll need these to specify how to render objects of each class.

Generic dispatch

Generic dispatch is the main programming technique used by quartodoc renderers. It let’s you define how a function (like render()) should operate on different types of objects.

from plum import dispatch

from griffe import Alias, Object, Docstring


@dispatch
def render(el: object):
    print(f"Default rendering: {type(el)}")


@dispatch
def render(el: Alias):
    print("Alias rendering")
    render(el.parameters)


@dispatch
def render(el: list):
    print("List rendering")
    [render(entry) for entry in el]


render(f_obj)
Alias rendering
Default rendering: <class '_griffe.models.Parameters'>

Defining a Renderer

quartodoc uses tree visitors to render parsed docstrings to formats like markdown and HTML. Tree visitors define how each type of object in the parse tree should be handled.

from griffe import Alias, Object, Docstring

from quartodoc import get_object
from plum import dispatch
from typing import Union


class SomeRenderer:
    def __init__(self, header_level: int = 1):
        self.header_level = header_level

    @dispatch
    def render(self, el):
        raise NotImplementedError(f"Unsupported type: {type(el)}")

    @dispatch
    def render(self, el: Union[Alias, Object]):
        header = "#" * self.header_level
        str_header = f"{header} {el.name}"
        str_params = f"N PARAMETERS: {len(el.parameters)}"
        str_sections = "SECTIONS: " + self.render(el.docstring)

        # return something pretty
        return "\n".join([str_header, str_params, str_sections])

    @dispatch
    def render(self, el: Docstring):
        return f"A docstring with {len(el.parsed)} pieces"


f_obj = get_object("quartodoc", "get_object")

print(SomeRenderer(header_level=2).render(f_obj))
## get_object
N PARAMETERS: 6
SECTIONS: A docstring with 5 pieces

Note 3 big pieces:

  • Generic dispatch: The plum dispatch function decorates each render method. The type annotations specify the types of data each version of render should dispatch on.
  • Default behavior: The first render method ensures a NotImplementedError is raised by default.
  • Tree walking: render methods often call render again on sub elements.

Completing the Renderer

While the above example showed a simple example with a .render method, a complete renderer will often do two more things:

  • Subclass an existing renderer.
  • Also override other methods like .summarize()
from quartodoc import MdRenderer

class NewRenderer(MdRenderer):
    style = "new_renderer"

    @dispatch
    def render(self, el):
        print("calling parent method for render")
        return super().render(el)
    
    @dispatch
    def summarize(self, el):
        print("calling parent method for summarize")
        return super().summarize(el)

For a list of methods, see the MdRenderer docs.