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

494 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-16 09:59 +0000

1# ==================================================================================================================== # 

2# _____ _ _ _____ _ _ _ _ ___ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2017-2025 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 

44try: 

45 from pyTooling.Decorators import export, readonly 

46 from pyTooling.MetaClasses import ExtendedType, mixin 

47 from pyTooling.Exceptions import PlatformNotSupportedException, ExceptionBase 

48 from pyTooling.Common import lastItem 

49 from pyTooling.Platform import Platform 

50except (ImportError, ModuleNotFoundError): # pragma: no cover 

51 print("[pyTooling.TerminalUI] Could not import from 'pyTooling.*'!") 

52 

53 try: 

54 from Decorators import export, readonly 

55 from MetaClasses import ExtendedType, mixin 

56 from Exceptions import PlatformNotSupportedException, ExceptionBase 

57 from Common import lastItem 

58 from Platform import Platform 

59 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

60 print("[pyTooling.TerminalUI] Could not import directly!") 

61 raise ex 

62 

63 

64@export 

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

66 """ 

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

68 

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

70 terminal's width. 

71 """ 

72 

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

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

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

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

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

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

79 

80 try: 

81 from colorama import Fore as Foreground 

82 Foreground = { 

83 "RED": Foreground.LIGHTRED_EX, 

84 "DARK_RED": Foreground.RED, 

85 "GREEN": Foreground.LIGHTGREEN_EX, 

86 "DARK_GREEN": Foreground.GREEN, 

87 "YELLOW": Foreground.LIGHTYELLOW_EX, 

88 "DARK_YELLOW": Foreground.YELLOW, 

89 "MAGENTA": Foreground.LIGHTMAGENTA_EX, 

90 "BLUE": Foreground.LIGHTBLUE_EX, 

91 "DARK_BLUE": Foreground.BLUE, 

92 "CYAN": Foreground.LIGHTCYAN_EX, 

93 "DARK_CYAN": Foreground.CYAN, 

94 "GRAY": Foreground.WHITE, 

95 "DARK_GRAY": Foreground.LIGHTBLACK_EX, 

96 "WHITE": Foreground.LIGHTWHITE_EX, 

97 "NOCOLOR": Foreground.RESET, 

98 

99 "HEADLINE": Foreground.LIGHTMAGENTA_EX, 

100 "ERROR": Foreground.LIGHTRED_EX, 

101 "WARNING": Foreground.LIGHTYELLOW_EX 

102 } #: Terminal colors 

103 except ImportError: # pragma: no cover 

104 Foreground = { 

105 "RED": "", 

106 "DARK_RED": "", 

107 "GREEN": "", 

108 "DARK_GREEN": "", 

109 "YELLOW": "", 

110 "DARK_YELLOW": "", 

111 "MAGENTA": "", 

112 "BLUE": "", 

113 "DARK_BLUE": "", 

114 "CYAN": "", 

115 "DARK_CYAN": "", 

116 "GRAY": "", 

117 "DARK_GRAY": "", 

118 "WHITE": "", 

119 "NOCOLOR": "", 

120 

121 "HEADLINE": "", 

122 "ERROR": "", 

123 "WARNING": "" 

124 } #: Terminal colors 

125 

126 _stdin: TextIOWrapper #: STDIN 

127 _stdout: TextIOWrapper #: STDOUT 

128 _stderr: TextIOWrapper #: STDERR 

129 _width: int #: Terminal width in characters 

130 _height: int #: Terminal height in characters 

131 

132 def __init__(self) -> None: 

133 """ 

134 Initialize a terminal. 

135 

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

137 it for colored outputs. 

138 

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

140 """ 

141 

142 self._stdin = stdin 

143 self._stdout = stdout 

144 self._stderr = stderr 

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

146 self.InitializeColors() 

147 else: 

148 self.UninitializeColors() 

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

150 

151 def InitializeColors(self) -> bool: 

152 """ 

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

154 

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

156 """ 

157 try: 

158 from colorama import init 

159 

160 init() 

161 return True 

162 except ImportError: # pragma: no cover 

163 return False 

164 

165 def UninitializeColors(self) -> bool: 

166 """ 

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

168 

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

170 """ 

171 try: 

172 from colorama import deinit 

173 

174 deinit() 

175 return True 

176 except ImportError: # pragma: no cover 

177 return False 

178 

179 @readonly 

180 def Width(self) -> int: 

181 """ 

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

183 

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

185 """ 

186 return self._width 

187 

188 @readonly 

189 def Height(self) -> int: 

190 """ 

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

192 

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

194 """ 

195 return self._height 

196 

197 @staticmethod 

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

199 """ 

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

201 

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

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

204 """ 

205 platform = Platform() 

206 if platform.IsNativeWindows: 

207 size = TerminalBaseApplication.__GetTerminalSizeOnWindows() 

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

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

