Coverage for pyTooling/Testing/__init__.py: 100%

39 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +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""" 

32Enhanced classes for writing unit tests with Python's :mod:`unittest` framework, which pytest runs as well. 

33 

34The pieces here are the ones every test suite otherwise rewrites. Currently that is application testing: starting 

35the program under test the way a user does, because importing it cannot cover the console-script wiring, the 

36argument parsing or the exit codes. 

37 

38.. hint:: 

39 

40 See :ref:`high-level help <TESTING>` for explanations and usage examples. 

41""" 

42from pathlib import Path 

43from re import compile as re_compile 

44from shutil import which 

45from subprocess import CompletedProcess, run as subprocess_run 

46from unittest import TestCase 

47from sys import executable as PythonExecutable, version_info 

48from typing import Any, ClassVar, Optional as Nullable 

49from pyTooling.Decorators import export 

50from pyTooling.Exceptions import ToolingException 

51 

52 

53_ANSI_COLOR_CODES = re_compile(r"\x1B\[[0-9;]*m") #: Pattern matching an ANSI escape sequence selecting a color. 

54 

55 

56@export 

57class TestingException(ToolingException): 

58 """Base-exception of all exceptions raised by :mod:`pyTooling.Testing`.""" 

59 

60 

61@export 

62def stripANSIColorCodes(text: str) -> str: 

63 """ 

64 Remove ANSI color codes from a text, so it can be compared to an expected output. 

65 

66 A program writing to a terminal colors its output; the same program in a pipe usually does not, but that depends 

67 on the program. Comparing against an expectation is more robust with the codes removed than with a rule about 

68 when they appear. 

69 

70 :param text: The text to remove the color codes from. 

71 :returns: The text without ANSI color codes. 

72 """ 

73 return _ANSI_COLOR_CODES.sub("", text) 

74 

75 

76@export 

77class Testcase(TestCase): 

78 """ 

79 The base class for pyTooling's testcases, deriving from :class:`unittest.TestCase`. 

80 

81 It adds the assertions Python's :mod:`unittest` gained later than the oldest Python version pyTooling supports, 

82 so a test suite can use them whichever interpreter runs it: 

83 

84 .. code-block:: python 

85 

86 class Slots(Testcase): 

87 def test_SlotsAreDerived(self) -> None: 

88 self.assertHasAttr(MyClass, "__slots__") 

89 

90 On Python 3.14 and newer, :class:`unittest.TestCase` implements them and this class defines nothing, so the 

91 standard library's implementations and messages are used. 

92 """ 

93 

94 if version_info < (3, 14): # pragma: no cover 

95 def assertHasAttr(self, obj: Any, name: str, msg: Nullable[str] = None) -> None: 

96 """ 

97 Assert an object has an attribute of the given name. 

98 

99 Available in :class:`unittest.TestCase` from Python 3.14 on. 

100 

101 :param obj: The object to check. 

102 :param name: Name of the attribute the object is expected to have. 

103 :param msg: Optional, message replacing the generated one. 

104 """ 

105 if not hasattr(obj, name): 

106 self.fail(msg or f"{type(obj).__name__!r} object has no attribute {name!r}") 

107 

108 def assertNotHasAttr(self, obj: Any, name: str, msg: Nullable[str] = None) -> None: 

109 """ 

110 Assert an object has no attribute of the given name. 

111 

112 Available in :class:`unittest.TestCase` from Python 3.14 on. 

113 

114 :param obj: The object to check. 

115 :param name: Name of the attribute the object is expected not to have. 

116 :param msg: Optional, message replacing the generated one. 

117 """ 

118 if hasattr(obj, name): 

119 self.fail(msg or f"{type(obj).__name__!r} object has unexpected attribute {name!r}") 

120 

121 

122@export 

123class ApplicationTestcase(Testcase): 

124 """ 

125 The base class for testcases exercising an application through its command line. 

126 

127 It resolves the installed console script once per test class, offers two ways to run the program - through the 

128 installed entry point and through ``python -m <module>`` - and an assertion that reports the exit code together 

129 with what the program printed. 

130 

131 Derive from it and name what is being tested: 

132 

133 .. code-block:: python 

134 

135 class Commands(ApplicationTestcase): 

136 _consoleScript = "myprogram" 

137 _runnableModule = "myPackage.CLI" 

138 

139 def test_Version(self) -> None: 

140 result = self.RunEntrypoint("--version") 

141 

142 self.assertExitCode(result, 0) 

143 self.assertIn("myprogram", result.stdout) 

144 """ 

145 

146 _consoleScript: ClassVar[Nullable[str]] = None #: Name of the installed console script, resolved on ``PATH``. 

