Coverage for pyTooling/Testing/ReportWriter.py: 100%
71 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 21:24 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 21:24 +0000
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 pytest plugin writing a test report in :ref:`pyTooling's own format <TESTING/ReportFormat>`.
34JUnit XML cannot express two things a marked test suite has: test suites **nest**, where JUnit flattens them into a
35dotted ``classname``; and every item carries **four names** - an identifier, a title, a summary and a description -
36where JUnit has one name and a bag of flat ``<property>`` pairs.
38This writer is opt-in through ``--pytooling-xml=PATH`` and runs happily alongside ``--junit-xml``, so a pipeline
39keeps the format its dashboard understands while the richer file is produced from the same reports.
41The format's version is the version of its schema - :file:`TestReport-v0.1.xsd` today - so a later version is added
42beside it and a reader knows from ``xsi:noNamespaceSchemaLocation`` which one it is holding.
44.. todo:: TESTING::ReportWriter Name the generator in a ``<Generator>`` element
46 The tool that wrote a report is not part of the format's version, so it is no longer an attribute on the root
47 element. Where it belongs is an element of its own at root level, carrying the tool's name and version and
48 whatever further meta information a tool wants to add.
50.. todo:: TESTING::ReportWriter Add a ``<TestRun>`` level between the report and its test suites
52 A run happens on one machine, in one environment, at one time - so a report can hold several of them, and the
53 things that describe a run belong to the run rather than to the report:
55 .. code-block:: text
57 <TestReport>
58 <TestRun timestamp="..." duration="...">
59 <Environment>
60 <Hostname>...</Hostname>
61 <OperatingSystem>...</OperatingSystem>
62 </Environment>
63 <Summary>...</Summary>
64 <Description>...</Description>
65 <Testsuite ... />
66 </TestRun>
67 </TestReport>
69 The environment is optional, which is what makes a hostname acceptable there: it is written when whoever
70 generates the report decides it belongs in it, not by default.
72.. hint::
74 See :ref:`high-level help <TESTING/ReportFormat>` for the schema and an example.
75"""
76from datetime import datetime, timezone
77from pathlib import Path
78from typing import Any, TypedDict, Optional as Nullable
79from xml.etree.ElementTree import Element, ElementTree, SubElement, indent
81from pytest import Config, Parser, Session, StashKey
82from pyTooling.Decorators import export
83from pyTooling.MetaClasses import ExtendedType
84from pyTooling.Testing.PyTest import hierarchyKey
87__all__ = ["SCHEMA_VERSION_LATEST", "SCHEMA_FILES", "REPORT_WRITER_KEY"]
90SCHEMA_VERSION_LATEST = "v0.1" #: Latest version of the report format, and the one this writer produces.
92SCHEMA_FILES: dict[str, str] = {
93 "v0.1": "TestReport-v0.1.xsd",
94} #: Schema file per format version, so a later version is added beside the one in use, not instead of it.
96REPORT_WRITER_KEY: StashKey["TestReportWriter"] = StashKey() #: Where the writer is stashed on the configuration.
99class _Result(TypedDict, total=False):
100 """What is collected per testcase, assembled from the reports of its phases."""
102 nodeID: str #: Node ID of the testcase, whose parts name the test suite levels.
103 duration: float #: Sum of the durations of the testcase's phases.
104 message: str #: Text of the failure, or an empty string.
105 status: str #: ``passed``, ``failed``, ``errored`` or ``skipped``.
106 title: str #: Title of the testcase, if it is marked.
107 summary: str #: Summary of the testcase, if its doc-string has one.
108 description: str #: Description of the testcase, if its doc-string has one.
111@export
112class TestReportWriter(metaclass=ExtendedType, slots=True):
113 """
114 Collects the reports of a session and writes them as one nested XML document.
116 The nesting comes from the dotted ``classname`` pytest reports: ``tests.unit.Versioning`` becomes three levels
117 of ``<Testsuite>``, so a reader sees the hierarchy the test suite actually has.
118 """
120 _path: Path #: Where the report is written.
121 _results: dict[str, _Result] #: Collected results, keyed by node ID.
122 _hierarchy: dict[str, dict[str, str]] #: Names of every test suite level, keyed by its dotted path.
124 def __init__(self, path: Path, hierarchy: Nullable[dict[str, dict[str, str]]] = None) -> None:
125 """
126 Initializes the writer with the path it writes to.
128 :param path: Path of the report file to write.
129 :param hierarchy: Optional, the names of every test suite level, keyed by its dotted path, as
130 :mod:`pyTooling.Testing.PyTest` collects them. Without it - when the marker plugin is not
131 registered - the test suite elements carry no names.
132 """
133 self._path = path
134 self._results = {}
135 self._hierarchy = {} if hierarchy is None else hierarchy
137 def pytest_runtest_logreport(self, report: Any) -> None:
138 """
139 Collect one phase of one testcase.
141 :param report: The report of one phase of one testcase.
142 """
143 entry = self._results.setdefault(report.nodeid, {"nodeID": report.nodeid, "duration": 0.0, "message": ""})
144 entry["duration"] += report.duration
145 entry.update(dict(report.user_properties))
147 if report.failed:
148 entry["status"] = "errored" if report.when != "call" else "failed"
149 entry["message"] = str(report.longrepr) if report.longrepr is not None else ""
150 elif report.skipped:
151 entry.setdefault("status", "skipped")
152 elif report.when == "call":
153 entry.setdefault("status", "passed")
155 def _testsuiteFor(self, root: Element, suites: dict[str, Element], nodeID: str) -> Element:
156 """
157 Return the ``<Testsuite>`` element a testcase belongs into, creating the missing levels on the way.
159 :param root: The report element every path starts at.
160 :param suites: Elements created so far, keyed by their dotted path.
161 :param nodeID: Node ID of the testcase, whose ``::``-separated parts name the levels.
162 :returns: The innermost test suite element.
163 """
164 modulePath, _, remainder = nodeID.partition("::")
165 levels = [*Path(modulePath).with_suffix("").parts, *remainder.split("::")[:-1]]
167 parent = root
168 path = ""
169 for level in levels:
170 path = f"{path}.{level}" if path != "" else level
171 if (suite := suites.get(path)) is None:
172 suite = suites[path] = SubElement(parent, "Testsuite", {"name": level})
174 # The names are the level's own, so they are written where the level is created - once, however many
175 # testcases it holds. The schema requires this order, and a level contributes only the names it has.
176 levelNames = self._hierarchy.get(path, {})
177 for name in ("Title", "Summary", "Description"):
178 if (value := levelNames.get(name[0].lower() + name[1:], "")) != "":
179 SubElement(suite, name).text = value
181 parent = suite
183 return parent
185 def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None:
186 """
187 Write the collected results as one XML document.
189 :param session: The finished session.
190 :param exitstatus: The exit status the session ended with.
191 """
192 statuses = [entry.get("status", "errored") for entry in self._results.values()]
193 root = Element("TestReport", {
194 "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
195 "xsi:noNamespaceSchemaLocation": SCHEMA_FILES[SCHEMA_VERSION_LATEST],
196 "timestamp": datetime.now(timezone.utc).isoformat(),
197 "duration": f"{sum(entry['duration'] for entry in self._results.values()):.6f}",
198 "tests": str(len(statuses)),
199 "failures": str(statuses.count("failed")),
200 "errors": str(statuses.count("errored")),
201 "skipped": str(statuses.count("skipped")),
202 })
204 suites: dict[str, Element] = {}
205 for entry in self._results.values():
206 testsuite = self._testsuiteFor(root, suites, entry["nodeID"])
208 testcase = SubElement(testsuite, "Testcase", {
209 "name": entry["nodeID"].rsplit("::", 1)[-1],
210 "status": entry.get("status", "errored"),
211 "duration": f"{entry['duration']:.6f}",
212 "nodeID": entry["nodeID"],
213 })
214 # Ordered as the schema requires; an unmarked testcase carries none of them.
215 for name in ("Title", "Summary", "Description"):
216 if (value := entry.get(name[0].lower() + name[1:], "")) != "":
217 SubElement(testcase, name).text = value
219 if entry["message"] != "":
220 SubElement(testcase, "Message").text = entry["message"]
222 tree = ElementTree(root)
223 indent(tree, space="\t")
224 self._path.parent.mkdir(parents=True, exist_ok=True)
225 tree.write(self._path, encoding="utf-8", xml_declaration=True)
228@export
229def pytest_addoption(parser: Parser) -> None:
230 """
231 Add the ``--pytooling-xml`` option selecting the report's path.
233 :param parser: The command line parser to add the option to.
234 """
235 group = parser.getgroup("pyTooling")
236 group.addoption(
237 "--pytooling-xml", action="store", default=None, metavar="PATH",
238 help="Write a pyTooling test report to PATH."
239 )
242@export
243def pytest_configure(config: Config) -> None:
244 """
245 Register the writer, if a path was given.
247 :param config: The session's configuration.
248 """
249 if (path := config.getoption("--pytooling-xml")) is not None:
250 # 'setdefault' rather than 'get': this hook runs before collection, so the marker plugin has not filled the
251 # stash yet. Creating the dictionary here hands both plugins the same object, which it then updates in place.
252 writer = TestReportWriter(Path(path), config.stash.setdefault(hierarchyKey, {}))
253 config.stash[REPORT_WRITER_KEY] = writer
254 config.pluginmanager.register(writer, "pyTooling.Testing.TestReportWriter")