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

497 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-27 22:27 +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 enum import Enum, unique 

34from io import TextIOWrapper 

35from sys import stdin, stdout, stderr 

36from textwrap import dedent 

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

38 

39try: 

40 from colorama import Fore as Foreground 

41except ImportError as ex: # pragma: no cover 

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

43 

44from pyTooling.Decorators import export, readonly 

45from pyTooling.MetaClasses import ExtendedType, mixin 

46from pyTooling.Exceptions import PlatformNotSupportedException, ExceptionBase 

47from pyTooling.Common import lastItem 

48from pyTooling.Platform import Platform 

49 

50 

51@export 

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

53 """ 

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

55 

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

57 terminal's width. 

58 """ 

59 

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

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

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

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

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

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

66 

67 try: 

68 from colorama import Fore as Foreground 

69 Foreground = { 

70 "RED": Foreground.LIGHTRED_EX, 

71 "DARK_RED": Foreground.RED, 

72 "GREEN": Foreground.LIGHTGREEN_EX, 

73 "DARK_GREEN": Foreground.GREEN, 

74 "YELLOW": Foreground.LIGHTYELLOW_EX, 

75 "DARK_YELLOW": Foreground.YELLOW, 

76 "MAGENTA": Foreground.LIGHTMAGENTA_EX, 

77 "BLUE": Foreground.LIGHTBLUE_EX, 

78 "DARK_BLUE": Foreground.BLUE, 

79 "CYAN": Foreground.LIGHTCYAN_EX, 

80 "DARK_CYAN": Foreground.CYAN, 

81 "GRAY": Foreground.WHITE, 

82 "DARK_GRAY": Foreground.LIGHTBLACK_EX, 

83 "WHITE": Foreground.LIGHTWHITE_EX, 

84 "NOCOLOR": Foreground.RESET, 

85 

86 "HEADLINE": Foreground.LIGHTMAGENTA_EX, 

87 "ERROR": Foreground.LIGHTRED_EX, 

88 "WARNING": Foreground.LIGHTYELLOW_EX 

89 } #: Terminal colors 

90 except ImportError: # pragma: no cover 

91 Foreground = { 

92 "RED": "", 

93 "DARK_RED": "", 

94 "GREEN": "", 

95 "DARK_GREEN": "", 

96 "YELLOW": "", 

97 "DARK_YELLOW": "", 

98 "MAGENTA": "", 

99 "BLUE": "", 

100 "DARK_BLUE": "", 

101 "CYAN": "", 

102 "DARK_CYAN": "", 

103 "GRAY": "", 

104 "DARK_GRAY": "", 

105 "WHITE": "", 

106 "NOCOLOR": "", 

107 

108 "HEADLINE": "", 

109 "ERROR": "", 

110 "WARNING": "" 

111 } #: Terminal colors 

112 

113 _stdin: TextIOWrapper #: STDIN 

114 _stdout: TextIOWrapper #: STDOUT 

115 _stderr: TextIOWrapper #: STDERR 

116 _width: int #: Terminal width in characters 

117 _height: int #: Terminal height in characters 

118 

119 def __init__(self) -> None: 

120 """ 

121 Initialize a terminal. 

122 

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

124 it for colored outputs. 

125 

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

127 """ 

128 

129 self._stdin = stdin 

130 self._stdout = stdout 

131 self._stderr = stderr 

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

133 self.InitializeColors() 

134 else: 

135 self.UninitializeColors() 

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

137 

138 def InitializeColors(self) -> bool: 

139 """ 

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

141 

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

143 """ 

144 try: 

145 from colorama import init 

146 

147 init() 

148 return True 

149 except ImportError: # pragma: no cover 

150 return False 

151 

152 def UninitializeColors(self) -> bool: 

153 """ 

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

155 

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

157 """ 

158 try: 

159 from colorama import deinit 

160 

161 deinit() 

162 return True 

163 except ImportError: # pragma: no cover 

164 return False 

165 

166 @readonly 

167 def Width(self) -> int: 

168 """ 

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

170 

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

172 """ 

173 return self._width 

174 

175 @readonly 

176 def Height(self) -> int: 

177 """ 

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

179 

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

181 """ 

