Coverage for pyTooling/TerminalUI/__init__.py: 73%

572 statements  

« 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 2017-2026 Patrick Lehmann - Bötzingen, Germany # 

15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33A set of helpers to implement a text user interface (TUI) in a terminal. 

34 

35:raises MissingDependencyError: If the 'terminal' extra isn't installed. 

36 

37.. seealso:: 

38 

39 :mod:`pyTooling.Attributes.ArgParse` 

40 |rarr| Declaring the commands and options the application accepts. 

41 :mod:`pyTooling.CLIAbstraction` 

42 |rarr| Calling other programs from such an application. 

43 :mod:`pyTooling.Warning` 

44 |rarr| Collecting warnings that the application then writes. 

45""" 

46from __future__ import annotations 

47 

48from datetime import datetime 

49from enum import Enum, unique 

50from io import TextIOWrapper 

51from sys import stdin, stdout, stderr 

52from textwrap import dedent 

53from types import ModuleType 

54from typing import NoReturn, Any, Optional as Nullable, Callable, ClassVar 

55from pyTooling.Exceptions import MissingDependencyError 

56from pyTooling.Versioning import PythonVersion 

57 

58try: 

59 from colorama import Fore as Foreground 

60except ImportError as ex: # pragma: no cover 

61 raise MissingDependencyError(dependency="colorama", extra="terminal") from ex 

62 

63from pyTooling.Decorators import export, readonly 

64from pyTooling.MetaClasses import ExtendedType, mixin 

65from pyTooling.Exceptions import PlatformNotSupportedError, ExceptionBase 

66from pyTooling.Common import lastItem, getFullyQualifiedName 

67from pyTooling.Platform import Platform 

68 

69 

70@export 

71class TerminalBaseApplication(metaclass=ExtendedType, slots=True, singleton=True): 

72 """ 

73 The class offers a basic terminal application base-class. 

74 

75 It offers basic colored output via `colorama <https://GitHub.com/tartley/colorama>`__ as well as retrieving the 

76 terminal's width. 

77 """ 

78 

79 NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE: ClassVar[int] = 240 #: Return code, if unimplemented methods or code sections were called. 

80 UNHANDLED_EXCEPTION_EXIT_CODE: ClassVar[int] = 241 #: Return code, if an unhandled exception reached the topmost exception handler. 

81 #: Return code (242), if an optional dependency is missing. The value lives on the exception, which stays 

82 #: importable when this module is not - see :meth:`PrintMissingDependencyError`. 

83 MISSING_DEPENDENCY_EXIT_CODE: ClassVar[int] = MissingDependencyError.EXIT_CODE 

84 FATAL_EXIT_CODE: ClassVar[int] = 255 #: Return code for fatal exits. 

85 ISSUE_TRACKER_URL: ClassVar[str] = None #: URL to the issue tracker for reporting bugs. 

86 INDENT: ClassVar[str] = " " #: Indentation. Default: ``" "`` (2 spaces) 

87 

88 try: 

89 from colorama import Fore as Foreground 

90 Foreground: ClassVar[dict[str, str]] = { 

91 "RED": Foreground.LIGHTRED_EX, 

92 "DARK_RED": Foreground.RED, 

93 "GREEN": Foreground.LIGHTGREEN_EX, 

94 "DARK_GREEN": Foreground.GREEN, 

95 "YELLOW": Foreground.LIGHTYELLOW_EX, 

96 "DARK_YELLOW": Foreground.YELLOW, 

97 "MAGENTA": Foreground.LIGHTMAGENTA_EX, 

98 "BLUE": Foreground.LIGHTBLUE_EX, 

99 "DARK_BLUE": Foreground.BLUE, 

100 "CYAN": Foreground.LIGHTCYAN_EX, 

101 "DARK_CYAN": Foreground.CYAN, 

102 "GRAY": Foreground.WHITE, 

103 "DARK_GRAY": Foreground.LIGHTBLACK_EX, 

104 "WHITE": Foreground.LIGHTWHITE_EX, 

105 "NOCOLOR": Foreground.RESET, 

106 

107 "HEADLINE": Foreground.LIGHTMAGENTA_EX, 

108 "ERROR": Foreground.LIGHTRED_EX, 

109 "WARNING": Foreground.LIGHTYELLOW_EX 

110 } #: Terminal colors 

111 except ImportError: # pragma: no cover 

112 Foreground: ClassVar[dict[str, str]] = { 

113 "RED": "", 

114 "DARK_RED": "", 

115 "GREEN": "", 

116 "DARK_GREEN": "", 

117 "YELLOW": "", 

118 "DARK_YELLOW": "", 

119 "MAGENTA": "", 

120 "BLUE": "", 

121 "DARK_BLUE": "", 

122 "CYAN": "", 

123 "DARK_CYAN": "", 

124 "GRAY": "", 

125 "DARK_GRAY": "", 

126 "WHITE": "", 

127 "NOCOLOR": "", 

128 

129 "HEADLINE": "", 

130 "ERROR": "", 

131 "WARNING": "" 

132 } #: Terminal colors 

133 

134 _stdin: TextIOWrapper #: STDIN 

135 _stdout: TextIOWrapper #: STDOUT 

136 _stderr: TextIOWrapper #: STDERR 

137 _width: int #: Terminal width in characters 

138 _height: int #: Terminal height in characters 

139 

140 def __init__(self) -> None: 

141 """ 

142 Initialize a terminal. 

143 