147 _runnableModule: ClassVar[Nullable[str]] = None #: Dotted name of the module to run with ``python -m``. 

148 _executable: ClassVar[Nullable[str]] = None #: The resolved console script, set by :meth:`setUpClass`. 

149 

150 @classmethod 

151 def setUpClass(cls) -> None: 

152 """ 

153 Check the test class is set up, and resolve the console script on ``PATH``, once per class. 

154 

155 :raises TestingException: If the test class named neither a console script nor a runnable module, or if the 

156 console script it named is not installed. Every testcase in the class would 

157 otherwise fail, each with a less obvious error. 

158 """ 

159 super().setUpClass() 

160 

161 if cls._consoleScript is None: 

162 raise TestingException(f"Testcase '{cls.__name__}' has no console script. Set '_consoleScript'.") 

163 

164 if cls._runnableModule is None: 

165 raise TestingException(f"Testcase '{cls.__name__}' has no runnable module. Set '_runnableModule'.") 

166 

167 if (resolved := which(cls._consoleScript)) is None: 

168 ex = TestingException(f"Console script '{cls._consoleScript}' was not found in PATH.") 

169 raise ex from FileNotFoundError(str(cls._consoleScript)) 

170 

171 cls._executable = resolved 

172 

173 def RunEntrypoint( 

174 self, 

175 *arguments: str, 

176 timeout: float = 10.0, 

177 stdInput: Nullable[str] = None, 

178 environment: Nullable[dict[str, str]] = None, 

179 workingDirectory: Nullable[Path] = None 

180 ) -> CompletedProcess: 

181 """ 

182 Run the installed console script. 

183 

184 This is the path a user takes, so it covers the entry-point wiring as well as the program itself. 

185 

186 :param arguments: Command line arguments to pass to the program. 

187 :param timeout: Optional, seconds to wait before the program is killed and :exc:`subprocess.TimeoutExpired` 

188 is raised. A test should fail rather than hang. 

189 :param stdInput: Optional, text to send to the program's standard input. 

190 :param environment: Optional, the environment to run in, or ``None`` to inherit this process's environment. 

191 :param workingDirectory: Optional, directory to run in, or ``None`` for the current one. 

192 :returns: The completed process, with ``stdout`` and ``stderr`` captured as text. 

193 """ 

194 return subprocess_run( 

195 [self._executable, *arguments], 

196 capture_output=True, 

197 text=True, 

198 timeout=timeout, 

199 input=stdInput, 

200 env=environment, 

201 cwd=None if workingDirectory is None else str(workingDirectory) 

202 ) 

203 

204 def RunModule( 

205 self, 

206 *arguments: str, 

207 timeout: float = 10.0, 

208 stdInput: Nullable[str] = None, 

209 environment: Nullable[dict[str, str]] = None, 

210 workingDirectory: Nullable[Path] = None 

211 ) -> CompletedProcess: 

212 """ 

213 Run the program as ``python -m <module>``, bypassing the console script. 

214 

215 Use it to tell a broken entry point apart from a broken program: if this passes while 

216 :meth:`RunEntrypoint` fails, the packaging is at fault, not the code. 

217 

218 :param arguments: Command line arguments to pass to the program. 

219 :param timeout: Optional, seconds to wait before the program is killed. 

220 :param stdInput: Optional, text to send to the program's standard input. 

221 :param environment: Optional, the environment to run in, or ``None`` to inherit this process's environment. 

222 :param workingDirectory: Optional, directory to run in, or ``None`` for the current one. 

223 :returns: The completed process, with ``stdout`` and ``stderr`` captured as text. 

224 """ 

225 return subprocess_run( 

226 [PythonExecutable, "-m", self._runnableModule, *arguments], 

227 capture_output=True, 

228 text=True, 

229 timeout=timeout, 

230 input=stdInput, 

231 env=environment, 

232 cwd=None if workingDirectory is None else str(workingDirectory) 

233 ) 

234 

235 def assertExitCode(self, result: CompletedProcess, expected: int = 0) -> None: 

236 """ 

237 Check the exit code of a completed process, reporting what the program printed when it doesn't match. 

238 

239 The output is what explains the failure, and it is gone once the test has finished, so it goes into the 

240 assertion message rather than into the console. 

241 

242 :param result: The completed process to check. 

243 :param expected: Optional, the expected exit code, zero by default. 

244 """ 

245 self.assertEqual( 

246 expected, 

247 result.returncode, 

248 msg=( 

249 f"Expected exit code {expected}, got {result.returncode} from: {result.args!r}\n" 

250 f"--- stdout ---\n{result.stdout}\n" 

251 f"--- stderr ---\n{result.stderr}" 

252 ) 

253 )