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

538 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 06:31 +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"""A set of helpers to implement a text user interface (TUI) in a terminal.""" 

33from datetime import datetime 

34from enum import Enum, unique 

35from io import TextIOWrapper 

36from sys import stdin, stdout, stderr 

37from textwrap import dedent 

38from types import ModuleType 

39from typing import NoReturn, Tuple, Any, List, Optional as Nullable, Dict, Callable, ClassVar 

40 

41from pyTooling.Versioning import PythonVersion 

42 

43try: 

44 from colorama import Fore as Foreground 

45except ImportError as ex: # pragma: no cover 

46 raise Exception(f"Optional dependency 'colorama' not installed. Either install pyTooling with extra dependencies 'pyTooling[terminal]' or install 'colorama' directly.") from ex 

47 

48from pyTooling.Decorators import export, readonly 

49from pyTooling.MetaClasses import ExtendedType, mixin 

50from pyTooling.Exceptions import PlatformNotSupportedException, ExceptionBase 

51from pyTooling.Common import lastItem 

52from pyTooling.Platform import Platform 

53 

54 

55@export 

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

57 """ 

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

59 

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

61 terminal's width. 

62 """ 

63 

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

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

66 PYTHON_VERSION_CHECK_FAILED_EXIT_CODE: ClassVar[int] = 254 #: Return code, if version check was not successful. 

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

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

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

70 

71 try: 

72 from colorama import Fore as Foreground 

73 Foreground = { 

74 "RED": Foreground.LIGHTRED_EX, 

75 "DARK_RED": Foreground.RED, 

76 "GREEN": Foreground.LIGHTGREEN_EX, 

77 "DARK_GREEN": Foreground.GREEN, 

78 "YELLOW": Foreground.LIGHTYELLOW_EX, 

79 "DARK_YELLOW": Foreground.YELLOW, 

80 "MAGENTA": Foreground.LIGHTMAGENTA_EX, 

81 "BLUE": Foreground.LIGHTBLUE_EX, 

82 "DARK_BLUE": Foreground.BLUE, 

83 "CYAN": Foreground.LIGHTCYAN_EX, 

84 "DARK_CYAN": Foreground.CYAN, 

85 "GRAY": Foreground.WHITE, 

86 "DARK_GRAY": Foreground.LIGHTBLACK_EX, 

87 "WHITE": Foreground.LIGHTWHITE_EX, 

88 "NOCOLOR": Foreground.RESET, 

89 

90 "HEADLINE": Foreground.LIGHTMAGENTA_EX, 

91 "ERROR": Foreground.LIGHTRED_EX, 

92 "WARNING": Foreground.LIGHTYELLOW_EX 

93 } #: Terminal colors 

94 except ImportError: # pragma: no cover 

95 Foreground = { 

96 "RED": "", 

97 "DARK_RED": "", 

98 "GREEN": "", 

99 "DARK_GREEN": "", 

100 "YELLOW": "", 

101 "DARK_YELLOW": "", 

102 "MAGENTA": "", 

103 "BLUE": "", 

104 "DARK_BLUE": "", 

105 "CYAN": "", 

106 "DARK_CYAN": "", 

107 "GRAY": "", 

108 "DARK_GRAY": "", 

109 "WHITE": "", 

110 "NOCOLOR": "", 

111 

112 "HEADLINE": "", 

113 "ERROR": "", 

114 "WARNING": "" 

115 } #: Terminal colors 

116 

117 _stdin: TextIOWrapper #: STDIN 

118 _stdout: TextIOWrapper #: STDOUT 

119 _stderr: TextIOWrapper #: STDERR 

120 _width: int #: Terminal width in characters 

121 _height: int #: Terminal height in characters 

122 

123 def __init__(self) -> None: 

124 """ 

125 Initialize a terminal. 

126 

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

128 it for colored outputs. 

129 

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

131 """ 

132 

133 self._stdin = stdin 

134 self._stdout = stdout 

135 self._stderr = stderr 

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

137 self.InitializeColors() 

138 else: 

139 self.UninitializeColors() 

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

141 

142 def InitializeColors(self) -> bool: 

143 """ 

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

145 

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

147 """ 

148 try: 

149 from colorama import init 

150 

151 init() 

152 return True 

153 except ImportError: # pragma: no cover 

154 return False 

155 

156 def UninitializeColors(self) -> bool: 