144 If the Python package `colorama <https://pypi.org/project/colorama/>`_ [#f_colorama]_ is available, then initialize 

145 it for colored outputs. 

146 

147 .. [#f_colorama] Colorama on Github: https://GitHub.com/tartley/colorama 

148 """ 

149 

150 self._stdin = stdin 

151 self._stdout = stdout 

152 self._stderr = stderr 

153 if stdout.isatty(): 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 self.InitializeColors() 

155 else: 

156 self.UninitializeColors() 

157 self._width, self._height = self.GetTerminalSize() 

158 

159 def InitializeColors(self) -> bool: 

160 """ 

161 Initialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__. 

162 

163 :returns: True, if 'colorama' package could be imported and initialized. 

164 """ 

165 try: 

166 from colorama import init 

167 

168 init() 

169 return True 

170 except ImportError: # pragma: no cover 

171 return False 

172 

173 def UninitializeColors(self) -> bool: 

174 """ 

175 Uninitialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__. 

176 

177 :returns: True, if 'colorama' package could be imported and uninitialized. 

178 """ 

179 try: 

180 from colorama import deinit 

181 

182 deinit() 

183 return True 

184 except ImportError: # pragma: no cover 

185 return False 

186 

187 @readonly 

188 def Width(self) -> int: 

189 """ 

190 Read-only property to access the terminal's width. 

191 

192 :returns: The terminal window's width in characters. 

193 """ 

194 return self._width 

195 

196 @readonly 

197 def Height(self) -> int: 

198 """ 

199 Read-only property to access the terminal's height. 

200 

201 :returns: The terminal window's height in characters. 

202 """ 

203 return self._height 

204 

205 @staticmethod 

206 def GetTerminalSize() -> tuple[int, int]: 

207 """ 

208 Returns the terminal size as tuple (width, height) for Windows, macOS (Darwin), Linux, cygwin (Windows), MinGW32/64 (Windows). 

209 

210 :returns: A tuple containing width and height of the terminal's size in characters. 

211 :raises PlatformNotSupportedError: When a platform is not yet supported. 

212 """ 

213 platform = Platform() 

214 if platform.IsNativeWindows: 

215 size = TerminalBaseApplication.__GetTerminalSizeOnWindows() 

216 elif (platform.IsNativeLinux or platform.IsNativeFreeBSD or platform.IsNativeMacOS or platform.IsMinGW32OnWindows or platform.IsMinGW64OnWindows 

217 or platform.IsUCRT64OnWindows or platform.IsCygwin32OnWindows or platform.IsClang64OnWindows): 

218 size = TerminalBaseApplication.__GetTerminalSizeOnLinux() 

219 else: # pragma: no cover 

220 raise PlatformNotSupportedError(f"Platform '{platform}' not yet supported.") 

221 

222 if size is None: # pragma: no cover 

223 size = (80, 25) # default size 

224 

225 return size 

226 

227 @staticmethod 

228 def __GetTerminalSizeOnWindows() -> Nullable[tuple[int, int]]: 

229 """ 

230 Returns the current terminal window's size for Windows. 

231 

232 ``kernel32.dll:GetConsoleScreenBufferInfo()`` is used to retrieve the information. 

233 

234 :returns: A tuple containing width and height of the terminal's size in characters. 

235 """ 

236 try: 

237 from ctypes import windll, create_string_buffer 

238 from struct import unpack as struct_unpack 

239 

240 hStdError = windll.kernel32.GetStdHandle(-12) # stderr handle = -12 

241 stringBuffer = create_string_buffer(22) 

242 result = windll.kernel32.GetConsoleScreenBufferInfo(hStdError, stringBuffer) 

243 if result: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy = struct_unpack("hhhhHhhhhhh", stringBuffer.raw) 

245 width = right - left + 1 

246 height = bottom - top + 1 

247 return width, height 

248 except ImportError: 

249 pass 

250 

251 return None 

252 # return Terminal.__GetTerminalSizeWithTPut() 

253 

254 # @staticmethod 

255 # def __GetTerminalSizeWithTPut() -> tuple[int, int]: 

256 # """ 

257 # Returns the current terminal window's size for Windows. 

258 # 

259 # ``tput`` is used to retrieve the information. 

260 # 

261 # :returns: A tuple containing width and height of the terminal's size in characters. 

262 # """ 

263 # from subprocess import check_output 

264 # 

265 # try: 

266 # width = int(check_output(("tput", "cols"))) 

267 # height = int(check_output(("tput", "lines"))) 

268 # return (width, height) 

269 # except: 

270 # pass 

271 

272 @staticmethod 

273 def __GetTerminalSizeOfFileDescriptor(fd: int) -> Nullable[tuple[int, int]]: 

274 """ 

275 Get window size of a file descriptor. 

276 

277 Call `ioctl` with ``TIOCGWINSZ`` (GetWindowsSize) for the given file descriptor. 

278 

279 :param fd: File descriptor to query. 

280 :returns: A 2-tuple of terminal width and height, or ``None`` if the size couldn't be determined. 

281 """ 

282 try: 

283 from array import array 

284 from fcntl import ioctl 

285 from termios import TIOCGWINSZ 

286 except ImportError: 

287 return None 

288 

289 # Allocate an array of 4x unsigned short (C struct) 

290 # H = unsigned short (16-bit) 

291 buffer = array('H', [0, 0, 0, 0]) # rows, columns, x-pixels, y-pixels 

292 try: 

293 ioctl(fd, TIOCGWINSZ, buffer, True) 

294 return buffer[1], buffer[0] 

295 except OSError: 

296 return None 

297 

298 @staticmethod 

299 def __GetTerminalSizeOnLinux() -> Nullable[tuple[int, int]]: 

300 """ 

301 Returns the current terminal window's size for Linux. 

302 

303 ``ioctl(TIOCGWINSZ)`` is used to retrieve the information. As a fallback, environment variables ``COLUMNS`` and 

304 ``LINES`` are checked. 

305 

306 :returns: A tuple containing width and height of the terminal's size in characters. 

307 """ 

308 # STDIN, STDOUT, STDERR 

309 for fd in range(3): 

310 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 return size 

312 

313 # Fallback 

314 fd = None 

315 try: 

316 from os import open, close, ctermid, O_RDONLY 

317 

318 fd = open(ctermid(), O_RDONLY) 

319 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None: 

320 return size 

321 except (ImportError, OSError): 

322 # ImportError - If ctermid is not available (e.g. MSYS2) 

323 # OSError - If ctermid() or open() fails 

324 pass 

325 finally: 

326 if fd is not None: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 try: 

328 close(fd) 

329 except OSError: 

330 pass 

331 

332 # Fall-fallback 

333 from os import getenv 

334 

335 try: 

336 columns = int(getenv("COLUMNS")) 

337 lines = int(getenv("LINES")) 

338 return columns, lines 

339 except TypeError: 

340 pass 

341 

342 return None 

343 

344 def WriteToStdOut(self, message: str) -> int: 

345 """ 

346 Low-level method for writing to ``STDOUT``. 

347 

348 :param message: Message to write to ``STDOUT``. 

349 :returns: Number of written characters. 

350 """ 

351 return self._stdout.write(message) 

352 

353 def WriteLineToStdOut(self, message: str, end: str = "\n") -> int: 

354 """ 

355 Low-level method for writing to ``STDOUT``. 

356 

357 :param message: Message to write to ``STDOUT``. 

358 :param end: Optional, use newline character. Default: ``\\n``. 

359 :returns: Number of written characters. 

360 """ 

361 return self._stdout.write(message + end) 

362 

363 def WriteToStdErr(self, message: str) -> int: 

364 """ 

365 Low-level method for writing to ``STDERR``. 

366 

367 :param message: Message to write to ``STDERR``. 

368 :returns: Number of written characters. 

369 """ 

370 return self._stderr.write(message) 

371 

372 def WriteLineToStdErr(self, message: str, end: str = "\n") -> int: 

373 """ 

374 Low-level method for writing to ``STDERR``. 

375 

376 :param message: Message to write to ``STDERR``. 

377 :param end: Optional, use newline character. Default: ``\\n``. 

378 :returns: Number of written characters. 

379 """ 

380 return self._stderr.write(message + end) 

381 

382 def FatalExit(self, returnCode: int = 0) -> NoReturn: 

383 """ 

384 Exit the terminal application by uninitializing color support and returning a fatal Exit code. 

385 

386 :param returnCode: Optional, return code for application exit. 

387 """ 

388 self.Exit(self.FATAL_EXIT_CODE if returnCode == 0 else returnCode) 

389 

390 def Exit(self, returnCode: int = 0) -> NoReturn: 

391 """ 

392 Exit the terminal application by uninitializing color support and returning an Exit code. 

393 

394 :param returnCode: Optional, return code for application exit. 

395 """ 

396 self.UninitializeColors() 

397 exit(returnCode) 

398 

399 def PrintException(self, ex: Exception) -> NoReturn: 

400 """ 

401 Prints an exception of type :exc:`Exception` and its traceback. 

402 

403 If the exception as a nested action, the cause is printed as well. 

404 

405 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added. 

406 

407 :param ex: The exception to print. 

408 """ 

409 from traceback import format_tb, walk_tb 

410 

411 frame, sourceLine = lastItem(walk_tb(ex.__traceback__)) 

412 filename = frame.f_code.co_filename 

413 funcName = frame.f_code.co_name 

414 

415 exceptionType = getFullyQualifiedName(ex) 

416 

417 message = f"{{RED}}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{{NOCOLOR}}\n" 

418 message += f"{{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{exceptionType}{{NOCOLOR}}\n" 

419 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n" 

420 

421 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0: 

422 note = next(iterator := iter(ex.__notes__)) 

423 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n" 

424 for note in iterator: 

425 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n" 

426 

427 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n" 

428 

429 if (ex2 := ex.__cause__) is not None: 

430 causeType = getFullyQualifiedName(ex2) 

431 

432 message += f"{{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{causeType}{{NOCOLOR}}\n" 

433 message += f"{{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex2!s}{{NOCOLOR}}\n" 

434 

435 if hasattr(ex2, "__notes__") and len(ex2.__notes__) > 0: 435 ↛ 441line 435 didn't jump to line 441 because the condition on line 435 was always true

436 note = next(iterator := iter(ex2.__notes__)) 

437 message += f"{{indent2}}{{DARK_YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n" 

438 for note in iterator: 

439 message += f"{{indent2}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n" 

440 

441 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}\n" 

442 for line in format_tb(ex.__traceback__): 

443 message += f"{line.replace('{', '{{').replace('}', '}}')}" 

444 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}" 

