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

449 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 22:22 +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 """Returns the current terminal window's width.""" 

182 return self._width 

183 

184 @readonly 

185 def Height(self) -> int: 

186 """Returns the current terminal window's height.""" 

187 return self._height 

188 

189 @staticmethod 

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

191 """ 

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

193 

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

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

196 """ 

197 platform = Platform() 

198 if platform.IsNativeWindows: 

199 size = TerminalBaseApplication.__GetTerminalSizeOnWindows() 

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

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

202 size = TerminalBaseApplication.__GetTerminalSizeOnLinux() 

203 else: # pragma: no cover 

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

205 

206 if size is None: # pragma: no cover 

207 size = (80, 25) # default size 

208 

209 return size 

210 

211 @staticmethod 

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

213 """ 

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

215 

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

217 

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

219 """ 

220 try: 

221 from ctypes import windll, create_string_buffer 

222 from struct import unpack as struct_unpack 

223 

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

225 stringBuffer = create_string_buffer(22) 

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

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

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

229 width = right - left + 1 

230 height = bottom - top + 1 

231 return (width, height) 

232 except ImportError: 

233 pass 

234 

235 return None 

236 # return Terminal.__GetTerminalSizeWithTPut() 

237 

238 # @staticmethod 

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

240 # """ 

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

242 # 

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

244 # 

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

246 # """ 

247 # from subprocess import check_output 

248 # 

249 # try: 

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

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

252 # return (width, height) 

253 # except: 

254 # pass 

255 

256 @staticmethod 

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

258 """ 

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

260 

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

262 ``LINES`` are checked. 

263 

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

265 """ 

266 import os 

267 

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

269 """GetWindowSize of file descriptor.""" 

270 try: 

271 from fcntl import ioctl as fcntl_ioctl 

272 from struct import unpack as struct_unpack 

273 from termios import TIOCGWINSZ 

274 except ImportError: 

275 return None 

276 

277 try: 

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

279 except OSError: 

280 return None 

281 try: 

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

283 except TypeError: 

284 return None 

285 

286 # STDIN, STDOUT, STDERR 

287 for fd in range(3): 

288 size = ioctl_GWINSZ(fd) 

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

290 return size 

291 else: 

292 try: 

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

294 size = ioctl_GWINSZ(fd) 

295 os.close(fd) 

296 return size 

297 except (OSError, AttributeError): 

298 pass 

299 

300 try: 

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

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

303 return (columns, lines) 

304 except TypeError: 

305 pass 

306 

307 return None 

308 

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

310 """ 

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

312 

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

314 :return: Number of written characters. 

315 """ 

316 return self._stdout.write(message) 

317 

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

319 """ 

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

321 

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

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

324 :return: Number of written characters. 

325 """ 

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

327 

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

329 """ 

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

331 

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

333 :return: Number of written characters. 

334 """ 

335 return self._stderr.write(message) 

336 

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

338 """ 

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

340 

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

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

343 :returns: Number of written characters. 

344 """ 

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

346 

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

348 """ 

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

350 """ 

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

352 

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

354 """ 

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

356 """ 

357 self.UninitializeColors() 

358 exit(returnCode) 

359 

360 def CheckPythonVersion(self, version) -> None: 

361 """ 

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

363 """ 

364 from sys import version_info as info 

365 

366 if info < version: 

367 self.InitializeColors() 

368 

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

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

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

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

373 

374 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE) 

375 

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

377 """ 

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

379 

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

381 

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

383 """ 

384 from traceback import print_tb, walk_tb 

385 

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

387 filename = frame.f_code.co_filename 

388 funcName = frame.f_code.co_name 

389 

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

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

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

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

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

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

396 

397 if ex.__cause__ is not None: 

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

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

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

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

402 

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

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

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

406 

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

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

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

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

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

412 

413 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

414 

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

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

417 from traceback import walk_tb 

418 

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

420 filename = frame.f_code.co_filename 

421 funcName = frame.f_code.co_name 

422 

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

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

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

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

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

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

429 

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

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

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

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

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

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

436 

437 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE) 

438 

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

440 """ 

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

442 

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

444 

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

446 """ 

447 from traceback import print_tb, walk_tb 

448 

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

450 filename = frame.f_code.co_filename 

451 funcName = frame.f_code.co_name 

452 

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

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

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

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

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

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

459 

460 if ex.__cause__ is not None: 

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

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

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

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

465 

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

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

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

469 

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

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

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

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

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

475 

476 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE) 

477 

478 

479@export 

480@unique 

481class Severity(Enum): 

