I wrote an extension for Sphinx to read code coverage files and present them as a table in a Sphinx generated HTML documentation.
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
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
Link to example: https://pytooling.github.io/sphinx-reports/coverage/index.html