Coverage for pyTooling/Testing/PyTest.py: 98%
65 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 collecting what :deco:`~pyTooling.Testing.testsuite` and :deco:`~pyTooling.Testing.testcase` mark.
34pytest decides what a test is from a *name*: ``python_classes`` matches ``Test*`` and ``python_functions`` matches
35``test_*``. The identifier therefore has to enable collection as well as describe the check. This plugin adds a
36second route in - a class or method carrying a marker is collected whatever it is called - and reports the title
37its marker gives it as a JUnit property.
39The plugin is inert until something is marked, so enabling it changes nothing for a name-based test suite. Both
40styles can live in the same run, and even in the same file.
42.. hint::
44 See :ref:`high-level help <TESTING/Markers>` for explanations and usage examples.
45"""
46from inspect import cleandoc
47from pathlib import PurePath
48from sys import modules as loadedModules
49from types import ModuleType
50from typing import Any, Callable, Union, Optional as Nullable
51from unittest import TestCase
53from pytest import Class, Collector, Item, StashKey, fixture
54from pyTooling.Decorators import export
55from pyTooling.Documentation import splitDocString
58hierarchyKey: StashKey[dict[str, dict[str, str]]] = StashKey()
59"""Where the names of every test suite level are stashed, keyed by the dotted path matching ``classname``."""
62@export
63def getTestcases(cls: type) -> dict[str, Any]:
64 """
65 Return the methods marked as testcases.
67 :param cls: Class to search for marked methods.
68 :returns: Dictionary of a method's name to the method, for every method carrying ``__testcase_title__``.
69 """
70 return {
71 name: member
72 for name, member in vars(cls).items()
73 if callable(member) and hasattr(member, "__testcase_title__")
74 }
77@export
78def pytest_pycollect_makeitem(collector: Collector, name: str, obj: Any) -> Nullable[Any]:
79 """
80 Collect a marked class or a marked method, whatever it is named.
82 A marked :class:`unittest.TestCase` is a special case: such a class is collected by pytest's :mod:`unittest`
83 support, which asks :meth:`unittest.TestLoader.getTestCaseNames` for the test methods - and that loader matches
84 :attr:`~unittest.TestLoader.testMethodPrefix`, which is ``"test"`` and is *not* the ``python_functions`` setting.
85 Each marked method is therefore aliased under a name that loader accepts, and the class is handed back to pytest,
86 which collects a :class:`~unittest.TestCase` subclass regardless of ``python_classes``. The alias reaches no
87 report, because the entry is titled from the marker.
89 :param collector: The module collector asking about the object.
90 :param name: Name the object is bound to in the module.
91 :param obj: The object to decide about.
92 :returns: A collector or a list of items for a marked entity, otherwise ``None`` to let pytest decide.
93 """
94 if isinstance(obj, type) and hasattr(obj, "__testsuite_title__"):
95 if issubclass(obj, TestCase):
96 for methodName, method in getTestcases(obj).items():
97 if not methodName.startswith("test"): 97 ↛ 96line 97 didn't jump to line 96 because the condition on line 97 was always true
98 setattr(obj, f"test_{methodName}", method)
100 return None
102 return Class.from_parent(collector, name=name)
104 if callable(obj) and hasattr(obj, "__testcase_title__"):
105 return list(collector._genfunctions(name, obj))
107 return None
110@export
111def getNamesOfTestItem(holder: Union[ModuleType, type]) -> dict[str, str]:
112 """
113 Return the names an item carries as a test suite level.
115 A class marked with :deco:`~pyTooling.Testing.testsuite` carries all three in its ``__testsuite_***__`` fields.
116 A package or a module has only its doc-string, whose summary and full text are the summary and the description.
118 :param holder: The package, module or class to read the names from.
119 :returns: Dictionary of a name's kind to its value, holding only the ones that are not empty.
120 """
121 if hasattr(holder, "__testsuite_title__"):
122 names = {
123 "title": holder.__testsuite_title__,
124 "summary": holder.__testsuite_summary__,
125 "description": holder.__testsuite_description__,
126 }
127 else:
128 summary, _ = splitDocString(holder.__doc__)
129 names = {
130 "summary": summary,
131 "description": "" if holder.__doc__ is None else cleandoc(holder.__doc__),
132 }
134 return {kind: value for kind, value in names.items() if value != ""}
137@export
138def getLevelNames(item: Item) -> dict[str, dict[str, str]]:
139 """
140 Return the names of every test suite level a testcase sits in, keyed by the level's dotted path.
142 The path is built the way pytest builds a testcase's ``classname``: the module's dotted path, then every class
143 between the module and the testcase. So the keys of the result are prefixes of - and finally equal to - the
144 ``classname`` the same testcase gets in the JUnit report, which is what lets a reader join the two.
146 A level contributes only the names it has, and a level with none is skipped, so an unmarked test suite of
147 undocumented packages produces an empty result.
149 :param item: The collected testcase to walk the levels of.
150 :returns: Dictionary of a level's dotted path to its names.
151 """
152 modulePath, _, remainder = item.nodeid.partition("::")
153 classNames = remainder.split("::")[:-1]
155 levels: dict[str, dict[str, str]] = {}
156 path, holder = "", None
157 for level in (*PurePath(modulePath).with_suffix("").parts, *classNames):
158 path = f"{path}.{level}" if path != "" else level
160 # below the module, the levels are classes reached from it; at and above it, they are loaded modules
161 holder = getattr(holder, level, None) if level in classNames else loadedModules.get(path, None)
162 if holder is None:
163 continue
165 if len(names := getNamesOfTestItem(holder)) > 0: 165 ↛ 157line 165 didn't jump to line 157 because the condition on line 165 was always true
166 levels[path] = names
168 return levels
171@export
172def pytest_collection_modifyitems(items: list[Item]) -> None:
173 """
174 Attach the names of every marked item to the item, for the report to pick up.
176 A test item has four names, and only the first of them is what Python calls it:
178 * the **ID** - the module, class or method name, which is the item's ``classname``/``name``,
179 * the **title** - what the marker was given,
180 * the **summary** - the first paragraph of the doc-string,
181 * the **description** - the doc-string.
183 They travel as :attr:`~_pytest.nodes.Item.user_properties`, which is the channel the
184 :func:`~_pytest.python_api.record_property` fixture writes to: they are part of the test report, so they survive
185 being sent from a ``pytest-xdist`` worker, and they reach the JUnit report as ``<property>`` elements.
187 **The item's own name and node ID are deliberately left alone.** They are what selects a test - on the command
188 line, from an IDE, and from ``--last-failed``'s cache - and post-processing tools expect them to be identifiers,
189 free of spaces and punctuation. The title is additional information, not a replacement.
191 :param items: The collected items, modified in place.
192 """
193 hierarchy = items[0].config.stash.setdefault(hierarchyKey, {}) if len(items) > 0 else {}
195 for item in items:
196 hierarchy.update(getLevelNames(item))
198 testcaseTitle = getattr(getattr(item, "function", None), "__testcase_title__", None)
199 if testcaseTitle is None:
200 continue
202 function = item.function
204 # an item has four names: the ID (its 'classname'/'name'), a title, a summary and a description.
205 # The test suite's own names are not repeated here - they are in the session's properties, keyed by the
206 # level's dotted path, so they are written once instead of once per testcase.
207 for propertyName, value in (
208 ("title", testcaseTitle),
209 ("summary", getattr(function, "__testcase_summary__", "")),
210 ("description", getattr(function, "__testcase_description__", "")),
211 ):
212 if value != "":
213 item.user_properties.append((propertyName, value))
216@export
217@fixture(scope="session", autouse=True)
218def _recordTestsuiteHierarchy(request, record_testsuite_property: Callable[[str, object], None]) -> None:
219 """
220 Write the names of every test suite level into the report's session-level ``<properties>``.
222 JUnit has one flat ``<testsuite>`` element and squeezes the hierarchy into a dotted ``classname``, so a level
223 between the root and the class has no element that could carry a title or a description. The names are therefore
224 written as *keys*: ``tests.unit.Versioning.description`` names the level whose path is ``tests.unit.Versioning``,
225 which is a prefix of that testcase's ``classname``.
227 They are written **once per session**, not once per testcase - a property inside ``<testcase>`` would repeat for
228 every testcase in the level.
230 :param request: The fixture request, holding the configuration the levels were stashed on.
231 :param record_testsuite_property: pytest's fixture writing a property into the session's ``<testsuite>``.
232 """
233 for path, names in request.config.stash.get(hierarchyKey, {}).items():
234 for kind, value in names.items():
235 record_testsuite_property(f"{path}.{kind}", value)