445 

446 if self.ISSUE_TRACKER_URL is not None: 

447 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n" 

448 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}" 

449 

450 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT*2, **self.Foreground)) 

451 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

452 

453 def PrintMissingDependencyError(self, ex: MissingDependencyError) -> NoReturn: 

454 """ 

455 Print a missing optional dependency and the command lines installing it. 

456 

457 Unlike the other printers, this one does **not** report a bug: there is no traceback, and no invitation to 

458 open an issue, because nothing is wrong with the program - a package it can use is not installed. The message 

459 names the missing package and every installation option the exception carries 

460 (:attr:`~pyTooling.Exceptions.MissingDependencyError.InstallCommands`). 

461 

462 .. attention:: 

463 

464 :mod:`pyTooling.TerminalUI` raises this exception **itself** when *colorama* is missing, and that happens 

465 while the module is imported - long before an application object exists, so this method cannot report that 

466 case. An application that wants to survive it catches the exception around its own imports and prints the 

467 commands directly: 

468 

469 .. code-block:: python 

470 

471 from pyTooling.Exceptions import MissingDependencyError 

472 

473 try: 

474 from pyTooling.TerminalUI import TerminalApplication 

475 except MissingDependencyError as ex: 

476 print(f"{ex}\n" + "\n".join(f" {command}" for command in ex.InstallCommands)) 

477 raise SystemExit(MissingDependencyError.EXIT_CODE) from ex 

478 

479 :param ex: The exception to print. 

480 :returns: Never - the method exits the application with :attr:`MISSING_DEPENDENCY_EXIT_CODE`. 

481 

482 .. seealso:: 

483 

484 :meth:`PrintException` 

485 |rarr| Print an unhandled exception and its traceback. 

486 :meth:`PrintNotImplementedError` 

487 |rarr| Print a call to an unimplemented function or abstract method. 

488 """ 

489 message = f"{{RED}}[MISSING DEPENDENCY] An optional dependency is not installed!{{NOCOLOR}}\n" 

490 message += f"{{indent}}{{YELLOW}}Missing package:{{NOCOLOR}} {{DARK_RED}}{ex.Dependency}{{NOCOLOR}}\n" 

491 

492 commands = iter(ex.InstallCommands) 

493 message += f"{{indent}}{{YELLOW}}Install it with:{{NOCOLOR}} {{DARK_CYAN}}{next(commands)}{{NOCOLOR}}\n" 

494 for command in commands: 

495 message += f"{{indent}} {{DARK_CYAN}}{command}{{NOCOLOR}}\n" 

496 

497 if (cause := ex.__cause__) is not None: 

498 message += f"{{indent}}{{YELLOW}}Caused by:{{NOCOLOR}} {{RED}}{cause!s}{{NOCOLOR}}\n" 

499 

500 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.Foreground)) 

501 self.Exit(self.MISSING_DEPENDENCY_EXIT_CODE) 

502 

503 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn: 

504 """ 

505 Prints a not-implemented exception of type :exc:`NotImplementedError`. 

506 

507 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added. 

508 

509 :param ex: The exception to print. 

510 """ 

511 from traceback import walk_tb 

512 

513 frame, sourceLine = lastItem(walk_tb(ex.__traceback__)) 

514 filename = frame.f_code.co_filename 

515 funcName = frame.f_code.co_name 

516 

517 message = f"{{RED}}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{{NOCOLOR}}\n" 

518 message += f"{{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{DARK_RED}}{funcName}(...){{NOCOLOR}}\n" 

519 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n" 

520 

521 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true

522 note = next(iterator := iter(ex.__notes__)) 

523 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n" 

524 for note in iterator: 

525 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n" 

526 

527 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n" 

528 

529 if self.ISSUE_TRACKER_URL is not None: 529 ↛ 533line 529 didn't jump to line 533 because the condition on line 529 was always true

530 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n" 

531 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}" 

532 

533 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.Foreground)) 

534 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE) 

535 

536 def PrintExceptionBase(self, ex: Exception) -> NoReturn: 

537 """ 

538 Prints an exception of type :exc:`~pyTooling.Exceptions.ExceptionBase` and its traceback. 

539 

540 If the exception as a nested action, the cause is printed as well. 

541 

542 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added. 

543 

544 :param ex: The exception to print. 

545 """ 

546 from traceback import print_tb, walk_tb 

547 

548 frame, sourceLine = lastItem(walk_tb(ex.__traceback__)) 

549 filename = frame.f_code.co_filename 

550 funcName = frame.f_code.co_name 

551 

552 exceptionType = getFullyQualifiedName(ex) 

553 

554 self.WriteLineToStdErr(dedent(f"""\ 

555 {{RED}}[FATAL] A known but unhandled exception reached the topmost exception handler!{{NOCOLOR}} 

556 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{exceptionType}{{NOCOLOR}} 

557 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}} 

558 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\ 

559 """).format(indent=self.INDENT, **self.Foreground)) 

560 

561 if ex.__cause__ is not None: 

562 causeType = getFullyQualifiedName(ex.__cause__) 

563 

564 self.WriteLineToStdErr(dedent(f"""\ 

565 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{causeType}{{NOCOLOR}} 

566 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {{RED}}{ex.__cause__!s}{{NOCOLOR}}\ 

567 """).format(indent2=self.INDENT * 2, **self.Foreground)) 

568 

569 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground)) 

570 print_tb(ex.__traceback__, file=self._stderr) 

571 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground)) 

572 

573 if self.ISSUE_TRACKER_URL is not None: 573 ↛ 579line 573 didn't jump to line 579 because the condition on line 573 was always true

574 self.WriteLineToStdErr(dedent(f"""\ 

575 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}} 

576 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\ 

577 """).format(indent=self.INDENT, **self.Foreground)) 

578 

579 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

580 

581 

582@export 

583@unique 

584class Severity(Enum): 

585 """Logging message severity levels.""" 

586 

587 Exception = 120 #: Unhandled exception messages 

588 ExceptionCause = 115 #: Exception cause 

589 ExceptionNote = 110 #: Exception notes 

590 Fatal = 100 #: Fatal messages 

591 Error = 80 #: Error messages 

592 Quiet = 70 #: Always visible messages, even in quiet mode. 

593 

594 Critical = 60 #: Critical messages 

595 CriticalNote = 55 #: Critical notes 

596 Warning = 50 #: Warning messages 

597 WarningNote = 45 #: Warning notes 

598 Silent = 40 #: Severity level for silenced messages. 

599 

600 Info = 20 #: Informative messages 

601 Normal = 10 #: Normal messages 

602 DryRun = 8 #: Messages visible in a dry-run 

603 Verbose = 5 #: Verbose messages 

604 Debug = 2 #: Debug messages 

605 All = 0 #: All messages 

606 

607 def __hash__(self) -> int: 

608 """ 

609 Compute a hash of the severity level, so it can be used as a key in a dictionary. 

610 

611 :returns: Hash of the severity level's name. 

612 """ 

613 return hash(self.name) 

614 

615 def __eq__(self, other: Any) -> bool: 