157 """ 

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

159 

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

161 """ 

162 try: 

163 from colorama import deinit 

164 

165 deinit() 

166 return True 

167 except ImportError: # pragma: no cover 

168 return False 

169 

170 @readonly 

171 def Width(self) -> int: 

172 """ 

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

174 

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

176 """ 

177 return self._width 

178 

179 @readonly 

180 def Height(self) -> int: 

181 """ 

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

183 

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

185 """ 

186 return self._height 

187 

188 @staticmethod 

189 def GetTerminalSize() -> Tuple[int, int]: 

190 """ 

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

192 

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

194 :raises PlatformNotSupportedException: When a platform is not yet supported. 

195 """ 

196 platform = Platform() 

197 if platform.IsNativeWindows: 

198 size = TerminalBaseApplication.__GetTerminalSizeOnWindows() 

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

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

201 size = TerminalBaseApplication.__GetTerminalSizeOnLinux() 

202 else: # pragma: no cover 

203 raise PlatformNotSupportedException(f"Platform '{platform}' not yet supported.") 

204 

205 if size is None: # pragma: no cover 

206 size = (80, 25) # default size 

207 

208 return size 

209 

210 @staticmethod 

211 def __GetTerminalSizeOnWindows() -> Nullable[Tuple[int, int]]: 

212 """ 

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

214 

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

216 

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

218 """ 

219 try: 

220 from ctypes import windll, create_string_buffer 

221 from struct import unpack as struct_unpack 

222 

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

224 stringBuffer = create_string_buffer(22) 

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

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

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

228 width = right - left + 1 

229 height = bottom - top + 1 

230 return width, height 

231 except ImportError: 

232 pass 

233 

234 return None 

235 # return Terminal.__GetTerminalSizeWithTPut() 

236 

237 # @staticmethod 

238 # def __GetTerminalSizeWithTPut() -> Tuple[int, int]: 

239 # """ 

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

241 # 

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

243 # 

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

245 # """ 

246 # from subprocess import check_output 

247 # 

248 # try: 

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

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

251 # return (width, height) 

252 # except: 

253 # pass 

254 

255 @staticmethod 

256 def __GetTerminalSizeOfFileDescriptor(fd: int) -> Nullable[Tuple[int, int]]: # Python 3.10: Use bitwise-or for union type: | None: 

257 """ 

258 Get window size of a file descriptor. 

259 

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

261 

262 :param fd: File descriptor 

263 :return: 

264 """ 

265 try: 

266 from array import array 

267 from fcntl import ioctl 

268 from termios import TIOCGWINSZ 

269 except ImportError: 

270 return None 

271 

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

273 # H = unsigned short (16-bit) 

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

275 try: 

276 ioctl(fd, TIOCGWINSZ, buffer, True) 

277 return buffer[1], buffer[0] 

278 except OSError: 

279 return None 

280 

281 @staticmethod 

282 def __GetTerminalSizeOnLinux() -> Nullable[Tuple[int, int]]: # Python 3.10: Use bitwise-or for union type: | None: 

283 """ 

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

285 

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

287 ``LINES`` are checked. 

288 

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

290 """ 

291 # STDIN, STDOUT, STDERR 

292 for fd in range(3): 

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

294 return size 

295 

296 # Fallback 

297 fd = None 

298 try: 

299 from os import open, close, ctermid, O_RDONLY 

300 

301 fd = open(ctermid(), O_RDONLY) 

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

303 return size 

304 except (ImportError, OSError): 

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

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

307 pass 

308 finally: 

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

310 try: 

311 close(fd) 

312 except OSError: 

313 pass 

314 

315 # Fall-fallback 

316 from os import getenv 

317 

318 try: 

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

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

321 return columns, lines 

322 except TypeError: 

323 pass 

324 

325 return None 

326 

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

328 """ 

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

330 

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

332 :returns: Number of written characters. 

333 """ 

334 return self._stdout.write(message) 

335 

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

337 """ 

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

339 

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

341 :param end: Use newline character. Default: ``\\n``. 

342 :returns: Number of written characters. 

343 """ 

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

345 

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

347 """ 

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

349 

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

351 :returns: Number of written characters. 

352 """ 

353 return self._stderr.write(message) 

354 

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

356 """ 

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

358 

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

360 :param end: Use newline character. Default: ``\\n``. 

361 :returns: Number of written characters. 

362 """ 

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

