Terminal
A set of helpers to implement a text user interface (TUI) in a terminal.
The package is built on the idea that a command line program emits one line of text per message, and that
every message has a severity: a normal message, a warning, an error, a debug message, …
The severity decides three things at once: whether the message is visible at the configured verbosity, how it is
formatted and colored, and whether it is written to STDOUT or STDERR.
An application derives from TerminalApplication and writes its messages with the
matching Write* method. Coloring is provided by
colorama, so an application doesn’t handle escape sequences itself.
from pyTooling.TerminalUI import TerminalApplication
class Application(TerminalApplication):
HeadLine = "My Application"
def Run(self) -> None:
self._PrintHeadline()
self.WriteQuiet("Always visible.")
self.WriteNormal("A normal message.")
self.WriteVerbose("Only with --verbose.")
self.WriteDebug("Only with --debug.")
self.WriteWarning("A warning.")
self.ExitOnPreviousWarnings()
def main() -> NoReturn:
program = Application()
program.Configure(verbose=("-v" in argv or "--verbose" in argv))
try:
program.Run()
except Exception as ex:
program.PrintException(ex)
if __name__ == "__main__":
main()
Classes at a Glance
Class |
Purpose |
|---|---|
Colors, terminal size, low-level writing, exiting and exception printing. |
|
Line-based messages with severities, verbosity handling and counters. |
|
The severity levels a message can have. |
|
Which stream ( |
|
A single message: text, severity, indentation and timestamp. |
|
Mixin giving any class the |
TerminalBaseApplication
TerminalBaseApplication is the base-class of every terminal application. It handles
color support, the terminal’s size, writing to the standard streams, and leaving the program.
Singleton
The class is created with ExtendedType(singleton=True), so a class is instantiated once: every further
Application() returns the same object. That is what allows helper classes to reach the terminal without it being
passed around, but it also means state - written lines, message counters - survives. In unit tests, give every
testcase its own derived class instead of instantiating the same class twice.
Colored Output
If STDOUT is a terminal, colorama is initialized in the constructor, otherwise colors are switched off - so a
redirected output doesn’t contain escape sequences. The color palette is offered as a dictionary in the class variable
Foreground, which is used as the keyword arguments of a
str.format() call:
self.WriteLineToStdErr("{RED}[ERROR] {message}{NOCOLOR}".format(message="It failed.", **self.Foreground))
Besides plain colors (RED, DARK_RED, GREEN, YELLOW, MAGENTA, BLUE, CYAN, GRAY,
WHITE, NOCOLOR, …), the palette contains the semantic entries HEADLINE, WARNING and ERROR. If
colorama isn’t installed, every entry is an empty string, so the same code emits uncolored text.
Hint
colorama is an optional dependency: install pyTooling[terminal], otherwise importing the package raises an
exception naming that extra.
Terminal Size
Width and
Height return the terminal’s size in characters, as determined
once in the constructor by GetTerminalSize(). That static method
supports native Windows (kernel32.dll:GetConsoleScreenBufferInfo) as well as Linux, macOS, FreeBSD, MinGW32/64,
UCRT64, Clang64 and Cygwin (ioctl(TIOCGWINSZ), falling back to the environment variables COLUMNS and LINES).
If the size can’t be determined, (80, 25) is assumed; on an unsupported platform, a
PlatformNotSupportedError is raised.
Low-Level Writing
Four methods write to the standard streams without any formatting, severity handling or verbosity check:
WriteToStdOut(),
WriteLineToStdOut(),
WriteToStdErr() and
WriteLineToStdErr(). They are the escape hatch for output that must
appear regardless of the configured log level - an error message from the topmost exception handler, for example.
Exiting and Exit Codes
Exit() uninitializes the colors and terminates the program;
FatalExit() does the same, but substitutes
FATAL_EXIT_CODE when the given exit code is 0. The reserved
exit codes are class variables, so an application can override them:
Class variable |
Value |
Used when |
|---|---|---|
|
240 |
An unimplemented function or abstract method was called. |
|
241 |
An exception reached the topmost exception handler. |
|
242 |
An optional dependency of the application is not installed. |
|
255 |
A fatal message was written, or |
Printing Exceptions
Three methods render an exception for a human reader instead of a Python traceback dump. Each of them prints the
exception, its notes (BaseException.add_note()), the causing exception if there is one, the traceback, and finally
exits:
PrintException()- for anyException.PrintExceptionBase()- for aExceptionBase, a known exception that was nevertheless not handled.
program = Application()
try:
program.Run()
except ExceptionBase as ex:
program.PrintExceptionBase(ex)
except NotImplementedError as ex:
program.PrintNotImplementedError(ex)
except Exception as ex:
program.PrintException(ex)
Reporting Bugs: ISSUE_TRACKER_URL
If the class variable ISSUE_TRACKER_URL is set, every exception
printed by the methods above ends with an invitation to report the bug, followed by that URL. It is None by default,
in which case the invitation is omitted. It is independent of the Issue tracker: line of
the version information, which is read from the application’s dunder module.
An application connects the class variable to its own dunder variable. pyTooling can’t find that variable on its own:
which module carries __issue_tracker_url__ is up to the application - <package>/__init__.py for a simple
package, <namespace>/<package>/__init__.py for a namespace package.
from pyTooling.TerminalUI import TerminalApplication
from myPackage import __issue_tracker_url__
class Application(TerminalApplication):
ISSUE_TRACKER_URL = __issue_tracker_url__
TerminalApplication
TerminalApplication adds line-based messaging on top of the base-class: a family of
Write* methods, a verbosity setting deciding which of them are visible, message counters, and a recorded history of
everything written.
Each Write* method takes the message and two optional keyword arguments - indent and appendLinebreak - wraps
them in a Line and hands it to
WriteLine(). The return value tells whether the message was actually
written, or dropped because its severity is below the current log level.
Method |
Severity |
Notes |
|---|---|---|
|
|
Exits the application, unless |
|
|
Increments the error counter. |
|
|
Visible even in quiet mode. |
|
|
Increments the critical warning counter. |
|
|
Follow-up line of a critical warning, rendered indented. |
|
|
Increments the warning counter. |
|
|
Follow-up line of a warning, rendered indented. |
|
|
Visible at the default log level, like a normal message. |
|
|
The default severity of a message. |
|
|
For actions skipped in a dry-run. |
|
|
Visible from |
|
|
Visible only in |
TryWriteLine() answers whether a line would be written, without
writing it - useful before assembling an expensive message.
A note is a follow-up line belonging to the message above it, rendered with a leading > at the severity’s
indentation. Only warnings and critical warnings have one, although Severity also
defines ExceptionNote:
self.WriteWarning(f"File '{file}' was ignored.")
self.WriteWarningNote(f"Only '.vhdl' and '.vhd' are read.")
Verbosity: Configure
Configure() translates the usual command line switches into a log level.
It takes keyword arguments only, and debug implies verbose:
from sys import argv
program = Application()
program.Configure(
verbose=("-v" in argv or "--verbose" in argv),
debug=( "-d" in argv or "--debug" in argv),
quiet=( "-q" in argv or "--quiet" in argv)
)
The resulting log level - readable and writable as
LogLevel - is the minimum severity a message needs to be written:
Configuration |
Log level |
Lowest severity still visible |
|---|---|---|
(default) |
|
|
|
|
additionally |
|
|
everything, including |
|
|
only warnings, errors and fatal messages. |
|
|
only |
The chosen mode is also readable as Verbose,
Debug,
Silent and
Quiet, so an application can skip work whose result would never be
printed.
Counters and Exit Conditions
Writing a warning, a critical warning or an error increments the matching counter:
WarningCount,
CriticalWarningCount and
ErrorCount. Counting happens even when the message itself is
suppressed by the log level.
Three methods end a processing step by those counters, each writing a fatal message and exiting if anything was counted:
ExitOnPreviousErrors()- errors only.ExitOnPreviousCriticalWarnings()- critical warnings, errors included by default.ExitOnPreviousWarnings()- warnings, critical warnings and errors included by default.
def Run(self) -> None:
self.ParseInputFiles()
self.ExitOnPreviousErrors() # don't start processing with unreadable inputs
Every written line is also kept in Lines as a
Line object, which is what makes a terminal application testable: the test configures the
application, runs it and inspects the recorded messages and their severities.
Severity Levels
Severity is an enumeration whose values are ordered - the comparison operators are
implemented, and comparing against anything but another Severity raises a TypeError. A message is written
when its severity is greater than or equal to the current log level, so the numeric values are the actual policy:
Severity |
Value |
Meaning |
|---|---|---|
|
120 |
An unhandled exception. |
|
115 |
The exception that caused it. |
|
110 |
A note attached to an exception. |
|
100 |
The application cannot continue. |
|
80 |
An error, counted and reported. |
|
70 |
Always visible, even in quiet mode. |
|
60 |
A critical warning. |
|
55 |
A follow-up line of a critical warning. |
|
50 |
A warning. |
|
45 |
A follow-up line of a warning. |
|
40 |
The threshold of silent mode - not used as a message severity. |
|
20 |
An informative message. |
|
10 |
The default message severity. |
|
8 |
An action that was skipped in a dry-run. |
|
5 |
A verbose message. |
|
2 |
A debug message. |
|
0 |
The threshold letting every message pass. |
Quiet sitting between Error and Critical is what makes a “quiet” program still print its actual result:
in quiet mode the log level is Quiet, so warnings disappear while a WriteQuiet message survives.
Message Routing (Mode)
Mode decides which stream a severity is written to. It’s given to the constructor of
TerminalApplication and expanded into a routing table, one entry per severity:
Mode |
Routing |
|---|---|
|
Everything to |
|
Warnings and above to |
|
Everything to |
# a program whose result is piped into another program
app = Application(Mode.DataToStdOut_OtherToStdErr)
Line
A Line is a single message: its text
(Message), its Severity, its
Indent level, whether a linebreak is appended
(AppendLinebreak), and the timestamp of its creation. Applications rarely construct
one - the Write* methods do it - but every recorded message in
Lines is such an object.
IndentBy() raises the indentation level of an existing line, and str(line) renders
the message with the severity’s prefix (ERROR: ..., WARNING: ..., DEBUG: ...) but without colors - the
colored format used when printing lives in TerminalApplication instead.
Note
The indentation is recorded (indent per message plus the application’s
BaseIndent), but it is not yet applied when a line is printed.
ILineTerminal
Not every class writing messages is the application itself. ILineTerminal is a
mixin that gives a class the same Write* methods, forwarding them to an attached terminal - or
silently doing nothing if none is attached. Every method additionally takes condition, so a message can be made
dependent on a check without an if statement:
from pyTooling.TerminalUI import ILineTerminal
class Parser(ILineTerminal):
def __init__(self, terminal) -> None:
super().__init__(terminal)
def Parse(self, file) -> None:
self.WriteVerbose(f"Parsing '{file}'...")
self.WriteWarning(f"File '{file}' is empty.", condition=file.stat().st_size == 0)
Headline, Help and Version Information
Three helper methods print the parts of a program’s user interface that look the same in every application:
_PrintHeadline() prints the class variable
HeadLine, centered between two horizontal lines of the given width
(0 meaning the terminal’s width):
================================================================
Report Service Program
================================================================
_PrintHelp() prints the help page of the argument parser,
or of one subcommand. It belongs to the mixin-class, which owns the parsers, and expects the Write*** methods of
TerminalApplication - so it is usable in an application combining both classes.
_PrintVersion() prints the program’s meta data, read from the dunder
variables of the module handed to it:
Dunder variable |
Printed as |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A missing copyright, license, author or version is printed in red rather than omitted, because those four are expected of every program.
If a package name is passed as the second parameter, PyPI is queried for the latest release and the version line reports whether an update is available:
def _PrintVersion(self) -> None:
import myPackage as DunderModule
super()._PrintVersion(DunderModule, "myPackage")
Version: v1.0.0 (Update available: v1.2.0)
The query is given a timeout (1 second by default, versionCheckTimeout) and every failure is absorbed: an
unreachable index prints (PyPI timeout) instead of raising. Without a package name, no request is made at all.
A Complete Application
A real command line program combines TerminalApplication with
ArgParseHelperMixin, so commands and options are declared as decorated methods
while the messages, the verbosity and the exception reporting come from this package:
@export
class Application(TerminalApplication, ArgParseHelperMixin):
HeadLine: ClassVar[str] = "My Application"
ISSUE_TRACKER_URL: ClassVar[str] = __issue_tracker_url__
def __init__(self) -> None:
super().__init__()
ArgParseHelperMixin.__init__(self, prog="myapp", add_help=False)
def Run(self) -> None:
ArgParseHelperMixin.Run(self)
The Terminal Application tutorial builds such a program step by step - from the first message to the command handlers, the exception reporting and the unit tests.