616 """ 

617 Compare two Severity instances (severity level) for equality. 

618 

619 :param other: Operand to compare against. 

620 :returns: ``True``, if both severity levels are equal. 

621 :raises TypeError: If operand ``other`` is not of type :class:`Severity`. 

622 """ 

623 if isinstance(other, Severity): 

624 return self.value == other.value 

625 else: 

626 ex = TypeError("Second operand is not supported by == operator.") 

627 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

628 ex.add_note("Supported types for second operand: Severity") 

629 raise ex 

630 

631 def __ne__(self, other: Any) -> bool: 

632 """ 

633 Compare two Severity instances (severity level) for inequality. 

634 

635 :param other: Operand to compare against. 

636 :returns: ``True``, if both severity levels are unequal. 

637 :raises TypeError: If operand ``other`` is not of type :class:`Severity`. 

638 """ 

639 if isinstance(other, Severity): 

640 return self.value != other.value 

641 else: 

642 ex = TypeError("Second operand is not supported by != operator.") 

643 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

644 ex.add_note("Supported types for second operand: Severity") 

645 raise ex 

646 

647 def __lt__(self, other: Any) -> bool: 

648 """ 

649 Compare two Severity instances (severity level) for less-than. 

650 

651 :param other: Operand to compare against. 

652 :returns: ``True``, if severity levels is less than other severity level. 

653 :raises TypeError: If operand ``other`` is not of type :class:`Severity`. 

654 """ 

655 if isinstance(other, Severity): 

656 return self.value < other.value 

657 else: 

658 ex = TypeError("Second operand is not supported by < operator.") 

659 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

660 ex.add_note("Supported types for second operand: Severity") 

661 raise ex 

662 

663 def __le__(self, other: Any) -> bool: 

664 """ 

665 Compare two Severity instances (severity level) for less-than-or-equal. 

666 

667 :param other: Operand to compare against. 

668 :returns: ``True``, if severity levels is less than or equal other severity level. 

669 :raises TypeError: If operand ``other`` is not of type :class:`Severity`. 

670 """ 

671 if isinstance(other, Severity): 

672 return self.value <= other.value 

673 else: 

674 ex = TypeError("Second operand is not supported by <= operator.") 

675 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

676 ex.add_note("Supported types for second operand: Severity") 

677 raise ex 

678 

679 def __gt__(self, other: Any) -> bool: 

680 """ 

681 Compare two Severity instances (severity level) for greater-than. 

682 

683 :param other: Operand to compare against. 

684 :returns: ``True``, if severity levels is greater than other severity level. 

685 :raises TypeError: If operand ``other`` is not of type :class:`Severity`. 

686 """ 

687 if isinstance(other, Severity): 

688 return self.value > other.value 

689 else: 

690 ex = TypeError("Second operand is not supported by > operator.") 

691 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

692 ex.add_note("Supported types for second operand: Severity") 

693 raise ex 

694 

695 def __ge__(self, other: Any) -> bool: 

696 """ 

697 Compare two Severity instances (severity level) for greater-than-or-equal. 

698 

699 :param other: Operand to compare against. 

700 :returns: ``True``, if severity levels is greater than or equal other severity level. 

701 :raises TypeError: If operand ``other`` is not of type :class:`Severity`. 

702 """ 

703 if isinstance(other, Severity): 

704 return self.value >= other.value 

705 else: 

706 ex = TypeError("Second operand is not supported by >= operator.") 

707 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

708 ex.add_note("Supported types for second operand: Severity") 

709 raise ex 

710 

711 

712@export 

713@unique 

714class Mode(Enum): 

715 """Routing modes deciding to which stream (``STDOUT``/``STDERR``) a message of a certain severity is written.""" 

716 

717 TextToStdOut_ErrorsToStdErr = 0 #: Warnings and higher severities to ``STDERR``, except :attr:`Severity.Quiet`. 

718 AllLinearToStdOut = 1 #: All messages to ``STDOUT``, so the message order is preserved in a log file. 

719 DataToStdOut_OtherToStdErr = 2 #: All messages to ``STDERR``, leaving ``STDOUT`` for the program's data. 

720 

721 

722@export 

723class Line(metaclass=ExtendedType, slots=True): 

724 """ 

725 Represents a single message line with a severity and indentation level. 

726 """ 

727 

728 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = { 

729 Severity.Exception: "EXCEPTION: {message}", 

730 Severity.ExceptionNote: " > {message}", 

731 Severity.Fatal: "FATAL: {message}", 

732 Severity.Error: "ERROR: {message}", 

733 Severity.Quiet: "{message}", 

734 Severity.Critical: "CRITICAL: {message}", 

735 Severity.CriticalNote: " > {message}", 

736 Severity.Warning: "WARNING: {message}", 

737 Severity.WarningNote: " > {message}", 

738 Severity.Info: "INFO: {message}", 

739 Severity.Normal: "{message}", 

740 Severity.DryRun: "DRYRUN: {message}", 

741 Severity.Verbose: "VERBOSE: {message}", 

742 Severity.Debug: "DEBUG: {message}", 

743 } #: Message line formatting rules. 

744 

745 _timestamp: datetime #: Timestamp when the line was created. 

746 _message: str #: Text message (line content). 

747 _severity: Severity #: Message severity 

748 _indent: int #: Indentation 

749 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object. 

750 

751 def __init__( 

752 self, 

753 message: str, 

754 severity: Severity = Severity.Normal, 

755 *, 

756 indent: int = 0, 

757 appendLinebreak: bool = True 

758 ) -> None: 

759 """ 

760 Initialize a line object representing the single-line message. 

761 

762 :param message: Message to display. 

763 :param severity: Optional, severity level of the message. 

764 :param indent: Optional, indentation level of the message. 

765 :param appendLinebreak: Optional, if ``True``, append a line break at the end of the message. 

766 """ 

767 self._timestamp = datetime.now() 

768 self._severity = severity 

769 self._message = message 

770 self._indent = indent 

771 self._appendLinebreak = appendLinebreak 

772 

773 @readonly 

774 def Message(self) -> str: 

775 """ 

776 Read-only property to access the line's raw message. 

777 

778 :returns: Raw message of the line. 

779 """ 

780 return self._message 

781 

782 @readonly 

783 def Severity(self) -> Severity: 

784 """ 

785 Read-only property to access the line's severity level. 

786 

787 :returns: Severity level of the message line. 

788 """ 

789 return self._severity 

790 

791 @readonly 

792 def Indent(self) -> int: 

793 """ 

794 Read-only property to access the line's indentation level. 

795 

796 :returns: Indentation level of the message line. 

797 """ 

798 return self._indent 

799 

800 def IndentBy(self, indent: int) -> int: 

801 """ 

802 Increase a line's indentation level. 

803 

804 :param indent: Optional, indentation level added to the current indentation level. 

805 :returns: The new indentation level. 

806 """ 

807 self._indent = (newIndent := self._indent + indent) 

808 return newIndent 

809 

810 @readonly 

811 def AppendLinebreak(self) -> bool: 

812 """ 

813 Read-only property to access if a linebreak is added after the line's message. 

814 

815 :returns: True, if a linebreak should be added. 

816 """ 

817 return self._appendLinebreak 

818 

819 def __str__(self) -> str: 

820 """ 

821 Returns a formatted version of a ``Line`` objects as a string. 

822 

823 The formatting is defined in :attr:`_LOG_MESSAGE_FORMAT__`. 

824 

825 :returns: Formatted version of a ``Line`` object. 

826 """ 

827 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message) 

828 

829 

830@export 

831@mixin 

832class ILineTerminal: 

833 """A mixin class (interface) to provide class-local terminal writing methods.""" 

834 

