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

467 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-31 22:23 +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 ex.add_note(f"Supported types for second operand: Severity") 

524 raise ex 

525 

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

527 """ 

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

529 

530 :param other: Operand to compare against. 

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

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

533 """ 

534 if isinstance(other, Severity): 

535 return self.value != other.value 

536 else: 

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

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

539 raise ex 

540 

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

542 """ 

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

544 

545 :param other: Operand to compare against. 

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

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

548 """ 

549 if isinstance(other, Severity): 

550 return self.value < other.value 

551 else: 

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

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

554 raise ex 

555 

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

557 """ 

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

559 

560 :param other: Operand to compare against. 

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

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

563 """ 

564 if isinstance(other, Severity): 

565 return self.value <= other.value 

566 else: 

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

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

569 raise ex 

570 

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

572 """ 

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

574 

575 :param other: Operand to compare against. 

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

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

578 """ 

579 if isinstance(other, Severity): 

580 return self.value > other.value 

581 else: 

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

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

584 raise ex 

585 

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

587 """ 

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

589 

590 :param other: Operand to compare against. 

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

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

593 """ 

594 if isinstance(other, Severity): 

595 return self.value >= other.value 

596 else: 

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

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

599 raise ex 

600 

601 

602@export 

603@unique 

604class Mode(Enum): 

605 TextToStdOut_ErrorsToStdErr = 0 

606 AllLinearToStdOut = 1 

607 DataToStdOut_OtherToStdErr = 2 

608 

609 

610@export 

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

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

613 

614 _LOG_MESSAGE_FORMAT__ = { 

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

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

617 Severity.Quiet: "{message}", 

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

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

620 Severity.Normal: "{message}", 

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

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

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

624 } #: Message line formatting rules. 

625 

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

627 _severity: Severity #: Message severity 

628 _indent: int #: Indentation 

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

630 

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

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

633 self._severity = severity 

634 self._message = message 

635 self._indent = indent 

636 self._appendLinebreak = appendLinebreak 

637 

638 @readonly 

639 def Message(self) -> str: 

640 """ 

641 Return the indented line. 

642 

643 :returns: Raw message of the line. 

644 """ 

645 return self._message 

646 

647 @readonly 

648 def Severity(self) -> Severity: 

649 """ 

650 Return the line's severity level. 

651 

652 :returns: Severity level of the message line. 

653 """ 

654 return self._severity 

655 

656 @readonly 

657 def Indent(self) -> int: 

658 """ 

659 Return the line's indentation level. 

660 

661 :returns: Indentation level. 

662 """ 

663 return self._indent 

664 

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

666 """ 

667 Increase a line's indentation level. 

668 

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

670 """ 

671 # TODO: used named expression starting from Python 3.8 

672 indent += self._indent 

673 self._indent = indent 

674 return indent 

675 

676 @readonly 

677 def AppendLinebreak(self) -> bool: 

678 """ 

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

680 

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

682 """ 

683 return self._appendLinebreak 

684 

685 def __str__(self) -> str: 

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

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

688 

689 

690@export 

691@mixin 

692class ILineTerminal: 

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

694 

695 _terminal: TerminalBaseApplication 

696 

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

698 """MixIn initializer.""" 

699 self._terminal = terminal 

700 

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

702 

703 @readonly 

704 def Terminal(self) -> TerminalBaseApplication: 

705 """Return the local terminal instance.""" 

706 return self._terminal 

707 

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

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

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

711 return self._terminal.WriteLine(line) 

712 return False 

713 

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

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

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

717 # return False 

718 

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

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

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

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

723 return False 

724 

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

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

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

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

729 return False 

730 

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

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

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

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

735 return False 

736 

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

741 return False 

742 

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

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

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

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

747 return False 

748 

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

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

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

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

753 return False 

754 

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

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

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

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

759 return False 

760 

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

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

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

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

765 return False 

766 

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

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

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

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

771 return False 

772 

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

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

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

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

777 return False 

778 

779 

780@export 

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

782 """ 

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

784 """ 

785 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

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

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

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

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

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

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

796 } #: Message formatting rules. 

797 

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

799 _verbose: bool 

800 _debug: bool 

801 _quiet: bool 

802 _writeLevel: Severity 

803 _writeToStdOut: bool 

804 

805 _lines: List[Line] 

806 _baseIndent: int 

807 

808 _errorCount: int 

809 _criticalWarningCount: int 

810 _warningCount: int 

811 

812 HeadLine: ClassVar[str] 

813 

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

815 """ 

816 Initializer of a line-based terminal interface. 

817 

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

819 """ 

