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:

Sphinx

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)