835 _terminal: Nullable[TerminalApplication] #: The terminal application the messages are written to. 

836 

837 def __init__(self, terminal: Nullable[TerminalApplication] = None) -> None: 

838 """ 

839 MixIn initializer. 

840 

841 :param terminal: Optional, the terminal to write to. If ``None``, every writing method does nothing. 

842 """ 

843 self._terminal = terminal 

844 

845 # FIXME: Alter methods if a terminal is present or set dummy methods 

846 

847 @readonly 

848 def Terminal(self) -> Nullable[TerminalApplication]: 

849 """ 

850 Read-only property to access the local terminal instance (:attr:`_terminal`). 

851 

852 :returns: The terminal instance, or ``None`` if no terminal is attached. 

853 """ 

854 return self._terminal 

855 

856 def WriteLine(self, line: Line, condition: bool = True) -> bool: 

857 """ 

858 Write a line to the local terminal if ``condition`` is ``True``. 

859 

860 :param line: Line object to write. 

861 :param condition: Optional, write the line only if this condition is ``True``. Default: ``True``. 

862 :returns: True, if the line was actually written. 

863 """ 

864 if (self._terminal is not None) and condition: 

865 return self._terminal.WriteLine(line) 

866 return False 

867 

868 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any): 

869 # if (self._terminal is not None) and condition: 

870 # return self._terminal.TryWrite(*args, **kwargs) 

871 # return False 

872 

873 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

874 """ 

875 Write a fatal message to the local terminal if ``condition`` is ``True``. 

876 

877 :param args: Positional parameters forwarded to the terminal's writing method. 

878 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

879 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

880 :returns: True, if the message was actually written. 

881 """ 

882 if (self._terminal is not None) and condition: 

883 return self._terminal.WriteFatal(*args, **kwargs) 

884 return False 

885 

886 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

887 """ 

888 Write an error message to the local terminal if ``condition`` is ``True``. 

889 

890 :param args: Positional parameters forwarded to the terminal's writing method. 

891 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

892 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

893 :returns: True, if the message was actually written. 

894 """ 

895 if (self._terminal is not None) and condition: 

896 return self._terminal.WriteError(*args, **kwargs) 

897 return False 

898 

899 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

900 """ 

901 Write a critical warning message to the local terminal if ``condition`` is ``True``. 

902 

903 :param args: Positional parameters forwarded to the terminal's writing method. 

904 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

905 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

906 :returns: True, if the message was actually written. 

907 """ 

908 if (self._terminal is not None) and condition: 

909 return self._terminal.WriteCritical(*args, **kwargs) 

910 return False 

911 

912 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

913 """ 

914 Write a warning message to the local terminal if ``condition`` is ``True``. 

915 

916 :param args: Positional parameters forwarded to the terminal's writing method. 

917 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

918 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

919 :returns: True, if the message was actually written. 

920 """ 

921 if (self._terminal is not None) and condition: 

922 return self._terminal.WriteWarning(*args, **kwargs) 

923 return False 

924 

925 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

926 """ 

927 Write an info message to the local terminal if ``condition`` is ``True``. 

928 

929 :param args: Positional parameters forwarded to the terminal's writing method. 

930 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

931 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

932 :returns: True, if the message was actually written. 

933 """ 

934 if (self._terminal is not None) and condition: 

935 return self._terminal.WriteInfo(*args, **kwargs) 

936 return False 

937 

938 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

939 """ 

940 Write an always visible message, even in quiet mode, to the local terminal if ``condition`` is ``True``. 

941 

942 :param args: Positional parameters forwarded to the terminal's writing method. 

943 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

944 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

945 :returns: True, if the message was actually written. 

946 """ 

947 if (self._terminal is not None) and condition: 

948 return self._terminal.WriteQuiet(*args, **kwargs) 

949 return False 

950 

951 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

952 """ 

953 Write a *normal* message to the local terminal if ``condition`` is ``True``. 

954 

955 :param args: Positional parameters forwarded to the terminal's writing method. 

956 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

957 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

958 :returns: True, if the message was actually written. 

959 """ 

960 if (self._terminal is not None) and condition: 

961 return self._terminal.WriteNormal(*args, **kwargs) 

962 return False 

963 

964 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

965 """ 

966 Write a verbose message to the local terminal if ``condition`` is ``True``. 

967 

968 :param args: Positional parameters forwarded to the terminal's writing method. 

969 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

970 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

971 :returns: True, if the message was actually written. 

972 """ 

973 if (self._terminal is not None) and condition: 

974 return self._terminal.WriteVerbose(*args, **kwargs) 

975 return False 

976 

977 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

978 """ 

979 Write a debug message to the local terminal if ``condition`` is ``True``. 

980 

981 :param args: Positional parameters forwarded to the terminal's writing method. 

982 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

983 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

984 :returns: True, if the message was actually written. 

985 """ 

986 if (self._terminal is not None) and condition: 

987 return self._terminal.WriteDebug(*args, **kwargs) 

988 return False 

989 

990 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool: 

991 """ 

992 Write a dry-run message to the local terminal if ``condition`` is ``True``. 

993 

994 :param args: Positional parameters forwarded to the terminal's writing method. 

995 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``. 

996 :param kwargs: Keyword parameters forwarded to the terminal's writing method. 

997 :returns: True, if the message was actually written. 

998 """ 

999 if (self._terminal is not None) and condition: 

1000 return self._terminal.WriteDryRun(*args, **kwargs) 

1001 return False 

1002 

1003 

1004@export 

1005class TerminalApplication(TerminalBaseApplication): #, ILineTerminal): 

1006 """ 

1007 A base-class for implementation of terminal applications emitting line-by-line messages. 

1008 """ 

1009 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = { 

1010 Severity.Exception: "{RED}[EXCEPTION] {message}{NOCOLOR}", 

1011 Severity.ExceptionNote: "{DARK_RED} > {message}{NOCOLOR}", 

1012 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}", 

1013 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}", 

1014 Severity.Quiet: "{WHITE}{message}{NOCOLOR}", 

1015 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}", 

1016 Severity.CriticalNote: "{DARK_YELLOW} > {message}{NOCOLOR}", 

1017 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}", 

1018 Severity.WarningNote: "{DARK_YELLOW} > {message}{NOCOLOR}", 

1019 Severity.Info: "{WHITE}{message}{NOCOLOR}", 

1020 Severity.Normal: "{WHITE}{message}{NOCOLOR}", 

1021 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}", 

1022 Severity.Verbose: "{GRAY}{message}{NOCOLOR}", 

1023 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}" 

1024 } #: Message formatting rules. 

1025 

1026 _LOG_LEVEL_ROUTING__: dict[Severity, tuple[Callable[[str, str], int]]] #: Message routing rules. 

1027 _verbose: bool #: ``True``, if verbose messages are written. 

1028 _debug: bool #: ``True``, if debug messages are written. 

1029 _silent: bool #: ``True``, if no messages are written at all. 

1030 _quiet: bool #: ``True``, if only errors and quiet messages are written. 

1031 _writeLevel: Severity #: Minimal severity a message needs to be written. 

1032 _writeToStdOut: bool #: ``True``, if messages are written to ``STDOUT`` instead of ``STDERR``. 

1033 

1034 _lines: list[Line] #: Every message written so far, in the order it was written. 

1035 _baseIndent: int #: Indentation level added to every message's own indentation. 

1036 

1037 _errorCount: int #: Number of errors written so far. 

1038 _criticalWarningCount: int #: Number of critical warnings written so far. 

1039 _warningCount: int #: Number of warnings written so far. 

1040 

1041 HeadLine: ClassVar[str] #: Headline of the application, printed by :meth:`_PrintHeadline`. 

1042 

1043 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None: 