364 

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

366 """ 

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

368 

369 :param returnCode: Return code for application exit. 

370 """ 

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

372 

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

374 """ 

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

376 

377 :param returnCode: Return code for application exit. 

378 """ 

379 self.UninitializeColors() 

380 exit(returnCode) 

381 

382 def CheckPythonVersion(self, version: Tuple[int, ...]) -> None: 

383 """ 

384 Check if the used Python interpreter fulfills the minimum version requirements. 

385 """ 

386 from sys import version_info as info 

387 

388 if info < version: 

389 self.InitializeColors() 

390 

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

392 {{RED}}[ERROR]{{NOCOLOR}} Used Python interpreter ({info.major}.{info.minor}.{info.micro}-{info.releaselevel}) is to old. 

393 {{indent}}{{YELLOW}}Minimal required Python version is {version[0]}.{version[1]}.{version[2]}{{NOCOLOR}}\ 

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

395 

396 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE) 

397 

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

399 """ 

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

401 

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

403 

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

405 """ 

406 from traceback import format_tb, walk_tb 

407 

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

409 filename = frame.f_code.co_filename 

410 funcName = frame.f_code.co_name 

411 

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

413 message += f"{{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{ex.__class__.__name__}{{NOCOLOR}}\n" 

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

415 

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

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

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

419 for note in iterator: 

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

421 

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

423 

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

425 message += f"{{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{ex2.__class__.__name__}{{NOCOLOR}}\n" 

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

427 

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

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

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

431 for note in iterator: 

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

433 

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

435 for line in format_tb(ex.__traceback__): 

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

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

438 

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

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

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

442 

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

444 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

445 

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

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

448 from traceback import walk_tb 

449 

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

451 filename = frame.f_code.co_filename 

452 funcName = frame.f_code.co_name 

453 

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

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

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

457 

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

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

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

461 for note in iterator: 

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

463 

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

465 

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

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

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

469 

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

471 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE) 

472 

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

474 """ 

475 Prints an exception of type :exc:`ExceptionBase` and its traceback. 

476 

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

478 

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

480 """ 

481 from traceback import print_tb, walk_tb 

482 

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

484 filename = frame.f_code.co_filename 

485 funcName = frame.f_code.co_name 

486 

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

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

489 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{ex.__class__.__name__}{{NOCOLOR}} 

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

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

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

493 

494 if ex.__cause__ is not None: 

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

496 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}} 

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

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

499 

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

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

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

503 

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

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

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

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

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

509 

510 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

511 

512 

513@export 

514@unique 

515class Severity(Enum): 

516 """Logging message severity levels.""" 

517 

518 Exception = 120 #: Unhandled exception messages 

519 ExceptionCause = 115 #: Exception cause 

520 ExceptionNote = 110 #: Exception notes 

521 Fatal = 100 #: Fatal messages 

522 Error = 80 #: Error messages 

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

524 

525 Critical = 60 #: Critical messages 

526 CriticalNote = 55 #: Critical notes 

527 Warning = 50 #: Warning messages 

528 WarningNote = 45 #: Warning notes 

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

530 

531 Info = 20 #: Informative messages 

532 Normal = 10 #: Normal messages 

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

534 Verbose = 5 #: Verbose messages 

535 Debug = 2 #: Debug messages 

536 All = 0 #: All messages 

537 

538 def __hash__(self) -> int: 

539 return hash(self.name) 

540 

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

542 """ 

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

544 

545 :param other: Operand to compare against. 

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

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

548 """ 

549 if isinstance(other, Severity): 

550 return self.value == other.value 

551 else: 

552 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by == operator.") 

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

554 raise ex 

555 

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

557 """ 

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

559 

560 :param other: Operand to compare against. 

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

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

563 """ 

564 if isinstance(other, Severity): 

565 return self.value != other.value 

566 else: 

567 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.") 

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

569 raise ex 

570 

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

572 """ 

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

574 

575 :param other: Operand to compare against. 

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

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

578 """ 

579 if isinstance(other, Severity): 

580 return self.value < other.value 

581 else: 

582 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.") 

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

584 raise ex 

585 

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

587 """ 

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

589 

590 :param other: Operand to compare against. 

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

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

593 """ 

594 if isinstance(other, Severity): 

595 return self.value <= other.value 

596 else: 

597 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.") 

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