182 return self._height 

183 

184 @staticmethod 

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

186 """ 

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

188 

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

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

191 """ 

192 platform = Platform() 

193 if platform.IsNativeWindows: 

194 size = TerminalBaseApplication.__GetTerminalSizeOnWindows() 

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

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

197 size = TerminalBaseApplication.__GetTerminalSizeOnLinux() 

198 else: # pragma: no cover 

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

200 

201 if size is None: # pragma: no cover 

202 size = (80, 25) # default size 

203 

204 return size 

205 

206 @staticmethod 

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

208 """ 

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

210 

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

212 

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

214 """ 

215 try: 

216 from ctypes import windll, create_string_buffer 

217 from struct import unpack as struct_unpack 

218 

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

220 stringBuffer = create_string_buffer(22) 

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

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

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

224 width = right - left + 1 

225 height = bottom - top + 1 

226 return width, height 

227 except ImportError: 

228 pass 

229 

230 return None 

231 # return Terminal.__GetTerminalSizeWithTPut() 

232 

233 # @staticmethod 

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

235 # """ 

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

237 # 

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

239 # 

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

241 # """ 

242 # from subprocess import check_output 

243 # 

244 # try: 

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

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

247 # return (width, height) 

248 # except: 

249 # pass 

250 

251 @staticmethod 

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

253 """ 

254 Get window size of a file descriptor. 

255 

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

257 

258 :param fd: File descriptor 

259 :return: 

260 """ 

261 try: 

262 from array import array 

263 from fcntl import ioctl 

264 from termios import TIOCGWINSZ 

265 except ImportError: 

266 return None 

267 

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

269 # H = unsigned short (16-bit) 

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

271 try: 

272 ioctl(fd, TIOCGWINSZ, buffer, True) 

273 return buffer[1], buffer[0] 

274 except OSError: 

275 return None 

276 

277 @staticmethod 

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

279 """ 

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

281 

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

283 ``LINES`` are checked. 

284 

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

286 """ 

287 # STDIN, STDOUT, STDERR 

288 for fd in range(3): 

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

290 return size 

291 

292 # Fallback 

293 fd = None 

294 try: 

295 from os import open, close, ctermid, O_RDONLY 

296 

297 fd = open(ctermid(), O_RDONLY) 

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

299 return size 

300 except (ImportError, OSError): 

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

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

303 pass 

304 finally: 

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

306 try: 

307 close(fd) 

308 except OSError: 

309 pass 

310 

311 # Fall-fallback 

312 from os import getenv 

313 

314 try: 

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

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

317 return columns, lines 

318 except TypeError: 

319 pass 

320 

321 return None 

322 

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

324 """ 

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

326 

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

328 :return: Number of written characters. 

329 """ 

330 return self._stdout.write(message) 

331 

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

333 """ 

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

335 

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

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

338 :return: Number of written characters. 

339 """ 

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

341 

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

343 """ 

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

345 

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

347 :return: Number of written characters. 

348 """ 

349 return self._stderr.write(message) 

350 

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

352 """ 

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

354 

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

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

357 :returns: Number of written characters. 

358 """ 

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

360 

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

362 """ 

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

364 

365 :param returnCode: Return code for application exit. 

366 """ 

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

368 

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

370 """ 

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

372 

373 :param returnCode: Return code for application exit. 

374 """ 

375 self.UninitializeColors() 

376 exit(returnCode) 

377 

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

379 """ 

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

381 """ 

382 from sys import version_info as info 

383 

384 if info < version: 

385 self.InitializeColors() 

386 

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

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

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

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

391 

392 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE) 

393 

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

395 """ 

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

397 

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

399 

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

401 """ 

402 from traceback import format_tb, walk_tb 

403 

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

405 filename = frame.f_code.co_filename 

406 funcName = frame.f_code.co_name 

407 

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

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

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

411 

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

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

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

415 for note in iterator: 

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

417 

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

419 

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

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

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

423 

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

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

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

427 for note in iterator: 

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

429 

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

431 for line in format_tb(ex.__traceback__): 

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

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

434 

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

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

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

438 

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

440 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

441 

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

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

444 from traceback import walk_tb 

445 

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

