Unittest.py
Summary
- Lines:
399 of 416 covered (95.9%)
- Excluded lines:
2
- Branches:
126 of 134 taken (94.0%), 8 lines partially
- Coverage:
95.5%
- Directory:
- Missed lines:
300, 302-304, 307, 477-478, 485-486, 515, 561, 590, 824-829, 966-967
Source
pyTooling/Sphinx/Unittest.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2023-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 directive rendering a unit test report as a table: per testsuite and testcase, the counts and the runtime.
33
34The reports are read by :mod:`pyEDAA.Reports`: a JUnit XML file in any dialect - e.g. the reports of pytest or
35OSVVM -, or a test report in pyTooling's own XML format, recognized by its root element ``<TestReport>``. The report
36files are declared in :file:`conf.py` under ``pyTooling_Unittest_Testsuites``, each with an identifier a directive
37names in its ``:reportid:`` option. An entry with the key ``pages`` gets a page per testsuite and testcase, generated
38below that document name (see :mod:`pyTooling.Sphinx.UnittestPages`):
39
40.. code-block:: Python
41
42 # doc/conf.py
43 pyTooling_Unittest_Testsuites = {
44 "src": {
45 "xml_report": "../report/unit/unittest.xml",
46 "pages": "unittests/src",
47 }
48 }
49
50The reports are read once, when the builder is initialized, and shared by every directive and page using them.
51
52The table's columns after the first are :class:`Column` and :class:`ColumnGroup` instances. A directive of another kind
53of test report - e.g. a simulation framework's build summary - derives from :class:`UnittestSummaryBase`, which renders
54the rows, and declares its own columns.
55
56.. seealso::
57
58 :ref:`DIR/UnittestSummary`
59 |rarr| The directive's options and configuration, with examples.
60"""
61from __future__ import annotations
62
63from datetime import timedelta
64from enum import Flag
65from pathlib import Path
66from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, Iterable, Mapping, \
67 Optional as Nullable, TypedDict
68from xml.etree.ElementTree import iterparse # nosec B405 - reads the root element of the project's report
69
70from docutils import nodes
71from docutils.parsers.rst.directives import flag
72from sphinx.application import Sphinx
73from sphinx.config import Config
74from sphinx.util.logging import getLogger
75
76from pyTooling.Common import getFullyQualifiedName, getTypeName
77from pyTooling.Decorators import export, notimplemented, readonly
78from pyTooling.MetaClasses import ExtendedType
79
80from pyTooling.Sphinx import BaseDirective, ReportExtensionError, ReportsPackageMissingError, \
81 SphinxExtensionError, strip, stripAndNormalize
82from pyTooling.Sphinx.Node import Landscape, ReportIndentation
83from pyTooling.Sphinx.Pages import ReportPages
84from pyTooling.Sphinx.Tree import LegendPosition
85
86if TYPE_CHECKING: # pragma: no cover
87 # pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
88 from pyEDAA.Reports.Unittesting import TestcaseStatus, TestsuiteStatus, Testcase, Testsuite, TestsuiteBase, \
89 TestsuiteSummary
90
91
92__all__ = ["CONFIG_PREFIX", "REPORT_FORMATS"]
93
94CONFIG_PREFIX = "pyTooling_Unittest"
95"""Prefix every configuration value of the unit test directive carries in :file:`conf.py`."""
96
97REPORT_FORMATS = {
98 "testsuites": "JUnit",
99 "testsuite": "JUnit",
100 "TestReport": "pyTooling",
101}
102"""The report formats, by the root element of a report file."""
103
104
105@export
106class TestsuiteConfiguration(TypedDict):
107 """An entry of ``pyTooling_Unittest_Testsuites``, after :meth:`UnittestSummary.CheckConfiguration` read it."""
108
109 xml_report: Path #: The unit test report, in JUnit XML or pyTooling's XML format.
110 pages: Nullable[str] #: Document name the pages per testsuite and testcase are generated below, if any.
111
112
113@export
114class ShowTestcases(Flag):
115 """
116 Which testcases a unit test summary lists, by their status.
117
118 A member compares equal to a :class:`~pyEDAA.Reports.Unittesting.TestcaseStatus` it includes.
119 """
120
121 passed = 1 #: Passed testcases, and testcases failing as expected.
122 failed = 2 #: Failed testcases, and testcases passing unexpectedly.
123 skipped = 4 #: Skipped testcases.
124 excluded = 8 #: Excluded testcases.
125 errors = 16 #: Errored testcases, and testcases whose setup failed.
126 aborted = 32 #: Aborted testcases.
127
128 all = passed | failed | skipped | excluded | errors | aborted #: Every testcase.
129 not_passed = all & ~passed #: Every testcase but the passed ones.
130
131 def __eq__(self, other: Any) -> bool:
132 """
133 Check whether a testcase status is included.
134
135 :param other: A testcase status.
136 :returns: ``True`` if ``other`` is a :class:`~pyEDAA.Reports.Unittesting.TestcaseStatus` this member includes.
137 """
138 from pyEDAA.Reports.Unittesting import TestcaseStatus
139
140 if isinstance(other, TestcaseStatus):
141 if other is TestcaseStatus.Passed or other is TestcaseStatus.ExpectedFailed:
142 return ShowTestcases.passed in self
143 elif other is TestcaseStatus.Failed or other is TestcaseStatus.UnexpectedPassed:
144 return ShowTestcases.failed in self
145 elif other is TestcaseStatus.Skipped:
146 return ShowTestcases.skipped in self
147 elif other is TestcaseStatus.Excluded:
148 return ShowTestcases.excluded in self
149 elif other is TestcaseStatus.Errored or other is TestcaseStatus.SetupError:
150 return ShowTestcases.errors in self
151 elif other is TestcaseStatus.Aborted:
152 return ShowTestcases.aborted in self
153
154 return False
155
156
157@export
158class Column(metaclass=ExtendedType, slots=True):
159 """
160 A column of a unit test summary table: its title, its width, and the cells it adds to the rows.
161
162 A row shows a testsuite summary, a testsuite or a testcase. A column names a function per kind of row, which returns
163 the cell's content: a text, a node, or the cell itself - an :class:`~docutils.nodes.entry`, e.g. to give it a CSS
164 class. A column without a function for a kind of row leaves the cell of such a row empty.
165 """
166
167 _title: str #: Title of the column.
168 _width: Nullable[int] #: Relative width, or ``None``.
169 _testsuite: Nullable[Callable[[TestsuiteBase[Any]], str | nodes.Node]] #: Content of a testsuite('s summary) row.
170 _testcase: Nullable[Callable[[Testcase], str | nodes.Node]] #: Content of a testcase's row.
171
172 def __init__(
173 self,
174 title: str,
175 width: Nullable[int] = None,
176 *,
177 testsuite: Nullable[Callable[[TestsuiteBase[Any]], str | nodes.Node]] = None,
178 testcase: Nullable[Callable[[Testcase], str | nodes.Node]] = None
179 ) -> None:
180 """
181 Initializes a column of a unit test summary table.
182
183 :param title: Title of the column.
184 :param width: Optional, relative width of the column; ``None`` leaves it to the writer. Default: ``None``.
185 :param testsuite: Optional, function returning the content of the cell of a testsuite's row and of a testsuite
186 summary's rows. Default: ``None``.
187 :param testcase: Optional, function returning the content of the cell of a testcase's row. Default: ``None``.
188 :raises ValueError: If parameter 'title' is None.
189 :raises TypeError: If parameter 'title' is not of type :class:`str`.
190 :raises TypeError: If parameter 'width' is not of type :class:`int`.
191 :raises TypeError: If parameter 'testsuite' is not callable.
192 :raises TypeError: If parameter 'testcase' is not callable.
193 """
194 if title is None:
195 raise ValueError("Parameter 'title' is None.")
196 elif not isinstance(title, str):
197 ex = TypeError("Parameter 'title' is not of type 'str'.")
198 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.")
199 raise ex
200
201 if width is not None and not isinstance(width, int):
202 ex = TypeError("Parameter 'width' is not of type 'int'.")
203 ex.add_note(f"Got type '{getFullyQualifiedName(width)}'.")
204 raise ex
205
206 for parameterName, function in (
207 ("testsuite", testsuite),
208 ("testcase", testcase)
209 ):
210 if function is not None and not callable(function):
211 ex = TypeError(f"Parameter '{parameterName}' is not callable.")
212 ex.add_note(f"Got type '{getFullyQualifiedName(function)}'.")
213 raise ex
214
215 self._title = title
216 self._width = width
217 self._testsuite = testsuite
218 self._testcase = testcase
219
220 @readonly
221 def Title(self) -> str:
222 """
223 Read-only property to access the column's title (:attr:`_title`).
224
225 :returns: The title.
226 """
227 return self._title
228
229 @readonly
230 def Width(self) -> Nullable[int]:
231 """
232 Read-only property to access the column's relative width (:attr:`_width`).
233
234 :returns: The width, or ``None`` if the writer chooses it.
235 """
236 return self._width
237
238 def TestsuiteCell(self, testsuite: TestsuiteBase[Any]) -> nodes.entry:
239 """
240 Create the column's cell of a testsuite's row, or of a testsuite summary's row.
241
242 :param testsuite: The testsuite or testsuite summary.
243 :returns: The cell.
244 """
245 return self._Cell(self._testsuite, testsuite)
246
247 def TestcaseCell(self, testcase: Testcase) -> nodes.entry:
248 """
249 Create the column's cell of a testcase's row.
250
251 :param testcase: The testcase.
252 :returns: The cell.
253 """
254 return self._Cell(self._testcase, testcase)
255
256 @staticmethod
257 def _Cell(function: Nullable[Callable[[Any], str | nodes.Node]], entity: Any) -> nodes.entry:
258 """
259 Create a cell from the content a function returns for a row's testsuite or testcase.
260
261 :param function: The function, or ``None`` for an empty cell.
262 :param entity: The row's testsuite, testsuite summary or testcase.
263 :returns: The cell.
264 """
265 if function is None:
266 return nodes.entry("", nodes.Text(""))
267
268 content = function(entity)
269 if isinstance(content, nodes.entry):
270 return content
271 elif isinstance(content, str):
272 return nodes.entry("", nodes.Text(content))
273
274 return nodes.entry("", content)
275
276
277@export
278class ColumnGroup(metaclass=ExtendedType, slots=True):
279 """
280 A group of columns of a unit test summary table, under a common title: the table's header gets a second row.
281 """
282
283 _title: str #: Title of the group.
284 _columns: tuple[Column, ...] #: The grouped columns.
285
286 def __init__(self, title: str, columns: Iterable[Column]) -> None:
287 """
288 Initializes a group of columns.
289
290 :param title: Title of the group.
291 :param columns: The grouped columns.
292 :raises ValueError: If parameter 'title' is None.
293 :raises TypeError: If parameter 'title' is not of type :class:`str`.
294 :raises ValueError: If parameter 'columns' is None.
295 :raises TypeError: If parameter 'columns' is not iterable.
296 :raises TypeError: If parameter 'columns' contains an element not of type :class:`Column`.
297 :raises ValueError: If parameter 'columns' is empty.
298 """
299 if title is None:
300 raise ValueError("Parameter 'title' is None.")
301 elif not isinstance(title, str):
302 ex = TypeError("Parameter 'title' is not of type 'str'.")
303 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.")
304 raise ex
305
306 if columns is None:
307 raise ValueError("Parameter 'columns' is None.")
308 elif not isinstance(columns, Iterable):
309 ex = TypeError("Parameter 'columns' is not iterable.")
310 ex.add_note(f"Got type '{getFullyQualifiedName(columns)}'.")
311 raise ex
312
313 columnList = []
314 for column in columns:
315 if not isinstance(column, Column):
316 ex = TypeError("Parameter 'columns' contains an element not of type 'Column'.")
317 ex.add_note(f"Got type '{getFullyQualifiedName(column)}'.")
318 raise ex
319
320 columnList.append(column)
321
322 if len(columnList) == 0:
323 raise ValueError("Parameter 'columns' is empty.")
324
325 self._title = title
326 self._columns = tuple(columnList)
327
328 @readonly
329 def Title(self) -> str:
330 """
331 Read-only property to access the group's title (:attr:`_title`).
332
333 :returns: The title.
334 """
335 return self._title
336
337 @readonly
338 def Columns(self) -> tuple[Column, ...]:
339 """
340 Read-only property to access the grouped columns (:attr:`_columns`).
341
342 :returns: The columns, in their order in the table.
343 """
344 return self._columns
345
346
347@export
348class UnittestSummaryBase(BaseDirective):
349 """
350 Base-class of a directive rendering a testsuite summary - unit test results - as a table, per testsuite and testcase.
351
352 The table has a row per testsuite and listed testcase, indented by its depth, optionally a row of the testsuite
353 summary above them, and a row of the overall status below them. Its first column names the row - linked to the
354 row's page, if the report has pages -, the other columns are the :class:`Column` and :class:`ColumnGroup` instances
355 :meth:`_Columns` returns.
356
357 The report files are declared in a configuration value of :file:`conf.py` (:attr:`_CONFIG_VALUE`), each with an
358 identifier, the report file (key :attr:`_REPORT_KEY`) and optionally the key ``pages``. A derived class names them,
359 declares its own class variables :attr:`_testSummaries`, :attr:`_reports` and :attr:`_readErrors`, reads a report
360 file by :meth:`_ReadReport`, reads its options in :meth:`_CheckOptions`, and returns its pages by :meth:`_Pages`.
361
362 A legend of the status symbols (:attr:`_LEGEND`) is drawn below the table, or above it or not at all, as option
363 ``:legend:`` chooses; a derived class accepts the option by adding ``"legend": LegendPosition.Parse`` to its
364 ``option_spec``.
365 """
366
367 _CONFIG_VALUE: ClassVar[str] #: Configuration value declaring the reports.
368 _REPORT_KEY: ClassVar[str] #: Key of a report's file, e.g. ``xml_report``.
369 _REPORT_KIND: ClassVar[str] #: Kind of report, e.g. ``Unittest report``.
370 _NAME_COLUMN: ClassVar[Column] = Column("Testsuite / Testcase", 6) #: The first column, naming the rows.
371
372 _testSummaries: ClassVar[dict[str, dict[str, Any]]] #: Report configurations, by report ID.
373 _reports: ClassVar[dict[str, TestsuiteSummary]] #: Reports read by :meth:`ReadReports`, by ID.
374 _readErrors: ClassVar[dict[str, Exception]] #: Why a report couldn't be read, by report ID.
375
376 _LEGEND: ClassVar[tuple[tuple[str, str], ...]] = (
377 ("✅", "passed"),
378 ("❌", "failed"),
379 ("⚠️", "skipped"),
380 ("➖", "excluded"),
381 ("❗", "errored"),
382 ("⛔", "setup error"),
383 ("🚫", "aborted"),
384 ("❓", "unknown")
385 ) #: The status symbols of testsuites and testcases with the word explaining each, in the order of the legend.
386
387 _cssClasses: list[str] #: Additional CSS classes of the table.
388 _reportID: str #: Identifier of the report in the configuration.
389 _reportFile: Path #: The report file.
390 _hideTestsuiteSummary: bool #: Whether the testsuite summary's row is left out.
391 _testsuiteSummaryName: str #: Name replacing the testsuite summary's name, or ``""``.
392 _showTestcases: ShowTestcases #: Which testcases are listed.
393 _legend: LegendPosition #: Where the legend of the status symbols is drawn, by option ``:legend:``.
394 _testsuite: TestsuiteSummary #: The report's testsuite summary.
395
396 @notimplemented("A derived directive implements '_CheckOptions'.")
397 def _CheckOptions(self) -> None:
398 """
399 Parse all directive options or use default values.
400
401 A derived class sets :attr:`_cssClasses`, :attr:`_reportID`, :attr:`_reportFile`, :attr:`_hideTestsuiteSummary`,
402 :attr:`_testsuiteSummaryName` and :attr:`_showTestcases`.
403 """
404
405 @classmethod
406 def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None:
407 """
408 Check the configuration value and load the report configurations.
409
410 :param sphinxApplication: Sphinx application instance.
411 :param sphinxConfiguration: Sphinx configuration instance.
412 """
413 cls._CheckConfiguration(sphinxConfiguration)
414
415 @classmethod
416 def ReadReports(cls, sphinxApplication: Sphinx) -> None:
417 """
418 Read every configured report, once per build.
419
420 A report that can't be read keeps the exception, which a directive or a report's pages show when they need it.
421
422 :param sphinxApplication: Sphinx application instance.
423 """
424 getLogger(__name__).info(f"[REPORT] Reading {cls._REPORT_KIND.lower()}s ...")
425
426 cls._reports = {}
427 cls._readErrors = {}
428 for reportID, testSummary in cls._testSummaries.items():
429 try:
430 cls._reports[reportID] = cls._ReadReport(testSummary[cls._REPORT_KEY])
431 except Exception as ex:
432 cls._readErrors[reportID] = ex
433
434 @classmethod
435 def GetReport(cls, reportID: str) -> TestsuiteSummary:
436 """
437 Return a report read by :meth:`ReadReports`.
438
439 :param reportID: Identifier of the report.
440 :returns: The report's testsuite summary.
441 :raises ReportExtensionError: If the report wasn't read, chained to the exception reading it raised.
442 """
443 try:
444 return cls._reports[reportID]
445 except KeyError:
446 cause = cls._readErrors.get(reportID, None)
447 raise ReportExtensionError(f"{cls._REPORT_KIND} '{reportID}' wasn't read.") from cause
448
449 @classmethod
450 @notimplemented("A derived directive implements '_ReadReport'.")
451 def _ReadReport(cls, reportFile: Path) -> TestsuiteSummary:
452 """
453 Read a report file.
454
455 :param reportFile: The report file.
456 :returns: The report's testsuite summary, aggregated.
457 """
458
459 @classmethod
460 def _CheckConfiguration(cls, sphinxConfiguration: Config) -> None:
461 """
462 Check and load the report configurations from the configuration value :attr:`_CONFIG_VALUE`.
463
464 :param sphinxConfiguration: Sphinx configuration instance.
465 :raises ReportExtensionError: If the configuration value isn't registered.
466 :raises ReportExtensionError: If a report configuration has no report file (key :attr:`_REPORT_KEY`).
467 :raises ReportExtensionError: If the report file doesn't exist.
468 :raises ReportExtensionError: If ``pages`` isn't a string.
469 :raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
470 Use a name like 'unittests/src', separated by '/', without '.' or '..'.
471 """
472 variableName = cls._CONFIG_VALUE
473 cls._testSummaries = {}
474
475 try:
476 allTestsuites: dict[str, dict[str, Any]] = sphinxConfiguration[variableName]
477 except (KeyError, AttributeError) as ex:
478 raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
479
480 for reportID, testSummary in allTestsuites.items():
481 summaryName = f"conf.py: {variableName}:[{reportID}]"
482
483 try:
484 reportFile = Path(testSummary[cls._REPORT_KEY])
485 except KeyError as ex:
486 raise ReportExtensionError(f"{summaryName}.{cls._REPORT_KEY}: Configuration is missing.") from ex
487
488 if not reportFile.exists():
489 raise ReportExtensionError(
490 f"{summaryName}.{cls._REPORT_KEY}: {cls._REPORT_KIND} file '{reportFile}' doesn't exist."
491 ) from FileNotFoundError(reportFile)
492
493 if (pages := testSummary.get("pages", None)) is not None:
494 pages = ReportPages.CheckPagesConfiguration(summaryName, pages)
495
496 cls._testSummaries[reportID] = {
497 cls._REPORT_KEY: reportFile,
498 "pages": pages
499 }
500
501 @notimplemented("A derived directive implements '_Columns'.")
502 def _Columns(self) -> list[Column | ColumnGroup]:
503 """
504 Return the table's columns after the first one, which names the rows.
505
506 :returns: The columns and groups of columns, in their order in the table.
507 """
508
509 def _Pages(self) -> Nullable[ReportPages[Any]]:
510 """
511 Return the pages of the report, which the first column links to.
512
513 :returns: The report's pages, or ``None`` if it has none.
514 """
515 return None
516
517 def _TableClasses(self) -> list[str]:
518 """
519 Return the CSS classes of the table: ``report-unittest-table``, ``report-unittest-<report ID>`` and the classes of
520 option ``:class:``.
521
522 :returns: The CSS classes.
523 """
524 return ["report-unittest-table", f"report-unittest-{self._reportID}", *self._cssClasses]
525
526 def _SortedValues(self, d: Mapping[str, Any]) -> Generator[Any, None, None]:
527 """
528 Yield the values of a mapping, sorted by key.
529
530 :param d: The mapping, e.g. a testsuite's testcases by name.
531 :returns: A generator of the values, sorted by their keys.
532 """
533 for key in sorted(d.keys()):
534 yield d[key]
535
536 @staticmethod
537 def _ConvertTestcaseStatusToSymbol(status: TestcaseStatus) -> str:
538 """
539 Return the symbol shown for a testcase's status.
540
541 :param status: The testcase's status.
542 :returns: An emoji, e.g. ✅ for a passed testcase.
543 """
544 from pyEDAA.Reports.Unittesting import TestcaseStatus
545
546 if status is TestcaseStatus.Passed or status is TestcaseStatus.ExpectedFailed:
547 return "✅"
548 elif status is TestcaseStatus.Failed:
549 return "❌"
550 elif status is TestcaseStatus.Skipped:
551 return "⚠️"
552 elif status is TestcaseStatus.Aborted:
553 return "🚫"
554 elif status is TestcaseStatus.Excluded:
555 return "➖"
556 elif status is TestcaseStatus.Errored:
557 return "❗"
558 elif status is TestcaseStatus.SetupError:
559 return "⛔"
560 elif status is TestcaseStatus.Unknown:
561 return "❓"
562 else:
563 return "❌"
564
565 @staticmethod
566 def _ConvertTestsuiteStatusToSymbol(status: TestsuiteStatus) -> str:
567 """
568 Return the symbol shown for a testsuite's status.
569
570 :param status: The testsuite's status.
571 :returns: An emoji, e.g. ✅ for a passed testsuite.
572 """
573 from pyEDAA.Reports.Unittesting import TestsuiteStatus
574
575 if status is TestsuiteStatus.Passed:
576 return "✅"
577 elif status is TestsuiteStatus.Failed:
578 return "❌"
579 elif status is TestsuiteStatus.Skipped:
580 return "⚠️"
581 elif status is TestsuiteStatus.Aborted:
582 return "🚫"
583 elif status is TestsuiteStatus.Excluded:
584 return "➖"
585 elif status is TestsuiteStatus.Errored:
586 return "❗"
587 elif status is TestsuiteStatus.SetupError:
588 return "⛔"
589 elif status is TestsuiteStatus.Unknown:
590 return "❓"
591 else:
592 return "❌"
593
594 @staticmethod
595 def _FormatTimedelta(delta: Nullable[timedelta]) -> str:
596 """
597 Format a duration as ``HH:MM:SS.sss``, rounded to milliseconds.
598
599 :param delta: The duration, or ``None``.
600 :returns: The formatted duration, or ``""`` for ``None``.
601 """
602 if delta is None:
603 return ""
604
605 # Compute by hand, because timedelta._to_microseconds is not officially documented
606 microseconds = (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
607 milliseconds = (microseconds + 500) // 1000
608 seconds = milliseconds // 1000
609 minutes = seconds // 60
610 hours = minutes // 60
611 return f"{hours:02}:{minutes % 60:02}:{seconds % 60:02}.{milliseconds % 1000:03}"
612
613 def _GenerateTestSummaryTable(self) -> nodes.table:
614 """
615 Build the table: a header of the columns, a row per testsuite and listed testcase, and a summary row.
616
617 A header has a second row, if a column is a :class:`ColumnGroup`.
618
619 :returns: The table.
620 """
621 columns: list[Column] = []
622 groups = False
623 headerColumns: list[tuple[str, Nullable[list[tuple[str, int]]], Nullable[int]]] = [
624 (self._NAME_COLUMN._title, None, self._NAME_COLUMN._width)
625 ]
626 for column in self._Columns():
627 if isinstance(column, ColumnGroup):
628 groups = True
629 columns.extend(column._columns)
630 headerColumns.append((column._title, [(c._title, c._width) for c in column._columns], None))
631 else:
632 columns.append(column)
633 headerColumns.append((column._title, None, column._width))
634
635 if groups:
636 tableGroup = self._CreateDoubleRowTableHeader(
637 identifier=self._reportID,
638 columns=headerColumns,
639 classes=self._TableClasses()
640 )
641 else:
642 tableGroup = self._CreateSingleRowTableHeader(
643 identifier=self._reportID,
644 columns=[(title, width) for title, _, width in headerColumns],
645 classes=self._TableClasses()
646 )
647
648 tableBody = nodes.tbody()
649 tableGroup += tableBody
650
651 self._RenderRoot(tableBody, columns, self._testsuite, not self._hideTestsuiteSummary, self._testsuiteSummaryName)
652
653 return tableGroup.parent
654
655 def _CreateLegend(self) -> nodes.paragraph:
656 """
657 Build the legend of the status symbols: a paragraph naming each symbol of :attr:`_LEGEND` with its word.
658
659 The paragraph gets the CSS classes ``report-unittest-legend`` and ``report-unittest-legend-<position>``.
660
661 :returns: The legend.
662 """
663 classes = ["report-unittest-legend", f"report-unittest-legend-{self._legend}"]
664 legend = nodes.paragraph("", "Legend: ", classes=classes)
665 for index, (symbol, text) in enumerate(self._LEGEND):
666 if index > 0:
667 legend += nodes.Text(", ")
668
669 legend += nodes.inline("", f"{symbol} {text}", classes=["report-unittest-legend-item"])
670
671 return legend
672
673 def _RenderRoot(
674 self,
675 tableBody: nodes.tbody,
676 columns: list[Column],
677 testsuiteSummary: TestsuiteSummary,
678 includeRoot: bool = True,
679 testsuiteSummaryName: Nullable[str] = None
680 ) -> None:
681 """
682 Add the rows of a testsuite summary: optionally its own row, its testsuites' rows, and the summary row.
683
684 :param tableBody: The table body the rows are added to.
685 :param columns: The columns after the first one.
686 :param testsuiteSummary: The testsuite summary.
687 :param includeRoot: Optional, whether the testsuite summary gets a row of its own. Default: ``True``.
688 :param testsuiteSummaryName: Optional, name replacing the testsuite summary's name; ``""`` keeps it.
689 Default: ``None``.
690 """
691 level = 0
692
693 if includeRoot:
694 level += 1
695 state = self._ConvertTestsuiteStatusToSymbol(testsuiteSummary._status)
696
697 tableRow = nodes.row(
698 "", classes=["report-testsuitesummary", f"testsuitesummary-{testsuiteSummary._status.name.lower()}"]
699 )
700 tableBody += tableRow
701
702 name = testsuiteSummary.Name if testsuiteSummaryName == "" else testsuiteSummaryName
703 tableRow += nodes.entry("", nodes.Text(f"{state}{name}"))
704 for column in columns:
705 tableRow += column.TestsuiteCell(testsuiteSummary)
706
707 for ts in self._SortedValues(testsuiteSummary._testsuites):
708 self._RenderTestsuite(tableBody, columns, ts, level)
709
710 self._RenderSummary(tableBody, columns, testsuiteSummary)
711
712 def _RenderTestsuite(self, tableBody: nodes.tbody, columns: list[Column], testsuite: Testsuite, level: int) -> None:
713 """
714 Add a row for a testsuite, then - recursively - rows for its testsuites and its listed testcases.
715
716 :param tableBody: The table body the rows are added to.
717 :param columns: The columns after the first one.
718 :param testsuite: The testsuite.
719 :param level: The testsuite's depth in the hierarchy, shown by indentation.
720 """
721 state = self._ConvertTestsuiteStatusToSymbol(testsuite._status)
722
723 tableRow = nodes.row("", classes=["report-testsuite", f"testsuite-{testsuite._status.name.lower()}"])
724 tableBody += tableRow
725
726 tableRow += self._NameEntry(2 * level, state, testsuite, "ts")
727 for column in columns:
728 tableRow += column.TestsuiteCell(testsuite)
729
730 for ts in self._SortedValues(testsuite._testsuites):
731 self._RenderTestsuite(tableBody, columns, ts, level + 1)
732
733 for testcase in self._SortedValues(testsuite._testcases):
734 if testcase._status == self._showTestcases:
735 self._RenderTestcase(tableBody, columns, testcase, level + 1)
736
737 def _RenderTestcase(self, tableBody: nodes.tbody, columns: list[Column], testcase: Testcase, level: int) -> None:
738 """
739 Add a row for a testcase.
740
741 :param tableBody: The table body the row is added to.
742 :param columns: The columns after the first one.
743 :param testcase: The testcase.
744 :param level: The testcase's depth in the hierarchy, shown by indentation.
745 """
746 state = self._ConvertTestcaseStatusToSymbol(testcase._status)
747
748 tableRow = nodes.row("", classes=["report-testcase", f"testcase-{testcase._status.name.lower()}"])
749 tableBody += tableRow
750
751 tableRow += self._NameEntry(2 * level, state, testcase, "tc")
752 for column in columns:
753 tableRow += column.TestcaseCell(testcase)
754
755 def _NameEntry(self, level: int, state: str, entity: Testsuite | Testcase, roleName: str) -> nodes.entry:
756 """
757 Create the cell naming a testsuite or testcase, indented, and linked to its page if the report has pages.
758
759 :param level: The number of indentation steps.
760 :param state: The status symbol written before the name.
761 :param entity: The testsuite or testcase.
762 :param roleName: The role referring to it: ``ts`` for a testsuite, ``tc`` for a testcase.
763 :returns: The table cell.
764 """
765 if (pages := self._Pages()) is not None and (entry := pages.Entry(entity)) is not None:
766 # a reference has to be inside a text element; an inline keeps the cell free of a paragraph, as the others are
767 reference = pages.CreateReference(roleName, entry, self.env.docname)
768 return nodes.entry("", ReportIndentation("", "", nodes.Text(state), reference, level=level))
769
770 return nodes.entry("", ReportIndentation("", f"{state}{entity.Name}", level=level))
771
772 def _RenderSummary(self, tableBody: nodes.tbody, columns: list[Column], testsuiteSummary: TestsuiteSummary) -> None:
773 """
774 Add the summary row: the overall status and counts.
775
776 :param tableBody: The table body the row is added to.
777 :param columns: The columns after the first one.
778 :param testsuiteSummary: The testsuite summary.
779 """
780 state = self._ConvertTestsuiteStatusToSymbol(testsuiteSummary._status)
781
782 tableRow = nodes.row(
783 "", classes=["report-summary", f"testsuitesummary-{testsuiteSummary._status.name.lower()}"]
784 )
785 tableBody += tableRow
786
787 tableRow += nodes.entry("", nodes.Text(f"{state} {testsuiteSummary.Status.name.upper()}"))
788 for column in columns:
789 tableRow += column.TestsuiteCell(testsuiteSummary)
790
791 def run(self) -> list[nodes.Node]:
792 """
793 Read the configured report and return it as a table, on landscape pages in LaTeX.
794
795 The legend of the status symbols is drawn below the table, or above it, as option ``:legend:`` chooses.
796 ``:legend: none`` draws no legend.
797
798 A report with pages adds a hidden table of contents of its top-level testsuites, so the pages are below this
799 document in the navigation.
800
801 :returns: A :class:`~pyTooling.Sphinx.Node.Landscape` container holding the table and its legend, or the error
802 message; then the table of contents, if the report has pages.
803 """
804 container = Landscape()
805
806 try:
807 self._CheckOptions()
808 except SphinxExtensionError as ex:
809 message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
810 return self._internalError(container, __name__, message, ex)
811
812 self._legend = self.options.get("legend", LegendPosition.DEFAULT)
813
814 if (readError := self._readErrors.get(self._reportID, None)) is not None:
815 if isinstance(readError, ReportsPackageMissingError):
816 message = f"Caught {getTypeName(readError)} when reading '{self._reportFile}'."
817 else:
818 message = f"Caught {getTypeName(readError)} when reading and parsing '{self._reportFile}'."
819 return self._internalError(container, __name__, message, readError)
820
821 try:
822 self._testsuite = self.GetReport(self._reportID)
823 container += self._GenerateTestSummaryTable()
824 except Exception as ex:
825 message = (
826 f"Caught {getTypeName(ex)} when generating the document structure for {self._REPORT_KIND.lower()} "
827 f"'{self._reportFile}'."
828 )
829 return self._internalError(container, __name__, message, ex)
830
831 if self._legend is LegendPosition.Top:
832 container.insert(0, self._CreateLegend())
833 elif self._legend is LegendPosition.Bottom:
834 container += self._CreateLegend()
835
836 if (pages := self._Pages()) is None:
837 return [container]
838
839 return [container, pages.TableOfContents(self.env.docname)]
840
841
842@export
843class UnittestSummary(UnittestSummaryBase):
844 """
845 The ``report:unittest-summary`` directive: a table of a unit test report, per testsuite and testcase.
846 """
847
848 directiveName: str = "unittest-summary" #: Name the directive is invoked by.
849
850 has_content = False #: A boolean; ``True`` if content is allowed.
851 required_arguments = 0 #: Number of required directive arguments.
852 optional_arguments = 6 #: Number of optional arguments.
853
854 option_spec: dict[str, Any] = { # type: ignore[misc]
855 "class": strip,
856 "reportid": stripAndNormalize,
857 "testsuite-summary-name": strip,
858 "show-testcases": stripAndNormalize,
859 "no-assertions": flag,
860 "hide-testsuite-summary": flag,
861 "legend": LegendPosition.Parse
862 } #: Mapping of option names to validator functions.
863
864 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
865 "Testsuites": ({}, "env", dict)
866 } #: Values added to :file:`conf.py`, as ``name: (default, rebuild, types)``, prefixed by :data:`CONFIG_PREFIX`.
867
868 _CONFIG_VALUE: ClassVar[str] = f"{CONFIG_PREFIX}_Testsuites" #: Configuration value declaring the reports.
869 _REPORT_KEY: ClassVar[str] = "xml_report" #: Key of a report's file in its configuration.
870 _REPORT_KIND: ClassVar[str] = "Unittest report" #: The kind of report, as a message names it.
871
872 _testSummaries: ClassVar[dict[str, TestsuiteConfiguration]] = {} #: Report configurations, by report ID.
873 _reports: ClassVar[dict[str, TestsuiteSummary]] = {} #: Reports read by :meth:`ReadReports`, by ID.
874 _readErrors: ClassVar[dict[str, Exception]] = {} #: Why a report couldn't be read, by report ID.
875
876 _noAssertions: bool #: Whether the assertion column is left out.
877
878 def _CheckOptions(self) -> None:
879 """
880 Parse all directive options or use default values.
881
882 :raises SphinxExtensionError: If option ``:class:`` isn't a list of CSS class names.
883 :raises SphinxExtensionError: If option ``:show-testcases:`` is neither ``all`` nor ``not-passed``.
884 :raises SphinxExtensionError: If option ``:reportid:`` is missing.
885 :raises ReportExtensionError: If ``pyTooling_Unittest_Testsuites`` has no entry for the report ID.
886 """
887 cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*")
888 showTestcases = self._ParseStringOption("show-testcases", "all", r"all|not-passed")
889
890 self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
891 self._reportID = self._ParseStringOption("reportid")
892 self._testsuiteSummaryName = self._ParseStringOption("testsuite-summary-name", "", r".+")
893 self._showTestcases = ShowTestcases[showTestcases.replace("-", "_")]
894 self._noAssertions = "no-assertions" in self.options
895 self._hideTestsuiteSummary = "hide-testsuite-summary" in self.options
896
897 try:
898 testSummary = self._testSummaries[self._reportID]
899 except KeyError as ex:
900 raise ReportExtensionError(f"No unit testing configuration item for '{self._reportID}'.") from ex
901 self._reportFile = testSummary["xml_report"]
902
903 def _Columns(self) -> list[Column | ColumnGroup]:
904 """
905 Return the columns after the first one: the counts of testcases, skipped, errored, failed and passed ones, the
906 assertions - unless option ``:no-assertions:`` is given -, and the runtime.
907
908 :returns: The columns.
909 """
910 def runtime(entity: TestsuiteBase[Any] | Testcase) -> str:
911 """
912 Nested function formatting the runtime of a testsuite, testsuite summary or testcase.
913
914 :param entity: The testsuite, testsuite summary or testcase.
915 :returns: The runtime.
916 """
917 return self._FormatTimedelta(entity.TotalDuration)
918
919 columns: list[Column | ColumnGroup] = [
920 Column("Testcases", 1, testsuite=lambda testsuite: f"{testsuite.TestcaseCount}"),
921 Column("Skipped", 1, testsuite=lambda testsuite: f"{testsuite.Skipped}"),
922 Column("Errored", 1, testsuite=lambda testsuite: f"{testsuite.Errored}"),
923 Column("Failed", 1, testsuite=lambda testsuite: f"{testsuite.Failed}"),
924 Column("Passed", 1, testsuite=lambda testsuite: f"{testsuite.Passed}"),
925 ]
926 if not self._noAssertions:
927 columns.append(Column("Assertions", 1, testcase=lambda testcase: f"{testcase.AssertionCount}"))
928 columns.append(Column("Runtime (HH:MM:SS.sss)", 2, testsuite=runtime, testcase=runtime))
929
930 return columns
931
932 def _Pages(self) -> Nullable[ReportPages[Any]]:
933 """
934 Return the pages of the report, if it was declared with the key ``pages``.
935
936 :returns: The report's pages, or ``None`` if it has none.
937 """
938 from pyTooling.Sphinx.UnittestPages import UnittestReportPages
939
940 if not UnittestReportPages.HasPages(self._reportID):
941 return None
942
943 return UnittestReportPages.GetPages(self._reportID)
944
945 @classmethod
946 def _ReadReport(cls, xmlReport: Path) -> TestsuiteSummary:
947 """
948 Read a report file, choosing the reader by the file's root element.
949
950 :param xmlReport: The report file.
951 :returns: The report's testsuite summary, aggregated.
952 :raises ReportExtensionError: If the file's root element is no known report format.
953 :raises ReportsPackageMissingError: If pyEDAA.Reports, or its reader for the report's format, isn't installed.
954 """
955 _, rootElement = next(iterparse(xmlReport, events=("start",))) # nosec B314 - the project's own report
956 try:
957 reportFormat = REPORT_FORMATS[rootElement.tag]
958 except KeyError:
959 ex = ReportExtensionError(f"Unittest report '{xmlReport}' has an unknown format.")
960 ex.add_note(f"Got root element '<{rootElement.tag}>'; supported: {', '.join(REPORT_FORMATS)}")
961 raise ex from None
962
963 if reportFormat == "pyTooling":
964 try:
965 from pyEDAA.Reports.Unittesting.pyTooling import Document as pyToolingDocument
966 except ImportError as cause:
967 raise ReportsPackageMissingError("Reading a pyTooling test report") from cause
968
969 return pyToolingDocument(xmlReport, analyzeAndConvert=True)
970
971 try:
972 from pyEDAA.Reports.Unittesting.JUnit import Document as JUnitDocument
973 except ImportError as cause:
974 raise ReportsPackageMissingError("Reading a unit test report") from cause
975
976 document = JUnitDocument(xmlReport, analyzeAndConvert=True)
977 testsuiteSummary = document.ToTestsuiteSummary()
978 testsuiteSummary.Aggregate()
979
980 return testsuiteSummary