1044 """ 

1045 Initializer of a line-based terminal interface. 

1046 

1047 :param mode: Optional, defines what output (normal, error, data) to write where. Default: a linear flow all to 

1048 *STDOUT*. 

1049 """ 

1050 TerminalBaseApplication.__init__(self) 

1051 # ILineTerminal.__init__(self, self) 

1052 

1053 self._LOG_LEVEL_ROUTING__ = {} 

1054 self.__InitializeLogLevelRouting(mode) 

1055 

1056 self._verbose = False 

1057 self._debug = False 

1058 self._silent = False 

1059 self._quiet = False 

1060 self._writeLevel = Severity.Normal 

1061 self._writeToStdOut = True 

1062 

1063 self._lines = [] 

1064 self._baseIndent = 0 

1065 

1066 self._errorCount = 0 

1067 self._criticalWarningCount = 0 

1068 self._warningCount = 0 

1069 

1070 def __InitializeLogLevelRouting(self, mode: Mode = Mode.AllLinearToStdOut) -> None: 

1071 """ 

1072 Expand a routing mode into a routing table containing one writing method per severity level. 

1073 

1074 :param mode: Optional, routing mode to expand. 

1075 :raises ExceptionBase: If the routing mode is not supported. |br| 

1076 The note lists the modes that are supported. 

1077 """ 

1078 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

1079 for severity in Severity: 

1080 if severity >= Severity.Silent and severity != Severity.Quiet: 

1081 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,) 

1082 else: 

1083 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,) 

1084 elif mode is Mode.AllLinearToStdOut: 

1085 for severity in Severity: 

1086 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, ) 

1087 elif mode is Mode.DataToStdOut_OtherToStdErr: 

1088 for severity in Severity: 

1089 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, ) 

1090 else: # pragma: no cover 

1091 ex = ExceptionBase(f"Unsupported mode '{mode}'.") 

1092 ex.add_note(f"Unsupported modes '{', '.join(m.name for m in Mode)}'.") 

1093 raise ex 

1094 

1095 def _PrintHeadline(self, width: int = 80) -> None: 

1096 """ 

1097 Helper method to print the program headline. 

1098 

1099 :param width: Optional, number of characters for horizontal lines. 

1100 

1101 .. admonition:: Generated output 

1102 

1103 .. code-block:: 

1104 

1105 ========================= 

1106 centered headline 

1107 ========================= 

1108 """ 

1109 if width == 0: 1109 ↛ 1110line 1109 didn't jump to line 1110 because the condition on line 1109 was never true

1110 width = self._width 

1111 

1112 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground)) 

1113 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground)) 

1114 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground)) 

1115 

1116 def _PrintVersion( 

1117 self, 

1118 dunderModule: ModuleType, 

1119 packageName: Nullable[str] = None, 

1120 versionCheckTimeout: int = 1 

1121 ) -> None: 

1122 """ 

1123 Helper method to print the version information. 

1124 

1125 :param dunderModule: The Python module containing the dunder variables for author(s), email, copyright, 

1126 version, ... 

1127 :param packageName: Optional, name of the package on PyPI. If given, the latest released version is 

1128 queried and reported as an available update. Default: ``None``. 

1129 :param versionCheckTimeout: Optional, timeout in seconds for the PyPI request. Default: ``1``. 

1130 

1131 .. admonition:: Example usage 

1132 

1133 .. code-block:: Python 

1134 

1135 def _PrintVersion(self): 

1136 import myPackage.MyModule as DunderModule 

1137 

1138 super()._PrintVersion( 

1139 DunderModule, 

1140 "MyModule" 

1141 ) 

1142 """ 

1143 copyrights = getattr(dunderModule, "__copyright__", "{RED}Copyright not set!".format(RED=Foreground.RED)).split("\n", 1) 

1144 self.WriteNormal(f"Copyright: {copyrights[0]}") 

1145 for copyright in copyrights[1:]: 

1146 self.WriteNormal(f" {copyright}") 

1147 

1148 license = getattr(dunderModule, "__license__", "{RED}License not set!".format(RED=Foreground.RED)) 

1149 self.WriteNormal(f"License: {license}") 

1150 

1151 authors = getattr(dunderModule, "__author__", "{RED}Unknown author!".format(RED=Foreground.RED)).split(", ") 

1152 self.WriteNormal(f"Authors: {authors[0]}") 

1153 for author in authors[1:]: 

1154 self.WriteNormal(f" {author}") 

1155 

1156 if (email := getattr(dunderModule, "__email__", None)) is not None: 

1157 self.WriteNormal(f"Email: {email}") 

1158 

1159 if (version := getattr(dunderModule, "__version__", None)) is None: 

1160 self.WriteNormal("Version: {RED}Version not set!".format(RED=Foreground.RED)) 

1161 else: 

1162 currentVersion = PythonVersion.Parse(version) 

1163 if packageName is None: 

1164 update = "" 

1165 elif (pypiVersion := self._GetLatestVersion(packageName, versionCheckTimeout)) is not None: 

1166 latestVersion = PythonVersion.Parse(pypiVersion) 

1167 update = f" (Update available: v{latestVersion})" if currentVersion < latestVersion else " (latest)" 

1168 else: 

1169 update = " (PyPI timeout)" 

1170 self.WriteNormal(f"Version: v{version}{update}") 

1171 

1172 if (projectURL := getattr(dunderModule, "__project_url__", None)) is not None: 

1173 self.WriteNormal(f"Project: {projectURL}") 

1174 

1175 if (documentationURL := getattr(dunderModule, "__documentation_url__", None)) is not None: 

1176 self.WriteNormal(f"Documentation: {documentationURL}") 

1177 

1178 if (issueTrackerURL := getattr(dunderModule, "__issue_tracker_url__", None)) is not None: 

1179 self.WriteNormal(f"Issue tracker: {issueTrackerURL}") 

1180 

1181 def _GetLatestVersion(self, packageName: str, timeout: int = 1) -> Nullable[str]: 

1182 """ 

1183 Query PyPI for the latest released version of a package. 

1184 

1185 Every error - an unreachable index, a timeout, an unknown package - is answered with ``None``, because a version 

1186 check must not fail the application it is printing the version of. 

1187 

1188 :param packageName: Optional, name of the package on PyPI. 

1189 :param timeout: Optional, timeout in seconds for the request. Default: ``1``. 

1190 :returns: The latest version as a string, or ``None``, if it couldn't be determined. 

1191 """ 

1192 from json import loads 

1193 from urllib.request import urlopen, Request 

1194 

1195 request = Request( 

1196 url=f"https://pypi.org/pypi/{packageName}/json", 

1197 headers={'User-Agent': f'{packageName}-Version-Check'} 

1198 ) 

1199 try: 

1200 with urlopen(request, timeout=timeout) as response: 

1201 data: dict[str, dict[str, str]] = loads(response.read().decode()) 

1202 return data["info"]["version"] 

1203 except Exception: 

1204 return None 

1205 

1206 def Configure( 

1207 self, 

1208 *, 

1209 verbose: bool = False, 

1210 debug: bool = False, 

1211 silent: bool = False, 

1212 quiet: bool = False, 

1213 writeToStdOut: bool = True 

1214 ) -> None: 

1215 """ 

1216 Configure the verbosity of the application, usually from the command line switches. 

1217 

1218 The resulting :attr:`LogLevel` is the minimum severity a message needs to be written: ``Severity.Debug`` in debug 

1219 mode, ``Severity.Verbose`` in verbose mode, ``Severity.Silent`` in silent mode, ``Severity.Quiet`` in quiet mode, 

1220 otherwise ``Severity.Normal``. Debug mode implies verbose mode. 

1221 

1222 :param verbose: Optional, write verbose messages. Default: ``False``. 

1223 :param debug: Optional, write debug messages, implying verbose messages. Default: ``False``. 

1224 :param silent: Optional, reduce the messages to warnings and higher severities. Default: ``False``. 

1225 :param quiet: Optional, reduce the messages to errors and always visible messages. Default: ``False``. 

1226 :param writeToStdOut: Optional, write to ``STDOUT``. Default: ``True``. 

1227 """ 