447 filename = frame.f_code.co_filename 

448 funcName = frame.f_code.co_name 

449 

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

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

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

453 

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

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

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

457 for note in iterator: 

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

459 

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

461 

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

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

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

465 

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

467 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE) 

468 

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

470 """ 

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

472 

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

474 

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

476 """ 

477 from traceback import print_tb, walk_tb 

478 

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

480 filename = frame.f_code.co_filename 

481 funcName = frame.f_code.co_name 

482 

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

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

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

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

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

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

489 

490 if ex.__cause__ is not None: 

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

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

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

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

495 

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

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

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

499 

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

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

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

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

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

505 

506 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

507 

508 

509@export 

510@unique 

511class Severity(Enum): 

512 """Logging message severity levels.""" 

513 

514 Fatal = 100 #: Fatal messages 

515 Error = 80 #: Error messages 

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

517 Critical = 60 #: Critical messages 

518 Warning = 50 #: Warning messages 

519 Info = 20 #: Informative messages 

520 Normal = 10 #: Normal messages 

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

522 Verbose = 5 #: Verbose messages 

523 Debug = 2 #: Debug messages 

524 All = 0 #: All messages 

525 

526 def __hash__(self) -> int: 

527 return hash(self.name) 

528 

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

530 """ 

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

532 

533 :param other: Operand to compare against. 

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

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

536 """ 

537 if isinstance(other, Severity): 

538 return self.value == other.value 

539 else: 

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

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

542 raise ex 

543 

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

545 """ 

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

547 

548 :param other: Operand to compare against. 

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

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

551 """ 

552 if isinstance(other, Severity): 

553 return self.value != other.value 

554 else: 

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

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

557 raise ex 

558 

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

560 """ 

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

562 

563 :param other: Operand to compare against. 

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

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

566 """ 

567 if isinstance(other, Severity): 

568 return self.value < other.value 

569 else: 

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

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

572 raise ex 

573 

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

575 """ 

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

577 

578 :param other: Operand to compare against. 

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

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

581 """ 

582 if isinstance(other, Severity): 

583 return self.value <= other.value 

584 else: 

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

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

587 raise ex 

588 

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

590 """ 

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

592 

593 :param other: Operand to compare against. 

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

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

596 """ 

597 if isinstance(other, Severity): 

598 return self.value > other.value 

599 else: 

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

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

602 raise ex 

603 

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

605 """ 

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

607 

608 :param other: Operand to compare against. 

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

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

611 """ 

612 if isinstance(other, Severity): 

613 return self.value >= other.value 

614 else: 

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

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

617 raise ex 

618 

619 

620@export 

621@unique 

622class Mode(Enum): 

623 TextToStdOut_ErrorsToStdErr = 0 

624 AllLinearToStdOut = 1 

625 DataToStdOut_OtherToStdErr = 2 

626 

627 

628@export 

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

630 """Represents a single message line with a severity and indentation level.""" 

631 

632 _LOG_MESSAGE_FORMAT__ = { 

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

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

635 Severity.Quiet: "{message}", 

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

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

638 Severity.Normal: "{message}", 

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

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

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

642 } #: Message line formatting rules. 

643 

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

645 _severity: Severity #: Message severity 

646 _indent: int #: Indentation 

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

648 

649 def __init__(self, message: str, severity: Severity = Severity.Normal, indent: int = 0, appendLinebreak: bool = True) -> None: 

650 """Constructor for a new ``Line`` object.""" 

651 self._severity = severity 

652 self._message = message 

653 self._indent = indent 

654 self._appendLinebreak = appendLinebreak 

655 

656 @readonly 

657 def Message(self) -> str: 

658 """ 

659 Return the indented line. 

660 

661 :returns: Raw message of the line. 

662 """ 

663 return self._message 

664 

665 @readonly 

666 def Severity(self) -> Severity: 

667 """ 

668 Return the line's severity level. 

669 

670 :returns: Severity level of the message line. 

671 """ 

672 return self._severity 

673 

674 @readonly 

675 def Indent(self) -> int: 

676 """ 

677 Return the line's indentation level. 

678 

679 :returns: Indentation level. 

680 """ 

681 return self._indent 

