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

461 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-12 20:40 +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, version_info # needed for versions before Python 3.11 

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 print_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 self.WriteLineToStdErr(dedent(f"""\ 

403 {{RED}}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{{NOCOLOR}} 

404 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{RED}}{ex.__class__.__name__}{{NOCOLOR}} 

405 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {ex!s} 

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

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

408 

409 if ex.__cause__ is not None: 

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

411 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}} 

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

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

414 

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

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

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

418 

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

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

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

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

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

424 

425 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

426 

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

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

429 from traceback import walk_tb 

430 

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

432 filename = frame.f_code.co_filename 

433 funcName = frame.f_code.co_name 

434 

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

436 {{RED}}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{{NOCOLOR}} 

437 {{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{RED}}{funcName}(...){{NOCOLOR}} 

438 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {ex!s} 

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

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

441 

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

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

444 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}} 

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

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

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

448 

449 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE) 

450 

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

452 """ 

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

454 

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

456 

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

458 """ 

459 from traceback import print_tb, walk_tb 

460 

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

462 filename = frame.f_code.co_filename 

463 funcName = frame.f_code.co_name 

464 

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

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

467 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{RED}}{ex.__class__.__name__}{{NOCOLOR}} 

468 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {ex!s} 

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

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

471 

472 if ex.__cause__ is not None: 

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

474 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}} 

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

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

477 

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

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

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

481 

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

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

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

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

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

487 

488 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

489 

490 

491@export 

492@unique 

493class Severity(Enum): 

494 """Logging message severity levels.""" 

495 

496 Fatal = 100 #: Fatal messages 

497 Error = 80 #: Error messages 

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

499 Critical = 60 #: Critical messages 

500 Warning = 50 #: Warning messages 

501 Info = 20 #: Informative messages 

502 Normal = 10 #: Normal messages 

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

504 Verbose = 5 #: Verbose messages 

505 Debug = 2 #: Debug messages 

506 All = 0 #: All messages 

507 

508 def __hash__(self): 

509 return hash(self.name) 

510 

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

512 """ 

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

514 

515 :param other: Operand to compare against. 

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

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

518 """ 

519 if isinstance(other, Severity): 

520 return self.value == other.value 

521 else: 

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

523 if version_info >= (3, 11): # pragma: no cover 

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

525 raise ex 

526 

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

528 """ 

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

530 

531 :param other: Operand to compare against. 

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

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

534 """ 

535 if isinstance(other, Severity): 

536 return self.value != other.value 

537 else: 

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

539 if version_info >= (3, 11): # pragma: no cover 

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

541 raise ex 

542 

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

544 """ 

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

546 

547 :param other: Operand to compare against. 

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

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

550 """ 

551 if isinstance(other, Severity): 

552 return self.value < other.value 

553 else: 

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

555 if version_info >= (3, 11): # pragma: no cover 

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

557 raise ex 

558 

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

560 """ 

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

562 

563 :param other: Operand to compare against. 

564 :returns: ``True``, if severity levels is less than or equal 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 if version_info >= (3, 11): # pragma: no cover 

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

573 raise ex 

574 

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

576 """ 

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

578 

579 :param other: Operand to compare against. 

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

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

582 """ 

583 if isinstance(other, Severity): 

584 return self.value > other.value 

585 else: 

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

587 if version_info >= (3, 11): # pragma: no cover 

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

589 raise ex 

590 

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

592 """ 

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

594 

595 :param other: Operand to compare against. 

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

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

598 """ 

599 if isinstance(other, Severity): 

600 return self.value >= other.value 

601 else: 

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

603 if version_info >= (3, 11): # pragma: no cover 

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

605 raise ex 

606 

607 

608@export 

609@unique 

610class Mode(Enum): 

611 TextToStdOut_ErrorsToStdErr = 0 

612 AllLinearToStdOut = 1 

613 DataToStdOut_OtherToStdErr = 2 

614 

615 

616@export 

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

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

619 

620 _LOG_MESSAGE_FORMAT__ = { 

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

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

623 Severity.Quiet: "{message}", 

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

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

626 Severity.Normal: "{message}", 

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

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

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

630 } #: Message line formatting rules. 

631 

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

633 _severity: Severity #: Message severity 

634 _indent: int #: Indentation 

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

636 

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

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

639 self._severity = severity 

640 self._message = message 

641 self._indent = indent 

642 self._appendLinebreak = appendLinebreak 

643 

644 @readonly 

645 def Message(self) -> str: 

646 """ 

647 Return the indented line. 

648 

649 :returns: Raw message of the line. 

650 """ 

651 return self._message 

652 

653 @readonly 

654 def Severity(self) -> Severity: 

655 """ 

656 Return the line's severity level. 

657 

658 :returns: Severity level of the message line. 

659 """ 

660 return self._severity 

661 

662 @readonly 

663 def Indent(self) -> int: 

664 """ 

665 Return the line's indentation level. 

666 

667 :returns: Indentation level. 

668 """ 

669 return self._indent 

670 

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

672 """ 

673 Increase a line's indentation level. 

674 

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

676 """ 

677 # TODO: used named expression starting from Python 3.8 

678 indent += self._indent 

679 self._indent = indent 

680 return indent 

681 

682 @readonly 

683 def AppendLinebreak(self) -> bool: 

684 """ 

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

686 

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

688 """ 

689 return self._appendLinebreak 

690 

691 def __str__(self) -> str: 

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

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

694 

695 

696@export 

697@mixin 

698class ILineTerminal: 

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

700 

701 _terminal: TerminalBaseApplication 

702 

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

704 """MixIn initializer.""" 

705 self._terminal = terminal 

706 

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

708 

709 @readonly 

710 def Terminal(self) -> TerminalBaseApplication: 

711 """Return the local terminal instance.""" 

712 return self._terminal 

713 

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

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

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

717 return self._terminal.WriteLine(line) 

718 return False 

719 

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

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

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

723 # return False 

724 

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

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

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

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

729 return False 

730 

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

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

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

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

735 return False 

736 

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

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

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

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

741 return False 

742 

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

747 return False 

748 

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

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

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

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

753 return False 

754 

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

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

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

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

759 return False 

760 

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

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

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

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

765 return False 

766 

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

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

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

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

771 return False 

772 

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

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

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

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

777 return False 

778 

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

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

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

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

783 return False 

784 

785 

786@export 

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

788 """ 

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

790 """ 

791 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

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

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

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

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

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

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

802 } #: Message formatting rules. 

803 

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

805 _verbose: bool 

806 _debug: bool 

807 _quiet: bool 

808 _writeLevel: Severity 

809 _writeToStdOut: bool 

810 

811 _lines: List[Line] 

812 _baseIndent: int 

813 

814 _errorCount: int 

815 _criticalWarningCount: int 

816 _warningCount: int 

817 

818 HeadLine: ClassVar[str] 

819 

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

821 """ 

822 Initializer of a line-based terminal interface. 

823 

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

825 """ 

826 TerminalBaseApplication.__init__(self) 

827 # ILineTerminal.__init__(self, self) 

828 

829 self._LOG_LEVEL_ROUTING__ = {} 

830 self.__InitializeLogLevelRouting(mode) 

831 

832 self._verbose = False 

833 self._debug = False 

834 self._quiet = False 

835 self._writeLevel = Severity.Normal 

836 self._writeToStdOut = True 

837 

838 self._lines = [] 

839 self._baseIndent = 0 

840 

841 self._errorCount = 0 

842 self._criticalWarningCount = 0 

843 self._warningCount = 0 

844 

845 def __InitializeLogLevelRouting(self, mode: Mode): 

846 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

847 for severity in Severity: 

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

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

850 else: 

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

852 elif mode is Mode.AllLinearToStdOut: 

853 for severity in Severity: 

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

855 elif mode is Mode.DataToStdOut_OtherToStdErr: 

856 for severity in Severity: 

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

858 else: # pragma: no cover 

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

860 

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

862 """ 

863 Helper method to print the program headline. 

864 

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

866 

867 .. admonition:: Generated output 

868 

869 .. code-block:: 

870 

871 ========================= 

872 centered headline 

873 ========================= 

874 """ 

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

876 width = self._width 

877 

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

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

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

881 

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

883 """ 

884 Helper method to print the version information. 

885 

886 :param author: Author of the application. 

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

888 :param copyright: The copyright information. 

889 :param license: The license. 

890 :param version: The application's version. 

891 

892 .. admonition:: Example usage 

893 

894 .. code-block:: Python 

895 

896 def _PrintVersion(self): 

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

898 

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

900 """ 

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

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

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

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

905 

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

907 self._verbose = True if debug else verbose 

908 self._debug = debug 

909 self._quiet = quiet 

910 

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

912 self._writeLevel = Severity.Quiet 

913 elif debug: 

914 self._writeLevel = Severity.Debug 

915 elif verbose: 

916 self._writeLevel = Severity.Verbose 

917 else: 

918 self._writeLevel = Severity.Normal 

919 

920 self._writeToStdOut = writeToStdOut 

921 

922 @readonly 

923 def Verbose(self) -> bool: 

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

925 return self._verbose 

926 

927 @readonly 

928 def Debug(self) -> bool: 

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

930 return self._debug 

931 

932 @readonly 

933 def Quiet(self) -> bool: 

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

935 return self._quiet 

936 

937 @property 

938 def LogLevel(self) -> Severity: 

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

940 return self._writeLevel 

941 

942 @LogLevel.setter 

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

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

945 self._writeLevel = value 

946 

947 @property 

948 def BaseIndent(self) -> int: 

949 return self._baseIndent 

950 

951 @BaseIndent.setter 

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

953 self._baseIndent = value 

954 

955 @readonly 

956 def WarningCount(self) -> int: 

957 return self._warningCount 

958 

959 @readonly 

960 def CriticalWarningCount(self) -> int: 

961 return self._criticalWarningCount 

962 

963 @readonly 

964 def ErrorCount(self) -> int: 

965 return self._errorCount 

966 

967 @readonly 

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

969 return self._lines 

970 

971 def ExitOnPreviousErrors(self) -> None: 

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

973 if self._errorCount > 0: 

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

975 

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

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

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

979 if self._criticalWarningCount > 0: 

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

981 else: 

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

983 elif self._criticalWarningCount > 0: 

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

985 

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

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

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

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

990 if self._warningCount > 0: 

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

992 else: 

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

994 elif self._warningCount > 0: 

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

996 else: 

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

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

999 if self._warningCount > 0: 

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

1001 else: 

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

1003 elif self._warningCount > 0: 

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

1005 

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

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

1008 if line.Severity >= self._writeLevel: 

1009 self._lines.append(line) 

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

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

1012 return True 

1013 else: 

1014 return False 

1015 

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

1017 return line.Severity >= self._writeLevel 

1018 

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

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

1021 if immediateExit: 

1022 self.FatalExit() 

1023 return ret 

1024 

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

1026 self._errorCount += 1 

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

1028 

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

1030 self._criticalWarningCount += 1 

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

1032 

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

1034 self._warningCount += 1 

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

1036 

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

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

1039 

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

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

1042 

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

1044 """ 

1045 Write a normal message. 

1046 

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

1048 

1049 :param message: Message to write. 

1050 :param indent: Indentation level of the message. 

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

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

1053 """ 

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

1055 

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

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

1058 

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

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

1061 

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

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