How create a 2-row table header with docutils

154 Views Asked by At

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

2

There are 2 best solutions below

0
Paebbels On BEST ANSWER

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

3
Bhav Bhela On

Unfamiliar with the docutils API, but have you considered using a tree with level-order traversal? Something like:

from collections import deque


class _Column:
    def __init__(self, title: str, width: Optional[int] = None, children: Optional[List['_Column']] = None):
        # Only leaf nodes can have widths
        assert width is None or children is None
        self.width = width
        self.children = children
        self._width_computed = None

    def getWidth(self) -> int:
        if self._width_computed is None:
            self._width_computed = self.width or sum([c.getWidth() for c in self.children or []])
        return self._width_computed
        
    def countLeaves(self) -> int:
        if self.children is None:
            return 1
        return sum([c.countLeaves() for c in self.children or []])
  
...

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

        tableGroup = nodes.tgroup(cols=(sum([col.countLeaves() for col in columns])))
        table += tableGroup

        tableRow = nodes.row()
        
        # level-order traversal to get widths.
        current_level = 0
        queue = deque([(c, current_level) for c in columns])
        for col, level in queue:
            # Starting new header row, write previous.
            if current_level != level:
                current_level = level
                tableGroup += nodes.thead("", tableRow)
                tableRow = nodes.row()
            # Write a column.
            tableGroup += nodes.colspec(colwidth=col.getWidth())
            tableRow += nodes.entry("", nodes.paragraph(text=col.title))
            # Recurse over next level of headers.
            for child_col in col.children or []:
                queue.appendleft((child_col, level + 1))

        # Add the last header.
        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=[
                _Column(
                    title="Module",
                    width=500,
                ),
                _Column(
                    title="Statements",
                    children=[
                        _Column(
                            title="Total Statements",
                            width=100,
                        ),
                        _Column(
                            title="Excluded Statements",
                            width=100,
                        ),
                        _Column(
                            title="Covered Statements",
                            width=100,
                        ),
                        _Column(
                            title="Missing Statements",
                            width=100,
                        ),
                    ]
                ),
                _Column(
                    title="Branches",
                    children=[
                        _Column(
                            title="Total Branches",
                            width=100,
                        ),
                        _Column(
                            title="Covered Branches",
                            width=100,
                        ),
                        _Column(
                            title="Partial Branches",
                            width=100,
                        ),
                        _Column(
                            title="Missing Branches",
                            width=100,
                        ),
                    ]
                ),
                _Column(
                    title="Coverage in %",
                    width=100,
                ),
            ],
            classes=["report-doccov-table"]
        )
        tableGroup += nodes.tbody()