682 

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

684 """ 

685 Increase a line's indentation level. 

686 

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

688 """ 

689 # TODO: used named expression starting from Python 3.8 

690 indent += self._indent 

691 self._indent = indent 

692 return indent 

693 

694 @readonly 

695 def AppendLinebreak(self) -> bool: 

696 """ 

697 Returns if a linebreak should be added at the end of the message. 

698 

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

700 """ 

701 return self._appendLinebreak 

702 

703 def __str__(self) -> str: 

704 """Returns a formatted version of a ``Line`` objects as a string.""" 

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

706 

707 

708@export 

709@mixin 

710class ILineTerminal: 

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

712 

713 _terminal: TerminalBaseApplication 

714 

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

716 """MixIn initializer.""" 

717 self._terminal = terminal 

718 

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

720 

721 @readonly 

722 def Terminal(self) -> TerminalBaseApplication: 

723 """Return the local terminal instance.""" 

724 return self._terminal 

725 

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

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

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

729 return self._terminal.WriteLine(line) 

730 return False 

731 

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

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

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

735 # return False 

736 

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

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

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

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

741 return False 

742 

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

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

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

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

747 return False 

748 

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

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

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

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

753 return False 

754 

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

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

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

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

759 return False 

760 

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

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

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

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

765 return False 

766 

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

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

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

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

771 return False 

772 

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

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

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

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

777 return False 

778 

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

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

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

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

783 return False 

784 

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

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

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

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

789 return False 

790 

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

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

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

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

795 return False 

796 

797 

798@export 

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

800 """ 

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

802 """ 

803 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

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

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

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

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

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

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

814 } #: Message formatting rules. 

815 

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

817 _verbose: bool 

818 _debug: bool 

819 _quiet: bool 

820 _writeLevel: Severity 

821 _writeToStdOut: bool 

822 

823 _lines: List[Line] 

824 _baseIndent: int 

825 

826 _errorCount: int 

827 _criticalWarningCount: int 

828 _warningCount: int 

829 

830 HeadLine: ClassVar[str] 

831 

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

833 """ 

834 Initializer of a line-based terminal interface. 

835 

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

837 """ 

838 TerminalBaseApplication.__init__(self) 

839 # ILineTerminal.__init__(self, self) 

840 

841 self._LOG_LEVEL_ROUTING__ = {} 

842 self.__InitializeLogLevelRouting(mode) 

843 

844 self._verbose = False 

845 self._debug = False 

846 self._quiet = False 

847 self._writeLevel = Severity.Normal 

848 self._writeToStdOut = True 

849 

850 self._lines = [] 

851 self._baseIndent = 0 

852 

853 self._errorCount = 0 

854 self._criticalWarningCount = 0 

855 self._warningCount = 0 

856 

857 def __InitializeLogLevelRouting(self, mode: Mode) -> None: 

858 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

859 for severity in Severity: 

860 if severity >= Severity.Warning and severity != Severity.Quiet: 

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

862 else: 

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

864 elif mode is Mode.AllLinearToStdOut: 

865 for severity in Severity: 

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

867 elif mode is Mode.DataToStdOut_OtherToStdErr: 

868 for severity in Severity: 

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

870 else: # pragma: no cover 

871 raise ExceptionBase(f"Unsupported mode '{mode}'.") 

872 

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

874 """ 

875 Helper method to print the program headline. 

876 

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

878 

879 .. admonition:: Generated output 

880 

881 .. code-block:: 

882 

883 ========================= 

884 centered headline 

885 ========================= 

886 """ 

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

888 width = self._width 

889 

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

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

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

893 

894 def _PrintVersion(self, author: str, email: str, copyright: str, license: str, version: str) -> None: 

895 """ 

896 Helper method to print the version information. 

897 

898 :param author: Author of the application. 

899 :param email: The author's email address. 

900 :param copyright: The copyright information. 

901 :param license: The license. 

902 :param version: The application's version. 

903 

904 .. admonition:: Example usage 

905 

906 .. code-block:: Python 

907 

908 def _PrintVersion(self): 

909 from MyModule import __author__, __email__, __copyright__, __license__, __version__ 

910 

911 super()._PrintVersion(__author__, __email__, __copyright__, __license__, __version__) 

912 """ 