210 size = TerminalBaseApplication.__GetTerminalSizeOnLinux() 

211 else: # pragma: no cover 

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

213 

214 if size is None: # pragma: no cover 

215 size = (80, 25) # default size 

216 

217 return size 

218 

219 @staticmethod 

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

221 """ 

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

223 

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

225 

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

227 """ 

228 try: 

229 from ctypes import windll, create_string_buffer 

230 from struct import unpack as struct_unpack 

231 

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

233 stringBuffer = create_string_buffer(22) 

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

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

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

237 width = right - left + 1 

238 height = bottom - top + 1 

239 return (width, height) 

240 except ImportError: 

241 pass 

242 

243 return None 

244 # return Terminal.__GetTerminalSizeWithTPut() 

245 

246 # @staticmethod 

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

248 # """ 

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

250 # 

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

252 # 

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

254 # """ 

255 # from subprocess import check_output 

256 # 

257 # try: 

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

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

260 # return (width, height) 

261 # except: 

262 # pass 

263 

264 @staticmethod 

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

266 """ 

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

268 

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

270 ``LINES`` are checked. 

271 

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

273 """ 

274 import os 

275 

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

277 """GetWindowSize of file descriptor.""" 

278 try: 

279 from fcntl import ioctl as fcntl_ioctl 

280 from struct import unpack as struct_unpack 

281 from termios import TIOCGWINSZ 

282 except ImportError: 

283 return None 

284 

285 try: 

286 struct = struct_unpack('hh', fcntl_ioctl(fd, TIOCGWINSZ, '1234')) 

287 except OSError: 

288 return None 

289 try: 

290 return (int(struct[1]), int(struct[0])) 

291 except TypeError: 

292 return None 

293 

294 # STDIN, STDOUT, STDERR 

295 for fd in range(3): 

296 size = ioctl_GWINSZ(fd) 

297 if size is not None: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 return size 

299 else: 

300 try: 

301 fd = os.open(os.ctermid(), os.O_RDONLY) 

302 size = ioctl_GWINSZ(fd) 

303 os.close(fd) 

304 return size 

305 except (OSError, AttributeError): 

306 pass 

307 

308 try: 

309 columns = int(os.getenv("COLUMNS")) 

310 lines = int(os.getenv("LINES")) 

311 return (columns, lines) 

312 except TypeError: 

313 pass 

314 

315 return None 

316 

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

318 """ 

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

320 

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

322 :return: Number of written characters. 

323 """ 

324 return self._stdout.write(message) 

325 

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

327 """ 

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

329 

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

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

332 :return: Number of written characters. 

333 """ 

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

335 

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

337 """ 

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

339 

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

341 :return: Number of written characters. 

342 """ 

343 return self._stderr.write(message) 

344 

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

346 """ 

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

348 

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

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

351 :returns: Number of written characters. 

352 """ 

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

354 

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

356 """ 

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

358 

359 :param returnCode: Return code for application exit. 

360 """ 

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

362 

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

364 """ 

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

366 

367 :param returnCode: Return code for application exit. 

368 """ 

369 self.UninitializeColors() 

370 exit(returnCode) 

371 

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

373 """ 

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

375 """ 

376 from sys import version_info as info 

377 

378 if info < version: 

379 self.InitializeColors() 

380 

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

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

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

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

385 

386 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE) 

387 

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

389 """ 

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

391 

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

393 

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

395 """ 

396 from traceback import format_tb, walk_tb 

397 

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

399 filename = frame.f_code.co_filename 

400 funcName = frame.f_code.co_name 

401 

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

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

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

405 

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

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

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

409 for note in iterator: 

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

411 

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

413 

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

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

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

417 

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

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

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

421 for note in iterator: 

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

423 

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

425 for line in format_tb(ex.__traceback__): 

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

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

428 

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

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

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

432 

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

434 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

435 

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

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

438 from traceback import walk_tb 

439 

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

441 filename = frame.f_code.co_filename 

442 funcName = frame.f_code.co_name 

443 

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

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

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

447 

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

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

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

451 for note in iterator: 

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

453 

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

455 

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

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

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

459 

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

461 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE) 

462 

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

464 """ 

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

466 

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

468 

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

470 """ 

471 from traceback import print_tb, walk_tb 

472 

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

474 filename = frame.f_code.co_filename 

475 funcName = frame.f_code.co_name 

476 

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

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

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

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

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

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

483 

484 if ex.__cause__ is not None: 

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

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

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

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

489 

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

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

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

493 

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

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

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

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

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

499 

500 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

501 

502 

503@export 

504@unique 

505class Severity(Enum): 

506 """Logging message severity levels.""" 

507 

508 Fatal = 100 #: Fatal messages 

509 Error = 80 #: Error messages 

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

511 Critical = 60 #: Critical messages 