599 raise ex 

600 

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

602 """ 

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

604 

605 :param other: Operand to compare against. 

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

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

608 """ 

609 if isinstance(other, Severity): 

610 return self.value > other.value 

611 else: 

612 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.") 

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

614 raise ex 

615 

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

617 """ 

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

619 

620 :param other: Operand to compare against. 

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

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

623 """ 

624 if isinstance(other, Severity): 

625 return self.value >= other.value 

626 else: 

627 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.") 

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

629 raise ex 

630 

631 

632@export 

633@unique 

634class Mode(Enum): 

635 TextToStdOut_ErrorsToStdErr = 0 

636 AllLinearToStdOut = 1 

637 DataToStdOut_OtherToStdErr = 2 

638 

639 

640@export 

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

642 """ 

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

644 """ 

645 

646 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

651 Severity.Quiet: "{message}", 

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

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

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

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

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

657 Severity.Normal: "{message}", 

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

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

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

661 } #: Message line formatting rules. 

662 

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

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

665 _severity: Severity #: Message severity 

666 _indent: int #: Indentation 

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

668 

669 def __init__( 

670 self, 

671 message: str, 

672 severity: Severity = Severity.Normal, 

673 *, 

674 indent: int = 0, 

675 appendLinebreak: bool = True 

676 ) -> None: 

677 """ 

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

679 

680 :param message: Message to display. 

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

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

683 :param appendLinebreak: Optional, append a line break at the end of the message. 

684 """ 

685 self._timestamp = datetime.now() 

686 self._severity = severity 

687 self._message = message 

688 self._indent = indent 

689 self._appendLinebreak = appendLinebreak 

690 

691 @readonly 

692 def Message(self) -> str: 

693 """ 

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

695 

696 :returns: Raw message of the line. 

697 """ 

698 return self._message 

699 

700 @readonly 

701 def Severity(self) -> Severity: 

702 """ 

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

704 

705 :returns: Severity level of the message line. 

706 """ 

707 return self._severity 

708 

709 @readonly 

710 def Indent(self) -> int: 

711 """ 

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

713 

714 :returns: Indentation level of the message line. 

715 """ 

716 return self._indent 

717 

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

719 """ 

720 Increase a line's indentation level. 

721 

722 :param indent: Indentation level added to the current indentation level. 

723 """ 

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

725 return newIndent 

726 

727 @readonly 

728 def AppendLinebreak(self) -> bool: 

729 """ 

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

731 

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

733 """ 

734 return self._appendLinebreak 

735 

736 def __str__(self) -> str: 

737 """ 

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

739 

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

741 

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

743 """ 

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

745 

746 

747@export 

748@mixin 

749class ILineTerminal: 

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

751 

752 _terminal: TerminalBaseApplication 

753 

754 def __init__(self, terminal: Nullable[TerminalBaseApplication] = None) -> None: 

755 """MixIn initializer.""" 

756 self._terminal = terminal 

757 

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

759 

760 @readonly 

761 def Terminal(self) -> TerminalBaseApplication: 

762 """Return the local terminal instance.""" 

763 return self._terminal 

764 

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

766 """Write an entry to the local terminal.""" 

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

768 return self._terminal.WriteLine(line) 

769 return False 

770 

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

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

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

774 # return False 

775 

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

777 """Write a fatal message if ``condition`` is true.""" 

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

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

780 return False 

781 

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

783 """Write an error message if ``condition`` is true.""" 

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

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

786 return False 

787 

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

789 """Write a warning message if ``condition`` is true.""" 

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

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

792 return False 

793 

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

795 """Write a warning message if ``condition`` is true.""" 

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

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

798 return False 

799 

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

801 """Write an info message if ``condition`` is true.""" 

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

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

804 return False 

805 

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

807 """Write a message even in quiet mode if ``condition`` is true.""" 

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

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

810 return False 

811 

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

813 """Write a *normal* message if ``condition`` is true.""" 

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

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

816 return False 

817 

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

819 """Write a verbose message if ``condition`` is true.""" 

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

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

822 return False 

823 

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

825 """Write a debug message if ``condition`` is true.""" 

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

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

828 return False 

829 

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

831 """Write a dry-run message if ``condition`` is true.""" 

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

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

834 return False 

835 

836 

837@export 

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

839 """ 

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

841 """ 

842 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

857 } #: Message formatting rules. 