913 self.WriteNormal(f"Author: {author} ({email})") 

914 self.WriteNormal(f"Copyright: {copyright}") 

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

916 self.WriteNormal(f"Version: {version}") 

917 

918 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True) -> None: 

919 self._verbose = True if debug else verbose 

920 self._debug = debug 

921 self._quiet = quiet 

922 

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

924 self._writeLevel = Severity.Quiet 

925 elif debug: 

926 self._writeLevel = Severity.Debug 

927 elif verbose: 

928 self._writeLevel = Severity.Verbose 

929 else: 

930 self._writeLevel = Severity.Normal 

931 

932 self._writeToStdOut = writeToStdOut 

933 

934 @readonly 

935 def Verbose(self) -> bool: 

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

937 return self._verbose 

938 

939 @readonly 

940 def Debug(self) -> bool: 

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

942 return self._debug 

943 

944 @readonly 

945 def Quiet(self) -> bool: 

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

947 return self._quiet 

948 

949 @property 

950 def LogLevel(self) -> Severity: 

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

952 return self._writeLevel 

953 

954 @LogLevel.setter 

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

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

957 self._writeLevel = value 

958 

959 @property 

960 def BaseIndent(self) -> int: 

961 return self._baseIndent 

962 

963 @BaseIndent.setter 

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

965 self._baseIndent = value 

966 

967 @readonly 

968 def WarningCount(self) -> int: 

969 return self._warningCount 

970 

971 @readonly 

972 def CriticalWarningCount(self) -> int: 

973 return self._criticalWarningCount 

974 

975 @readonly 

976 def ErrorCount(self) -> int: 

977 return self._errorCount 

978 

979 @readonly 

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

981 return self._lines 

982 

983 def ExitOnPreviousErrors(self) -> None: 

984 """Exit application if errors have been printed.""" 

985 if self._errorCount > 0: 

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

987 

988 def ExitOnPreviousCriticalWarnings(self, includeErrors: bool = True) -> None: 

989 """Exit application if critical warnings have been printed.""" 

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

991 if self._criticalWarningCount > 0: 

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

993 else: 

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

995 elif self._criticalWarningCount > 0: 

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

997 

998 def ExitOnPreviousWarnings(self, includeCriticalWarnings: bool = True, includeErrors: bool = True) -> None: 

999 """Exit application if warnings have been printed.""" 

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

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

1002 if self._warningCount > 0: 

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

1004 else: 

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

1006 elif self._warningCount > 0: 

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

1008 else: 

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

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

1011 if self._warningCount > 0: 

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

1013 else: 

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

1015 elif self._warningCount > 0: 

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

1017 

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

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

1020 if line.Severity >= self._writeLevel: 

1021 self._lines.append(line) 

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

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

1024 return True 

1025 else: 

1026 return False 

1027 

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

1029 return line.Severity >= self._writeLevel 

1030 

1031 def WriteFatal(self, message: str, indent: int = 0, appendLinebreak: bool = True, immediateExit: bool = True) -> bool: 

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

1033 if immediateExit: 

1034 self.FatalExit() 

1035 return ret 

1036 

1037 def WriteError(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool: 

1038 self._errorCount += 1 

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

1040 

1041 def WriteCritical(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool: 

1042 self._criticalWarningCount += 1 

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

1044 

1045 def WriteWarning(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool: 

1046 self._warningCount += 1 

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

1048 

1049 def WriteInfo(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool: 

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

1051 

1052 def WriteQuiet(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool: 

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

1054 

1055 def WriteNormal(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool: 

1056 """ 

1057 Write a normal message. 

1058 

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

1060 

1061 :param message: Message to write. 

1062 :param indent: Indentation level of the message. 

1063 :param appendLinebreak: Append a linebreak after the message. Default: ``True`` 

1064 :return: True, if message was actually written. 

1065 """ 

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

1067 

1068 def WriteVerbose(self, message: str, indent: int = 1, appendLinebreak: bool = True) -> bool: 

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

1070 

1071 def WriteDebug(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool: 

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

1073 

1074 def WriteDryRun(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool: 

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