CodeCoveragePages.py
Summary
- Lines:
164 of 171 covered (95.9%)
- Excluded lines:
2
- Branches:
52 of 54 taken (96.3%), 2 lines partially
- Coverage:
96.0%
- Directory:
- Missed lines:
159-161, 235-241
Source
pyTooling/Sphinx/CodeCoveragePages.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A page per directory and per source file of a code coverage report, generated without a source file.
33
34A report declared in ``pyTooling_CodeCoverage_Packages`` with the key ``pages`` gets a document per directory and per
35source file, named below that key's document name by its path in the report, e.g. ``<pages>/src/Counter.vhdl``. A
36chain of directories holding no file and one directory each is one page, e.g. ``pyTooling/Sphinx`` (see
37:func:`~pyTooling.Sphinx.CodeCoverage.compactDirectory`).
38
39* A directory's page shows its counters, and tables of its directories and files, each linked to its page.
40* A source file's page shows its counters, its missed lines, and its listing: the source read from the report's
41 ``sources`` directory, syntax highlighted with the Pygments lexer of the file's name, each line marked by its
42 coverage state.
43
44The pages are built by the machinery of :class:`~pyTooling.Sphinx.Pages.ReportPages`, so they are in the navigation
45below the directive ``report:code-coverage``. Every directory and file with a page is an object of domain ``report``,
46referred to by the role ``:cov:`` by its path - e.g. ``:cov:`src/Counter.vhdl``` -, which may end in ``#<line>`` to
47link to a line of a file's listing.
48
49.. seealso::
50
51 :ref:`DIR/CodeCoverage/Pages`
52 |rarr| The pages and the role, with examples.
53 :mod:`pyTooling.Sphinx.Pages`
54 |rarr| The pages of a report: document names, registration, generation and navigation.
55"""
56from __future__ import annotations
57
58from pathlib import Path
59from typing import TYPE_CHECKING, ClassVar, Iterable, Optional as Nullable, Union
60
61from docutils import nodes
62from pygments.lexers import get_lexer_for_filename
63from pygments.lexers.special import TextLexer
64from pygments.token import STANDARD_TYPES, _TokenType
65from pygments.util import ClassNotFound
66from sphinx.application import Sphinx
67from sphinx.util.logging import getLogger
68
69from pyTooling.Decorators import export
70
71from pyTooling.Sphinx import ReportExtensionError
72from pyTooling.Sphinx.CodeCoverage import CodeCoverageBase, compactDirectory, compactedName, isCompacted
73from pyTooling.Sphinx.Node import CoverageListing
74from pyTooling.Sphinx.Pages import ReportPages
75
76if TYPE_CHECKING: # pragma: no cover
77 # pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
78 from pyEDAA.Reports.CodeCoverage import BaseWithPath, CoverageSummary, Directory, File
79
80
81__all__ = ["LINE_STATES"]
82
83#: The state a listing marks a line with, by the name of the line's
84#: :class:`~pyEDAA.Reports.CodeCoverage.LineCoverageStatus`.
85LINE_STATES = {
86 "Covered": "covered",
87 "PartiallyCovered": "partial",
88 "Uncovered": "uncovered",
89 "Excluded": "excluded",
90 "Unknown": "",
91}
92
93
94@export
95def tokenClass(tokenType: _TokenType) -> str:
96 """
97 Return the short CSS class Pygments writes for a token type, as its HTML formatter does.
98
99 A keyword gets ``k``. A token type without a class of its own gets its nearest parent's class, extended by the names
100 below it.
101
102 :param tokenType: The token type.
103 :returns: The CSS class; empty for plain text.
104 """
105 suffix = ""
106 while (name := STANDARD_TYPES.get(tokenType)) is None:
107 suffix = f"-{tokenType[-1]}{suffix}"
108 tokenType = tokenType.parent
109
110 return f"{name}{suffix}"
111
112
113@export
114def tokenizeSource(source: str, fileName: str) -> list[list[tuple[str, str]]]:
115 """
116 Lex a source file with the Pygments lexer of its file name, and split the tokens into lines.
117
118 The whole file is lexed, as a token may span lines - e.g. a doc-string -; such a token is split at the line ends, each
119 part keeping its class. A file name without a lexer is lexed as plain text.
120
121 :param source: The source file's content.
122 :param fileName: The source file's name, choosing the lexer.
123 :returns: Per line, its tokens as ``(CSS class, text)`` pairs.
124 """
125 try:
126 lexer = get_lexer_for_filename(fileName, stripnl=False, ensurenl=True)
127 except ClassNotFound:
128 lexer = TextLexer(stripnl=False, ensurenl=True)
129
130 lines: list[list[tuple[str, str]]] = [[]]
131 for tokenType, value in lexer.get_tokens(source):
132 cssClass = tokenClass(tokenType)
133 for index, part in enumerate(value.split("\n")):
134 if index > 0:
135 lines.append([])
136
137 if part != "":
138 lines[-1].append((cssClass, part))
139
140 return lines[:-1] if len(lines[-1]) == 0 else lines
141
142
143@export
144def createListing(file: File, sources: Path, anchors: bool) -> Union[CoverageListing, nodes.paragraph]:
145 """
146 Create a source file's listing: its lines, syntax highlighted, each marked by its coverage state.
147
148 A report naming a line beyond the source file's end was measured on another version of the file; this is logged as
149 a warning.
150
151 :param file: The file of the report.
152 :param sources: The directory the report's file paths are relative to.
153 :param anchors: Whether a line gets the ID ``L<number>``, so the role ``:cov:`` can link to it.
154 :returns: The listing; or a paragraph saying so, if the source file can't be read.
155 """
156 sourceFile = sources / file.Path
157 try:
158 source = sourceFile.read_text(encoding="utf-8", errors="replace")
159 except OSError as ex:
160 getLogger(__name__).warning(f"Source file '{sourceFile}' of the code coverage report can't be read: {ex}")
161 return nodes.paragraph(text=f"The source file '{file.Path.as_posix()}' can't be read.")
162
163 tokens = tokenizeSource(source, file._name)
164 if (lastLineNumber := file.LastLineNumber) > len(tokens):
165 getLogger(__name__).warning(
166 f"The code coverage report names line {lastLineNumber} of '{file.Path.as_posix()}', which has {len(tokens)} "
167 f"lines: the report wasn't measured on this version of the file."
168 )
169
170 fileLines = file.Lines
171 lines = []
172 for number, lineTokens in enumerate(tokens, start=1):
173 if number > lastLineNumber or (line := fileLines[number]) is None:
174 lines.append((number, "", None, 0, 0, lineTokens))
175 else:
176 state = LINE_STATES[line._status.name]
177 lines.append((number, state, line._coverageCount, len(line._branches), line.CoveredBranches, lineTokens))
178
179 return CoverageListing(source, source, lines=lines, anchors=anchors, path=file.Path.as_posix(), language="text")
180
181
182@export
183class CodeCoverageReportPages(ReportPages):
184 """
185 The pages of one code coverage report: a page per directory and per source file.
186 """
187
188 _reportPages: ClassVar[dict[str, ReportPages]] = {} #: The pages of every code coverage report with pages, by ID.
189 _separator: ClassVar[str] = "/" #: Separator of the names in a path.
190
191 _sources: Path #: Directory the report's file paths are relative to.
192 _levels: dict[Union[int, str], dict[str, str]] #: The report's coverage levels, coloring the tables.
193
194 def __init__(
195 self,
196 reportID: str,
197 prefix: str,
198 reportFile: Path,
199 coverage: CoverageSummary,
200 sources: Path,
201 levels: dict[Union[int, str], dict[str, str]]
202 ) -> None:
203 """
204 Compute the document names and entries of a report's directories and source files.
205
206 :param reportID: Identifier of the report.
207 :param prefix: Document name the pages are generated below.
208 :param reportFile: The report file.
209 :param coverage: The report's root directory.
210 :param sources: Directory the report's file paths are relative to.
211 :param levels: The report's coverage levels.
212 """
213 super().__init__(reportID, prefix, reportFile, self._DirectoryChildren(coverage))
214
215 self._sources = sources
216 self._levels = levels
217
218 @classmethod
219 def CreatePages(cls, sphinxApplication: Sphinx) -> None:
220 """
221 Call-back for Sphinx' ``builder-inited`` event, after the reports are read: compute and register the pages.
222
223 Only a report with the key ``pages`` gets pages. A report that couldn't be read gets none; this is logged as an
224 error.
225
226 :param sphinxApplication: Sphinx application instance.
227 """
228 cls._reportPages = {}
229 for reportID, packageConfiguration in CodeCoverageBase._packageConfigurations.items():
230 if (prefix := packageConfiguration["pages"]) is None:
231 continue
232
233 try:
234 coverage = CodeCoverageBase.GetReport(reportID)
235 except ReportExtensionError as ex:
236 cause = ex if ex.__cause__ is None else ex.__cause__
237 getLogger(__name__).error(
238 f"Caught {ex.__class__.__name__} when generating the pages of code coverage report '{reportID}'.\n"
239 f" {cause.__class__.__name__}: {cause}"
240 )
241 continue
242
243 pages = cls(
244 reportID, prefix, packageConfiguration["report"], coverage, packageConfiguration["sources"],
245 packageConfiguration["levels"]
246 )
247 cls._reportPages[reportID] = pages
248 pages.Register(sphinxApplication.env)
249
250 @staticmethod
251 def _DirectoryChildren(directory: Directory) -> list[tuple[str, BaseWithPath]]:
252 """
253 Return a directory's directories - each compacted with its single subdirectories -, then its files, each sorted by
254 name.
255
256 :param directory: The directory.
257 :returns: The children as ``(role name, object)`` pairs.
258 """
259 children: list[tuple[str, BaseWithPath]] = []
260 children.extend(("cov", compactDirectory(directory._directories[name])) for name in sorted(directory._directories))
261 children.extend(("cov", directory._files[name]) for name in sorted(directory._files))
262
263 return children
264
265 def _Name(self, entity: BaseWithPath) -> str:
266 """
267 Return the name a directory or file is shown with; a compacted directory's includes its parents' names.
268
269 :param entity: The directory or file.
270 :returns: The name, e.g. ``Counter.vhdl`` or ``pyTooling/Sphinx``.
271 """
272 return compactedName(entity)
273
274 def _PathElements(self, name: str) -> tuple[str, ...]:
275 """
276 Return the elements a name adds to the path: a compacted directory's name adds one per directory.
277
278 :param name: The directory's or file's name.
279 :returns: The path elements.
280 """
281 return tuple(name.split("/"))
282
283 def _Title(self, entity: BaseWithPath) -> Nullable[str]:
284 """
285 Return no title, as a report has none for its directories and files.
286
287 :param entity: The directory or file.
288 :returns: ``None``
289 """
290 return None
291
292 def _Children(self, roleName: str, entity: BaseWithPath) -> Iterable[tuple[str, BaseWithPath]]:
293 """
294 Return a directory's directories, then its files; a file has none.
295
296 :param roleName: The role referring to the object: ``cov``.
297 :param entity: The directory or file.
298 :returns: The children as ``(role name, object)`` pairs.
299 """
300 if hasattr(entity, "_directories"):
301 return self._DirectoryChildren(entity)
302
303 return []
304
305 def _Page(self, docName: str, roleName: str, entity: BaseWithPath) -> nodes.section:
306 """
307 Build a directory's or file's page.
308
309 :param docName: Name of the page's document.
310 :param roleName: The role referring to the object: ``cov``.
311 :param entity: The directory or file.
312 :returns: The page's top section.
313 """
314 if hasattr(entity, "_directories"):
315 return self._DirectoryPage(docName, entity)
316 else:
317 return self._FilePage(docName, entity)
318
319 def _SummarySection(self, docName: str, entity: BaseWithPath) -> tuple[nodes.section, nodes.field_list]:
320 """
321 Create a page's section *Summary*: the line and branch counters, the coverage, and the parent directory.
322
323 :param docName: Name of the page's document.
324 :param entity: The directory or file.
325 :returns: The section, and its field list, which a page may add fields to.
326 """
327 section = nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"])
328 section += (fieldList := nodes.field_list(classes=["report-codecov-fields"]))
329
330 self._Field(fieldList, "Lines", nodes.Text(
331 f"{entity.CoveredLines} of {entity.TotalLines} covered ({entity.LineCoverage:.1%})"
332 ))
333 if entity.ExcludedLines > 0:
334 self._Field(fieldList, "Excluded lines", nodes.Text(f"{entity.ExcludedLines}"))
335
336 if entity.TotalBranches > 0:
337 self._Field(fieldList, "Branches", nodes.Text(
338 f"{entity.CoveredBranches} of {entity.TotalBranches} taken ({entity.BranchCoverage:.1%}), "
339 f"{entity.PartialLines} lines partially"
340 ))
341 self._Field(fieldList, "Coverage", nodes.Text(f"{entity.Coverage:.1%}"))
342
343 parent = entity._parent
344 while isCompacted(parent):
345 parent = parent._parent
346
347 if parent is not None and parent._parent is not None:
348 self._Field(fieldList, "Directory", self._Reference("cov", parent, docName))
349
350 return section, fieldList
351
352 def _DirectoryPage(self, docName: str, directory: Directory) -> nodes.section:
353 """
354 Build a directory's page: its counters, then tables of its directories and files, each linked to its page.
355
356 :param docName: Name of the page's document.
357 :param directory: The directory.
358 :returns: The page's top section.
359 """
360 name = compactedName(directory)
361 page = nodes.section("", nodes.title(name, name), ids=["report-codecov-page"])
362 summarySection, _ = self._SummarySection(docName, directory)
363 page += summarySection
364
365 children: list[str] = []
366 subdirectories = [child for _, child in self._DirectoryChildren(directory) if hasattr(child, "_directories")]
367 files = [directory._files[fileName] for fileName in sorted(directory._files)]
368 for title, column, identifier, entities in (
369 ("Directories", "Directory", "directories", subdirectories),
370 ("Files", "File", "files", files),
371 ):
372 if len(entities) == 0:
373 continue
374
375 page += (section := nodes.section("", nodes.title(title, title), ids=[identifier]))
376 table, tableBody = self._Table(
377 [(column, 6), ("Lines", 2), ("Branches", 2), ("Coverage", 1)],
378 ["report-codecov-table", "report-codecov-children"]
379 )
380 section += table
381 for entity in entities:
382 level = CodeCoverageBase.LevelOf(self._levels, entity.Coverage, "class")
383 tableBody += (row := nodes.row("", classes=[level]))
384 row += nodes.entry("", nodes.paragraph("", "", self._Reference("cov", entity, docName)))
385 row += nodes.entry("", nodes.paragraph("", f"{entity.CoveredLines} of {entity.TotalLines}"))
386 row += nodes.entry("", nodes.paragraph("", f"{entity.CoveredBranches} of {entity.TotalBranches}"))
387 row += nodes.entry("", nodes.paragraph("", f"{entity.Coverage:.1%}"))
388 if (entry := self.Entry(entity)) is not None:
389 children.append(entry.docName)
390
391 if len(children) > 0:
392 # body elements precede a section's subsections, so the hidden table of contents goes before 'Summary'
393 page.insert(page.index(summarySection), self._TableOfContents(docName, children))
394
395 return page
396
397 def _FilePage(self, docName: str, file: File) -> nodes.section:
398 """
399 Build a source file's page: its counters, its missed lines, and its listing.
400
401 :param docName: Name of the page's document.
402 :param file: The source file.
403 :returns: The page's top section.
404 """
405 page = nodes.section("", nodes.title(file._name, file._name), ids=["report-codecov-page"])
406 summarySection, fieldList = self._SummarySection(docName, file)
407 page += summarySection
408
409 if (missed := self._MissedLines(file)) != "":
410 self._Field(fieldList, "Missed lines", nodes.Text(missed))
411
412 page += (section := nodes.section("", nodes.title("Source", "Source"), ids=["source"]))
413 section += createListing(file, self._sources, anchors=True)
414
415 return page
416
417 @staticmethod
418 def _MissedLines(file: File) -> str:
419 """
420 Return a file's lines, which never ran, as ranges of consecutive executable lines, as coverage.py writes them.
421
422 :param file: The source file.
423 :returns: The ranges, e.g. ``8, 14-17``; empty if every executable line ran.
424 """
425 ranges: list[str] = []
426 start = end = None
427 for line in file.IterateLines():
428 number = line._lineNumber
429 state = line._status.name
430 if state == "Excluded":
431 continue
432
433 if state == "Uncovered":
434 start = number if start is None else start
435 end = number
436 elif start is not None:
437 ranges.append(f"{start}" if start == end else f"{start}-{end}")
438 start = None
439
440 if start is not None:
441 ranges.append(f"{start}" if start == end else f"{start}-{end}")
442
443 return ", ".join(ranges)