1228 self._verbose = True if debug else verbose 

1229 self._debug = debug 

1230 self._silent = silent 

1231 self._quiet = quiet 

1232 

1233 if quiet: 1233 ↛ 1235line 1233 didn't jump to line 1235 because the condition on line 1233 was always true

1234 self._writeLevel = Severity.Quiet 

1235 elif silent: 

1236 self._writeLevel = Severity.Silent 

1237 elif debug: 

1238 self._writeLevel = Severity.Debug 

1239 elif verbose: 

1240 self._writeLevel = Severity.Verbose 

1241 else: 

1242 self._writeLevel = Severity.Normal 

1243 

1244 self._writeToStdOut = writeToStdOut 

1245 

1246 @readonly 

1247 def Verbose(self) -> bool: 

1248 """ 

1249 Check if verbose messages are enabled. 

1250 

1251 :returns: ``True``, if verbose messages are written. 

1252 """ 

1253 return self._verbose 

1254 

1255 @readonly 

1256 def Debug(self) -> bool: 

1257 """ 

1258 Check if debug messages are enabled. 

1259 

1260 :returns: ``True``, if debug messages are written. 

1261 """ 

1262 return self._debug 

1263 

1264 @readonly 

1265 def Silent(self) -> bool: 

1266 """ 

1267 Check if silent mode is enabled. 

1268 

1269 :returns: ``True``, if silent mode is enabled. 

1270 """ 

1271 return self._silent 

1272 

1273 @readonly 

1274 def Quiet(self) -> bool: 

1275 """ 

1276 Check if quiet mode is enabled. 

1277 

1278 :returns: ``True``, if quiet mode is enabled. 

1279 """ 

1280 return self._quiet 

1281 

1282 @property 

1283 def LogLevel(self) -> Severity: 

1284 """ 

1285 Property to access the minimal severity level a message needs to be written (:attr:`_writeLevel`). 

1286 

1287 Assigning a level replaces what :meth:`Configure` computed from the verbosity switches. 

1288 

1289 :returns: The current minimal severity level. 

1290 """ 

1291 return self._writeLevel 

1292 

1293 @LogLevel.setter 

1294 def LogLevel(self, value: Severity) -> None: 

1295 self._writeLevel = value 

1296 

1297 @property 

1298 def BaseIndent(self) -> int: 

1299 """ 

1300 Property to access the base indentation level of written messages (:attr:`_baseIndent`). 

1301 

1302 The assigned level is added to every message's own indentation. 

1303 

1304 :returns: Base indentation level. 

1305 """ 

1306 return self._baseIndent 

1307 

1308 @BaseIndent.setter 

1309 def BaseIndent(self, value: int) -> None: 

1310 self._baseIndent = value 

1311 

1312 @readonly 

1313 def WarningCount(self) -> int: 

1314 """ 

1315 Read-only property to access the number of counted warnings. 

1316 

1317 :returns: Number of warnings. 

1318 """ 

1319 return self._warningCount 

1320 

1321 @readonly 

1322 def CriticalWarningCount(self) -> int: 

1323 """ 

1324 Read-only property to access the number of counted critical warnings. 

1325 

1326 :returns: Number of critical warnings. 

1327 """ 

1328 return self._criticalWarningCount 

1329 

1330 @readonly 

1331 def ErrorCount(self) -> int: 

1332 """ 

1333 Read-only property to access the number of counted errors. 

1334 

1335 :returns: Number of errors. 

1336 """ 

1337 return self._errorCount 

1338 

1339 @readonly 

1340 def Lines(self) -> list[Line]: 

1341 """ 

1342 Read-only property to access the list of printed lines (messages). 

1343 

1344 :returns: List of lines. 

1345 """ 

1346 return self._lines 

1347 

1348 def ExitOnPreviousErrors(self) -> None: 

1349 """ 

1350 Exit application if errors have been printed. 

1351 """ 

1352 if self._errorCount > 0: 

1353 self.WriteFatal("Too many errors in previous steps.") 

1354 

1355 def ExitOnPreviousCriticalWarnings( 

1356 self, 

1357 includeErrors: bool = True 

1358 ) -> None: 

1359 """ 

1360 Exit application if error or critical warnings have been printed. 

1361 

1362 :param includeErrors: Optional, if ``True``, count previous errors as well as critical warnings. 

1363 """ 

1364 if includeErrors and (self._errorCount > 0): 1364 ↛ 1365line 1364 didn't jump to line 1365 because the condition on line 1364 was never true

1365 if self._criticalWarningCount > 0: 

1366 self.WriteFatal("Too many errors and critical warnings in previous steps.") 

1367 else: 

1368 self.WriteFatal("Too many errors in previous steps.") 

1369 elif self._criticalWarningCount > 0: 

1370 self.WriteFatal("Too many critical warnings in previous steps.") 

1371 

1372 def ExitOnPreviousWarnings( 

1373 self, 

1374 includeCriticalWarnings: bool = True, 

1375 includeErrors: bool = True 

1376 ) -> None: 

1377 """ 

1378 Exit application if error or (critical) warnings have been printed. 

1379 

1380 :param includeCriticalWarnings: Optional, if ``True``, count previous critical warnings as well as warnings. 

1381 :param includeErrors: Optional, if ``True``, count previous errors as well. 

1382 """ 

1383 if includeErrors and (self._errorCount > 0): 1383 ↛ 1384line 1383 didn't jump to line 1384 because the condition on line 1383 was never true

1384 if includeCriticalWarnings and (self._criticalWarningCount > 0): 

1385 if self._warningCount > 0: 

1386 self.WriteFatal("Too many errors and (critical) warnings in previous steps.") 

1387 else: 

1388 self.WriteFatal("Too many errors and critical warnings in previous steps.") 

1389 elif self._warningCount > 0: 

1390 self.WriteFatal("Too many warnings in previous steps.") 

1391 else: 

1392 self.WriteFatal("Too many errors in previous steps.") 

1393 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 1393 ↛ 1394line 1393 didn't jump to line 1394 because the condition on line 1393 was never true

1394 if self._warningCount > 0: 

1395 self.WriteFatal("Too many (critical) warnings in previous steps.") 

1396 else: 

1397 self.WriteFatal("Too many critical warnings in previous steps.") 

1398 elif self._warningCount > 0: 

1399 self.WriteFatal("Too many warnings in previous steps.") 

1400 

1401 def WriteLine(self, line: Line) -> bool: 

1402 """ 

1403 Print a formatted line to the underlying terminal/console offered by the operating system. 

1404 

1405 The message is indented by :attr:`INDENT` repeated :attr:`Line.Indent` times. The indentation is applied to the 

1406 message, not to the whole line, so the severity markers stay in one column. 

1407 

1408 :param line: Line object to indent, format and print. 

1409 :returns: True, if line was actually written. 

1410 """ 

1411 if line.Severity < self._writeLevel: 

1412 return False 

1413 

1414 self._lines.append(line) 

1415 for method in self._LOG_LEVEL_ROUTING__[line.Severity]: 

1416 indentedMessage = self.INDENT * line.Indent + line.Message 

1417 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=indentedMessage, **self.Foreground), end="\n" if line.AppendLinebreak else "") 

1418 

1419 return True 

1420 

1421 def TryWriteLine(self, line) -> bool: 

