Coverage for pyTooling / TerminalUI / __init__.py: 79%
497 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-27 22:27 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-27 22:27 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ _ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany #
15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""A set of helpers to implement a text user interface (TUI) in a terminal."""
33from enum import Enum, unique
34from io import TextIOWrapper
35from sys import stdin, stdout, stderr
36from textwrap import dedent
37from typing import NoReturn, Tuple, Any, List, Optional as Nullable, Dict, Callable, ClassVar
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
44from pyTooling.Decorators import export, readonly
45from pyTooling.MetaClasses import ExtendedType, mixin
46from pyTooling.Exceptions import PlatformNotSupportedException, ExceptionBase
47from pyTooling.Common import lastItem
48from pyTooling.Platform import Platform
51@export
52class TerminalBaseApplication(metaclass=ExtendedType, slots=True, singleton=True):
53 """
54 The class offers a basic terminal application base-class.
56 It offers basic colored output via `colorama <https://GitHub.com/tartley/colorama>`__ as well as retrieving the
57 terminal's width.
58 """
60 NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE: ClassVar[int] = 240 #: Return code, if unimplemented methods or code sections were called.
61 UNHANDLED_EXCEPTION_EXIT_CODE: ClassVar[int] = 241 #: Return code, if an unhandled exception reached the topmost exception handler.
62 PYTHON_VERSION_CHECK_FAILED_EXIT_CODE: ClassVar[int] = 254 #: Return code, if version check was not successful.
63 FATAL_EXIT_CODE: ClassVar[int] = 255 #: Return code for fatal exits.
64 ISSUE_TRACKER_URL: ClassVar[str] = None #: URL to the issue tracker for reporting bugs.
65 INDENT: ClassVar[str] = " " #: Indentation. Default: ``" "`` (2 spaces)
67 try:
68 from colorama import Fore as Foreground
69 Foreground = {
70 "RED": Foreground.LIGHTRED_EX,
71 "DARK_RED": Foreground.RED,
72 "GREEN": Foreground.LIGHTGREEN_EX,
73 "DARK_GREEN": Foreground.GREEN,
74 "YELLOW": Foreground.LIGHTYELLOW_EX,
75 "DARK_YELLOW": Foreground.YELLOW,
76 "MAGENTA": Foreground.LIGHTMAGENTA_EX,
77 "BLUE": Foreground.LIGHTBLUE_EX,
78 "DARK_BLUE": Foreground.BLUE,
79 "CYAN": Foreground.LIGHTCYAN_EX,
80 "DARK_CYAN": Foreground.CYAN,
81 "GRAY": Foreground.WHITE,
82 "DARK_GRAY": Foreground.LIGHTBLACK_EX,
83 "WHITE": Foreground.LIGHTWHITE_EX,
84 "NOCOLOR": Foreground.RESET,
86 "HEADLINE": Foreground.LIGHTMAGENTA_EX,
87 "ERROR": Foreground.LIGHTRED_EX,
88 "WARNING": Foreground.LIGHTYELLOW_EX
89 } #: Terminal colors
90 except ImportError: # pragma: no cover
91 Foreground = {
92 "RED": "",
93 "DARK_RED": "",
94 "GREEN": "",
95 "DARK_GREEN": "",
96 "YELLOW": "",
97 "DARK_YELLOW": "",
98 "MAGENTA": "",
99 "BLUE": "",
100 "DARK_BLUE": "",
101 "CYAN": "",
102 "DARK_CYAN": "",
103 "GRAY": "",
104 "DARK_GRAY": "",
105 "WHITE": "",
106 "NOCOLOR": "",
108 "HEADLINE": "",
109 "ERROR": "",
110 "WARNING": ""
111 } #: Terminal colors
113 _stdin: TextIOWrapper #: STDIN
114 _stdout: TextIOWrapper #: STDOUT
115 _stderr: TextIOWrapper #: STDERR
116 _width: int #: Terminal width in characters
117 _height: int #: Terminal height in characters
119 def __init__(self) -> None:
120 """
121 Initialize a terminal.
123 If the Python package `colorama <https://pypi.org/project/colorama/>`_ [#f_colorama]_ is available, then initialize
124 it for colored outputs.
126 .. [#f_colorama] Colorama on Github: https://GitHub.com/tartley/colorama
127 """
129 self._stdin = stdin
130 self._stdout = stdout
131 self._stderr = stderr
132 if stdout.isatty(): 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 self.InitializeColors()
134 else:
135 self.UninitializeColors()
136 self._width, self._height = self.GetTerminalSize()
138 def InitializeColors(self) -> bool:
139 """
140 Initialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__.
142 :returns: True, if 'colorama' package could be imported and initialized.
143 """
144 try:
145 from colorama import init
147 init()
148 return True
149 except ImportError: # pragma: no cover
150 return False
152 def UninitializeColors(self) -> bool:
153 """
154 Uninitialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__.
156 :returns: True, if 'colorama' package could be imported and uninitialized.
157 """
158 try:
159 from colorama import deinit
161 deinit()
162 return True
163 except ImportError: # pragma: no cover
164 return False
166 @readonly
167 def Width(self) -> int:
168 """
169 Read-only property to access the terminal's width.
171 :returns: The terminal window's width in characters.
172 """
173 return self._width
175 @readonly
176 def Height(self) -> int:
177 """
178 Read-only property to access the terminal's height.
180 :returns: The terminal window's height in characters.
181 """
182 return self._height
184 @staticmethod
185 def GetTerminalSize() -> Tuple[int, int]:
186 """
187 Returns the terminal size as tuple (width, height) for Windows, macOS (Darwin), Linux, cygwin (Windows), MinGW32/64 (Windows).
189 :returns: A tuple containing width and height of the terminal's size in characters.
190 :raises PlatformNotSupportedException: When a platform is not yet supported.
191 """
192 platform = Platform()
193 if platform.IsNativeWindows:
194 size = TerminalBaseApplication.__GetTerminalSizeOnWindows()
195 elif (platform.IsNativeLinux or platform.IsNativeFreeBSD or platform.IsNativeMacOS or platform.IsMinGW32OnWindows or platform.IsMinGW64OnWindows
196 or platform.IsUCRT64OnWindows or platform.IsCygwin32OnWindows or platform.IsClang64OnWindows):
197 size = TerminalBaseApplication.__GetTerminalSizeOnLinux()
198 else: # pragma: no cover
199 raise PlatformNotSupportedException(f"Platform '{platform}' not yet supported.")
201 if size is None: # pragma: no cover
202 size = (80, 25) # default size
204 return size
206 @staticmethod
207 def __GetTerminalSizeOnWindows() -> Nullable[Tuple[int, int]]:
208 """
209 Returns the current terminal window's size for Windows.
211 ``kernel32.dll:GetConsoleScreenBufferInfo()`` is used to retrieve the information.
213 :returns: A tuple containing width and height of the terminal's size in characters.
214 """
215 try:
216 from ctypes import windll, create_string_buffer
217 from struct import unpack as struct_unpack
219 hStdError = windll.kernel32.GetStdHandle(-12) # stderr handle = -12
220 stringBuffer = create_string_buffer(22)
221 result = windll.kernel32.GetConsoleScreenBufferInfo(hStdError, stringBuffer)
222 if result: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy = struct_unpack("hhhhHhhhhhh", stringBuffer.raw)
224 width = right - left + 1
225 height = bottom - top + 1
226 return width, height
227 except ImportError:
228 pass
230 return None
231 # return Terminal.__GetTerminalSizeWithTPut()
233 # @staticmethod
234 # def __GetTerminalSizeWithTPut() -> Tuple[int, int]:
235 # """
236 # Returns the current terminal window's size for Windows.
237 #
238 # ``tput`` is used to retrieve the information.
239 #
240 # :returns: A tuple containing width and height of the terminal's size in characters.
241 # """
242 # from subprocess import check_output
243 #
244 # try:
245 # width = int(check_output(("tput", "cols")))
246 # height = int(check_output(("tput", "lines")))
247 # return (width, height)
248 # except:
249 # pass
251 @staticmethod
252 def __GetTerminalSizeOfFileDescriptor(fd: int) -> Nullable[Tuple[int, int]]: # Python 3.10: Use bitwise-or for union type: | None:
253 """
254 Get window size of a file descriptor.
256 Call `ioctl` with ``TIOCGWINSZ`` (GetWindowsSize) for the given file descriptor.
258 :param fd: File descriptor
259 :return:
260 """
261 try:
262 from array import array
263 from fcntl import ioctl
264 from termios import TIOCGWINSZ
265 except ImportError:
266 return None
268 # Allocate an array of 4x unsigned short (C struct)
269 # H = unsigned short (16-bit)
270 buffer = array('H', [0, 0, 0, 0]) # rows, columns, x-pixels, y-pixels
271 try:
272 ioctl(fd, TIOCGWINSZ, buffer, True)
273 return buffer[1], buffer[0]
274 except OSError:
275 return None
277 @staticmethod
278 def __GetTerminalSizeOnLinux() -> Nullable[Tuple[int, int]]: # Python 3.10: Use bitwise-or for union type: | None:
279 """
280 Returns the current terminal window's size for Linux.
282 ``ioctl(TIOCGWINSZ)`` is used to retrieve the information. As a fallback, environment variables ``COLUMNS`` and
283 ``LINES`` are checked.
285 :returns: A tuple containing width and height of the terminal's size in characters.
286 """
287 # STDIN, STDOUT, STDERR
288 for fd in range(3):
289 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 return size
292 # Fallback
293 fd = None
294 try:
295 from os import open, close, ctermid, O_RDONLY
297 fd = open(ctermid(), O_RDONLY)
298 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None:
299 return size
300 except (ImportError, OSError):
301 # ImportError - If ctermid is not available (e.g. MSYS2)
302 # OSError - If ctermid() or open() fails
303 pass
304 finally:
305 if fd is not None: 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true
306 try:
307 close(fd)
308 except OSError:
309 pass
311 # Fall-fallback
312 from os import getenv
314 try:
315 columns = int(getenv("COLUMNS"))
316 lines = int(getenv("LINES"))
317 return columns, lines
318 except TypeError:
319 pass
321 return None
323 def WriteToStdOut(self, message: str) -> int:
324 """
325 Low-level method for writing to ``STDOUT``.
327 :param message: Message to write to ``STDOUT``.
328 :return: Number of written characters.
329 """
330 return self._stdout.write(message)
332 def WriteLineToStdOut(self, message: str, end: str = "\n") -> int:
333 """
334 Low-level method for writing to ``STDOUT``.
336 :param message: Message to write to ``STDOUT``.
337 :param end: Use newline character. Default: ``\\n``.
338 :return: Number of written characters.
339 """
340 return self._stdout.write(message + end)
342 def WriteToStdErr(self, message: str) -> int:
343 """
344 Low-level method for writing to ``STDERR``.
346 :param message: Message to write to ``STDERR``.
347 :return: Number of written characters.
348 """
349 return self._stderr.write(message)
351 def WriteLineToStdErr(self, message: str, end: str = "\n") -> int:
352 """
353 Low-level method for writing to ``STDERR``.
355 :param message: Message to write to ``STDERR``.
356 :param end: Use newline character. Default: ``\\n``.
357 :returns: Number of written characters.
358 """
359 return self._stderr.write(message + end)
361 def FatalExit(self, returnCode: int = 0) -> NoReturn:
362 """
363 Exit the terminal application by uninitializing color support and returning a fatal Exit code.
365 :param returnCode: Return code for application exit.
366 """
367 self.Exit(self.FATAL_EXIT_CODE if returnCode == 0 else returnCode)
369 def Exit(self, returnCode: int = 0) -> NoReturn:
370 """
371 Exit the terminal application by uninitializing color support and returning an Exit code.
373 :param returnCode: Return code for application exit.
374 """
375 self.UninitializeColors()
376 exit(returnCode)
378 def CheckPythonVersion(self, version: Tuple[int, ...]) -> None:
379 """
380 Check if the used Python interpreter fulfills the minimum version requirements.
381 """
382 from sys import version_info as info
384 if info < version:
385 self.InitializeColors()
387 self.WriteLineToStdErr(dedent(f"""\
388 {{RED}}[ERROR]{{NOCOLOR}} Used Python interpreter ({info.major}.{info.minor}.{info.micro}-{info.releaselevel}) is to old.
389 {{indent}}{{YELLOW}}Minimal required Python version is {version[0]}.{version[1]}.{version[2]}{{NOCOLOR}}\
390 """).format(indent=self.INDENT, **self.Foreground))
392 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE)
394 def PrintException(self, ex: Exception) -> NoReturn:
395 """
396 Prints an exception of type :exc:`Exception` and its traceback.
398 If the exception as a nested action, the cause is printed as well.
400 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
401 """
402 from traceback import format_tb, walk_tb
404 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
405 filename = frame.f_code.co_filename
406 funcName = frame.f_code.co_name
408 message = f"{{RED}}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{{NOCOLOR}}\n"
409 message += f"{{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{ex.__class__.__name__}{{NOCOLOR}}\n"
410 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
412 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0:
413 note = next(iterator := iter(ex.__notes__))
414 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
415 for note in iterator:
416 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
418 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
420 if (ex2 := ex.__cause__) is not None:
421 message += f"{{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{ex2.__class__.__name__}{{NOCOLOR}}\n"
422 message += f"{{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex2!s}{{NOCOLOR}}\n"
424 if hasattr(ex2, "__notes__") and len(ex2.__notes__) > 0: 424 ↛ 430line 424 didn't jump to line 430 because the condition on line 424 was always true
425 note = next(iterator := iter(ex2.__notes__))
426 message += f"{{indent2}}{{DARK_YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
427 for note in iterator:
428 message += f"{{indent2}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
430 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}\n"
431 for line in format_tb(ex.__traceback__):
432 message += f"{line.replace('{', '{{').replace('}', '}}')}"
433 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
435 if self.ISSUE_TRACKER_URL is not None: 435 ↛ 439line 435 didn't jump to line 439 because the condition on line 435 was always true
436 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
437 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
439 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT*2, **self.Foreground))
440 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
442 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn:
443 """Prints a not-implemented exception of type :exc:`NotImplementedError`."""
444 from traceback import walk_tb
446 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
447 filename = frame.f_code.co_filename
448 funcName = frame.f_code.co_name
450 message = f"{{RED}}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{{NOCOLOR}}\n"
451 message += f"{{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{DARK_RED}}{funcName}(...){{NOCOLOR}}\n"
452 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
454 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 note = next(iterator := iter(ex.__notes__))
456 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
457 for note in iterator:
458 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
460 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
462 if self.ISSUE_TRACKER_URL is not None: 462 ↛ 466line 462 didn't jump to line 466 because the condition on line 462 was always true
463 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
464 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
466 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.Foreground))
467 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE)
469 def PrintExceptionBase(self, ex: Exception) -> NoReturn:
470 """
471 Prints an exception of type :exc:`ExceptionBase` and its traceback.
473 If the exception as a nested action, the cause is printed as well.
475 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
476 """
477 from traceback import print_tb, walk_tb
479 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
480 filename = frame.f_code.co_filename
481 funcName = frame.f_code.co_name
483 self.WriteLineToStdErr(dedent(f"""\
484 {{RED}}[FATAL] A known but unhandled exception reached the topmost exception handler!{{NOCOLOR}}
485 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{ex.__class__.__name__}{{NOCOLOR}}
486 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}
487 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
488 """).format(indent=self.INDENT, **self.Foreground))
490 if ex.__cause__ is not None:
491 self.WriteLineToStdErr(dedent(f"""\
492 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}}
493 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {{RED}}{ex.__cause__!s}{{NOCOLOR}}\
494 """).format(indent2=self.INDENT * 2, **self.Foreground))
496 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
497 print_tb(ex.__traceback__, file=self._stderr)
498 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
500 if self.ISSUE_TRACKER_URL is not None: 500 ↛ 506line 500 didn't jump to line 506 because the condition on line 500 was always true
501 self.WriteLineToStdErr(dedent(f"""\
502 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
503 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
504 """).format(indent=self.INDENT, **self.Foreground))
506 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
509@export
510@unique
511class Severity(Enum):
512 """Logging message severity levels."""
514 Fatal = 100 #: Fatal messages
515 Error = 80 #: Error messages
516 Quiet = 70 #: Always visible messages, even in quiet mode.
517 Critical = 60 #: Critical messages
518 Warning = 50 #: Warning messages
519 Info = 20 #: Informative messages
520 Normal = 10 #: Normal messages
521 DryRun = 8 #: Messages visible in a dry-run
522 Verbose = 5 #: Verbose messages
523 Debug = 2 #: Debug messages
524 All = 0 #: All messages
526 def __hash__(self) -> int:
527 return hash(self.name)
529 def __eq__(self, other: Any) -> bool:
530 """
531 Compare two Severity instances (severity level) for equality.
533 :param other: Operand to compare against.
534 :returns: ``True``, if both severity levels are equal.
535 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
536 """
537 if isinstance(other, Severity):
538 return self.value == other.value
539 else:
540 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by == operator.")
541 ex.add_note(f"Supported types for second operand: Severity")
542 raise ex
544 def __ne__(self, other: Any) -> bool:
545 """
546 Compare two Severity instances (severity level) for inequality.
548 :param other: Operand to compare against.
549 :returns: ``True``, if both severity levels are unequal.
550 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
551 """
552 if isinstance(other, Severity):
553 return self.value != other.value
554 else:
555 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.")
556 ex.add_note(f"Supported types for second operand: Severity")
557 raise ex
559 def __lt__(self, other: Any) -> bool:
560 """
561 Compare two Severity instances (severity level) for less-than.
563 :param other: Operand to compare against.
564 :returns: ``True``, if severity levels is less than other severity level.
565 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
566 """
567 if isinstance(other, Severity):
568 return self.value < other.value
569 else:
570 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.")
571 ex.add_note(f"Supported types for second operand: Severity")
572 raise ex
574 def __le__(self, other: Any) -> bool:
575 """
576 Compare two Severity instances (severity level) for less-than-or-equal.
578 :param other: Operand to compare against.
579 :returns: ``True``, if severity levels is less than or equal other severity level.
580 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
581 """
582 if isinstance(other, Severity):
583 return self.value <= other.value
584 else:
585 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.")
586 ex.add_note(f"Supported types for second operand: Severity")
587 raise ex
589 def __gt__(self, other: Any) -> bool:
590 """
591 Compare two Severity instances (severity level) for greater-than.
593 :param other: Operand to compare against.
594 :returns: ``True``, if severity levels is greater than other severity level.
595 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
596 """
597 if isinstance(other, Severity):
598 return self.value > other.value
599 else:
600 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.")
601 ex.add_note(f"Supported types for second operand: Severity")
602 raise ex
604 def __ge__(self, other: Any) -> bool:
605 """
606 Compare two Severity instances (severity level) for greater-than-or-equal.
608 :param other: Operand to compare against.
609 :returns: ``True``, if severity levels is greater than or equal other severity level.
610 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
611 """
612 if isinstance(other, Severity):
613 return self.value >= other.value
614 else:
615 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.")
616 ex.add_note(f"Supported types for second operand: Severity")
617 raise ex
620@export
621@unique
622class Mode(Enum):
623 TextToStdOut_ErrorsToStdErr = 0
624 AllLinearToStdOut = 1
625 DataToStdOut_OtherToStdErr = 2
628@export
629class Line(metaclass=ExtendedType, slots=True):
630 """Represents a single message line with a severity and indentation level."""
632 _LOG_MESSAGE_FORMAT__ = {
633 Severity.Fatal: "FATAL: {message}",
634 Severity.Error: "ERROR: {message}",
635 Severity.Quiet: "{message}",
636 Severity.Warning: "WARNING: {message}",
637 Severity.Info: "INFO: {message}",
638 Severity.Normal: "{message}",
639 Severity.DryRun: "DRYRUN: {message}",
640 Severity.Verbose: "VERBOSE: {message}",
641 Severity.Debug: "DEBUG: {message}",
642 } #: Message line formatting rules.
644 _message: str #: Text message (line content).
645 _severity: Severity #: Message severity
646 _indent: int #: Indentation
647 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
649 def __init__(self, message: str, severity: Severity = Severity.Normal, indent: int = 0, appendLinebreak: bool = True) -> None:
650 """Constructor for a new ``Line`` object."""
651 self._severity = severity
652 self._message = message
653 self._indent = indent
654 self._appendLinebreak = appendLinebreak
656 @readonly
657 def Message(self) -> str:
658 """
659 Return the indented line.
661 :returns: Raw message of the line.
662 """
663 return self._message
665 @readonly
666 def Severity(self) -> Severity:
667 """
668 Return the line's severity level.
670 :returns: Severity level of the message line.
671 """
672 return self._severity
674 @readonly
675 def Indent(self) -> int:
676 """
677 Return the line's indentation level.
679 :returns: Indentation level.
680 """
681 return self._indent
683 def IndentBy(self, indent: int) -> int:
684 """
685 Increase a line's indentation level.
687 :param indent: Indentation level added to the current indentation level.
688 """
689 # TODO: used named expression starting from Python 3.8
690 indent += self._indent
691 self._indent = indent
692 return indent
694 @readonly
695 def AppendLinebreak(self) -> bool:
696 """
697 Returns if a linebreak should be added at the end of the message.
699 :returns: True, if a linebreak should be added.
700 """
701 return self._appendLinebreak
703 def __str__(self) -> str:
704 """Returns a formatted version of a ``Line`` objects as a string."""
705 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
708@export
709@mixin
710class ILineTerminal:
711 """A mixin class (interface) to provide class-local terminal writing methods."""
713 _terminal: TerminalBaseApplication
715 def __init__(self, terminal: Nullable[TerminalBaseApplication] = None) -> None:
716 """MixIn initializer."""
717 self._terminal = terminal
719 # FIXME: Alter methods if a terminal is present or set dummy methods
721 @readonly
722 def Terminal(self) -> TerminalBaseApplication:
723 """Return the local terminal instance."""
724 return self._terminal
726 def WriteLine(self, line: Line, condition: bool = True) -> bool:
727 """Write an entry to the local terminal."""
728 if (self._terminal is not None) and condition:
729 return self._terminal.WriteLine(line)
730 return False
732 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
733 # if (self._terminal is not None) and condition:
734 # return self._terminal.TryWrite(*args, **kwargs)
735 # return False
737 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
738 """Write a fatal message if ``condition`` is true."""
739 if (self._terminal is not None) and condition:
740 return self._terminal.WriteFatal(*args, **kwargs)
741 return False
743 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
744 """Write an error message if ``condition`` is true."""
745 if (self._terminal is not None) and condition:
746 return self._terminal.WriteError(*args, **kwargs)
747 return False
749 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
750 """Write a warning message if ``condition`` is true."""
751 if (self._terminal is not None) and condition:
752 return self._terminal.WriteCritical(*args, **kwargs)
753 return False
755 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
756 """Write a warning message if ``condition`` is true."""
757 if (self._terminal is not None) and condition:
758 return self._terminal.WriteWarning(*args, **kwargs)
759 return False
761 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
762 """Write an info message if ``condition`` is true."""
763 if (self._terminal is not None) and condition:
764 return self._terminal.WriteInfo(*args, **kwargs)
765 return False
767 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
768 """Write a message even in quiet mode if ``condition`` is true."""
769 if (self._terminal is not None) and condition:
770 return self._terminal.WriteQuiet(*args, **kwargs)
771 return False
773 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
774 """Write a *normal* message if ``condition`` is true."""
775 if (self._terminal is not None) and condition:
776 return self._terminal.WriteNormal(*args, **kwargs)
777 return False
779 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
780 """Write a verbose message if ``condition`` is true."""
781 if (self._terminal is not None) and condition:
782 return self._terminal.WriteVerbose(*args, **kwargs)
783 return False
785 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
786 """Write a debug message if ``condition`` is true."""
787 if (self._terminal is not None) and condition:
788 return self._terminal.WriteDebug(*args, **kwargs)
789 return False
791 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
792 """Write a dry-run message if ``condition`` is true."""
793 if (self._terminal is not None) and condition:
794 return self._terminal.WriteDryRun(*args, **kwargs)
795 return False
798@export
799class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
800 """
801 A base-class for implementation of terminal applications emitting line-by-line messages.
802 """
803 _LOG_MESSAGE_FORMAT__ = {
804 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
805 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
806 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
807 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
808 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
809 Severity.Info: "{WHITE}{message}{NOCOLOR}",
810 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
811 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
812 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
813 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
814 } #: Message formatting rules.
816 _LOG_LEVEL_ROUTING__: Dict[Severity, Tuple[Callable[[str, str], int]]] #: Message routing rules.
817 _verbose: bool
818 _debug: bool
819 _quiet: bool
820 _writeLevel: Severity
821 _writeToStdOut: bool
823 _lines: List[Line]
824 _baseIndent: int
826 _errorCount: int
827 _criticalWarningCount: int
828 _warningCount: int
830 HeadLine: ClassVar[str]
832 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
833 """
834 Initializer of a line-based terminal interface.
836 :param mode: Defines what output (normal, error, data) to write where. Default: a linear flow all to *STDOUT*.
837 """
838 TerminalBaseApplication.__init__(self)
839 # ILineTerminal.__init__(self, self)
841 self._LOG_LEVEL_ROUTING__ = {}
842 self.__InitializeLogLevelRouting(mode)
844 self._verbose = False
845 self._debug = False
846 self._quiet = False
847 self._writeLevel = Severity.Normal
848 self._writeToStdOut = True
850 self._lines = []
851 self._baseIndent = 0
853 self._errorCount = 0
854 self._criticalWarningCount = 0
855 self._warningCount = 0
857 def __InitializeLogLevelRouting(self, mode: Mode) -> None:
858 if mode is Mode.TextToStdOut_ErrorsToStdErr:
859 for severity in Severity:
860 if severity >= Severity.Warning and severity != Severity.Quiet:
861 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
862 else:
863 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
864 elif mode is Mode.AllLinearToStdOut:
865 for severity in Severity:
866 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
867 elif mode is Mode.DataToStdOut_OtherToStdErr:
868 for severity in Severity:
869 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
870 else: # pragma: no cover
871 raise ExceptionBase(f"Unsupported mode '{mode}'.")
873 def _PrintHeadline(self, width: int = 80) -> None:
874 """
875 Helper method to print the program headline.
877 :param width: Number of characters for horizontal lines.
879 .. admonition:: Generated output
881 .. code-block::
883 =========================
884 centered headline
885 =========================
886 """
887 if width == 0: 887 ↛ 888line 887 didn't jump to line 888 because the condition on line 887 was never true
888 width = self._width
890 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
891 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground))
892 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
894 def _PrintVersion(self, author: str, email: str, copyright: str, license: str, version: str) -> None:
895 """
896 Helper method to print the version information.
898 :param author: Author of the application.
899 :param email: The author's email address.
900 :param copyright: The copyright information.
901 :param license: The license.
902 :param version: The application's version.
904 .. admonition:: Example usage
906 .. code-block:: Python
908 def _PrintVersion(self):
909 from MyModule import __author__, __email__, __copyright__, __license__, __version__
911 super()._PrintVersion(__author__, __email__, __copyright__, __license__, __version__)
912 """
913 self.WriteNormal(f"Author: {author} ({email})")
914 self.WriteNormal(f"Copyright: {copyright}")
915 self.WriteNormal(f"License: {license}")
916 self.WriteNormal(f"Version: {version}")
918 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True) -> None:
919 self._verbose = True if debug else verbose
920 self._debug = debug
921 self._quiet = quiet
923 if quiet: 923 ↛ 925line 923 didn't jump to line 925 because the condition on line 923 was always true
924 self._writeLevel = Severity.Quiet
925 elif debug:
926 self._writeLevel = Severity.Debug
927 elif verbose:
928 self._writeLevel = Severity.Verbose
929 else:
930 self._writeLevel = Severity.Normal
932 self._writeToStdOut = writeToStdOut
934 @readonly
935 def Verbose(self) -> bool:
936 """Returns true, if verbose messages are enabled."""
937 return self._verbose
939 @readonly
940 def Debug(self) -> bool:
941 """Returns true, if debug messages are enabled."""
942 return self._debug
944 @readonly
945 def Quiet(self) -> bool:
946 """Returns true, if quiet mode is enabled."""
947 return self._quiet
949 @property
950 def LogLevel(self) -> Severity:
951 """Return the current minimal severity level for writing."""
952 return self._writeLevel
954 @LogLevel.setter
955 def LogLevel(self, value: Severity) -> None:
956 """Set the minimal severity level for writing."""
957 self._writeLevel = value
959 @property
960 def BaseIndent(self) -> int:
961 return self._baseIndent
963 @BaseIndent.setter
964 def BaseIndent(self, value: int) -> None:
965 self._baseIndent = value
967 @readonly
968 def WarningCount(self) -> int:
969 return self._warningCount
971 @readonly
972 def CriticalWarningCount(self) -> int:
973 return self._criticalWarningCount
975 @readonly
976 def ErrorCount(self) -> int:
977 return self._errorCount
979 @readonly
980 def Lines(self) -> List[Line]:
981 return self._lines
983 def ExitOnPreviousErrors(self) -> None:
984 """Exit application if errors have been printed."""
985 if self._errorCount > 0:
986 self.WriteFatal("Too many errors in previous steps.")
988 def ExitOnPreviousCriticalWarnings(self, includeErrors: bool = True) -> None:
989 """Exit application if critical warnings have been printed."""
990 if includeErrors and (self._errorCount > 0): 990 ↛ 991line 990 didn't jump to line 991 because the condition on line 990 was never true
991 if self._criticalWarningCount > 0:
992 self.WriteFatal("Too many errors and critical warnings in previous steps.")
993 else:
994 self.WriteFatal("Too many errors in previous steps.")
995 elif self._criticalWarningCount > 0:
996 self.WriteFatal("Too many critical warnings in previous steps.")
998 def ExitOnPreviousWarnings(self, includeCriticalWarnings: bool = True, includeErrors: bool = True) -> None:
999 """Exit application if warnings have been printed."""
1000 if includeErrors and (self._errorCount > 0): 1000 ↛ 1001line 1000 didn't jump to line 1001 because the condition on line 1000 was never true
1001 if includeCriticalWarnings and (self._criticalWarningCount > 0):
1002 if self._warningCount > 0:
1003 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
1004 else:
1005 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1006 elif self._warningCount > 0:
1007 self.WriteFatal("Too many warnings in previous steps.")
1008 else:
1009 self.WriteFatal("Too many errors in previous steps.")
1010 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 1010 ↛ 1011line 1010 didn't jump to line 1011 because the condition on line 1010 was never true
1011 if self._warningCount > 0:
1012 self.WriteFatal("Too many (critical) warnings in previous steps.")
1013 else:
1014 self.WriteFatal("Too many critical warnings in previous steps.")
1015 elif self._warningCount > 0:
1016 self.WriteFatal("Too many warnings in previous steps.")
1018 def WriteLine(self, line: Line) -> bool:
1019 """Print a formatted line to the underlying terminal/console offered by the operating system."""
1020 if line.Severity >= self._writeLevel:
1021 self._lines.append(line)
1022 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1023 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=line.Message, **self.Foreground), end="\n" if line.AppendLinebreak else "")
1024 return True
1025 else:
1026 return False
1028 def TryWriteLine(self, line) -> bool:
1029 return line.Severity >= self._writeLevel
1031 def WriteFatal(self, message: str, indent: int = 0, appendLinebreak: bool = True, immediateExit: bool = True) -> bool:
1032 ret = self.WriteLine(Line(message, Severity.Fatal, self._baseIndent + indent, appendLinebreak))
1033 if immediateExit:
1034 self.FatalExit()
1035 return ret
1037 def WriteError(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1038 self._errorCount += 1
1039 return self.WriteLine(Line(message, Severity.Error, self._baseIndent + indent, appendLinebreak))
1041 def WriteCritical(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1042 self._criticalWarningCount += 1
1043 return self.WriteLine(Line(message, Severity.Critical, self._baseIndent + indent, appendLinebreak))
1045 def WriteWarning(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1046 self._warningCount += 1
1047 return self.WriteLine(Line(message, Severity.Warning, self._baseIndent + indent, appendLinebreak))
1049 def WriteInfo(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1050 return self.WriteLine(Line(message, Severity.Info, self._baseIndent + indent, appendLinebreak))
1052 def WriteQuiet(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1053 return self.WriteLine(Line(message, Severity.Quiet, self._baseIndent + indent, appendLinebreak))
1055 def WriteNormal(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1056 """
1057 Write a normal message.
1059 Depending on internal settings and rules, a message might be skipped.
1061 :param message: Message to write.
1062 :param indent: Indentation level of the message.
1063 :param appendLinebreak: Append a linebreak after the message. Default: ``True``
1064 :return: True, if message was actually written.
1065 """
1066 return self.WriteLine(Line(message, Severity.Normal, self._baseIndent + indent, appendLinebreak))
1068 def WriteVerbose(self, message: str, indent: int = 1, appendLinebreak: bool = True) -> bool:
1069 return self.WriteLine(Line(message, Severity.Verbose, self._baseIndent + indent, appendLinebreak))
1071 def WriteDebug(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1072 return self.WriteLine(Line(message, Severity.Debug, self._baseIndent + indent, appendLinebreak))
1074 def WriteDryRun(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1075 return self.WriteLine(Line(message, Severity.DryRun, self._baseIndent + indent, appendLinebreak))