858 

859 _LOG_LEVEL_ROUTING__: Dict[Severity, Tuple[Callable[[str, str], int]]] #: Message routing rules. 

860 _verbose: bool 

861 _debug: bool 

862 _silent: bool 

863 _quiet: bool 

864 _writeLevel: Severity 

865 _writeToStdOut: bool 

866 

867 _lines: List[Line] 

868 _baseIndent: int 

869 

870 _errorCount: int 

871 _criticalWarningCount: int 

872 _warningCount: int 

873 

874 HeadLine: ClassVar[str] 

875 

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

877 """ 

878 Initializer of a line-based terminal interface. 

879 

880 :param mode: Defines what output (normal, error, data) to write where. Default: a linear flow all to *STDOUT*. 

881 """ 

882 TerminalBaseApplication.__init__(self) 

883 # ILineTerminal.__init__(self, self) 

884 

885 self._LOG_LEVEL_ROUTING__ = {} 

886 self.__InitializeLogLevelRouting(mode) 

887 

888 self._verbose = False 

889 self._debug = False 

890 self._silent = False 

891 self._quiet = False 

892 self._writeLevel = Severity.Normal 

893 self._writeToStdOut = True 

894 

895 self._lines = [] 

896 self._baseIndent = 0 

897 

898 self._errorCount = 0 

899 self._criticalWarningCount = 0 

900 self._warningCount = 0 

901 

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

903 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

904 for severity in Severity: 

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

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

907 else: 

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

909 elif mode is Mode.AllLinearToStdOut: 

910 for severity in Severity: 

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

912 elif mode is Mode.DataToStdOut_OtherToStdErr: 

913 for severity in Severity: 

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

915 else: # pragma: no cover 

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

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

918 raise ex 

919 

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

921 """ 

922 Helper method to print the program headline. 

923 

924 :param width: Number of characters for horizontal lines. 

925 

926 .. admonition:: Generated output 

927 

928 .. code-block:: 

929 

930 ========================= 

931 centered headline 

932 ========================= 

933 """ 

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

935 width = self._width 

936 

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

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

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

940 

941 def _PrintHelp(self, command: Nullable[str] = None) -> None: 

942 """ 

943 Helper function to print the command line parsers help page(s). 

944 

945 :param command: The subcommand to print the help page(s) for. 

946 """ 

947 if command is None: 

948 self.MainParser.print_help() 

949 elif command == "help": 

950 self.WriteWarning("This is a recursion ...") 

951 else: 

952 try: 

953 self.SubParsers[command].print_help() 

954 except KeyError: 

955 self.WriteError(f"Command {command} is unknown.") 

956 

957 def _PrintVersion( 

958 self, 

959 dunderModule: ModuleType, 

960 packageName: Nullable[str] = None, 

961 versionCheckTimeout: int = 1 

962 ) -> None: 

963 """ 

964 Helper method to print the version information. 

965 

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

967 

968 .. admonition:: Example usage 

969 

970 .. code-block:: Python 

971 

972 def _PrintVersion(self): 

973 import myPackage.MyModule as DunderModule 

974 

975 super()._PrintVersion( 

976 DunderModule, 

977 "MyModule" 

978 ) 

979 """ 

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

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

982 for copyright in copyrights[1:]: 

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

984 

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

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

987 

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

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

990 for author in authors[1:]: 

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

992 

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

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

995 

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

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

998 else: 

999 currentVersion = PythonVersion.Parse(version) 

1000 if packageName is None: 

1001 update = "" 

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

1003 latestVersion = PythonVersion.Parse(pypiVersion) 

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

1005 else: 

1006 update = " (PyPI timeout)" 

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

1008 

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

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

1011 

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

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

1014 

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

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

1017 

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

1019 from json import loads 

1020 from urllib.request import urlopen, Request 

1021 

1022 request = Request( 

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

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

1025 ) 

1026 try: 

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

1028 data = loads(response.read().decode()) 

1029 return data["info"]["version"] 

1030 except Exception: 

1031 return None 

1032 

1033 def Configure( 

1034 self, 

1035 *, 

1036 verbose: bool = False, 

1037 debug: bool = False, 

1038 silent: bool = False, 

1039 quiet: bool = False, 

1040 writeToStdOut: bool = True 

1041 ) -> None: 

1042 self._verbose = True if debug else verbose 

1043 self._debug = debug 