1422 """ 

1423 Check if a line object of a certain severity would be written. 

1424 

1425 :param line: Line object to check. 

1426 :returns: True, if line would be written. 

1427 """ 

1428 severity: Severity = line.Severity # '@readonly' hands out 'Any' until it is typed - see T75 

1429 return severity >= self._writeLevel 

1430 

1431 def WriteFatal( 

1432 self, 

1433 message: str, 

1434 *, 

1435 indent: int = 0, 

1436 appendLinebreak: bool = True, 

1437 exitCode: int = 0, 

1438 immediateExit: bool = True 

1439 ) -> bool: 

1440 """ 

1441 Write a fatal message and exit. 

1442 

1443 Depending on internal settings and rules, a message might be skipped. 

1444 

1445 :param message: Message to write. 

1446 :param indent: Optional, indentation level of the message. 

1447 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1448 :param exitCode: Optional, exit application with this exit code. Default: ``0`` |br| 

1449 If ``0``, use :attr:`FATAL_EXIT_CODE` as exit code. 

1450 :param immediateExit: Optional, exit application immediately. Default: ``True`` 

1451 :returns: True, if message was actually written. 

1452 """ 

1453 ret = self.WriteLine(Line(message, Severity.Fatal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1454 if immediateExit: 

1455 self.FatalExit(exitCode) 

1456 return ret 

1457 

1458 def WriteError( 

1459 self, 

1460 message: str, 

1461 *, 

1462 indent: int = 0, 

1463 appendLinebreak: bool = True 

1464 ) -> bool: 

1465 """ 

1466 Write an error message. 

1467 

1468 Depending on internal settings and rules, a message might be skipped. 

1469 

1470 :param message: Message to write. 

1471 :param indent: Optional, indentation level of the message. 

1472 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1473 :returns: True, if message was actually written. 

1474 """ 

1475 self._errorCount += 1 

1476 return self.WriteLine(Line(message, Severity.Error, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1477 

1478 def WriteQuiet( 

1479 self, 

1480 message: str, 

1481 *, 

1482 indent: int = 0, 

1483 appendLinebreak: bool = True 

1484 ) -> bool: 

1485 """ 

1486 Write an always visible message. 

1487 

1488 This message is even visible in quiet mode. 

1489 

1490 Depending on internal settings and rules, a message might be skipped. 

1491 

1492 :param message: Message to write. 

1493 :param indent: Optional, indentation level of the message. 

1494 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1495 :returns: True, if message was actually written. 

1496 """ 

1497 return self.WriteLine(Line(message, Severity.Quiet, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1498 

1499 def WriteCritical( 

1500 self, 

1501 message: str, 

1502 *, 

1503 indent: int = 0, 

1504 appendLinebreak: bool = True 

1505 ) -> bool: 

1506 """ 

1507 Write a critical message. 

1508 

1509 Depending on internal settings and rules, a message might be skipped. 

1510 

1511 :param message: Message to write. 

1512 :param indent: Optional, indentation level of the message. 

1513 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1514 :returns: True, if message was actually written. 

1515 """ 

1516 self._criticalWarningCount += 1 

1517 return self.WriteLine(Line(message, Severity.Critical, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1518 

1519 def WriteCriticalNote( 

1520 self, 

1521 message: str, 

1522 *, 

1523 indent: int = 0, 

1524 appendLinebreak: bool = True 

1525 ) -> bool: 

1526 """ 

1527 Write a critical note. 

1528 

1529 Depending on internal settings and rules, a note might be skipped. 

1530 

1531 :param message: Message to write. 

1532 :param indent: Optional, indentation level of the note. 

1533 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True`` 

1534 :returns: True, if note was actually written. 

1535 """ 

1536 return self.WriteLine(Line(message, Severity.CriticalNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1537 

1538 def WriteWarning( 

1539 self, 

1540 message: str, 

1541 *, 

1542 indent: int = 0, 

1543 appendLinebreak: bool = True 

1544 ) -> bool: 

1545 """ 

1546 Write a warning message. 

1547 

1548 Depending on internal settings and rules, a message might be skipped. 

1549 

1550 :param message: Message to write. 

1551 :param indent: Optional, indentation level of the message. 

1552 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1553 :returns: True, if message was actually written. 

1554 """ 

1555 self._warningCount += 1 

1556 return self.WriteLine(Line(message, Severity.Warning, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1557 

1558 def WriteWarningNote( 

1559 self, 

1560 message: str, 

1561 *, 

1562 indent: int = 0, 

1563 appendLinebreak: bool = True 

1564 ) -> bool: 

1565 """ 

1566 Write a warning note. 

1567 

1568 Depending on internal settings and rules, a note might be skipped. 

1569 

1570 :param message: Message to write. 

1571 :param indent: Optional, indentation level of the note. 

1572 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True`` 

1573 :returns: True, if note was actually written. 

1574 """ 

1575 return self.WriteLine(Line(message, Severity.WarningNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1576 

1577 def WriteInfo( 

1578 self, 

1579 message: str, 

1580 *, 

1581 indent: int = 0, 

1582 appendLinebreak: bool = True 

1583 ) -> bool: 

1584 """ 

1585 Write an info message. 

1586 

1587 Depending on internal settings and rules, a message might be skipped. 

1588 

1589 :param message: Message to write. 

1590 :param indent: Optional, indentation level of the message. 

1591 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1592 :returns: True, if message was actually written. 

1593 """ 

1594 return self.WriteLine(Line(message, Severity.Info, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1595 

1596 def WriteNormal( 

1597 self, 

1598 message: str, 

1599 *, 

1600 indent: int = 0, 

1601 appendLinebreak: bool = True 

1602 ) -> bool: 

1603 """ 

1604 Write a normal message. 

1605 

1606 Depending on internal settings and rules, a message might be skipped. 

1607 

1608 :param message: Message to write. 

1609 :param indent: Optional, indentation level of the message. 

1610 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1611 :returns: True, if message was actually written. 

1612 """ 

1613 return self.WriteLine(Line(message, Severity.Normal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1614 

1615 def WriteVerbose( 

1616 self, 

1617 message: str, 

1618 *, 

1619 indent: int = 0, 

1620 appendLinebreak: bool = True 

1621 ) -> bool: 

1622 """ 

1623 Write a verbose message. 

1624 

1625 Depending on internal settings and rules, a message might be skipped. 

1626 

1627 :param message: Message to write. 

1628 :param indent: Optional, indentation level of the message. 

1629 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1630 :returns: True, if message was actually written. 

1631 """ 

1632 return self.WriteLine(Line(message, Severity.Verbose, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1633 

1634 def WriteDebug( 

1635 self, 

1636 message: str, 

1637 *, 

1638 indent: int = 0, 

1639 appendLinebreak: bool = True 

1640 ) -> bool: 

1641 """ 

1642 Write a debug message. 

1643 

1644 Depending on internal settings and rules, a message might be skipped. 

1645 

1646 :param message: Message to write. 

1647 :param indent: Optional, indentation level of the message. 

1648 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1649 :returns: True, if message was actually written. 

1650 """ 

1651 return self.WriteLine(Line(message, Severity.Debug, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)) 

1652 

1653 def WriteDryRun( 

1654 self, 

1655 message: str, 

1656 *, 

1657 indent: int = 0, 

1658 appendLinebreak: bool = True 

1659 ) -> bool: 

1660 """ 

1661 Write a dry-run message message. 

1662 

1663 Depending on internal settings and rules, a message might be skipped. 

1664 

1665 :param message: Message to write. 

1666 :param indent: Optional, indentation level of the message. 

1667 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True`` 

1668 :returns: True, if message was actually written. 

1669 """ 

1670 return self.WriteLine(Line(message, Severity.DryRun, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))