512 Warning = 50 #: Warning messages 

513 Info = 20 #: Informative messages 

514 Normal = 10 #: Normal messages 

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

516 Verbose = 5 #: Verbose messages 

517 Debug = 2 #: Debug messages 

518 All = 0 #: All messages 

519 

520 def __hash__(self): 

521 return hash(self.name) 

522 

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

524 """ 

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

526 

527 :param other: Operand to compare against. 

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

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

530 """ 

531 if isinstance(other, Severity): 

532 return self.value == other.value 

533 else: 

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

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

536 raise ex 

537 

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

539 """ 

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

541 

542 :param other: Operand to compare against. 

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

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

545 """ 

546 if isinstance(other, Severity): 

547 return self.value != other.value 

548 else: 

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

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

551 raise ex 

552 

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

554 """ 

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

556 

557 :param other: Operand to compare against. 

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

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

560 """ 

561 if isinstance(other, Severity): 

562 return self.value < other.value 

563 else: 

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

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

566 raise ex 

567 

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

569 """ 

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

571 

572 :param other: Operand to compare against. 

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

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

575 """ 

576 if isinstance(other, Severity): 

577 return self.value <= other.value 

578 else: 

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

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

581 raise ex 

582 

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

584 """ 

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

586 

587 :param other: Operand to compare against. 

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

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

590 """ 

591 if isinstance(other, Severity): 

592 return self.value > other.value 

593 else: 

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

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

596 raise ex 

597 

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

599 """ 

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

601 

602 :param other: Operand to compare against. 

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

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

605 """ 

606 if isinstance(other, Severity): 

607 return self.value >= other.value 

608 else: 

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

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

611 raise ex 

612 

613 

614@export 

615@unique 

616class Mode(Enum): 

617 TextToStdOut_ErrorsToStdErr = 0 

618 AllLinearToStdOut = 1 

619 DataToStdOut_OtherToStdErr = 2 

620 

621 

622@export 

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

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

625 

626 _LOG_MESSAGE_FORMAT__ = { 

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

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

629 Severity.Quiet: "{message}", 

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

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

632 Severity.Normal: "{message}", 

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

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

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

636 } #: Message line formatting rules. 

637 

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

639 _severity: Severity #: Message severity 

640 _indent: int #: Indentation 

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

642 

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

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

645 self._severity = severity 

646 self._message = message 

647 self._indent = indent 

648 self._appendLinebreak = appendLinebreak 

649 

650 @readonly 

651 def Message(self) -> str: 

652 """ 

653 Return the indented line. 

654 

655 :returns: Raw message of the line. 

656 """ 

657 return self._message 

658 

659 @readonly 

660 def Severity(self) -> Severity: 

661 """ 

662 Return the line's severity level. 

663 

664 :returns: Severity level of the message line. 

665 """ 

666 return self._severity 

667 

668 @readonly 

669 def Indent(self) -> int: 

670 """ 

671 Return the line's indentation level. 

672 

673 :returns: Indentation level. 

674 """ 

675 return self._indent 

676 

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

678 """ 

679 Increase a line's indentation level. 

680 

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

682 """ 

683 # TODO: used named expression starting from Python 3.8 

684 indent += self._indent 

685 self._indent = indent 

686 return indent 

687 

688 @readonly 

689 def AppendLinebreak(self) -> bool: 

690 """ 

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

692 

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

694 """ 

695 return self._appendLinebreak 

696 

697 def __str__(self) -> str: 

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

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

700 

701 

702@export 

703@mixin 

704class ILineTerminal: 

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

706 

707 _terminal: TerminalBaseApplication 

708 

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

710 """MixIn initializer.""" 

711 self._terminal = terminal 

712 

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

714 

715 @readonly 

716 def Terminal(self) -> TerminalBaseApplication: 

717 """Return the local terminal instance.""" 

718 return self._terminal 

719 

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

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

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

723 return self._terminal.WriteLine(line) 

724 return False 

725 

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

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

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

729 # return False 

730 

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

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

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

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

735 return False 

736 

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

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

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

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

741 return False 

742 

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

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

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

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

747 return False 

748 

749 def WriteWarning(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.WriteWarning(*args, **kwargs) 

753 return False 

754 

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

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

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

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

759 return False 

760 

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

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

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

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

765 return False 

766 

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

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

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

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

771 return False 

772 

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

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

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

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

777 return False 

778 

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

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

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

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

783 return False 

784 

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

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

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

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

789 return False 

790 

791 

792@export 

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

794 """ 

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

796 """ 

797 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

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

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

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

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

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

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

808 } #: Message formatting rules. 

809 

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

811 _verbose: bool 

812 _debug: bool 

813 _quiet: bool 

814 _writeLevel: Severity 

815 _writeToStdOut: bool 

816 

817 _lines: List[Line] 

