Coverage for pyTooling/TerminalUI/__init__.py: 78%
467 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-31 22:23 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-31 22:23 +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 ex.add_note(f"Supported types for second operand: Severity")
524 raise ex
526 def __ne__(self, other: Any) -> bool:
527 """
528 Compare two Severity instances (severity level) for inequality.
530 :param other: Operand to compare against.
531 :returns: ``True``, if both severity levels are unequal.
532 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
533 """
534 if isinstance(other, Severity):
535 return self.value != other.value
536 else:
537 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.")
538 ex.add_note(f"Supported types for second operand: Severity")
539 raise ex
541 def __lt__(self, other: Any) -> bool:
542 """
543 Compare two Severity instances (severity level) for less-than.
545 :param other: Operand to compare against.
546 :returns: ``True``, if severity levels is less than other severity level.
547 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
548 """
549 if isinstance(other, Severity):
550 return self.value < other.value
551 else:
552 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.")
553 ex.add_note(f"Supported types for second operand: Severity")
554 raise ex
556 def __le__(self, other: Any) -> bool:
557 """
558 Compare two Severity instances (severity level) for less-than-or-equal.
560 :param other: Operand to compare against.
561 :returns: ``True``, if severity levels is less than or equal other severity level.
562 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
563 """
564 if isinstance(other, Severity):
565 return self.value <= other.value
566 else:
567 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.")
568 ex.add_note(f"Supported types for second operand: Severity")
569 raise ex
571 def __gt__(self, other: Any) -> bool:
572 """
573 Compare two Severity instances (severity level) for greater-than.
575 :param other: Operand to compare against.
576 :returns: ``True``, if severity levels is greater than other severity level.
577 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
578 """
579 if isinstance(other, Severity):
580 return self.value > other.value
581 else:
582 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.")
583 ex.add_note(f"Supported types for second operand: Severity")
584 raise ex
586 def __ge__(self, other: Any) -> bool:
587 """
588 Compare two Severity instances (severity level) for greater-than-or-equal.
590 :param other: Operand to compare against.
591 :returns: ``True``, if severity levels is greater than or equal other severity level.
592 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
593 """
594 if isinstance(other, Severity):
595 return self.value >= other.value
596 else:
597 ex = TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.")
598 ex.add_note(f"Supported types for second operand: Severity")
599 raise ex
602@export
603@unique
604class Mode(Enum):
605 TextToStdOut_ErrorsToStdErr = 0
606 AllLinearToStdOut = 1
607 DataToStdOut_OtherToStdErr = 2
610@export
611class Line(metaclass=ExtendedType, slots=True):
612 """Represents a single message line with a severity and indentation level."""
614 _LOG_MESSAGE_FORMAT__ = {
615 Severity.Fatal: "FATAL: {message}",
616 Severity.Error: "ERROR: {message}",
617 Severity.Quiet: "{message}",
618 Severity.Warning: "WARNING: {message}",
619 Severity.Info: "INFO: {message}",
620 Severity.Normal: "{message}",
621 Severity.DryRun: "DRYRUN: {message}",
622 Severity.Verbose: "VERBOSE: {message}",
623 Severity.Debug: "DEBUG: {message}",
624 } #: Message line formatting rules.
626 _message: str #: Text message (line content).
627 _severity: Severity #: Message severity
628 _indent: int #: Indentation
629 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
631 def __init__(self, message: str, severity: Severity = Severity.Normal, indent: int = 0, appendLinebreak: bool = True) -> None:
632 """Constructor for a new ``Line`` object."""
633 self._severity = severity
634 self._message = message
635 self._indent = indent
636 self._appendLinebreak = appendLinebreak
638 @readonly
639 def Message(self) -> str:
640 """
641 Return the indented line.
643 :returns: Raw message of the line.
644 """
645 return self._message
647 @readonly
648 def Severity(self) -> Severity:
649 """
650 Return the line's severity level.
652 :returns: Severity level of the message line.
653 """
654 return self._severity
656 @readonly
657 def Indent(self) -> int:
658 """
659 Return the line's indentation level.
661 :returns: Indentation level.
662 """
663 return self._indent
665 def IndentBy(self, indent: int) -> int:
666 """
667 Increase a line's indentation level.
669 :param indent: Indentation level added to the current indentation level.
670 """
671 # TODO: used named expression starting from Python 3.8
672 indent += self._indent
673 self._indent = indent
674 return indent
676 @readonly
677 def AppendLinebreak(self) -> bool:
678 """
679 Returns if a linebreak should be added at the end of the message.
681 :returns: True, if a linebreak should be added.
682 """
683 return self._appendLinebreak
685 def __str__(self) -> str:
686 """Returns a formatted version of a ``Line`` objects as a string."""
687 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
690@export
691@mixin
692class ILineTerminal:
693 """A mixin class (interface) to provide class-local terminal writing methods."""
695 _terminal: TerminalBaseApplication
697 def __init__(self, terminal: Nullable[TerminalBaseApplication] = None) -> None:
698 """MixIn initializer."""
699 self._terminal = terminal
701 # FIXME: Alter methods if a terminal is present or set dummy methods
703 @readonly
704 def Terminal(self) -> TerminalBaseApplication:
705 """Return the local terminal instance."""
706 return self._terminal
708 def WriteLine(self, line: Line, condition: bool = True) -> bool:
709 """Write an entry to the local terminal."""
710 if (self._terminal is not None) and condition:
711 return self._terminal.WriteLine(line)
712 return False
714 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
715 # if (self._terminal is not None) and condition:
716 # return self._terminal.TryWrite(*args, **kwargs)
717 # return False
719 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
720 """Write a fatal message if ``condition`` is true."""
721 if (self._terminal is not None) and condition:
722 return self._terminal.WriteFatal(*args, **kwargs)
723 return False
725 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
726 """Write an error message if ``condition`` is true."""
727 if (self._terminal is not None) and condition:
728 return self._terminal.WriteError(*args, **kwargs)
729 return False
731 def WriteCritical(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.WriteCritical(*args, **kwargs)
735 return False
737 def WriteWarning(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.WriteWarning(*args, **kwargs)
741 return False
743 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
744 """Write an info message if ``condition`` is true."""
745 if (self._terminal is not None) and condition:
746 return self._terminal.WriteInfo(*args, **kwargs)
747 return False
749 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
750 """Write a message even in quiet mode if ``condition`` is true."""
751 if (self._terminal is not None) and condition:
752 return self._terminal.WriteQuiet(*args, **kwargs)
753 return False
755 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
756 """Write a *normal* message if ``condition`` is true."""
757 if (self._terminal is not None) and condition:
758 return self._terminal.WriteNormal(*args, **kwargs)
759 return False
761 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
762 """Write a verbose message if ``condition`` is true."""
763 if (self._terminal is not None) and condition:
764 return self._terminal.WriteVerbose(*args, **kwargs)
765 return False
767 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
768 """Write a debug message if ``condition`` is true."""
769 if (self._terminal is not None) and condition:
770 return self._terminal.WriteDebug(*args, **kwargs)
771 return False
773 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
774 """Write a dry-run message if ``condition`` is true."""
775 if (self._terminal is not None) and condition:
776 return self._terminal.WriteDryRun(*args, **kwargs)
777 return False
780@export
781class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
782 """
783 A base-class for implementation of terminal applications emitting line-by-line messages.
784 """
785 _LOG_MESSAGE_FORMAT__ = {
786 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
787 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
788 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
789 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
790 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
791 Severity.Info: "{WHITE}{message}{NOCOLOR}",
792 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
793 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
794 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
795 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
796 } #: Message formatting rules.
798 _LOG_LEVEL_ROUTING__: Dict[Severity, Tuple[Callable[[str, str], int]]] #: Message routing rules.
799 _verbose: bool
800 _debug: bool
801 _quiet: bool
802 _writeLevel: Severity
803 _writeToStdOut: bool
805 _lines: List[Line]
806 _baseIndent: int
808 _errorCount: int
809 _criticalWarningCount: int
810 _warningCount: int
812 HeadLine: ClassVar[str]
814 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
815 """
816 Initializer of a line-based terminal interface.
818 :param mode: Defines what output (normal, error, data) to write where. Default: a linear flow all to *STDOUT*.
819 """
820 TerminalBaseApplication.__init__(self)
821 # ILineTerminal.__init__(self, self)
823 self._LOG_LEVEL_ROUTING__ = {}
824 self.__InitializeLogLevelRouting(mode)
826 self._verbose = False
827 self._debug = False
828 self._quiet = False
829 self._writeLevel = Severity.Normal
830 self._writeToStdOut = True
832 self._lines = []
833 self._baseIndent = 0
835 self._errorCount = 0
836 self._criticalWarningCount = 0
837 self._warningCount = 0
839 def __InitializeLogLevelRouting(self, mode: Mode):
840 if mode is Mode.TextToStdOut_ErrorsToStdErr:
841 for severity in Severity:
842 if severity >= Severity.Warning and severity != Severity.Quiet:
843 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
844 else:
845 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
846 elif mode is Mode.AllLinearToStdOut:
847 for severity in Severity:
848 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
849 elif mode is Mode.DataToStdOut_OtherToStdErr:
850 for severity in Severity:
851 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
852 else: # pragma: no cover
853 raise ExceptionBase(f"Unsupported mode '{mode}'.")
855 def _PrintHeadline(self, width: int = 80) -> None:
856 """
857 Helper method to print the program headline.
859 :param width: Number of characters for horizontal lines.
861 .. admonition:: Generated output
863 .. code-block::
865 =========================
866 centered headline
867 =========================
868 """
869 if width == 0: 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true
870 width = self._width
872 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
873 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground))
874 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
876 def _PrintVersion(self, author: str, email: str, copyright: str, license: str, version: str) -> None:
877 """
878 Helper method to print the version information.
880 :param author: Author of the application.
881 :param email: The author's email address.
882 :param copyright: The copyright information.
883 :param license: The license.
884 :param version: The application's version.
886 .. admonition:: Example usage
888 .. code-block:: Python
890 def _PrintVersion(self):
891 from MyModule import __author__, __email__, __copyright__, __license__, __version__
893 super()._PrintVersion(__author__, __email__, __copyright__, __license__, __version__)
894 """
895 self.WriteNormal(f"Author: {author} ({email})")
896 self.WriteNormal(f"Copyright: {copyright}")
897 self.WriteNormal(f"License: {license}")
898 self.WriteNormal(f"Version: {version}")
900 def Configure(self, verbose: bool = False, debug: bool = False, quiet: bool = False, writeToStdOut: bool = True):
901 self._verbose = True if debug else verbose
902 self._debug = debug
903 self._quiet = quiet
905 if quiet: 905 ↛ 907line 905 didn't jump to line 907 because the condition on line 905 was always true
906 self._writeLevel = Severity.Quiet
907 elif debug:
908 self._writeLevel = Severity.Debug
909 elif verbose:
910 self._writeLevel = Severity.Verbose
911 else:
912 self._writeLevel = Severity.Normal
914 self._writeToStdOut = writeToStdOut
916 @readonly
917 def Verbose(self) -> bool:
918 """Returns true, if verbose messages are enabled."""
919 return self._verbose
921 @readonly
922 def Debug(self) -> bool:
923 """Returns true, if debug messages are enabled."""
924 return self._debug
926 @readonly
927 def Quiet(self) -> bool:
928 """Returns true, if quiet mode is enabled."""
929 return self._quiet
931 @property
932 def LogLevel(self) -> Severity:
933 """Return the current minimal severity level for writing."""
934 return self._writeLevel
936 @LogLevel.setter
937 def LogLevel(self, value: Severity) -> None:
938 """Set the minimal severity level for writing."""
939 self._writeLevel = value
941 @property
942 def BaseIndent(self) -> int:
943 return self._baseIndent
945 @BaseIndent.setter
946 def BaseIndent(self, value: int) -> None:
947 self._baseIndent = value
949 @readonly
950 def WarningCount(self) -> int:
951 return self._warningCount
953 @readonly
954 def CriticalWarningCount(self) -> int:
955 return self._criticalWarningCount
957 @readonly
958 def ErrorCount(self) -> int:
959 return self._errorCount
961 @readonly
962 def Lines(self) -> List[Line]:
963 return self._lines
965 def ExitOnPreviousErrors(self) -> None:
966 """Exit application if errors have been printed."""
967 if self._errorCount > 0:
968 self.WriteFatal("Too many errors in previous steps.")
970 def ExitOnPreviousCriticalWarnings(self, includeErrors: bool = True) -> None:
971 """Exit application if critical warnings have been printed."""
972 if includeErrors and (self._errorCount > 0): 972 ↛ 973line 972 didn't jump to line 973 because the condition on line 972 was never true
973 if self._criticalWarningCount > 0:
974 self.WriteFatal("Too many errors and critical warnings in previous steps.")
975 else:
976 self.WriteFatal("Too many errors in previous steps.")
977 elif self._criticalWarningCount > 0:
978 self.WriteFatal("Too many critical warnings in previous steps.")
980 def ExitOnPreviousWarnings(self, includeCriticalWarnings: bool = True, includeErrors: bool = True) -> None:
981 """Exit application if warnings have been printed."""
982 if includeErrors and (self._errorCount > 0): 982 ↛ 983line 982 didn't jump to line 983 because the condition on line 982 was never true
983 if includeCriticalWarnings and (self._criticalWarningCount > 0):
984 if self._warningCount > 0:
985 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
986 else:
987 self.WriteFatal("Too many errors and critical warnings in previous steps.")
988 elif self._warningCount > 0:
989 self.WriteFatal("Too many warnings in previous steps.")
990 else:
991 self.WriteFatal("Too many errors in previous steps.")
992 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 992 ↛ 993line 992 didn't jump to line 993 because the condition on line 992 was never true
993 if self._warningCount > 0:
994 self.WriteFatal("Too many (critical) warnings in previous steps.")
995 else:
996 self.WriteFatal("Too many critical warnings in previous steps.")
997 elif self._warningCount > 0:
998 self.WriteFatal("Too many warnings in previous steps.")
1000 def WriteLine(self, line: Line) -> bool:
1001 """Print a formatted line to the underlying terminal/console offered by the operating system."""
1002 if line.Severity >= self._writeLevel:
1003 self._lines.append(line)
1004 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1005 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=line.Message, **self.Foreground), end="\n" if line.AppendLinebreak else "")
1006 return True
1007 else:
1008 return False
1010 def TryWriteLine(self, line) -> bool:
1011 return line.Severity >= self._writeLevel
1013 def WriteFatal(self, message: str, indent: int = 0, appendLinebreak: bool = True, immediateExit: bool = True) -> bool:
1014 ret = self.WriteLine(Line(message, Severity.Fatal, self._baseIndent + indent, appendLinebreak))
1015 if immediateExit:
1016 self.FatalExit()
1017 return ret
1019 def WriteError(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1020 self._errorCount += 1
1021 return self.WriteLine(Line(message, Severity.Error, self._baseIndent + indent, appendLinebreak))
1023 def WriteCritical(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1024 self._criticalWarningCount += 1
1025 return self.WriteLine(Line(message, Severity.Critical, self._baseIndent + indent, appendLinebreak))
1027 def WriteWarning(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1028 self._warningCount += 1
1029 return self.WriteLine(Line(message, Severity.Warning, self._baseIndent + indent, appendLinebreak))
1031 def WriteInfo(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1032 return self.WriteLine(Line(message, Severity.Info, self._baseIndent + indent, appendLinebreak))
1034 def WriteQuiet(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1035 return self.WriteLine(Line(message, Severity.Quiet, self._baseIndent + indent, appendLinebreak))
1037 def WriteNormal(self, message: str, indent: int = 0, appendLinebreak: bool = True) -> bool:
1038 """
1039 Write a normal message.
1041 Depending on internal settings and rules, a message might be skipped.
1043 :param message: Message to write.
1044 :param indent: Indentation level of the message.
1045 :param appendLinebreak: Append a linebreak after the message. Default: ``True``
1046 :return: True, if message was actually written.
1047 """
1048 return self.WriteLine(Line(message, Severity.Normal, self._baseIndent + indent, appendLinebreak))
1050 def WriteVerbose(self, message: str, indent: int = 1, appendLinebreak: bool = True) -> bool:
1051 return self.WriteLine(Line(message, Severity.Verbose, self._baseIndent + indent, appendLinebreak))
1053 def WriteDebug(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1054 return self.WriteLine(Line(message, Severity.Debug, self._baseIndent + indent, appendLinebreak))
1056 def WriteDryRun(self, message: str, indent: int = 2, appendLinebreak: bool = True) -> bool:
1057 return self.WriteLine(Line(message, Severity.DryRun, self._baseIndent + indent, appendLinebreak))