Coverage for pyTooling/TerminalUI/__init__.py: 79%
494 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-16 09:59 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-16 09:59 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ _ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2025 Patrick Lehmann - Bötzingen, Germany #
15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""A set of helpers to implement a text user interface (TUI) in a terminal."""
33from enum import Enum, unique
34from io import TextIOWrapper
35from sys import stdin, stdout, stderr
36from textwrap import dedent
37from typing import NoReturn, Tuple, Any, List, Optional as Nullable, Dict, Callable, ClassVar
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
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.*'!")
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
64@export
65class TerminalBaseApplication(metaclass=ExtendedType, slots=True, singleton=True):
66 """
67 The class offers a basic terminal application base-class.
69 It offers basic colored output via `colorama <https://GitHub.com/tartley/colorama>`__ as well as retrieving the
70 terminal's width.
71 """
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)
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,
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": "",
121 "HEADLINE": "",
122 "ERROR": "",
123 "WARNING": ""
124 } #: Terminal colors
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
132 def __init__(self) -> None:
133 """
134 Initialize a terminal.
136 If the Python package `colorama <https://pypi.org/project/colorama/>`_ [#f_colorama]_ is available, then initialize
137 it for colored outputs.
139 .. [#f_colorama] Colorama on Github: https://GitHub.com/tartley/colorama
140 """
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()
151 def InitializeColors(self) -> bool:
152 """
153 Initialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__.
155 :returns: True, if 'colorama' package could be imported and initialized.
156 """
157 try:
158 from colorama import init
160 init()
161 return True
162 except ImportError: # pragma: no cover
163 return False
165 def UninitializeColors(self) -> bool:
166 """
167 Uninitialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__.
169 :returns: True, if 'colorama' package could be imported and uninitialized.
170 """
171 try:
172 from colorama import deinit
174 deinit()
175 return True
176 except ImportError: # pragma: no cover
177 return False
179 @readonly
180 def Width(self) -> int:
181 """
182 Read-only property to access the terminal's width.
184 :returns: The terminal window's width in characters.
185 """
186 return self._width
188 @readonly
189 def Height(self) -> int:
190 """
191 Read-only property to access the terminal's height.
193 :returns: The terminal window's height in characters.
194 """
195 return self._height
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).
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.")
214 if size is None: # pragma: no cover
215 size = (80, 25) # default size
217 return size
219 @staticmethod
220 def __GetTerminalSizeOnWindows() -> Tuple[int, int]:
221 """
222 Returns the current terminal window's size for Windows.
224 ``kernel32.dll:GetConsoleScreenBufferInfo()`` is used to retrieve the information.
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
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
243 return None
244 # return Terminal.__GetTerminalSizeWithTPut()
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
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.
269 ``ioctl(TIOCGWINSZ)`` is used to retrieve the information. As a fallback, environment variables ``COLUMNS`` and
270 ``LINES`` are checked.
272 :returns: A tuple containing width and height of the terminal's size in characters.
273 """
274 import os
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
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
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
308 try:
309 columns = int(os.getenv("COLUMNS"))
310 lines = int(os.getenv("LINES"))
311 return (columns, lines)
312 except TypeError:
313 pass
315 return None
317 def WriteToStdOut(self, message: str) -> int:
318 """
319 Low-level method for writing to ``STDOUT``.
321 :param message: Message to write to ``STDOUT``.
322 :return: Number of written characters.
323 """
324 return self._stdout.write(message)
326 def WriteLineToStdOut(self, message: str, end: str = "\n") -> int:
327 """
328 Low-level method for writing to ``STDOUT``.
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)
336 def WriteToStdErr(self, message: str) -> int:
337 """
338 Low-level method for writing to ``STDERR``.
340 :param message: Message to write to ``STDERR``.
341 :return: Number of written characters.
342 """
343 return self._stderr.write(message)
345 def WriteLineToStdErr(self, message: str, end: str = "\n") -> int:
346 """
347 Low-level method for writing to ``STDERR``.
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)
355 def FatalExit(self, returnCode: int = 0) -> NoReturn:
356 """
357 Exit the terminal application by uninitializing color support and returning a fatal Exit code.
359 :param returnCode: Return code for application exit.
360 """
361 self.Exit(self.FATAL_EXIT_CODE if returnCode == 0 else returnCode)
363 def Exit(self, returnCode: int = 0) -> NoReturn:
364 """
365 Exit the terminal application by uninitializing color support and returning an Exit code.
367 :param returnCode: Return code for application exit.
368 """
369 self.UninitializeColors()
370 exit(returnCode)
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
378 if info < version:
379 self.InitializeColors()
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))
386 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE)
388 def PrintException(self, ex: Exception) -> NoReturn:
389 """
390 Prints an exception of type :exc:`Exception` and its traceback.
392 If the exception as a nested action, the cause is printed as well.
394 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
395 """
396 from traceback import format_tb, walk_tb
398 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
399 filename = frame.f_code.co_filename
400 funcName = frame.f_code.co_name
402 message = f"{{RED}}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{{NOCOLOR}}\n"
403 message += f"{{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{ex.__class__.__name__}{{NOCOLOR}}\n"
404 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
406 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0:
407 note = next(iterator := iter(ex.__notes__))
408 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
409 for note in iterator:
410 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
412 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
414 if (ex2 := ex.__cause__) is not None:
415 message += f"{{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{ex2.__class__.__name__}{{NOCOLOR}}\n"
416 message += f"{{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex2!s}{{NOCOLOR}}\n"
418 if hasattr(ex2, "__notes__") and len(ex2.__notes__) > 0: 418 ↛ 424line 418 didn't jump to line 424 because the condition on line 418 was always true
419 note = next(iterator := iter(ex2.__notes__))
420 message += f"{{indent2}}{{DARK_YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
421 for note in iterator:
422 message += f"{{indent2}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
424 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}\n"
425 for line in format_tb(ex.__traceback__):
426 message += f"{line.replace('{', '{{').replace('}', '}}')}"
427 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
429 if self.ISSUE_TRACKER_URL is not None: 429 ↛ 433line 429 didn't jump to line 433 because the condition on line 429 was always true
430 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
431 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
433 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT*2, **self.Foreground))
434 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
436 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn:
437 """Prints a not-implemented exception of type :exc:`NotImplementedError`."""
438 from traceback import walk_tb
440 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
441 filename = frame.f_code.co_filename
442 funcName = frame.f_code.co_name
444 message = f"{{RED}}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{{NOCOLOR}}\n"
445 message += f"{{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{DARK_RED}}{funcName}(...){{NOCOLOR}}\n"
446 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
448 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 note = next(iterator := iter(ex.__notes__))
450 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
451 for note in iterator:
452 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
454 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
456 if self.ISSUE_TRACKER_URL is not None: 456 ↛ 460line 456 didn't jump to line 460 because the condition on line 456 was always true
457 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
458 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
460 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.Foreground))
461 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE)
463 def PrintExceptionBase(self, ex: Exception) -> NoReturn:
464 """
465 Prints an exception of type :exc:`ExceptionBase` and its traceback.
467 If the exception as a nested action, the cause is printed as well.
469 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
470 """
471 from traceback import print_tb, walk_tb
473 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
474 filename = frame.f_code.co_filename
475 funcName = frame.f_code.co_name
477 self.WriteLineToStdErr(dedent(f"""\
478 {{RED}}[FATAL] A known but unhandled exception reached the topmost exception handler!{{NOCOLOR}}
479 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{ex.__class__.__name__}{{NOCOLOR}}
480 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}
481 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
482 """).format(indent=self.INDENT, **self.Foreground))
484 if ex.__cause__ is not None:
485 self.WriteLineToStdErr(dedent(f"""\
486 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}}
487 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {{RED}}{ex.__cause__!s}{{NOCOLOR}}\
488 """).format(indent2=self.INDENT * 2, **self.Foreground))
490 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
491 print_tb(ex.__traceback__, file=self._stderr)
492 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
494 if self.ISSUE_TRACKER_URL is not None: 494 ↛ 500line 494 didn't jump to line 500 because the condition on line 494 was always true
495 self.WriteLineToStdErr(dedent(f"""\
496 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
497 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
498 """).format(indent=self.INDENT, **self.Foreground))
500 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
503@export
504@unique
505class Severity(Enum):
506 """Logging message severity levels."""
508 Fatal = 100 #: Fatal messages
509 Error = 80 #: Error messages
510 Quiet = 70 #: Always visible messages, even in quiet mode.
511 Critical = 60 #: Critical messages
512 Warning = 50 #: Warning messages
513 Info = 20 #: Informative messages
514 Normal = 10 #: Normal messages
515 DryRun = 8 #: Messages visible in a dry-run
516 Verbose = 5 #: Verbose messages
517 Debug = 2 #: Debug messages
518 All = 0 #: All messages
520 def __hash__(self):
521 return hash(self.name)
523 def __eq__(self, other: Any) -> bool:
524 """
525 Compare two Severity instances (severity level) for equality.
527 :param other: Operand to compare against.
528 :returns: ``True``, if both severity levels are equal.
529 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
530 """
531 if isinstance(other, Severity):
532 return self.value == other.value
533 else:
534 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by == operator.")
535 ex.add_note(f"Supported types for second operand: Severity")
536 raise ex
538 def __ne__(self, other: Any) -> bool:
539 """
540 Compare two Severity instances (severity level) for inequality.
542 :param other: Operand to compare against.
543 :returns: ``True``, if both severity levels are unequal.
544 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
545 """
546 if isinstance(other, Severity):
547 return self.value != other.value
548 else:
549 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.")
550 ex.add_note(f"Supported types for second operand: Severity")
551 raise ex
553 def __lt__(self, other: Any) -> bool:
554 """
555 Compare two Severity instances (severity level) for less-than.
557 :param other: Operand to compare against.
558 :returns: ``True``, if severity levels is less than other severity level.
559 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
560 """
561 if isinstance(other, Severity):
562 return self.value < other.value
563 else:
564 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.")
565 ex.add_note(f"Supported types for second operand: Severity")
566 raise ex
568 def __le__(self, other: Any) -> bool:
569 """
570 Compare two Severity instances (severity level) for less-than-or-equal.
572 :param other: Operand to compare against.
573 :returns: ``True``, if severity levels is less than or equal other severity level.
574 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
575 """
576 if isinstance(other, Severity):
577 return self.value <= other.value
578 else:
579 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.")
580 ex.add_note(f"Supported types for second operand: Severity")
581 raise ex
583 def __gt__(self, other: Any) -> bool:
584 """
585 Compare two Severity instances (severity level) for greater-than.
587 :param other: Operand to compare against.
588 :returns: ``True``, if severity levels is greater than other severity level.
589 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
590 """
591 if isinstance(other, Severity):
592 return self.value > other.value
593 else:
594 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.")
595 ex.add_note(f"Supported types for second operand: Severity")
596 raise ex
598 def __ge__(self, other: Any) -> bool:
599 """
600 Compare two Severity instances (severity level) for greater-than-or-equal.
602 :param other: Operand to compare against.
603 :returns: ``True``, if severity levels is greater than or equal other severity level.
604 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
605 """
606 if isinstance(other, Severity):
607 return self.value >= other.value
608 else:
609 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.")
610 ex.add_note(f"Supported types for second operand: Severity")
611 raise ex
614@export
615@unique
616class Mode(Enum):
617 TextToStdOut_ErrorsToStdErr = 0
618 AllLinearToStdOut = 1
619 DataToStdOut_OtherToStdErr = 2
622@export
623class Line(metaclass=ExtendedType, slots=True):
624 """Represents a single message line with a severity and indentation level."""
626 _LOG_MESSAGE_FORMAT__ = {
627 Severity.Fatal: "FATAL: {message}",
628 Severity.Error: "ERROR: {message}",
629 Severity.Quiet: "{message}",
630 Severity.Warning: "WARNING: {message}",
631 Severity.Info: "INFO: {message}",
632 Severity.Normal: "{message}",
633 Severity.DryRun: "DRYRUN: {message}",
634 Severity.Verbose: "VERBOSE: {message}",
635 Severity.Debug: "DEBUG: {message}",
636 } #: Message line formatting rules.
638 _message: str #: Text message (line content).
639 _severity: Severity #: Message severity
640 _indent: int #: Indentation
641 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
643 def __init__(self, message: str, severity: Severity = Severity.Normal, indent: int = 0, appendLinebreak: bool = True) -> None:
644 """Constructor for a new ``Line`` object."""
645 self._severity = severity
646 self._message = message
647 self._indent = indent
648 self._appendLinebreak = appendLinebreak
650 @readonly
651 def Message(self) -> str:
652 """
653 Return the indented line.
655 :returns: Raw message of the line.
656 """
657 return self._message
659 @readonly
660 def Severity(self) -> Severity:
661 """
662 Return the line's severity level.
664 :returns: Severity level of the message line.
665 """
666 return self._severity
668 @readonly
669 def Indent(self) -> int:
670 """
671 Return the line's indentation level.
673 :returns: Indentation level.
674 """
675 return self._indent
677 def IndentBy(self, indent: int) -> int:
678 """
679 Increase a line's indentation level.
681 :param indent: Indentation level added to the current indentation level.
682 """
683 # TODO: used named expression starting from Python 3.8
684 indent += self._indent
685 self._indent = indent
686 return indent
688 @readonly
689 def AppendLinebreak(self) -> bool:
690 """
691 Returns if a linebreak should be added at the end of the message.
693 :returns: True, if a linebreak should be added.
694 """
695 return self._appendLinebreak
697 def __str__(self) -> str:
698 """Returns a formatted version of a ``Line`` objects as a string."""
699 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
702@export
703@mixin
704class ILineTerminal:
705 """A mixin class (interface) to provide class-local terminal writing methods."""
707 _terminal: TerminalBaseApplication
709 def __init__(self, terminal: Nullable[TerminalBaseApplication] = None) -> None:
710 """MixIn initializer."""
711 self._terminal = terminal
713 # FIXME: Alter methods if a terminal is present or set dummy methods
715 @readonly
716 def Terminal(self) -> TerminalBaseApplication:
717 """Return the local terminal instance."""
718 return self._terminal
720 def WriteLine(self, line: Line, condition: bool = True) -> bool:
721 """Write an entry to the local terminal."""
722 if (self._terminal is not None) and condition:
723 return self._terminal.WriteLine(line)
724 return False
726 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
727 # if (self._terminal is not None) and condition:
728 # return self._terminal.TryWrite(*args, **kwargs)
729 # return False
731 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
732 """Write a fatal message if ``condition`` is true."""
733 if (self._terminal is not None) and condition:
734 return self._terminal.WriteFatal(*args, **kwargs)
735 return False
737 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
738 """Write an error message if ``condition`` is true."""
739 if (self._terminal is not None) and condition:
740 return self._terminal.WriteError(*args, **kwargs)
741 return False
743 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
744 """Write a warning message if ``condition`` is true."""
745 if (self._terminal is not None) and condition:
746 return self._terminal.WriteCritical(*args, **kwargs)
747 return False
749 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
750 """Write a warning message if ``condition`` is true."""
751 if (self._terminal is not None) and condition:
752 return self._terminal.WriteWarning(*args, **kwargs)
753 return False
755 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
756 """Write an info message if ``condition`` is true."""
757 if (self._terminal is not None) and condition:
758 return self._terminal.WriteInfo(*args, **kwargs)
759 return False
761 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
762 """Write a message even in quiet mode if ``condition`` is true."""
763 if (self._terminal is not None) and condition:
764 return self._terminal.WriteQuiet(*args, **kwargs)
765 return False
767 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
768 """Write a *normal* message if ``condition`` is true."""
769 if (self._terminal is not None) and condition:
770 return self._terminal.WriteNormal(*args, **kwargs)
771 return False
773 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
774 """Write a verbose message if ``condition`` is true."""
775 if (self._terminal is not None) and condition:
776 return self._terminal.WriteVerbose(*args, **kwargs)
777 return False
779 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
780 """Write a debug message if ``condition`` is true."""
781 if (self._terminal is not None) and condition:
782 return self._terminal.WriteDebug(*args, **kwargs)
783 return False
785 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
786 """Write a dry-run message if ``condition`` is true."""
787 if (self._terminal is not None) and condition:
788 return self._terminal.WriteDryRun(*args, **kwargs)
789 return False
792@export
793class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
794 """
795 A base-class for implementation of terminal applications emitting line-by-line messages.
796 """
797 _LOG_MESSAGE_FORMAT__ = {
798 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
799 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
800 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
801 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
802 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
803 Severity.Info: "{WHITE}{message}{NOCOLOR}",
804 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
805 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
806 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
807 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
808 } #: Message formatting rules.
810 _LOG_LEVEL_ROUTING__: Dict[Severity, Tuple[Callable[[str, str], int]]] #: Message routing rules.
811 _verbose: bool
812 _debug: bool
813 _quiet: bool
814 _writeLevel: Severity
815 _writeToStdOut: bool
817 _lines: List[Line]
818 _baseIndent: int
820 _errorCount: int
821 _criticalWarningCount: int
822 _warningCount: int
824 HeadLine: ClassVar[str]
826 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
827 """
828 Initializer of a line-based terminal interface.
830 :param mode: Defines what output (normal, error, data) to write where. Default: a linear flow all to *STDOUT*.
831 """
832 TerminalBaseApplication.__init__(self)
833 # ILineTerminal.__init__(self, self)
835 self._LOG_LEVEL_ROUTING__ = {}
836 self.__InitializeLogLevelRouting(mode)
838 self._verbose = False
839 self._debug = False
840 self._quiet = False
841 self._writeLevel = Severity.Normal
842 self._writeToStdOut = True
844 self._lines = []
845 self._baseIndent = 0
847 self._errorCount = 0
848 self._criticalWarningCount = 0
849 self._warningCount = 0
851 def __InitializeLogLevelRouting(self, mode: Mode):
852 if mode is Mode.TextToStdOut_ErrorsToStdErr:
853 for severity in Severity:
854 if severity >= Severity.Warning and severity != Severity.Quiet:
855 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
856 else:
857 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
858 elif mode is Mode.AllLinearToStdOut:
859 for severity in Severity:
860 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
861 elif mode is Mode.DataToStdOut_OtherToStdErr:
862 for severity in Severity:
863 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
864 else: # pragma: no cover
865 raise ExceptionBase(f"Unsupported mode '{mode}'.")
867 def _PrintHeadline(self, width: int = 80) -> None:
868 """
869 Helper method to print the program headline.
871 :param width: Number of characters for horizontal lines.
873 .. admonition:: Generated output
875 .. code-block::
877 =========================
878 centered headline
879 =========================
880 """
881 if width == 0: 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true
882 width = self._width
884 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
885 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground))
886 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
888 def _PrintVersion(self, author: str, email: str, copyright: str, license: str, version: str) -> None:
889 """
890 Helper method to print the version information.
892 :param author: Author of the application.
893 :param email: The author's email address.
894 :param copyright: The copyright information.
895 :param license: The license.
896 :param version: The application's version.
898 .. admonition:: Example usage
900 .. code-block:: Python
902 def _PrintVersion(self):
903 from MyModule import __author__, __email__, __copyright__, __license__, __version__
905 super()._PrintVersion(__author__, __email__, __copyright__, __license__, __version__)
906 """
907 self.WriteNormal(f"Author: {author} ({email})")
908 self.WriteNormal(f"Copyright: {copyright}")
909 self.WriteNormal(f"License: {license}")
910 self.WriteNormal(f"Version: {version}")
912 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True):
913 self._verbose = True if debug else verbose
914 self._debug = debug
915 self._quiet = quiet
917 if quiet: 917 ↛ 919line 917 didn't jump to line 919 because the condition on line 917 was always true
918 self._writeLevel = Severity.Quiet
919 elif debug:
920 self._writeLevel = Severity.Debug
921 elif verbose:
922 self._writeLevel = Severity.Verbose
923 else:
924 self._writeLevel = Severity.Normal
926 self._writeToStdOut = writeToStdOut
928 @readonly
929 def Verbose(self) -> bool:
930 """Returns true, if verbose messages are enabled."""
931 return self._verbose
933 @readonly
934 def Debug(self) -> bool:
935 """Returns true, if debug messages are enabled."""
936 return self._debug
938 @readonly
939 def Quiet(self) -> bool:
940 """Returns true, if quiet mode is enabled."""
941 return self._quiet
943 @property
944 def LogLevel(self) -> Severity:
945 """Return the current minimal severity level for writing."""
946 return self._writeLevel
948 @LogLevel.setter
949 def LogLevel(self, value: Severity) -> None:
950 """Set the minimal severity level for writing."""
951 self._writeLevel = value
953 @property
954 def BaseIndent(self) -> int:
955 return self._baseIndent
957 @BaseIndent.setter
958 def BaseIndent(self, value: int) -> None:
959 self._baseIndent = value
961 @readonly
962 def WarningCount(self) -> int:
963 return self._warningCount
965 @readonly
966 def CriticalWarningCount(self) -> int:
967 return self._criticalWarningCount
969 @readonly
970 def ErrorCount(self) -> int:
971 return self._errorCount
973 @readonly
974 def Lines(self) -> List[Line]:
975 return self._lines
977 def ExitOnPreviousErrors(self) -> None:
978 """Exit application if errors have been printed."""
979 if self._errorCount > 0:
980 self.WriteFatal("Too many errors in previous steps.")
982 def ExitOnPreviousCriticalWarnings(self, includeErrors: bool = True) -> None:
983 """Exit application if critical warnings have been printed."""
984 if includeErrors and (self._errorCount > 0): 984 ↛ 985line 984 didn't jump to line 985 because the condition on line 984 was never true
985 if self._criticalWarningCount > 0:
986 self.WriteFatal("Too many errors and critical warnings in previous steps.")
987 else:
988 self.WriteFatal("Too many errors in previous steps.")
989 elif self._criticalWarningCount > 0:
990 self.WriteFatal("Too many critical warnings in previous steps.")
992 def ExitOnPreviousWarnings(self, includeCriticalWarnings: bool = True, includeErrors: bool = True) -> None:
993 """Exit application if warnings have been printed."""
994 if includeErrors and (self._errorCount > 0): 994 ↛ 995line 994 didn't jump to line 995 because the condition on line 994 was never true
995 if includeCriticalWarnings and (self._criticalWarningCount > 0):
996 if self._warningCount > 0:
997 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
998 else:
999 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1000 elif self._warningCount > 0:
1001 self.WriteFatal("Too many warnings in previous steps.")
1002 else:
1003 self.WriteFatal("Too many errors in previous steps.")
1004 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 1004 ↛ 1005line 1004 didn't jump to line 1005 because the condition on line 1004 was never true
1005 if self._warningCount > 0:
1006 self.WriteFatal("Too many (critical) warnings in previous steps.")
1007 else:
1008 self.WriteFatal("Too many critical warnings in previous steps.")
1009 elif self._warningCount > 0:
1010 self.WriteFatal("Too many warnings in previous steps.")
1012 def WriteLine(self, line: Line) -> bool:
1013 """Print a formatted line to the underlying terminal/console offered by the operating system."""
1014 if line.Severity >= self._writeLevel:
1015 self._lines.append(line)
1016 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1017 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=line.Message, **self.Foreground), end="\n" if line.AppendLinebreak else "")
1018 return True
1019 else:
1020 return False
1022 def TryWriteLine(self, line) -> bool:
1023 return line.Severity >= self._writeLevel
1025 def WriteFatal(self, message: str, indent: int = 0, appendLinebreak: bool = True, immediateExit: bool = True) -> bool:
1026 ret = self.WriteLine(Line(message, Severity.Fatal, self._baseIndent + indent, appendLinebreak))
1027 if immediateExit:
1028 self.FatalExit()
1029 return ret
1031 def WriteError(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1032 self._errorCount += 1
1033 return self.WriteLine(Line(message, Severity.Error, self._baseIndent + indent, appendLinebreak))
1035 def WriteCritical(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1036 self._criticalWarningCount += 1
1037 return self.WriteLine(Line(message, Severity.Critical, self._baseIndent + indent, appendLinebreak))
1039 def WriteWarning(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1040 self._warningCount += 1
1041 return self.WriteLine(Line(message, Severity.Warning, self._baseIndent + indent, appendLinebreak))
1043 def WriteInfo(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1044 return self.WriteLine(Line(message, Severity.Info, self._baseIndent + indent, appendLinebreak))
1046 def WriteQuiet(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1047 return self.WriteLine(Line(message, Severity.Quiet, self._baseIndent + indent, appendLinebreak))
1049 def WriteNormal(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1050 """
1051 Write a normal message.
1053 Depending on internal settings and rules, a message might be skipped.
1055 :param message: Message to write.
1056 :param indent: Indentation level of the message.
1057 :param appendLinebreak: Append a linebreak after the message. Default: ``True``
1058 :return: True, if message was actually written.
1059 """
1060 return self.WriteLine(Line(message, Severity.Normal, self._baseIndent + indent, appendLinebreak))
1062 def WriteVerbose(self, message: str, indent: int = 1, appendLinebreak: bool = True) -> bool:
1063 return self.WriteLine(Line(message, Severity.Verbose, self._baseIndent + indent, appendLinebreak))
1065 def WriteDebug(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1066 return self.WriteLine(Line(message, Severity.Debug, self._baseIndent + indent, appendLinebreak))
1068 def WriteDryRun(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1069 return self.WriteLine(Line(message, Severity.DryRun, self._baseIndent + indent, appendLinebreak))