1044 self._silent = silent 

1045 self._quiet = quiet 

1046 

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

1048 self._writeLevel = Severity.Quiet 

1049 elif silent: 

1050 self._writeLevel = Severity.Silent 

1051 elif debug: 

1052 self._writeLevel = Severity.Debug 

1053 elif verbose: 

1054 self._writeLevel = Severity.Verbose 

1055 else: 

1056 self._writeLevel = Severity.Normal 

1057 

1058 self._writeToStdOut = writeToStdOut 

1059 

1060 @readonly 

1061 def Verbose(self) -> bool: 

1062 """Returns true, if verbose messages are enabled.""" 

1063 return self._verbose 

1064 

1065 @readonly 

1066 def Debug(self) -> bool: 

1067 """Returns true, if debug messages are enabled.""" 

1068 return self._debug 

1069 

1070 @readonly 

1071 def Silent(self) -> bool: 

1072 """Returns true, if silent mode is enabled.""" 

1073 return self._silent 

1074 

1075 @readonly 

1076 def Quiet(self) -> bool: 

1077 """Returns true, if quiet mode is enabled.""" 

1078 return self._quiet 

1079 

1080 @property 

1081 def LogLevel(self) -> Severity: 

1082 """Return the current minimal severity level for writing.""" 

1083 return self._writeLevel 

1084 

1085 @LogLevel.setter 

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

1087 """Set the minimal severity level for writing.""" 

1088 self._writeLevel = value 

1089 

1090 @property 

1091 def BaseIndent(self) -> int: 

1092 return self._baseIndent 

1093 

1094 @BaseIndent.setter 

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

1096 self._baseIndent = value 

1097 

1098 @readonly 

1099 def WarningCount(self) -> int: 

1100 """ 

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

1102 

1103 :return: Number of warnings. 

1104 """ 

1105 return self._warningCount 

1106 

1107 @readonly 

1108 def CriticalWarningCount(self) -> int: 

1109 """ 

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

1111 

1112 :return: Number of critical warnings. 

1113 """ 

1114 return self._criticalWarningCount 

1115 

1116 @readonly 

1117 def ErrorCount(self) -> int: 

1118 """ 

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

1120 

1121 :return: Number of errors. 

1122 """ 

1123 return self._errorCount 

1124 

1125 @readonly 

1126 def Lines(self) -> List[Line]: 

1127 """ 

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

1129 

1130 :returns: List of lines. 

1131 """ 

1132 return self._lines 

1133 

1134 def ExitOnPreviousErrors(self) -> None: 

1135 """ 

1136 Exit application if errors have been printed. 

1137 """ 

1138 if self._errorCount > 0: 

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

1140 

1141 def ExitOnPreviousCriticalWarnings( 

1142 self, 

1143 includeErrors: bool = True 

1144 ) -> None: 

1145 """ 

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

1147 

1148 :param includeErrors: Include critical warning counts. 

1149 """ 

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

1151 if self._criticalWarningCount > 0: 

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

1153 else: 

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

1155 elif self._criticalWarningCount > 0: 

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

1157 

1158 def ExitOnPreviousWarnings( 

1159 self, 

1160 includeCriticalWarnings: bool = True, 

1161 includeErrors: bool = True 

1162 ) -> None: 

1163 """ 

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

1165 

1166 :param includeCriticalWarnings: Include critical warning counts. 

1167 :param includeErrors: Include error counts. 

1168 """ 

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

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

1171 if self._warningCount > 0: 

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

1173 else: 

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

1175 elif self._warningCount > 0: 

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

1177 else: 

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

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

1180 if self._warningCount > 0: 

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

1182 else: 

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

1184 elif self._warningCount > 0: 

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

1186 

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

1188 """ 

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

1190 

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

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

1193 """ 

1194 if line.Severity < self._writeLevel: 

1195 return False 

1196 

1197 self._lines.append(line) 

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

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

1200 

1201 return True 

1202 

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

1204 """ 

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

1206 

1207 :param line: Line object to check. 

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

1209 """ 

1210 return line.Severity >= self._writeLevel 

1211 

1212 def WriteFatal( 

1213 self, 

1214 message: str, 

1215 *, 

1216 indent: int = 0, 

1217 appendLinebreak: bool = True, 

1218 exitCode: int = 0, 

1219 immediateExit: bool = True 

1220 ) -> bool: 