482 """Logging message severity levels.""" 

483 

484 Fatal = 100 #: Fatal messages 

485 Error = 80 #: Error messages 

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

487 Critical = 60 #: Critical messages 

488 Warning = 50 #: Warning messages 

489 Info = 20 #: Informative messages 

490 Normal = 10 #: Normal messages 

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

492 Verbose = 5 #: Verbose messages 

493 Debug = 2 #: Debug messages 

494 All = 0 #: All messages 

495 

496 def __hash__(self): 

497 return hash(self.name) 

498 

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

500 """ 

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

502 

503 :param other: Parameter to compare against. 

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

505 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`. 

506 """ 

507 if isinstance(other, Severity): 

508 return self.value == other.value 

509 else: 

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

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

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

513 raise ex 

514 

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

516 """ 

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

518 

519 :param other: Parameter to compare against. 

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

521 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`. 

522 """ 

523 if isinstance(other, Severity): 

524 return self.value != other.value 

525 else: 

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

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

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

529 raise ex 

530 

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

532 """ 

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

534 

535 :param other: Parameter to compare against. 

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

537 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`. 

538 """ 

539 if isinstance(other, Severity): 

540 return self.value < other.value 

541 else: 

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

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

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

545 raise ex 

546 

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

548 """ 

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

550 

551 :param other: Parameter to compare against. 

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

553 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`. 

554 """ 

555 if isinstance(other, Severity): 

556 return self.value <= other.value 

557 else: 

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

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

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

561 raise ex 

562 

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

564 """ 

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

566 

567 :param other: Parameter to compare against. 

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

569 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`. 

570 """ 

571 if isinstance(other, Severity): 

572 return self.value > other.value 

573 else: 

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

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

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

577 raise ex 

578 

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

580 """ 

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

582 

583 :param other: Parameter to compare against. 

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

585 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`. 

586 """ 

587 if isinstance(other, Severity): 

588 return self.value >= other.value 

589 else: 

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

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

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

593 raise ex 

594 

595 

596@export 

597@unique 

598class Mode(Enum): 

599 TextToStdOut_ErrorsToStdErr = 0 

600 AllLinearToStdOut = 1 

601 DataToStdOut_OtherToStdErr = 2 

602 

603 

604@export 

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

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

607 

608 _LOG_MESSAGE_FORMAT__ = { 

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

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

611 Severity.Quiet: "{message}", 

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

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

614 Severity.Normal: "{message}", 

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

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

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

618 } #: Message line formatting rules. 

619 

620 _message: str 

621 _severity: Severity 

622 _indent: int 

623 _appendLinebreak: bool 

624 

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

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

627 self._severity = severity 

628 self._message = message 

629 self._indent = indent 

630 self._appendLinebreak = appendLinebreak 

631 

632 @readonly 

633 def Message(self) -> str: 

634 """ 

635 Return the indented line. 

636 

637 :returns: Raw message of the line. 

638 """ 

639 return self._message 

640 

641 @readonly 

642 def Severity(self) -> Severity: 

643 """ 

644 Return the line's severity level. 

645 

646 :returns: Severity level of the message line. 

647 """ 

648 return self._severity 

649 

650 @readonly 

651 def Indent(self) -> int: 

652 """ 

653 Return the line's indentation level. 

654 

655 :returns: Indentation level. 

656 """ 

657 return self._indent 

658 

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

660 """ 

661 Increase a line's indentation level. 

662 

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

664 """ 

665 # TODO: used named expression starting from Python 3.8 

666 indent += self._indent 

667 self._indent = indent 

668 return indent 

669 

670 @readonly 

671 def AppendLinebreak(self) -> bool: 

672 """ 

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

674 

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

676 """ 

677 return self._appendLinebreak 

678 

679 def __str__(self) -> str: 

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

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

682 

683 

684@export 

685@mixin 

686class ILineTerminal: 

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

688 

689 _terminal: TerminalBaseApplication 

690 

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

692 """MixIn initializer.""" 

693 self._terminal = terminal 

694 

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

696 

697 @readonly 

698 def Terminal(self) -> TerminalBaseApplication: 

699 """Return the local terminal instance.""" 

700 return self._terminal 

701 

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

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

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

705 return self._terminal.WriteLine(line) 

706 return False 

707 

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

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

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

711 # return False 

712 

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

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

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

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

717 return False 

718 

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

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

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

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

723 return False 

724 

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

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

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

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

729 return False 

730 

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

735 return False 

736 

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

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

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

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

741 return False 

742 

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

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

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

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

747 return False 

748 

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

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

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

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