818 _baseIndent: int 

819 

820 _errorCount: int 

821 _criticalWarningCount: int 

822 _warningCount: int 

823 

824 HeadLine: ClassVar[str] 

825 

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

827 """ 

828 Initializer of a line-based terminal interface. 

829 

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

831 """ 

832 TerminalBaseApplication.__init__(self) 

833 # ILineTerminal.__init__(self, self) 

834 

835 self._LOG_LEVEL_ROUTING__ = {} 

836 self.__InitializeLogLevelRouting(mode) 

837 

838 self._verbose = False 

839 self._debug = False 

840 self._quiet = False 

841 self._writeLevel = Severity.Normal 

842 self._writeToStdOut = True 

843 

844 self._lines = [] 

845 self._baseIndent = 0 

846 

847 self._errorCount = 0 

848 self._criticalWarningCount = 0 

849 self._warningCount = 0 

850 

851 def __InitializeLogLevelRouting(self, mode: Mode): 

852 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

853 for severity in Severity: 

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

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

856 else: 

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

858 elif mode is Mode.AllLinearToStdOut: 

859 for severity in Severity: 

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

861 elif mode is Mode.DataToStdOut_OtherToStdErr: 

862 for severity in Severity: 

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

864 else: # pragma: no cover 

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

866 

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

868 """ 

869 Helper method to print the program headline. 

870 

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

872 

873 .. admonition:: Generated output 

874 

875 .. code-block:: 

876 

877 ========================= 

878 centered headline 

879 ========================= 

880 """ 

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

882 width = self._width 

883 

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

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

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

887 

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

889 """ 

890 Helper method to print the version information. 

891 

892 :param author: Author of the application. 

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

894 :param copyright: The copyright information. 

895 :param license: The license. 

896 :param version: The application's version. 

897 

898 .. admonition:: Example usage 

899 

900 .. code-block:: Python 

901 

902 def _PrintVersion(self): 

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

904 

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

906 """ 

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

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

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

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

911 

912 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True): 

913 self._verbose = True if debug else verbose 

914 self._debug = debug 

915 self._quiet = quiet 

916 

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

918 self._writeLevel = Severity.Quiet 

919 elif debug: 

920 self._writeLevel = Severity.Debug 

921 elif verbose: 

922 self._writeLevel = Severity.Verbose 

923 else: 

924 self._writeLevel = Severity.Normal 

925 

926 self._writeToStdOut = writeToStdOut 

927 

928 @readonly 

929 def Verbose(self) -> bool: 

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

931 return self._verbose 

932 

933 @readonly 

934 def Debug(self) -> bool: 

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

936 return self._debug 

937 

938 @readonly 

939 def Quiet(self) -> bool: 

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

941 return self._quiet 

942 

943 @property 

944 def LogLevel(self) -> Severity: 

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

946 return self._writeLevel 

947 

948 @LogLevel.setter 

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

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

951 self._writeLevel = value 

952 

953 @property 

954 def BaseIndent(self) -> int: 

955 return self._baseIndent 

956 

957 @BaseIndent.setter 

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

959 self._baseIndent = value 

960 

961 @readonly 

962 def WarningCount(self) -> int: 

963 return self._warningCount 

964 

965 @readonly 

966 def CriticalWarningCount(self) -> int: 

967 return self._criticalWarningCount 

968 

969 @readonly 

970 def ErrorCount(self) -> int: 

971 return self._errorCount 

972 

973 @readonly 

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

975 return self._lines 

976 

977 def ExitOnPreviousErrors(self) -> None: 

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

979 if self._errorCount > 0: 

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

981 

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

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

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

985 if self._criticalWarningCount > 0: 

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

987 else: 

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

989 elif self._criticalWarningCount > 0: 

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

991 

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

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

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

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

996 if self._warningCount > 0: 

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

998 else: 

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

1000 elif self._warningCount > 0: 

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

1002 else: 

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

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

1005 if self._warningCount > 0: 

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

1007 else: 

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

1009 elif self._warningCount > 0: 

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

1011 

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

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

1014 if line.Severity >= self._writeLevel: 

1015 self._lines.append(line) 

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

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

1018 return True 

1019 else: 

1020 return False 

1021 

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

1023 return line.Severity >= self._writeLevel 

1024 

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

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

1027 if immediateExit: 

1028 self.FatalExit() 

1029 return ret 

1030 

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

1032 self._errorCount += 1 

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

1034 

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

1036 self._criticalWarningCount += 1 

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

1038 

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

1040 self._warningCount += 1 

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

1042 

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

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

1045 

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

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

1048 

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

1050 """ 

1051 Write a normal message. 

1052 

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

1054 

1055 :param message: Message to write. 

1056 :param indent: Indentation level of the message. 

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

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

1059 """ 

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

1061 

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

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

1064 

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

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

1067 

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

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