1221 """ 

1222 Write a fatal message and exit. 

1223 

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

1225 

1226 :param message: Message to write. 

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

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

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

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

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

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

1233 """ 

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

1235 if immediateExit: 

1236 self.FatalExit(exitCode) 

1237 return ret 

1238 

1239 def WriteError( 

1240 self, 

1241 message: str, 

1242 *, 

1243 indent: int = 0, 

1244 appendLinebreak: bool = True 

1245 ) -> bool: 

1246 """ 

1247 Write an error message. 

1248 

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

1250 

1251 :param message: Message to write. 

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

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

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

1255 """ 

1256 self._errorCount += 1 

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

1258 

1259 def WriteQuiet( 

1260 self, 

1261 message: str, 

1262 *, 

1263 indent: int = 0, 

1264 appendLinebreak: bool = True 

1265 ) -> bool: 

1266 """ 

1267 Write an always visible message. 

1268 

1269 This message is even visible in quiet mode. 

1270 

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

1272 

1273 :param message: Message to write. 

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

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

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

1277 """ 

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

1279 

1280 def WriteCritical( 

1281 self, 

1282 message: str, 

1283 *, 

1284 indent: int = 0, 

1285 appendLinebreak: bool = True 

1286 ) -> bool: 

1287 """ 

1288 Write a critical message. 

1289 

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

1291 

1292 :param message: Message to write. 

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

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

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

1296 """ 

1297 self._criticalWarningCount += 1 

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

1299 

1300 def WriteCriticalNote( 

1301 self, 

1302 message: str, 

1303 *, 

1304 indent: int = 0, 

1305 appendLinebreak: bool = True 

1306 ) -> bool: 

1307 """ 

1308 Write a critical note. 

1309 

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

1311 

1312 :param message: Message to write. 

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

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

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

1316 """ 

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

1318 

1319 def WriteWarning( 

1320 self, 

1321 message: str, 

1322 *, 

1323 indent: int = 0, 

1324 appendLinebreak: bool = True 

1325 ) -> bool: 

1326 """ 

1327 Write a warning message. 

1328 

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

1330 

1331 :param message: Message to write. 

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

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

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

1335 """ 

1336 self._warningCount += 1 

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

1338 

1339 def WriteWarningNote( 

1340 self, 

1341 message: str, 

1342 *, 

1343 indent: int = 0, 

1344 appendLinebreak: bool = True 

1345 ) -> bool: 

1346 """ 

1347 Write a warning note. 

1348 

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

1350 

1351 :param message: Message to write. 

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

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

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

1355 """ 

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

1357 

1358 def WriteInfo( 

1359 self, 

1360 message: str, 

1361 *, 

1362 indent: int = 0, 

1363 appendLinebreak: bool = True 

1364 ) -> bool: 

1365 """ 

1366 Write an info message. 

1367 

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

1369 

1370 :param message: Message to write. 

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

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

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

1374 """ 

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

1376 

1377 def WriteNormal( 

1378 self, 

1379 message: str, 

1380 *, 

1381 indent: int = 0, 

1382 appendLinebreak: bool = True 

1383 ) -> bool: 

1384 """ 

1385 Write a normal message. 

1386 

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

1388 

1389 :param message: Message to write. 

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

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

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

1393 """ 

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

1395 

1396 def WriteVerbose( 

1397 self, 

1398 message: str, 

1399 *, 

1400 indent: int = 0, 

1401 appendLinebreak: bool = True 

1402 ) -> bool: 

1403 """ 

1404 Write a verbose message. 

1405 

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

1407 

1408 :param message: Message to write. 

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

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

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

1412 """ 

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

1414 

1415 def WriteDebug( 

1416 self, 

1417 message: str, 

1418 *, 

1419 indent: int = 0, 

1420 appendLinebreak: bool = True 

1421 ) -> bool: 

1422 """ 

1423 Write a debug message. 

1424 

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

1426 

1427 :param message: Message to write. 

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

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

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

1431 """ 

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

1433 

1434 def WriteDryRun( 

1435 self, 

1436 message: str, 

1437 *, 

1438 indent: int = 0, 

1439 appendLinebreak: bool = True 

1440 ) -> bool: 

1441 """ 

1442 Write a dry-run message message. 

1443 

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

1445 

1446 :param message: Message to write. 

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

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

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

1450 """ 

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