Search code examples
pythonhtml-tablecode-coveragepython-sphinxdocutils

How create a 2-row table header with docutils


I wrote an extension for Sphinx to read code coverage files and present them as a table in a Sphinx generated HTML documentation.

enter image description here

Currently the table has a single header row with e.g. 3 columns for statement related values and 4 columns for branch related data. I would like to create a 2 row table header, so multiple columns are grouped.

In pure HTML it would be done by adding colspan=3. But how to solve that question with docutils?

The full sources can be found here: https://github.com/pyTooling/sphinx-reports/blob/main/sphinx_reports/CodeCoverage.py#L169

Interesting code is this:

    def _PrepareTable(self, columns: Dict[str, int], identifier: str, classes: List[str]) -> Tuple[nodes.table, nodes.tgroup]:
        table = nodes.table("", identifier=identifier, classes=classes)

        tableGroup = nodes.tgroup(cols=(len(columns)))
        table += tableGroup

        tableRow = nodes.row()
        for columnTitle, width in columns.items():
            tableGroup += nodes.colspec(colwidth=width)
            tableRow += nodes.entry("", nodes.paragraph(text=columnTitle))

        tableGroup += nodes.thead("", tableRow)

        return table, tableGroup

    def _GenerateCoverageTable(self) -> nodes.table:
        # Create a table and table header with 5 columns
        table, tableGroup = self._PrepareTable(
            identifier=self._packageID,
            columns={
                "Module": 500,
                "Total Statements": 100,
                "Excluded Statements": 100,
                "Covered Statements": 100,
                "Missing Statements": 100,
                "Total Branches": 100,
                "Covered Branches": 100,
                "Partial Branches": 100,
                "Missing Branches": 100,
                "Coverage in %": 100
            },
            classes=["report-doccov-table"]
        )
        tableBody = nodes.tbody()
        tableGroup += tableBody


Solution

  • The magic of multiple cells spanning rows or columns is done by morerows and morecols. In addition, merged cells need to be set as None. I found it by investigating the code for the table parser.

    Like always with Sphinx and docutils, such features are not documented (but isn't docutils and Sphinx meant to document code/itself?).

    Anyhow, I created a helper method which returns a table node with header rows in it. I used a simple approach to describe header columns in the primary rows that are divided into more columns in a secondary row. Alternatively, @Bhav-Bhela demonstrated a description technique for deeper nesting.

    The method expects a list of primary column descriptions, which is a tuple of column title, optional list of secondary columns, column width. If the secondary column list is present, then no column width is needed for the primary row. In the secondary row, a tuple of title and column width is used.

    from typing import Optional as Nullable
    
    List[
      Tuple[str, Nullable[List[
        Tuple[str, int]]
      ], Nullable[int]]
    ]
    
    class BaseDirective(ObjectDescription):
        # ...
    
        def _PrepareTable(self, columns: List[Tuple[str, Nullable[List[Tuple[str, int]]], Nullable[int]]], identifier: str, classes: List[str]) -> Tuple[nodes.table, nodes.tgroup]:
            table = nodes.table("", identifier=identifier, classes=classes)
    
            hasSecondHeaderRow = False
            columnCount = 0
            for groupColumn in columns:
                if groupColumn[1] is not None:
                    columnCount += len(groupColumn[1])
                    hasSecondHeaderRow = True
                else:
                    columnCount += 1
    
            tableGroup = nodes.tgroup(cols=columnCount)
            table += tableGroup
    
            # Setup column specifications
            for _, more, width in columns:
                if more is None:
                    tableGroup += nodes.colspec(colwidth=width)
                else:
                    for _, width in more:
                        tableGroup += nodes.colspec(colwidth=width)
    
            # Setup primary header row
            headerRow = nodes.row()
            for columnTitle, more, _ in columns:
                if more is None:
                    headerRow += nodes.entry("", nodes.paragraph(text=columnTitle), morerows=1)
                else:
                    morecols = len(more) - 1
                    headerRow += nodes.entry("", nodes.paragraph(text=columnTitle), morecols=morecols)
                    for i in range(morecols):
                        headerRow += None
    
            tableHeader = nodes.thead("", headerRow)
            tableGroup += tableHeader
    
            # If present, setup secondary header row
            if hasSecondHeaderRow:
                tableRow = nodes.row()
                for columnTitle, more, _ in columns:
                    if more is None:
                        tableRow += None
                    else:
                        for columnTitle, _ in more:
                            tableRow += nodes.entry("", nodes.paragraph(text=columnTitle))
    
                tableHeader += tableRow
    
            return table, tableGroup
    

    It's then used like that:

    class CodeCoverage(BaseDirective):
        # ...
    
        def _GenerateCoverageTable(self) -> nodes.table:
            # Create a table and table header with 10 columns
            table, tableGroup = self._PrepareTable(
                identifier=self._packageID,
                columns=[
                    ("Package", [
                        (" Module", 500)
                    ], None),
                    ("Statments", [
                        ("Total", 100),
                        ("Excluded", 100),
                        ("Covered", 100),
                        ("Missing", 100)
                    ], None),
                    ("Branches", [
                        ("Total", 100),
                        ("Covered", 100),
                        ("Partial", 100),
                        ("Missing", 100)
                    ], None),
                    ("Coverage", [
                        ("in %", 100)
                    ], None)
                ],
                classes=["report-codecov-table"]
            )
            tableBody = nodes.tbody()
            tableGroup += tableBody
    
        def run(self) -> List[nodes.Node]:
            self._CheckOptions()
    
            container = nodes.container()
            container += self._GenerateCoverageTable()
    
            return [container]
    

    The full code can be found here: Sphinx.py:BaseDirective._PrepareTable

    The result looks like this:
    enter image description here

    Link to example: https://pytooling.github.io/sphinx-reports/coverage/index.html