# ==================================================================================================================== #
# _____ _ _ _____ _ _ _ _ ___ #
# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| #
# |_| |___/ |___/ #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2017-2022 Patrick Lehmann - Bötzingen, Germany #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
# #
# SPDX-License-Identifier: Apache-2.0 #
# ==================================================================================================================== #
#
"""A set of helpers to implement a text user interface (TUI) in a terminal."""
__author__ = "Patrick Lehmann"
__email__ = "Paebbels@gmail.com"
__copyright__ = "2007-2022, Patrick Lehmann"
__license__ = "Apache License, Version 2.0"
__version__ = "1.5.9"
__keywords__ = ["terminal", "shell", "text user interface", "TUI", "console", "message logging"]
from enum import Enum, unique
from platform import system as platform_system
from typing import NoReturn, Tuple, Any
from pyTooling.Decorators import export
from pyTooling.MetaClasses import ExtendedType
[docs]@export
class Terminal:
FATAL_EXIT_CODE = 255
try:
from colorama import Fore as Foreground
Foreground = {
"RED": Foreground.LIGHTRED_EX,
"DARK_RED": Foreground.RED,
"GREEN": Foreground.LIGHTGREEN_EX,
"DARK_GREEN": Foreground.GREEN,
"YELLOW": Foreground.LIGHTYELLOW_EX,
"DARK_YELLOW": Foreground.YELLOW,
"MAGENTA": Foreground.LIGHTMAGENTA_EX,
"BLUE": Foreground.LIGHTBLUE_EX,
"DARK_BLUE": Foreground.BLUE,
"CYAN": Foreground.LIGHTCYAN_EX,
"DARK_CYAN": Foreground.CYAN,
"GRAY": Foreground.WHITE,
"DARK_GRAY": Foreground.LIGHTBLACK_EX,
"WHITE": Foreground.LIGHTWHITE_EX,
"NOCOLOR": Foreground.RESET,
"HEADLINE": Foreground.LIGHTMAGENTA_EX,
"ERROR": Foreground.LIGHTRED_EX,
"WARNING": Foreground.LIGHTYELLOW_EX
} #: Terminal colors
except:
Foreground = {
"RED": "",
"DARK_RED": "",
"GREEN": "",
"DARK_GREEN": "",
"YELLOW": "",
"DARK_YELLOW": "",
"MAGENTA": "",
"BLUE": "",
"DARK_BLUE": "",
"CYAN": "",
"DARK_CYAN": "",
"GRAY": "",
"DARK_GRAY": "",
"WHITE": "",
"NOCOLOR": "",
"HEADLINE": "",
"ERROR": "",
"WARNING": ""
} #: Terminal colors
_width : int = None #: Terminal width in characters
_height : int = None #: Terminal height in characters
def __init__(self):
"""
Initialize a terminal.
If the Python package `colorama <https://pypi.org/project/colorama/>`_ [#f_colorama]_ is available, then initialize it for colored outputs.
.. [#f_colorama] Colorama on Github: https://GitHub.com/tartley/colorama
"""
self.initColors()
(self._width, self._height) = self.GetTerminalSize()
[docs] @classmethod
def initColors(cls) -> None:
"""Initialize the terminal for color support by colorama."""
try:
from colorama import init
init()#strip=False)
except:
pass
[docs] @classmethod
def deinitColors(cls) -> None:
"""Uninitialize the terminal for color support by colorama."""
try:
from colorama import deinit
deinit()
except:
pass
[docs] @classmethod
def fatalExit(cls, returnCode:int =0) -> NoReturn:
"""Exit the terminal application by uninitializing color support and returning a fatal exit code."""
cls.exit(cls.FATAL_EXIT_CODE if returnCode == 0 else returnCode)
[docs] @classmethod
def exit(cls, returnCode:int =0) -> NoReturn:
"""Exit the terminal application by uninitializing color support and returning an exit code."""
cls.deinitColors()
exit(returnCode)
[docs] @classmethod
def versionCheck(cls, version) -> None:
"""Check if the used Python interpreter fulfills the minimum version requirements."""
from sys import version_info
if (version_info < version):
cls.initColors()
print("{RED}ERROR:{NOCOLOR} Used Python interpreter ({major}.{minor}.{micro}-{level}) is to old.".format(
major=version_info.major,
minor=version_info.minor,
micro=version_info.micro,
level=version_info.releaselevel,
**cls.Foreground
))
print(f" Minimal required Python version is {version[0]}.{version[1]}.{version[2]}")
cls.exit(1)
[docs] @classmethod
def printException(cls, ex) -> NoReturn:
"""Prints an exception of type :exc:`Exception`."""
from traceback import print_tb, walk_tb
cls.initColors()
print("{RED}FATAL: An unknown or unhandled exception reached the topmost exception handler!{NOCOLOR}".format(**cls.Foreground))
print(" {YELLOW}Exception type:{NOCOLOR} {typename}".format(typename=ex.__class__.__name__, **cls.Foreground))
print(" {YELLOW}Exception message:{NOCOLOR} {message!s}".format(message=ex, **cls.Foreground))
frame, sourceLine = [x for x in walk_tb(ex.__traceback__)][-1]
filename = frame.f_code.co_filename
funcName = frame.f_code.co_name
print(" {YELLOW}Caused in:{NOCOLOR} {function} in file '{filename}' at line {line}".format(
function=funcName,
filename=filename,
line=sourceLine,
**cls.Foreground
))
if (ex.__cause__ is not None):
print(" {DARK_YELLOW}Caused by type:{NOCOLOR} {typename}".format(typename=ex.__cause__.__class__.__name__, **cls.Foreground))
print(" {DARK_YELLOW}Caused by message:{NOCOLOR} {message!s}".format(message=ex.__cause__, **cls.Foreground))
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
print_tb(ex.__traceback__)
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
print(("{RED}Please report this bug at GitHub: https://GitHub.com/pyTooling/pyTooling.TerminalUI/issues{NOCOLOR}").format(**cls.Foreground))
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
cls.exit(1)
[docs] @classmethod
def printNotImplementedError(cls, ex) -> NoReturn:
"""Prints a not-implemented exception of type :exc:`NotImplementedError`."""
from traceback import walk_tb
cls.initColors()
frame, _ = [x for x in walk_tb(ex.__traceback__)][-1]
filename = frame.f_code.co_filename
funcName = frame.f_code.co_name
print("{RED}NOT IMPLEMENTED:{NOCOLOR} {function} in file '{filename}': {message!s}".format(
function=funcName,
filename=filename,
message=ex,
**cls.Foreground
))
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
print(("{RED}Please report this bug at GitHub: https://GitHub.com/pyTooling/pyTooling.TerminalUI/issues{NOCOLOR}").format(**cls.Foreground))
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
cls.exit(1)
@classmethod
def printExceptionBase(cls, ex) -> NoReturn:
cls.initColors()
print("{RED}FATAL: A known but unhandled exception reached the topmost exception handler!{NOCOLOR}".format(**cls.Foreground))
print("{RED}ERROR:{NOCOLOR} {message}".format(message=ex.message, **cls.Foreground))
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
print(("{RED}Please report this bug at GitHub: https://GitHub.com/pyTooling/pyTooling.TerminalUI/issues{NOCOLOR}").format(**cls.Foreground))
print(("{RED}" + ("-" * 80) + "{NOCOLOR}").format(**cls.Foreground))
cls.exit(1)
@property
def Width(self) -> int:
"""Returns the current terminal window's width."""
return self._width
@property
def Height(self) -> int:
"""Returns the current terminal window's height."""
return self._height
[docs] @staticmethod
def GetTerminalSize() -> Tuple[int, int]:
"""Returns the terminal size as tuple (width, height) for Windows, Mac OS (Darwin), Linux, cygwin (Windows), MinGW32/64 (Windows)."""
size = None
platform = platform_system()
if (platform == "Windows"):
size = Terminal.__GetTerminalSizeOnWindows()
elif ((platform in ["Linux", "Darwin"]) or
platform.startswith("CYGWIN") or
platform.startswith("MINGW32") or
platform.startswith("MINGW64")):
size = Terminal.__GetTerminalSizeOnLinux()
if (size is None):
size = (80, 25) # default size
return size
@staticmethod
def __GetTerminalSizeOnWindows() -> Tuple[int, int]:
"""Returns the current terminal window's size for Windows."""
try:
from ctypes import windll, create_string_buffer
from struct import unpack as struct_unpack
hStdError = windll.kernel32.GetStdHandle(-12) # stderr handle = -12
stringBuffer = create_string_buffer(22)
result = windll.kernel32.GetConsoleScreenBufferInfo(hStdError, stringBuffer)
if result:
(bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy) = struct_unpack("hhhhHhhhhhh", stringBuffer.raw)
width = right - left + 1
height = bottom - top + 1
return (width, height)
except:
pass
return Terminal.__GetTerminalSizeWithTPut()
@staticmethod
def __GetTerminalSizeWithTPut() -> Tuple[int, int]:
from shlex import split as shlex_split
from subprocess import check_output
try:
width = int(check_output(shlex_split('tput cols')))
height = int(check_output(shlex_split('tput lines')))
return (width, height)
except:
pass
@staticmethod
def __GetTerminalSizeOnLinux() -> Tuple[int, int]:
"""Returns the current terminal window's size for Linux."""
import os
def ioctl_GWINSZ(fd):
"""GetWindowSize of file descriptor."""
try:
from fcntl import ioctl as fcntl_ioctl
from struct import unpack as struct_unpack
from termios import TIOCGWINSZ
return struct_unpack('hh', fcntl_ioctl(fd, TIOCGWINSZ, '1234'))
except:
pass
# STDIN STDOUT STDERR
cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2)
if not cr:
try:
fd = os.open(os.ctermid(), os.O_RDONLY)
cr = ioctl_GWINSZ(fd)
os.close(fd)
except:
pass
if not cr:
try:
cr = (os.environ['LINES'], os.environ['COLUMNS'])
except:
return None
return (int(cr[1]), int(cr[0]))
[docs]@export
@unique
class Severity(Enum):
"""Logging message severity levels."""
Fatal = 30 #: Fatal messages
Error = 25 #: Error messages
Quiet = 20 #: Always visible messages, even in quiet mode.
Warning = 15 #: Warning messages
Info = 10 #: Informative messages
DryRun = 5 #: Messages visible in a dry-run
Normal = 4 #: Normal messages
Verbose = 2 #: Verbose messages
Debug = 1 #: Debug messages
All = 0 #: All messages
def __hash__(self):
return hash(self.name)
def __eq__(self, other: Any):
if isinstance(other, Severity):
return self.value == other.value
else:
raise TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by == operator.")
def __ne__(self, other: Any):
if isinstance(other, Severity):
return self.value != other.value
else:
raise TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by != operator.")
def __lt__(self, other: Any):
if isinstance(other, Severity):
return self.value < other.value
else:
raise TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by < operator.")
def __le__(self, other: Any):
if isinstance(other, Severity):
return self.value <= other.value
else:
raise TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by <= operator.")
def __gt__(self, other: Any):
if isinstance(other, Severity):
return self.value > other.value
else:
raise TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by > operator.")
def __ge__(self, other: Any):
if isinstance(other, Severity):
return self.value >= other.value
else:
raise TypeError(f"Second operand of type '{other.__class__.__name__}' is not supported by >= operator.")
[docs]@export
class Line:
"""Represents a single line message with a severity and indentation level."""
_LOG_MESSAGE_FORMAT__ = {
Severity.Fatal: "FATAL: {message}",
Severity.Error: "ERROR: {message}",
Severity.Warning: "WARNING: {message}",
Severity.Info: "INFO: {message}",
Severity.Quiet: "{message}",
Severity.Normal: "{message}",
Severity.Verbose: "VERBOSE: {message}",
Severity.Debug: "DEBUG: {message}",
Severity.DryRun: "DRYRUN: {message}"
} #: Terminal messages formatting rules
def __init__(self, message, severity=Severity.Normal, indent=0, appendLinebreak=True):
"""Constructor for a new ``Line`` object."""
self._severity = severity
self._message = message
self._indent = indent
self.AppendLinebreak = appendLinebreak
@property
def Severity(self) -> Severity:
"""Return the line's severity level."""
return self._severity
@property
def Indent(self) -> int:
"""Return the line's indentation level."""
return self._indent
@property
def Message(self) -> str:
"""Return the indented line."""
return (" " * self._indent) + self._message
[docs] def IndentBy(self, indent) -> None:
"""Increase a line's indentation level."""
self._indent += indent
def __str__(self) -> str:
"""Returns a formatted version of a ``Line`` objects as a string."""
return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
[docs]@export
class ILineTerminal:
"""A mixin class (interface) to provide class-local terminal writing methods."""
_terminal = None
def __init__(self, terminal=None):
"""MixIn initializer."""
self._terminal = terminal
# FIXME: Alter methods if a terminal is present or set dummy methods
@property
def Terminal(self) -> Terminal:
"""Return the local terminal instance."""
return self._terminal
[docs] def WriteLine(self, line : Line, condition=True):
"""Write an entry to the local terminal."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteLine(line)
return False
# def _TryWriteLine(self, *args, condition=True, **kwargs):
# if ((self._terminal is not None) and condition):
# return self._terminal.TryWrite(*args, **kwargs)
# return False
[docs] def WriteFatal(self, *args, condition=True, **kwargs):
"""Write a fatal message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteFatal(*args, **kwargs)
return False
[docs] def WriteError(self, *args, condition=True, **kwargs):
"""Write an error message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteError(*args, **kwargs)
return False
[docs] def WriteWarning(self, *args, condition=True, **kwargs):
"""Write a warning message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteWarning(*args, **kwargs)
return False
[docs] def WriteInfo(self, *args, condition=True, **kwargs):
"""Write a info message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteInfo(*args, **kwargs)
return False
[docs] def WriteQuiet(self, *args, condition=True, **kwargs):
"""Write a message even in quiet mode if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteQuiet(*args, **kwargs)
return False
[docs] def WriteNormal(self, *args, condition=True, **kwargs):
"""Write a *normal* message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteNormal(*args, **kwargs)
return False
[docs] def WriteVerbose(self, *args, condition=True, **kwargs):
"""Write a verbose message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteVerbose(*args, **kwargs)
return False
[docs] def WriteDebug(self, *args, condition=True, **kwargs):
"""Write a debug message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteDebug(*args, **kwargs)
return False
[docs] def WriteDryRun(self, *args, condition=True, **kwargs):
"""Write a dry-run message if ``condition`` is true."""
if ((self._terminal is not None) and condition):
return self._terminal.WriteDryRun(*args, **kwargs)
return False
[docs]@export
class LineTerminal(Terminal, ILineTerminal, metaclass=ExtendedType, singleton=True):
def __init__(self, verbose=False, debug=False, quiet=False, writeToStdOut=True):
"""Initializer of a line based terminal interface."""
Terminal.__init__(self)
ILineTerminal.__init__(self, self)
self._verbose = True if debug else verbose
self._debug = debug
self._quiet = quiet
if quiet:
self._WriteLevel = Severity.Quiet
elif debug:
self._WriteLevel = Severity.Debug
elif verbose:
self._WriteLevel = Severity.Verbose
else:
self._WriteLevel = Severity.Normal
self._writeToStdOut = writeToStdOut
self._lines = []
self._baseIndent = 0
self._errorCounter = 0
self._warningCounter = 0
@property
def Verbose(self) -> bool:
"""Returns true, if verbose messages are enabled."""
return self._verbose
@property
def Debug(self) -> bool:
"""Returns true, if debug messages are enabled."""
return self._debug
@property
def Quiet(self) -> bool:
"""Returns true, if quiet mode is enabled."""
return self._quiet
@property
def LogLevel(self) -> Severity:
"""Return the current minimal severity level for writing."""
return self._WriteLevel
@LogLevel.setter
def LogLevel(self, value: Severity) -> None:
"""Set the minimal severity level for writing."""
self._WriteLevel = value
@property
def BaseIndent(self) -> int:
return self._baseIndent
@BaseIndent.setter
def BaseIndent(self, value: int) -> None:
self._baseIndent = value
_LOG_MESSAGE_FORMAT__ = {
Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
Severity.Warning: "{YELLOW}[WARNING]{message}{NOCOLOR}",
Severity.Info: "{WHITE}{message}{NOCOLOR}",
Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
Severity.Normal: "{WHITE}{message}{NOCOLOR}",
Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
} #: Message formatting rules.
[docs] def ExitOnPreviousErrors(self) -> None:
"""Exit application if errors have been printed."""
if self._errorCounter > 0:
self.WriteFatal("Too many errors in previous steps.")
self.fatalExit()
[docs] def ExitOnPreviousWarnings(self) -> None:
"""Exit application if warnings have been printed."""
if self._warningCounter > 0:
self.WriteError("Too many warnings in previous steps.")
self.exit()
[docs] def WriteLine(self, line : Line):
"""Print a formatted line to the underlying terminal/console offered by the operating system."""
if (line.Severity >= self._WriteLevel):
self._lines.append(line)
if self._writeToStdOut:
print(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=line.Message, **self.Foreground), end="\n" if line.AppendLinebreak else "")
return True
else:
return False
def TryWriteLine(self, line) -> bool:
return (line.Severity >= self._WriteLevel)
[docs] def WriteFatal(self, message, indent=0, appendLinebreak=True, immediateExit=True):
ret = self.WriteLine(Line(message, Severity.Fatal, self._baseIndent + indent, appendLinebreak))
if immediateExit:
self.fatalExit()
return ret
[docs] def WriteError(self, message, indent=0, appendLinebreak=True):
self._errorCounter += 1
return self.WriteLine(Line(message, Severity.Error, self._baseIndent + indent, appendLinebreak))
[docs] def WriteWarning(self, message, indent=0, appendLinebreak=True):
self._warningCounter += 1
return self.WriteLine(Line(message, Severity.Warning, self._baseIndent + indent, appendLinebreak))
[docs] def WriteInfo(self, message, indent=0, appendLinebreak=True):
return self.WriteLine(Line(message, Severity.Info, self._baseIndent + indent, appendLinebreak))
[docs] def WriteQuiet(self, message, indent=0, appendLinebreak=True):
return self.WriteLine(Line(message, Severity.Quiet, self._baseIndent + indent, appendLinebreak))
[docs] def WriteNormal(self, message, indent=0, appendLinebreak=True):
return self.WriteLine(Line(message, Severity.Normal, self._baseIndent + indent, appendLinebreak))
[docs] def WriteVerbose(self, message, indent=1, appendLinebreak=True):
return self.WriteLine(Line(message, Severity.Verbose, self._baseIndent + indent, appendLinebreak))
[docs] def WriteDebug(self, message, indent=2, appendLinebreak=True):
return self.WriteLine(Line(message, Severity.Debug, self._baseIndent + indent, appendLinebreak))
[docs] def WriteDryRun(self, message, indent=2, appendLinebreak=True):
return self.WriteLine(Line(message, Severity.DryRun, self._baseIndent + indent, appendLinebreak))