820 TerminalBaseApplication.__init__(self) 

821 # ILineTerminal.__init__(self, self) 

822 

823 self._LOG_LEVEL_ROUTING__ = {} 

824 self.__InitializeLogLevelRouting(mode) 

825 

826 self._verbose = False 

827 self._debug = False 

828 self._quiet = False 

829 self._writeLevel = Severity.Normal 

830 self._writeToStdOut = True 

831 

832 self._lines = [] 

833 self._baseIndent = 0 

834 

835 self._errorCount = 0 

836 self._criticalWarningCount = 0 

837 self._warningCount = 0 

838 

839 def __InitializeLogLevelRouting(self, mode: Mode): 

840 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

841 for severity in Severity: 

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

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

844 else: 

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

846 elif mode is Mode.AllLinearToStdOut: 

847 for severity in Severity: 

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

849 elif mode is Mode.DataToStdOut_OtherToStdErr: 

850 for severity in Severity: 

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

852 else: # pragma: no cover 

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

854 

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

856 """ 

857 Helper method to print the program headline. 

858 

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

860 

861 .. admonition:: Generated output 

862 

863 .. code-block:: 

864 

865 ========================= 

866 centered headline 

867 ========================= 

868 """ 

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

870 width = self._width 

871 

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

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

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

875 

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

877 """ 

878 Helper method to print the version information. 

879 

880 :param author: Author of the application. 

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

882 :param copyright: The copyright information. 

883 :param license: The license. 

884 :param version: The application's version. 

885 

886 .. admonition:: Example usage 

887 

888 .. code-block:: Python 

889 

890 def _PrintVersion(self): 

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

892 

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

894 """ 

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

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

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

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

899 

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

901 self._verbose = True if debug else verbose 

902 self._debug = debug 

903 self._quiet = quiet 

904 

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

906 self._writeLevel = Severity.Quiet 

907 elif debug: 

908 self._writeLevel = Severity.Debug 

909 elif verbose: 

910 self._writeLevel = Severity.Verbose 

911 else: 

912 self._writeLevel = Severity.Normal 

913 

914 self._writeToStdOut = writeToStdOut 

915 

916 @readonly 

917 def Verbose(self) -> bool: 

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

919 return self._verbose 

920 

921 @readonly 

922 def Debug(self) -> bool: 

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

924 return self._debug 

925 

926 @readonly 

927 def Quiet(self) -> bool: 

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

929 return self._quiet 

930 

931 @property 

932 def LogLevel(self) -> Severity: 

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

934 return self._writeLevel 

935 

936 @LogLevel.setter 

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

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

939 self._writeLevel = value 

940 

941 @property 

942 def BaseIndent(self) -> int: 

943 return self._baseIndent 

944 

945 @BaseIndent.setter 

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

947 self._baseIndent = value 

948 

949 @readonly 

950 def WarningCount(self) -> int: 

951 return self._warningCount 

952 

953 @readonly 

954 def CriticalWarningCount(self) -> int: 

955 return self._criticalWarningCount 

956 

957 @readonly 

958 def ErrorCount(self) -> int: 

959 return self._errorCount 

960 

961 @readonly 

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

963 return self._lines 

964 

965 def ExitOnPreviousErrors(self) -> None: 

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

967 if self._errorCount > 0: 

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

969 

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

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

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

973 if self._criticalWarningCount > 0: 

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

975 else: 

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

977 elif self._criticalWarningCount > 0: 

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

979 

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

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

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

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

984 if self._warningCount > 0: 

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

986 else: 

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

988 elif self._warningCount > 0: 

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

990 else: 

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

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

993 if self._warningCount > 0: 

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

995 else: 

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

997 elif self._warningCount > 0: 

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

999 

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

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

1002 if line.Severity >= self._writeLevel: 

1003 self._lines.append(line) 

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

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

1006 return True 

1007 else: 

1008 return False 

1009 

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

1011 return line.Severity >= self._writeLevel 

1012 

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

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

1015 if immediateExit: 

1016 self.FatalExit() 

1017 return ret 

1018 

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

1020 self._errorCount += 1 

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

1022 

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

1024 self._criticalWarningCount += 1 

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

1026 

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

1028 self._warningCount += 1 

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

1030 

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

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

1033 

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

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

1036 

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

1038 """ 

1039 Write a normal message. 

1040 

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

1042 

1043 :param message: Message to write. 

1044 :param indent: Indentation level of the message. 

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

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

1047 """ 

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

1049 

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

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

1052 

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

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

1055 

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

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