Coverage for pyTooling/TerminalUI/__init__.py: 79%
449 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ _ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2025 Patrick Lehmann - Bötzingen, Germany #
15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""A set of helpers to implement a text user interface (TUI) in a terminal."""
33from enum import Enum, unique
34from io import TextIOWrapper
35from sys import stdin, stdout, stderr, version_info # needed for versions before Python 3.11
36from textwrap import dedent
37from typing import NoReturn, Tuple, Any, List, Optional as Nullable, Dict, Callable, ClassVar
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 """Returns the current terminal window's width."""
182 return self._width
184 @readonly
185 def Height(self) -> int:
186 """Returns the current terminal window's height."""
187 return self._height
189 @staticmethod
190 def GetTerminalSize() -> Tuple[int, int]:
191 """
192 Returns the terminal size as tuple (width, height) for Windows, macOS (Darwin), Linux, cygwin (Windows), MinGW32/64 (Windows).
194 :returns: A tuple containing width and height of the terminal's size in characters.
195 :raises PlatformNotSupportedException: When a platform is not yet supported.
196 """
197 platform = Platform()
198 if platform.IsNativeWindows:
199 size = TerminalBaseApplication.__GetTerminalSizeOnWindows()
200 elif (platform.IsNativeLinux or platform.IsNativeFreeBSD or platform.IsNativeMacOS or platform.IsMinGW32OnWindows or platform.IsMinGW64OnWindows
201 or platform.IsUCRT64OnWindows or platform.IsCygwin32OnWindows or platform.IsClang64OnWindows):
202 size = TerminalBaseApplication.__GetTerminalSizeOnLinux()
203 else: # pragma: no cover
204 raise PlatformNotSupportedException(f"Platform '{platform}' not yet supported.")
206 if size is None: # pragma: no cover
207 size = (80, 25) # default size
209 return size
211 @staticmethod
212 def __GetTerminalSizeOnWindows() -> Tuple[int, int]:
213 """
214 Returns the current terminal window's size for Windows.
216 ``kernel32.dll:GetConsoleScreenBufferInfo()`` is used to retrieve the information.
218 :returns: A tuple containing width and height of the terminal's size in characters.
219 """
220 try:
221 from ctypes import windll, create_string_buffer
222 from struct import unpack as struct_unpack
224 hStdError = windll.kernel32.GetStdHandle(-12) # stderr handle = -12
225 stringBuffer = create_string_buffer(22)
226 result = windll.kernel32.GetConsoleScreenBufferInfo(hStdError, stringBuffer)
227 if result: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true
228 bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy = struct_unpack("hhhhHhhhhhh", stringBuffer.raw)
229 width = right - left + 1
230 height = bottom - top + 1
231 return (width, height)
232 except ImportError:
233 pass
235 return None
236 # return Terminal.__GetTerminalSizeWithTPut()
238 # @staticmethod
239 # def __GetTerminalSizeWithTPut() -> Tuple[int, int]:
240 # """
241 # Returns the current terminal window's size for Windows.
242 #
243 # ``tput`` is used to retrieve the information.
244 #
245 # :returns: A tuple containing width and height of the terminal's size in characters.
246 # """
247 # from subprocess import check_output
248 #
249 # try:
250 # width = int(check_output(("tput", "cols")))
251 # height = int(check_output(("tput", "lines")))
252 # return (width, height)
253 # except:
254 # pass
256 @staticmethod
257 def __GetTerminalSizeOnLinux() -> Nullable[Tuple[int, int]]: # Python 3.10: Use bitwise-or for union type: | None:
258 """
259 Returns the current terminal window's size for Linux.
261 ``ioctl(TIOCGWINSZ)`` is used to retrieve the information. As a fallback, environment variables ``COLUMNS`` and
262 ``LINES`` are checked.
264 :returns: A tuple containing width and height of the terminal's size in characters.
265 """
266 import os
268 def ioctl_GWINSZ(fd) -> Nullable[Tuple[int, int]]: # Python 3.10: Use bitwise-or for union type: | None:
269 """GetWindowSize of file descriptor."""
270 try:
271 from fcntl import ioctl as fcntl_ioctl
272 from struct import unpack as struct_unpack
273 from termios import TIOCGWINSZ
274 except ImportError:
275 return None
277 try:
278 struct = struct_unpack('hh', fcntl_ioctl(fd, TIOCGWINSZ, '1234'))
279 except OSError:
280 return None
281 try:
282 return (int(struct[1]), int(struct[0]))
283 except TypeError:
284 return None
286 # STDIN, STDOUT, STDERR
287 for fd in range(3):
288 size = ioctl_GWINSZ(fd)
289 if size is not None: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 return size
291 else:
292 try:
293 fd = os.open(os.ctermid(), os.O_RDONLY)
294 size = ioctl_GWINSZ(fd)
295 os.close(fd)
296 return size
297 except (OSError, AttributeError):
298 pass
300 try:
301 columns = int(os.getenv("COLUMNS"))
302 lines = int(os.getenv("LINES"))
303 return (columns, lines)
304 except TypeError:
305 pass
307 return None
309 def WriteToStdOut(self, message: str) -> int:
310 """
311 Low-level method for writing to ``STDOUT``.
313 :param message: Message to write to ``STDOUT``.
314 :return: Number of written characters.
315 """
316 return self._stdout.write(message)
318 def WriteLineToStdOut(self, message: str, end: str = "\n") -> int:
319 """
320 Low-level method for writing to ``STDOUT``.
322 :param message: Message to write to ``STDOUT``.
323 :param end: Use newline character. Default: ``\\n``.
324 :return: Number of written characters.
325 """
326 return self._stdout.write(message + end)
328 def WriteToStdErr(self, message: str) -> int:
329 """
330 Low-level method for writing to ``STDERR``.
332 :param message: Message to write to ``STDERR``.
333 :return: Number of written characters.
334 """
335 return self._stderr.write(message)
337 def WriteLineToStdErr(self, message: str, end: str = "\n") -> int:
338 """
339 Low-level method for writing to ``STDERR``.
341 :param message: Message to write to ``STDERR``.
342 :param end: Use newline character. Default: ``\\n``.
343 :returns: Number of written characters.
344 """
345 return self._stderr.write(message + end)
347 def FatalExit(self, returnCode: int = 0) -> NoReturn:
348 """
349 Exit the terminal application by uninitializing color support and returning a fatal Exit code.
350 """
351 self.Exit(self.FATAL_EXIT_CODE if returnCode == 0 else returnCode)
353 def Exit(self, returnCode: int = 0) -> NoReturn:
354 """
355 Exit the terminal application by uninitializing color support and returning an Exit code.
356 """
357 self.UninitializeColors()
358 exit(returnCode)
360 def CheckPythonVersion(self, version) -> None:
361 """
362 Check if the used Python interpreter fulfills the minimum version requirements.
363 """
364 from sys import version_info as info
366 if info < version:
367 self.InitializeColors()
369 self.WriteLineToStdErr(dedent(f"""\
370 { RED} [ERROR]{ NOCOLOR} Used Python interpreter ({info.major}.{info.minor}.{info.micro}-{info.releaselevel}) is to old.
371 { indent} { YELLOW} Minimal required Python version is {version[0]}.{version[1]}.{version[2]}{ NOCOLOR} \
372 """).format(indent=self.INDENT, **self.Foreground))
374 self.Exit(self.PYTHON_VERSION_CHECK_FAILED_EXIT_CODE)
376 def PrintException(self, ex: Exception) -> NoReturn:
377 """
378 Prints an exception of type :exc:`Exception` and its traceback.
380 If the exception as a nested action, the cause is printed as well.
382 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
383 """
384 from traceback import print_tb, walk_tb
386 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
387 filename = frame.f_code.co_filename
388 funcName = frame.f_code.co_name
390 self.WriteLineToStdErr(dedent(f"""\
391 { RED} [FATAL] An unknown or unhandled exception reached the topmost exception handler!{ NOCOLOR}
392 { indent} { YELLOW} Exception type:{ NOCOLOR} { RED} {ex.__class__.__name__}{ NOCOLOR}
393 { indent} { YELLOW} Exception message:{ NOCOLOR} {ex!s}
394 { indent} { YELLOW} Caused in:{ NOCOLOR} {funcName}(...) in file '{filename}' at line {sourceLine}\
395 """).format(indent=self.INDENT, **self.Foreground))
397 if ex.__cause__ is not None:
398 self.WriteLineToStdErr(dedent(f"""\
399 { indent2} { DARK_YELLOW} Caused by ex. type:{ NOCOLOR} { RED} {ex.__cause__.__class__.__name__}{ NOCOLOR}
400 { indent2} { DARK_YELLOW} Caused by message:{ NOCOLOR} {ex.__cause__!s}\
401 """).format(indent2=self.INDENT*2, **self.Foreground))
403 self.WriteLineToStdErr(f"""{ indent} { RED} {'-' * 80}{ NOCOLOR} """.format(indent=self.INDENT, **self.Foreground))
404 print_tb(ex.__traceback__, file=self._stderr)
405 self.WriteLineToStdErr(f"""{ indent} { RED} {'-' * 80}{ NOCOLOR} """.format(indent=self.INDENT, **self.Foreground))
407 if self.ISSUE_TRACKER_URL is not None: 407 ↛ 413line 407 didn't jump to line 413 because the condition on line 407 was always true
408 self.WriteLineToStdErr(dedent(f"""\
409 { indent} { DARK_CYAN} Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{ NOCOLOR}
410 { indent} { RED} {'-' * 80}{ NOCOLOR} \
411 """).format(indent=self.INDENT, **self.Foreground))
413 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
415 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn:
416 """Prints a not-implemented exception of type :exc:`NotImplementedError`."""
417 from traceback import walk_tb
419 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
420 filename = frame.f_code.co_filename
421 funcName = frame.f_code.co_name
423 self.WriteLineToStdErr(dedent(f"""\
424 { RED} [NOT IMPLEMENTED] An unimplemented function or abstract method was called!{ NOCOLOR}
425 { indent} { YELLOW} Function or method:{ NOCOLOR} { RED} {funcName}(...){ NOCOLOR}
426 { indent} { YELLOW} Exception message:{ NOCOLOR} {ex!s}
427 { indent} { YELLOW} Caused in:{ NOCOLOR} {funcName}(...) in file '{filename}' at line {sourceLine}\
428 """).format(indent=self.INDENT, **self.Foreground))
430 if self.ISSUE_TRACKER_URL is not None: 430 ↛ 437line 430 didn't jump to line 437 because the condition on line 430 was always true
431 self.WriteLineToStdErr(dedent(f"""\
432 { indent} { RED} {'-' * 80}{ NOCOLOR}
433 { indent} { DARK_CYAN} Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{ NOCOLOR}
434 { indent} { RED} {'-' * 80}{ NOCOLOR} \
435 """).format(indent=self.INDENT, **self.Foreground))
437 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE)
439 def PrintExceptionBase(self, ex: Exception) -> NoReturn:
440 """
441 Prints an exception of type :exc:`ExceptionBase` and its traceback.
443 If the exception as a nested action, the cause is printed as well.
445 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
446 """
447 from traceback import print_tb, walk_tb
449 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
450 filename = frame.f_code.co_filename
451 funcName = frame.f_code.co_name
453 self.WriteLineToStdErr(dedent(f"""\
454 { RED} [FATAL] A known but unhandled exception reached the topmost exception handler!{ NOCOLOR}
455 { indent} { YELLOW} Exception type:{ NOCOLOR} { RED} {ex.__class__.__name__}{ NOCOLOR}
456 { indent} { YELLOW} Exception message:{ NOCOLOR} {ex!s}
457 { indent} { YELLOW} Caused in:{ NOCOLOR} {funcName}(...) in file '{filename}' at line {sourceLine}\
458 """).format(indent=self.INDENT, **self.Foreground))
460 if ex.__cause__ is not None:
461 self.WriteLineToStdErr(dedent(f"""\
462 { indent2} { DARK_YELLOW} Caused by ex. type:{ NOCOLOR} { RED} {ex.__cause__.__class__.__name__}{ NOCOLOR}
463 { indent2} { DARK_YELLOW} Caused by message:{ NOCOLOR} {ex.__cause__!s}\
464 """).format(indent2=self.INDENT * 2, **self.Foreground))
466 self.WriteLineToStdErr(f"""{ indent} { RED} {'-' * 80}{ NOCOLOR} """.format(indent=self.INDENT, **self.Foreground))
467 print_tb(ex.__traceback__, file=self._stderr)
468 self.WriteLineToStdErr(f"""{ indent} { RED} {'-' * 80}{ NOCOLOR} """.format(indent=self.INDENT, **self.Foreground))
470 if self.ISSUE_TRACKER_URL is not None: 470 ↛ 476line 470 didn't jump to line 476 because the condition on line 470 was always true
471 self.WriteLineToStdErr(dedent(f"""\
472 { indent} { DARK_CYAN} Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{ NOCOLOR}
473 { indent} { RED} {'-' * 80}{ NOCOLOR} \
474 """).format(indent=self.INDENT, **self.Foreground))
476 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
479@export
480@unique
481class Severity(Enum):
482 """Logging message severity levels."""
484 Fatal = 100 #: Fatal messages
485 Error = 80 #: Error messages
486 Quiet = 70 #: Always visible messages, even in quiet mode.
487 Critical = 60 #: Critical messages
488 Warning = 50 #: Warning messages
489 Info = 20 #: Informative messages
490 Normal = 10 #: Normal messages
491 DryRun = 8 #: Messages visible in a dry-run
492 Verbose = 5 #: Verbose messages
493 Debug = 2 #: Debug messages
494 All = 0 #: All messages
496 def __hash__(self):
497 return hash(self.name)
499 def __eq__(self, other: Any) -> bool:
500 """
501 Compare two Severity instances (severity level) for equality.
503 :param other: Parameter to compare against.
504 :returns: ``True``, if both severity levels are equal.
505 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`.
506 """
507 if isinstance(other, Severity):
508 return self.value == other.value
509 else:
510 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by == operator.")
511 if version_info >= (3, 11): # pragma: no cover
512 ex.add_note(f"Supported types for second operand: Severity")
513 raise ex
515 def __ne__(self, other: Any) -> bool:
516 """
517 Compare two Severity instances (severity level) for inequality.
519 :param other: Parameter to compare against.
520 :returns: ``True``, if both severity levels are unequal.
521 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`.
522 """
523 if isinstance(other, Severity):
524 return self.value != other.value
525 else:
526 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.")
527 if version_info >= (3, 11): # pragma: no cover
528 ex.add_note(f"Supported types for second operand: Severity")
529 raise ex
531 def __lt__(self, other: Any) -> bool:
532 """
533 Compare two Severity instances (severity level) for less-than.
535 :param other: Parameter to compare against.
536 :returns: ``True``, if severity levels is less than other severity level.
537 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`.
538 """
539 if isinstance(other, Severity):
540 return self.value < other.value
541 else:
542 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.")
543 if version_info >= (3, 11): # pragma: no cover
544 ex.add_note(f"Supported types for second operand: Severity")
545 raise ex
547 def __le__(self, other: Any) -> bool:
548 """
549 Compare two Severity instances (severity level) for less-than-or-equal.
551 :param other: Parameter to compare against.
552 :returns: ``True``, if severity levels is less than or equal other severity level.
553 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`.
554 """
555 if isinstance(other, Severity):
556 return self.value <= other.value
557 else:
558 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.")
559 if version_info >= (3, 11): # pragma: no cover
560 ex.add_note(f"Supported types for second operand: Severity")
561 raise ex
563 def __gt__(self, other: Any) -> bool:
564 """
565 Compare two Severity instances (severity level) for greater-than.
567 :param other: Parameter to compare against.
568 :returns: ``True``, if severity levels is greater than other severity level.
569 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`.
570 """
571 if isinstance(other, Severity):
572 return self.value > other.value
573 else:
574 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.")
575 if version_info >= (3, 11): # pragma: no cover
576 ex.add_note(f"Supported types for second operand: Severity")
577 raise ex
579 def __ge__(self, other: Any) -> bool:
580 """
581 Compare two Severity instances (severity level) for greater-than-or-equal.
583 :param other: Parameter to compare against.
584 :returns: ``True``, if severity levels is greater than or equal other severity level.
585 :raises TypeError: If parameter ``other`` is not of type :class:`Severity`.
586 """
587 if isinstance(other, Severity):
588 return self.value >= other.value
589 else:
590 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.")
591 if version_info >= (3, 11): # pragma: no cover
592 ex.add_note(f"Supported types for second operand: Severity")
593 raise ex
596@export
597@unique
598class Mode(Enum):
599 TextToStdOut_ErrorsToStdErr = 0
600 AllLinearToStdOut = 1
601 DataToStdOut_OtherToStdErr = 2
604@export
605class Line(metaclass=ExtendedType, slots=True):
606 """Represents a single message line with a severity and indentation level."""
608 _LOG_MESSAGE_FORMAT__ = {
609 Severity.Fatal: "FATAL: {message}",
610 Severity.Error: "ERROR: {message}",
611 Severity.Quiet: "{message}",
612 Severity.Warning: "WARNING: {message}",
613 Severity.Info: "INFO: {message}",
614 Severity.Normal: "{message}",
615 Severity.DryRun: "DRYRUN: {message}",
616 Severity.Verbose: "VERBOSE: {message}",
617 Severity.Debug: "DEBUG: {message}",
618 } #: Message line formatting rules.
620 _message: str
621 _severity: Severity
622 _indent: int
623 _appendLinebreak: bool
625 def __init__(self, message: str, severity: Severity = Severity.Normal, indent: int = 0, appendLinebreak: bool = True) -> None:
626 """Constructor for a new ``Line`` object."""
627 self._severity = severity
628 self._message = message
629 self._indent = indent
630 self._appendLinebreak = appendLinebreak
632 @readonly
633 def Message(self) -> str:
634 """
635 Return the indented line.
637 :returns: Raw message of the line.
638 """
639 return self._message
641 @readonly
642 def Severity(self) -> Severity:
643 """
644 Return the line's severity level.
646 :returns: Severity level of the message line.
647 """
648 return self._severity
650 @readonly
651 def Indent(self) -> int:
652 """
653 Return the line's indentation level.
655 :returns: Indentation level.
656 """
657 return self._indent
659 def IndentBy(self, indent: int) -> int:
660 """
661 Increase a line's indentation level.
663 :param indent: Indentation level added to the current indentation level.
664 """
665 # TODO: used named expression starting from Python 3.8
666 indent += self._indent
667 self._indent = indent
668 return indent
670 @readonly
671 def AppendLinebreak(self) -> bool:
672 """
673 Returns if a linebreak should be added at the end of the message.
675 :returns: True, if a linebreak should be added.
676 """
677 return self._appendLinebreak
679 def __str__(self) -> str:
680 """Returns a formatted version of a ``Line`` objects as a string."""
681 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
684@export
685@mixin
686class ILineTerminal:
687 """A mixin class (interface) to provide class-local terminal writing methods."""
689 _terminal: TerminalBaseApplication
691 def __init__(self, terminal: Nullable[TerminalBaseApplication] = None) -> None:
692 """MixIn initializer."""
693 self._terminal = terminal
695 # FIXME: Alter methods if a terminal is present or set dummy methods
697 @readonly
698 def Terminal(self) -> TerminalBaseApplication:
699 """Return the local terminal instance."""
700 return self._terminal
702 def WriteLine(self, line: Line, condition: bool = True) -> bool:
703 """Write an entry to the local terminal."""
704 if (self._terminal is not None) and condition:
705 return self._terminal.WriteLine(line)
706 return False
708 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
709 # if (self._terminal is not None) and condition:
710 # return self._terminal.TryWrite(*args, **kwargs)
711 # return False
713 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
714 """Write a fatal message if ``condition`` is true."""
715 if (self._terminal is not None) and condition:
716 return self._terminal.WriteFatal(*args, **kwargs)
717 return False
719 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
720 """Write an error message if ``condition`` is true."""
721 if (self._terminal is not None) and condition:
722 return self._terminal.WriteError(*args, **kwargs)
723 return False
725 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
726 """Write a warning message if ``condition`` is true."""
727 if (self._terminal is not None) and condition:
728 return self._terminal.WriteCritical(*args, **kwargs)
729 return False
731 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
732 """Write a warning message if ``condition`` is true."""
733 if (self._terminal is not None) and condition:
734 return self._terminal.WriteWarning(*args, **kwargs)
735 return False
737 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
738 """Write an info message if ``condition`` is true."""
739 if (self._terminal is not None) and condition:
740 return self._terminal.WriteInfo(*args, **kwargs)
741 return False
743 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
744 """Write a message even in quiet mode if ``condition`` is true."""
745 if (self._terminal is not None) and condition:
746 return self._terminal.WriteQuiet(*args, **kwargs)
747 return False
749 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
750 """Write a *normal* message if ``condition`` is true."""
751 if (self._terminal is not None) and condition:
752 return self._terminal.WriteNormal(*args, **kwargs)
753 return False
755 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
756 """Write a verbose message if ``condition`` is true."""
757 if (self._terminal is not None) and condition:
758 return self._terminal.WriteVerbose(*args, **kwargs)
759 return False
761 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
762 """Write a debug message if ``condition`` is true."""
763 if (self._terminal is not None) and condition:
764 return self._terminal.WriteDebug(*args, **kwargs)
765 return False
767 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
768 """Write a dry-run message if ``condition`` is true."""
769 if (self._terminal is not None) and condition:
770 return self._terminal.WriteDryRun(*args, **kwargs)
771 return False
774@export
775class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
776 _LOG_MESSAGE_FORMAT__ = {
777 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
778 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
779 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
780 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
781 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
782 Severity.Info: "{WHITE}{message}{NOCOLOR}",
783 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
784 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
785 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
786 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
787 } #: Message formatting rules.
789 _LOG_LEVEL_ROUTING__: Dict[Severity, Tuple[Callable[[str, str], int]]] #: Message routing rules.
790 _verbose: bool
791 _debug: bool
792 _quiet: bool
793 _writeLevel: Severity
794 _writeToStdOut: bool
796 _lines: List[Line]
797 _baseIndent: int
799 _errorCount: int
800 _criticalWarningCount: int
801 _warningCount: int
803 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
804 """Initializer of a line based terminal interface."""
805 TerminalBaseApplication.__init__(self)
806 # ILineTerminal.__init__(self, self)
808 self._LOG_LEVEL_ROUTING__ = {}
809 self.__InitializeLogLevelRouting(mode)
811 self._verbose = False
812 self._debug = False
813 self._quiet = False
814 self._writeLevel = Severity.Normal
815 self._writeToStdOut = True
817 self._lines = []
818 self._baseIndent = 0
820 self._errorCount = 0
821 self._criticalWarningCount = 0
822 self._warningCount = 0
824 def __InitializeLogLevelRouting(self, mode: Mode):
825 if mode is Mode.TextToStdOut_ErrorsToStdErr:
826 for severity in Severity:
827 if severity >= Severity.Warning and severity != Severity.Quiet:
828 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
829 else:
830 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
831 elif mode is Mode.AllLinearToStdOut:
832 for severity in Severity:
833 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
834 elif mode is Mode.DataToStdOut_OtherToStdErr:
835 for severity in Severity:
836 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
837 else: # pragma: no cover
838 raise ExceptionBase(f"Unsupported mode '{mode}'.")
840 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True):
841 self._verbose = True if debug else verbose
842 self._debug = debug
843 self._quiet = quiet
845 if quiet: 845 ↛ 847line 845 didn't jump to line 847 because the condition on line 845 was always true
846 self._writeLevel = Severity.Quiet
847 elif debug:
848 self._writeLevel = Severity.Debug
849 elif verbose:
850 self._writeLevel = Severity.Verbose
851 else:
852 self._writeLevel = Severity.Normal
854 self._writeToStdOut = writeToStdOut
856 @readonly
857 def Verbose(self) -> bool:
858 """Returns true, if verbose messages are enabled."""
859 return self._verbose
861 @readonly
862 def Debug(self) -> bool:
863 """Returns true, if debug messages are enabled."""
864 return self._debug
866 @readonly
867 def Quiet(self) -> bool:
868 """Returns true, if quiet mode is enabled."""
869 return self._quiet
871 @property
872 def LogLevel(self) -> Severity:
873 """Return the current minimal severity level for writing."""
874 return self._writeLevel
876 @LogLevel.setter
877 def LogLevel(self, value: Severity) -> None:
878 """Set the minimal severity level for writing."""
879 self._writeLevel = value
881 @property
882 def BaseIndent(self) -> int:
883 return self._baseIndent
885 @BaseIndent.setter
886 def BaseIndent(self, value: int) -> None:
887 self._baseIndent = value
889 @readonly
890 def WarningCount(self) -> int:
891 return self._warningCount
893 @readonly
894 def CriticalWarningCount(self) -> int:
895 return self._criticalWarningCount
897 @readonly
898 def ErrorCount(self) -> int:
899 return self._errorCount
901 @readonly
902 def Lines(self) -> List[Line]:
903 return self._lines
905 def ExitOnPreviousErrors(self) -> None:
906 """Exit application if errors have been printed."""
907 if self._errorCount > 0:
908 self.WriteFatal("Too many errors in previous steps.")
910 def ExitOnPreviousCriticalWarnings(self, includeErrors: bool = True) -> None:
911 """Exit application if critical warnings have been printed."""
912 if includeErrors and (self._errorCount > 0): 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 if self._criticalWarningCount > 0:
914 self.WriteFatal("Too many errors and critical warnings in previous steps.")
915 else:
916 self.WriteFatal("Too many errors in previous steps.")
917 elif self._criticalWarningCount > 0:
918 self.WriteFatal("Too many critical warnings in previous steps.")
920 def ExitOnPreviousWarnings(self, includeCriticalWarnings: bool = True, includeErrors: bool = True) -> None:
921 """Exit application if warnings have been printed."""
922 if includeErrors and (self._errorCount > 0): 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 if includeCriticalWarnings and (self._criticalWarningCount > 0):
924 if self._warningCount > 0:
925 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
926 else:
927 self.WriteFatal("Too many errors and critical warnings in previous steps.")
928 elif self._warningCount > 0:
929 self.WriteFatal("Too many warnings in previous steps.")
930 else:
931 self.WriteFatal("Too many errors in previous steps.")
932 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true
933 if self._warningCount > 0:
934 self.WriteFatal("Too many (critical) warnings in previous steps.")
935 else:
936 self.WriteFatal("Too many critical warnings in previous steps.")
937 elif self._warningCount > 0:
938 self.WriteFatal("Too many warnings in previous steps.")
940 def WriteLine(self, line: Line) -> bool:
941 """Print a formatted line to the underlying terminal/console offered by the operating system."""
942 if line.Severity >= self._writeLevel:
943 self._lines.append(line)
944 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
945 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=line.Message, **self.Foreground), end="\n" if line.AppendLinebreak else "")
946 return True
947 else:
948 return False
950 def TryWriteLine(self, line) -> bool:
951 return line.Severity >= self._writeLevel
953 def WriteFatal(self, message: str, indent: int = 0, appendLinebreak: bool = True, immediateExit: bool = True) -> bool:
954 ret = self.WriteLine(Line(message, Severity.Fatal, self._baseIndent + indent, appendLinebreak))
955 if immediateExit:
956 self.FatalExit()
957 return ret
959 def WriteError(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
960 self._errorCount += 1
961 return self.WriteLine(Line(message, Severity.Error, self._baseIndent + indent, appendLinebreak))
963 def WriteCritical(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
964 self._criticalWarningCount += 1
965 return self.WriteLine(Line(message, Severity.Critical, self._baseIndent + indent, appendLinebreak))
967 def WriteWarning(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
968 self._warningCount += 1
969 return self.WriteLine(Line(message, Severity.Warning, self._baseIndent + indent, appendLinebreak))
971 def WriteInfo(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
972 return self.WriteLine(Line(message, Severity.Info, self._baseIndent + indent, appendLinebreak))
974 def WriteQuiet(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
975 return self.WriteLine(Line(message, Severity.Quiet, self._baseIndent + indent, appendLinebreak))
977 def WriteNormal(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
978 """
979 Write a normal message.
981 Depending on internal settings and rules, a message might be skipped.
983 :param message: Message to write.
984 :param indent: Indentation level of the message.
985 :param appendLinebreak: Append a linebreak after the message. Default: ``True``
986 :return: True, if message was actually written.
987 """
988 return self.WriteLine(Line(message, Severity.Normal, self._baseIndent + indent, appendLinebreak))
990 def WriteVerbose(self, message: str, indent: int = 1, appendLinebreak: bool = True) -> bool:
991 return self.WriteLine(Line(message, Severity.Verbose, self._baseIndent + indent, appendLinebreak))
993 def WriteDebug(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
994 return self.WriteLine(Line(message, Severity.Debug, self._baseIndent + indent, appendLinebreak))
996 def WriteDryRun(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
997 return self.WriteLine(Line(message, Severity.DryRun, self._baseIndent + indent, appendLinebreak))