Coverage for pyTooling/TerminalUI/__init__.py: 78%
461 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ _ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2025 Patrick Lehmann - Bötzingen, Germany #
15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""A set of helpers to implement a text user interface (TUI) in a terminal."""
33from enum import Enum, unique
34from io import TextIOWrapper
35from sys import stdin, stdout, stderr, version_info # needed for versions before Python 3.11
36from textwrap import dedent
37from typing import NoReturn, Tuple, Any, List, Optional as Nullable, Dict, Callable, ClassVar
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 print_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 self.WriteLineToStdErr(dedent(f"""\
403 {{RED}}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{{NOCOLOR}}
404 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{RED}}{ex.__class__.__name__}{{NOCOLOR}}
405 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {ex!s}
406 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
407 """).format(indent=self.INDENT, **self.Foreground))
409 if ex.__cause__ is not None:
410 self.WriteLineToStdErr(dedent(f"""\
411 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}}
412 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex.__cause__!s}\
413 """).format(indent2=self.INDENT*2, **self.Foreground))
415 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
416 print_tb(ex.__traceback__, file=self._stderr)
417 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
419 if self.ISSUE_TRACKER_URL is not None: 419 ↛ 425line 419 didn't jump to line 425 because the condition on line 419 was always true
420 self.WriteLineToStdErr(dedent(f"""\
421 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
422 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
423 """).format(indent=self.INDENT, **self.Foreground))
425 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
427 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn:
428 """Prints a not-implemented exception of type :exc:`NotImplementedError`."""
429 from traceback import walk_tb
431 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
432 filename = frame.f_code.co_filename
433 funcName = frame.f_code.co_name
435 self.WriteLineToStdErr(dedent(f"""\
436 {{RED}}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{{NOCOLOR}}
437 {{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{RED}}{funcName}(...){{NOCOLOR}}
438 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {ex!s}
439 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
440 """).format(indent=self.INDENT, **self.Foreground))
442 if self.ISSUE_TRACKER_URL is not None: 442 ↛ 449line 442 didn't jump to line 449 because the condition on line 442 was always true
443 self.WriteLineToStdErr(dedent(f"""\
444 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}
445 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
446 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
447 """).format(indent=self.INDENT, **self.Foreground))
449 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE)
451 def PrintExceptionBase(self, ex: Exception) -> NoReturn:
452 """
453 Prints an exception of type :exc:`ExceptionBase` and its traceback.
455 If the exception as a nested action, the cause is printed as well.
457 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
458 """
459 from traceback import print_tb, walk_tb
461 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
462 filename = frame.f_code.co_filename
463 funcName = frame.f_code.co_name
465 self.WriteLineToStdErr(dedent(f"""\
466 {{RED}}[FATAL] A known but unhandled exception reached the topmost exception handler!{{NOCOLOR}}
467 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{RED}}{ex.__class__.__name__}{{NOCOLOR}}
468 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {ex!s}
469 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
470 """).format(indent=self.INDENT, **self.Foreground))
472 if ex.__cause__ is not None:
473 self.WriteLineToStdErr(dedent(f"""\
474 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{RED}}{ex.__cause__.__class__.__name__}{{NOCOLOR}}
475 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex.__cause__!s}\
476 """).format(indent2=self.INDENT * 2, **self.Foreground))
478 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
479 print_tb(ex.__traceback__, file=self._stderr)
480 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
482 if self.ISSUE_TRACKER_URL is not None: 482 ↛ 488line 482 didn't jump to line 488 because the condition on line 482 was always true
483 self.WriteLineToStdErr(dedent(f"""\
484 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
485 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
486 """).format(indent=self.INDENT, **self.Foreground))
488 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
491@export
492@unique
493class Severity(Enum):
494 """Logging message severity levels."""
496 Fatal = 100 #: Fatal messages
497 Error = 80 #: Error messages
498 Quiet = 70 #: Always visible messages, even in quiet mode.
499 Critical = 60 #: Critical messages
500 Warning = 50 #: Warning messages
501 Info = 20 #: Informative messages
502 Normal = 10 #: Normal messages
503 DryRun = 8 #: Messages visible in a dry-run
504 Verbose = 5 #: Verbose messages
505 Debug = 2 #: Debug messages
506 All = 0 #: All messages
508 def __hash__(self):
509 return hash(self.name)
511 def __eq__(self, other: Any) -> bool:
512 """
513 Compare two Severity instances (severity level) for equality.
515 :param other: Operand to compare against.
516 :returns: ``True``, if both severity levels are equal.
517 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
518 """
519 if isinstance(other, Severity):
520 return self.value == other.value
521 else:
522 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by == operator.")
523 if version_info >= (3, 11): # pragma: no cover
524 ex.add_note(f"Supported types for second operand: Severity")
525 raise ex
527 def __ne__(self, other: Any) -> bool:
528 """
529 Compare two Severity instances (severity level) for inequality.
531 :param other: Operand to compare against.
532 :returns: ``True``, if both severity levels are unequal.
533 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
534 """
535 if isinstance(other, Severity):
536 return self.value != other.value
537 else:
538 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.")
539 if version_info >= (3, 11): # pragma: no cover
540 ex.add_note(f"Supported types for second operand: Severity")
541 raise ex
543 def __lt__(self, other: Any) -> bool:
544 """
545 Compare two Severity instances (severity level) for less-than.
547 :param other: Operand to compare against.
548 :returns: ``True``, if severity levels is less than other severity level.
549 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
550 """
551 if isinstance(other, Severity):
552 return self.value < other.value
553 else:
554 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.")
555 if version_info >= (3, 11): # pragma: no cover
556 ex.add_note(f"Supported types for second operand: Severity")
557 raise ex
559 def __le__(self, other: Any) -> bool:
560 """
561 Compare two Severity instances (severity level) for less-than-or-equal.
563 :param other: Operand to compare against.
564 :returns: ``True``, if severity levels is less than or equal other severity level.
565 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
566 """
567 if isinstance(other, Severity):
568 return self.value <= other.value
569 else:
570 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.")
571 if version_info >= (3, 11): # pragma: no cover
572 ex.add_note(f"Supported types for second operand: Severity")
573 raise ex
575 def __gt__(self, other: Any) -> bool:
576 """
577 Compare two Severity instances (severity level) for greater-than.
579 :param other: Operand to compare against.
580 :returns: ``True``, if severity levels is greater than other severity level.
581 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
582 """
583 if isinstance(other, Severity):
584 return self.value > other.value
585 else:
586 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.")
587 if version_info >= (3, 11): # pragma: no cover
588 ex.add_note(f"Supported types for second operand: Severity")
589 raise ex
591 def __ge__(self, other: Any) -> bool:
592 """
593 Compare two Severity instances (severity level) for greater-than-or-equal.
595 :param other: Operand to compare against.
596 :returns: ``True``, if severity levels is greater than or equal other severity level.
597 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
598 """
599 if isinstance(other, Severity):
600 return self.value >= other.value
601 else:
602 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.")
603 if version_info >= (3, 11): # pragma: no cover
604 ex.add_note(f"Supported types for second operand: Severity")
605 raise ex
608@export
609@unique
610class Mode(Enum):
611 TextToStdOut_ErrorsToStdErr = 0
612 AllLinearToStdOut = 1
613 DataToStdOut_OtherToStdErr = 2
616@export
617class Line(metaclass=ExtendedType, slots=True):
618 """Represents a single message line with a severity and indentation level."""
620 _LOG_MESSAGE_FORMAT__ = {
621 Severity.Fatal: "FATAL: {message}",
622 Severity.Error: "ERROR: {message}",
623 Severity.Quiet: "{message}",
624 Severity.Warning: "WARNING: {message}",
625 Severity.Info: "INFO: {message}",
626 Severity.Normal: "{message}",
627 Severity.DryRun: "DRYRUN: {message}",
628 Severity.Verbose: "VERBOSE: {message}",
629 Severity.Debug: "DEBUG: {message}",
630 } #: Message line formatting rules.
632 _message: str #: Text message (line content).
633 _severity: Severity #: Message severity
634 _indent: int #: Indentation
635 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
637 def __init__(self, message: str, severity: Severity = Severity.Normal, indent: int = 0, appendLinebreak: bool = True) -> None:
638 """Constructor for a new ``Line`` object."""
639 self._severity = severity
640 self._message = message
641 self._indent = indent
642 self._appendLinebreak = appendLinebreak
644 @readonly
645 def Message(self) -> str:
646 """
647 Return the indented line.
649 :returns: Raw message of the line.
650 """
651 return self._message
653 @readonly
654 def Severity(self) -> Severity:
655 """
656 Return the line's severity level.
658 :returns: Severity level of the message line.
659 """
660 return self._severity
662 @readonly
663 def Indent(self) -> int:
664 """
665 Return the line's indentation level.
667 :returns: Indentation level.
668 """
669 return self._indent
671 def IndentBy(self, indent: int) -> int:
672 """
673 Increase a line's indentation level.
675 :param indent: Indentation level added to the current indentation level.
676 """
677 # TODO: used named expression starting from Python 3.8
678 indent += self._indent
679 self._indent = indent
680 return indent
682 @readonly
683 def AppendLinebreak(self) -> bool:
684 """
685 Returns if a linebreak should be added at the end of the message.
687 :returns: True, if a linebreak should be added.
688 """
689 return self._appendLinebreak
691 def __str__(self) -> str:
692 """Returns a formatted version of a ``Line`` objects as a string."""
693 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
696@export
697@mixin
698class ILineTerminal:
699 """A mixin class (interface) to provide class-local terminal writing methods."""
701 _terminal: TerminalBaseApplication
703 def __init__(self, terminal: Nullable[TerminalBaseApplication] = None) -> None:
704 """MixIn initializer."""
705 self._terminal = terminal
707 # FIXME: Alter methods if a terminal is present or set dummy methods
709 @readonly
710 def Terminal(self) -> TerminalBaseApplication:
711 """Return the local terminal instance."""
712 return self._terminal
714 def WriteLine(self, line: Line, condition: bool = True) -> bool:
715 """Write an entry to the local terminal."""
716 if (self._terminal is not None) and condition:
717 return self._terminal.WriteLine(line)
718 return False
720 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
721 # if (self._terminal is not None) and condition:
722 # return self._terminal.TryWrite(*args, **kwargs)
723 # return False
725 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
726 """Write a fatal message if ``condition`` is true."""
727 if (self._terminal is not None) and condition:
728 return self._terminal.WriteFatal(*args, **kwargs)
729 return False
731 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
732 """Write an error message if ``condition`` is true."""
733 if (self._terminal is not None) and condition:
734 return self._terminal.WriteError(*args, **kwargs)
735 return False
737 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
738 """Write a warning message if ``condition`` is true."""
739 if (self._terminal is not None) and condition:
740 return self._terminal.WriteCritical(*args, **kwargs)
741 return False
743 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
744 """Write a warning message if ``condition`` is true."""
745 if (self._terminal is not None) and condition:
746 return self._terminal.WriteWarning(*args, **kwargs)
747 return False
749 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
750 """Write an info message if ``condition`` is true."""
751 if (self._terminal is not None) and condition:
752 return self._terminal.WriteInfo(*args, **kwargs)
753 return False
755 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
756 """Write a message even in quiet mode if ``condition`` is true."""
757 if (self._terminal is not None) and condition:
758 return self._terminal.WriteQuiet(*args, **kwargs)
759 return False
761 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
762 """Write a *normal* message if ``condition`` is true."""
763 if (self._terminal is not None) and condition:
764 return self._terminal.WriteNormal(*args, **kwargs)
765 return False
767 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
768 """Write a verbose message if ``condition`` is true."""
769 if (self._terminal is not None) and condition:
770 return self._terminal.WriteVerbose(*args, **kwargs)
771 return False
773 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
774 """Write a debug message if ``condition`` is true."""
775 if (self._terminal is not None) and condition:
776 return self._terminal.WriteDebug(*args, **kwargs)
777 return False
779 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
780 """Write a dry-run message if ``condition`` is true."""
781 if (self._terminal is not None) and condition:
782 return self._terminal.WriteDryRun(*args, **kwargs)
783 return False
786@export
787class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
788 """
789 A base-class for implementation of terminal applications emitting line-by-line messages.
790 """
791 _LOG_MESSAGE_FORMAT__ = {
792 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
793 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
794 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
795 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
796 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
797 Severity.Info: "{WHITE}{message}{NOCOLOR}",
798 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
799 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
800 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
801 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
802 } #: Message formatting rules.
804 _LOG_LEVEL_ROUTING__: Dict[Severity, Tuple[Callable[[str, str], int]]] #: Message routing rules.
805 _verbose: bool
806 _debug: bool
807 _quiet: bool
808 _writeLevel: Severity
809 _writeToStdOut: bool
811 _lines: List[Line]
812 _baseIndent: int
814 _errorCount: int
815 _criticalWarningCount: int
816 _warningCount: int
818 HeadLine: ClassVar[str]
820 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
821 """
822 Initializer of a line-based terminal interface.
824 :param mode: Defines what output (normal, error, data) to write where. Default: a linear flow all to *STDOUT*.
825 """
826 TerminalBaseApplication.__init__(self)
827 # ILineTerminal.__init__(self, self)
829 self._LOG_LEVEL_ROUTING__ = {}
830 self.__InitializeLogLevelRouting(mode)
832 self._verbose = False
833 self._debug = False
834 self._quiet = False
835 self._writeLevel = Severity.Normal
836 self._writeToStdOut = True
838 self._lines = []
839 self._baseIndent = 0
841 self._errorCount = 0
842 self._criticalWarningCount = 0
843 self._warningCount = 0
845 def __InitializeLogLevelRouting(self, mode: Mode):
846 if mode is Mode.TextToStdOut_ErrorsToStdErr:
847 for severity in Severity:
848 if severity >= Severity.Warning and severity != Severity.Quiet:
849 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
850 else:
851 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
852 elif mode is Mode.AllLinearToStdOut:
853 for severity in Severity:
854 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
855 elif mode is Mode.DataToStdOut_OtherToStdErr:
856 for severity in Severity:
857 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
858 else: # pragma: no cover
859 raise ExceptionBase(f"Unsupported mode '{mode}'.")
861 def _PrintHeadline(self, width: int = 80) -> None:
862 """
863 Helper method to print the program headline.
865 :param width: Number of characters for horizontal lines.
867 .. admonition:: Generated output
869 .. code-block::
871 =========================
872 centered headline
873 =========================
874 """
875 if width == 0: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true
876 width = self._width
878 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
879 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground))
880 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
882 def _PrintVersion(self, author: str, email: str, copyright: str, license: str, version: str) -> None:
883 """
884 Helper method to print the version information.
886 :param author: Author of the application.
887 :param email: The author's email address.
888 :param copyright: The copyright information.
889 :param license: The license.
890 :param version: The application's version.
892 .. admonition:: Example usage
894 .. code-block:: Python
896 def _PrintVersion(self):
897 from MyModule import __author__, __email__, __copyright__, __license__, __version__
899 super()._PrintVersion(__author__, __email__, __copyright__, __license__, __version__)
900 """
901 self.WriteNormal(f"Author: {author} ({email})")
902 self.WriteNormal(f"Copyright: {copyright}")
903 self.WriteNormal(f"License: {license}")
904 self.WriteNormal(f"Version: {version}")
906 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True):
907 self._verbose = True if debug else verbose
908 self._debug = debug
909 self._quiet = quiet
911 if quiet: 911 ↛ 913line 911 didn't jump to line 913 because the condition on line 911 was always true
912 self._writeLevel = Severity.Quiet
913 elif debug:
914 self._writeLevel = Severity.Debug
915 elif verbose:
916 self._writeLevel = Severity.Verbose
917 else:
918 self._writeLevel = Severity.Normal
920 self._writeToStdOut = writeToStdOut
922 @readonly
923 def Verbose(self) -> bool:
924 """Returns true, if verbose messages are enabled."""
925 return self._verbose
927 @readonly
928 def Debug(self) -> bool:
929 """Returns true, if debug messages are enabled."""
930 return self._debug
932 @readonly
933 def Quiet(self) -> bool:
934 """Returns true, if quiet mode is enabled."""
935 return self._quiet
937 @property
938 def LogLevel(self) -> Severity:
939 """Return the current minimal severity level for writing."""
940 return self._writeLevel
942 @LogLevel.setter
943 def LogLevel(self, value: Severity) -> None:
944 """Set the minimal severity level for writing."""
945 self._writeLevel = value
947 @property
948 def BaseIndent(self) -> int:
949 return self._baseIndent
951 @BaseIndent.setter
952 def BaseIndent(self, value: int) -> None:
953 self._baseIndent = value
955 @readonly
956 def WarningCount(self) -> int:
957 return self._warningCount
959 @readonly
960 def CriticalWarningCount(self) -> int:
961 return self._criticalWarningCount
963 @readonly
964 def ErrorCount(self) -> int:
965 return self._errorCount
967 @readonly
968 def Lines(self) -> List[Line]:
969 return self._lines
971 def ExitOnPreviousErrors(self) -> None:
972 """Exit application if errors have been printed."""
973 if self._errorCount > 0:
974 self.WriteFatal("Too many errors in previous steps.")
976 def ExitOnPreviousCriticalWarnings(self, includeErrors: bool = True) -> None:
977 """Exit application if critical warnings have been printed."""
978 if includeErrors and (self._errorCount > 0): 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true
979 if self._criticalWarningCount > 0:
980 self.WriteFatal("Too many errors and critical warnings in previous steps.")
981 else:
982 self.WriteFatal("Too many errors in previous steps.")
983 elif self._criticalWarningCount > 0:
984 self.WriteFatal("Too many critical warnings in previous steps.")
986 def ExitOnPreviousWarnings(self, includeCriticalWarnings: bool = True, includeErrors: bool = True) -> None:
987 """Exit application if warnings have been printed."""
988 if includeErrors and (self._errorCount > 0): 988 ↛ 989line 988 didn't jump to line 989 because the condition on line 988 was never true
989 if includeCriticalWarnings and (self._criticalWarningCount > 0):
990 if self._warningCount > 0:
991 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
992 else:
993 self.WriteFatal("Too many errors and critical warnings in previous steps.")
994 elif self._warningCount > 0:
995 self.WriteFatal("Too many warnings in previous steps.")
996 else:
997 self.WriteFatal("Too many errors in previous steps.")
998 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true
999 if self._warningCount > 0:
1000 self.WriteFatal("Too many (critical) warnings in previous steps.")
1001 else:
1002 self.WriteFatal("Too many critical warnings in previous steps.")
1003 elif self._warningCount > 0:
1004 self.WriteFatal("Too many warnings in previous steps.")
1006 def WriteLine(self, line: Line) -> bool:
1007 """Print a formatted line to the underlying terminal/console offered by the operating system."""
1008 if line.Severity >= self._writeLevel:
1009 self._lines.append(line)
1010 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1011 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=line.Message, **self.Foreground), end="\n" if line.AppendLinebreak else "")
1012 return True
1013 else:
1014 return False
1016 def TryWriteLine(self, line) -> bool:
1017 return line.Severity >= self._writeLevel
1019 def WriteFatal(self, message: str, indent: int = 0, appendLinebreak: bool = True, immediateExit: bool = True) -> bool:
1020 ret = self.WriteLine(Line(message, Severity.Fatal, self._baseIndent + indent, appendLinebreak))
1021 if immediateExit:
1022 self.FatalExit()
1023 return ret
1025 def WriteError(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1026 self._errorCount += 1
1027 return self.WriteLine(Line(message, Severity.Error, self._baseIndent + indent, appendLinebreak))
1029 def WriteCritical(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1030 self._criticalWarningCount += 1
1031 return self.WriteLine(Line(message, Severity.Critical, self._baseIndent + indent, appendLinebreak))
1033 def WriteWarning(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1034 self._warningCount += 1
1035 return self.WriteLine(Line(message, Severity.Warning, self._baseIndent + indent, appendLinebreak))
1037 def WriteInfo(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1038 return self.WriteLine(Line(message, Severity.Info, self._baseIndent + indent, appendLinebreak))
1040 def WriteQuiet(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1041 return self.WriteLine(Line(message, Severity.Quiet, self._baseIndent + indent, appendLinebreak))
1043 def WriteNormal(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1044 """
1045 Write a normal message.
1047 Depending on internal settings and rules, a message might be skipped.
1049 :param message: Message to write.
1050 :param indent: Indentation level of the message.
1051 :param appendLinebreak: Append a linebreak after the message. Default: ``True``
1052 :return: True, if message was actually written.
1053 """
1054 return self.WriteLine(Line(message, Severity.Normal, self._baseIndent + indent, appendLinebreak))
1056 def WriteVerbose(self, message: str, indent: int = 1, appendLinebreak: bool = True) -> bool:
1057 return self.WriteLine(Line(message, Severity.Verbose, self._baseIndent + indent, appendLinebreak))
1059 def WriteDebug(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1060 return self.WriteLine(Line(message, Severity.Debug, self._baseIndent + indent, appendLinebreak))
1062 def WriteDryRun(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1063 return self.WriteLine(Line(message, Severity.DryRun, self._baseIndent + indent, appendLinebreak))