753 return False 

754 

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

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

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

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

759 return False 

760 

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

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

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

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

765 return False 

766 

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

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

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

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

771 return False 

772 

773 

774@export 

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

776 _LOG_MESSAGE_FORMAT__ = { 

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

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

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

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

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

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

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

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

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

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

787 } #: Message formatting rules. 

788 

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

790 _verbose: bool 

791 _debug: bool 

792 _quiet: bool 

793 _writeLevel: Severity 

794 _writeToStdOut: bool 

795 

796 _lines: List[Line] 

797 _baseIndent: int 

798 

799 _errorCount: int 

800 _criticalWarningCount: int 

801 _warningCount: int 

802 

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

804 """Initializer of a line based terminal interface.""" 

805 TerminalBaseApplication.__init__(self) 

806 # ILineTerminal.__init__(self, self) 

807 

808 self._LOG_LEVEL_ROUTING__ = {} 

809 self.__InitializeLogLevelRouting(mode) 

810 

811 self._verbose = False 

812 self._debug = False 

813 self._quiet = False 

814 self._writeLevel = Severity.Normal 

815 self._writeToStdOut = True 

816 

817 self._lines = [] 

818 self._baseIndent = 0 

819 

820 self._errorCount = 0 

821 self._criticalWarningCount = 0 

822 self._warningCount = 0 

823 

824 def __InitializeLogLevelRouting(self, mode: Mode): 

825 if mode is Mode.TextToStdOut_ErrorsToStdErr: 

826 for severity in Severity: 

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

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

829 else: 

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

831 elif mode is Mode.AllLinearToStdOut: 

832 for severity in Severity: 

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

834 elif mode is Mode.DataToStdOut_OtherToStdErr: 

835 for severity in Severity: 

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

837 else: # pragma: no cover 

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

839 

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

841 self._verbose = True if debug else verbose 

842 self._debug = debug 

843 self._quiet = quiet 

844 

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

846 self._writeLevel = Severity.Quiet 

847 elif debug: 

848 self._writeLevel = Severity.Debug 

849 elif verbose: 

850 self._writeLevel = Severity.Verbose 

851 else: 

852 self._writeLevel = Severity.Normal 

853 

854 self._writeToStdOut = writeToStdOut 

855 

856 @readonly 

857 def Verbose(self) -> bool: 

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

859 return self._verbose 

860 

861 @readonly 

862 def Debug(self) -> bool: 

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

864 return self._debug 

865 

866 @readonly 

867 def Quiet(self) -> bool: 

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

869 return self._quiet 

870 

871 @property 

872 def LogLevel(self) -> Severity: 

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

874 return self._writeLevel 

875 

876 @LogLevel.setter 

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

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

879 self._writeLevel = value 

880 

881 @property 

882 def BaseIndent(self) -> int: 

883 return self._baseIndent 

884 

885 @BaseIndent.setter 

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

887 self._baseIndent = value 

888 

889 @readonly 

890 def WarningCount(self) -> int: 

891 return self._warningCount 

892 

893 @readonly 

894 def CriticalWarningCount(self) -> int: 

895 return self._criticalWarningCount 

896 

897 @readonly 

898 def ErrorCount(self) -> int: 

899 return self._errorCount 

900 

901 @readonly 

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

903 return self._lines 

904 

905 def ExitOnPreviousErrors(self) -> None: 

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

907 if self._errorCount > 0: 

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

909 

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

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

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

913 if self._criticalWarningCount > 0: 

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

915 else: 

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

917 elif self._criticalWarningCount > 0: 

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

919 

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

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

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

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

924 if self._warningCount > 0: 

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

926 else: 

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

928 elif self._warningCount > 0: 

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

930 else: 

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

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

933 if self._warningCount > 0: 

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

935 else: 

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

937 elif self._warningCount > 0: 

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

939 

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

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

942 if line.Severity >= self._writeLevel: 

943 self._lines.append(line) 

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

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

946 return True 

947 else: 

948 return False 

949 

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

951 return line.Severity >= self._writeLevel 

952 

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

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

955 if immediateExit: 

956 self.FatalExit() 

957 return ret 

958 

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

960 self._errorCount += 1 

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

962 

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

964 self._criticalWarningCount += 1 

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

966 

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

968 self._warningCount += 1 

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

970 

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

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

973 

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

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

976 

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

978 """ 

979 Write a normal message. 

980 

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

982 

983 :param message: Message to write. 

984 :param indent: Indentation level of the message. 

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

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

987 """ 

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

989 

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

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

992 

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

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

995 

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

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