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

TerminalBaseApplication

Colors, terminal size, low-level writing, exiting and exception printing.

TerminalApplication

Line-based messages with severities, verbosity handling and counters.

Severity

The severity levels a message can have.

Mode

Which stream (STDOUT/STDERR) a severity is written to.

Line

A single message: text, severity, indentation and timestamp.

ILineTerminal

Mixin giving any class the Write* methods of an attached terminal.

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

NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE

240

An unimplemented function or abstract method was called.

UNHANDLED_EXCEPTION_EXIT_CODE

241

An exception reached the topmost exception handler.

MISSING_DEPENDENCY_EXIT_CODE

242

An optional dependency of the application is not installed.

FATAL_EXIT_CODE

255

A fatal message was written, or FatalExit() was called.

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:

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

WriteFatal

Severity.Fatal

Exits the application, unless immediateExit=False.

WriteError

Severity.Error

Increments the error counter.

WriteQuiet

Severity.Quiet

Visible even in quiet mode.

WriteCritical

Severity.Critical

Increments the critical warning counter.

WriteCriticalNote

Severity.CriticalNote

Follow-up line of a critical warning, rendered indented.

WriteWarning

Severity.Warning

Increments the warning counter.

WriteWarningNote

Severity.WarningNote

Follow-up line of a warning, rendered indented.

WriteInfo

Severity.Info

Visible at the default log level, like a normal message.

WriteNormal

Severity.Normal

The default severity of a message.

WriteDryRun

Severity.DryRun

For actions skipped in a dry-run.

WriteVerbose

Severity.Verbose

Visible from verbose upwards.

WriteDebug

Severity.Debug

Visible only in debug mode.

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)

Severity.Normal

WriteNormal and above; verbose, dry-run and debug are dropped.

verbose=True

Severity.Verbose

additionally WriteDryRun and WriteVerbose.

debug=True

Severity.Debug

everything, including WriteDebug.

silent=True

Severity.Silent

only warnings, errors and fatal messages.

quiet=True

Severity.Quiet

only WriteQuiet, errors and fatal messages.

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:

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

Exception

120

An unhandled exception.

ExceptionCause

115

The exception that caused it.

ExceptionNote

110

A note attached to an exception.

Fatal

100

The application cannot continue.

Error

80

An error, counted and reported.

Quiet

70

Always visible, even in quiet mode.

Critical

60

A critical warning.

CriticalNote

55

A follow-up line of a critical warning.

Warning

50

A warning.

WarningNote

45

A follow-up line of a warning.

Silent

40

The threshold of silent mode - not used as a message severity.

Info

20

An informative message.

Normal

10

The default message severity.

DryRun

8

An action that was skipped in a dry-run.

Verbose

5

A verbose message.

Debug

2

A debug message.

All

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

AllLinearToStdOut (default)

Everything to STDOUT, so the messages keep their order in a redirected log.

TextToStdOut_ErrorsToStdErr

Warnings and above to STDERR - except Quiet, which stays on STDOUT.

DataToStdOut_OtherToStdErr

Everything to STDERR, leaving STDOUT free for the program’s data.

# 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

__copyright__

Copyright:, one line per line of the value.

__license__

License:

__author__

Authors:, one line per comma-separated author.

__email__

Email:, if present.

__version__

Version:

__project_url__

Project:, if present.

__documentation_url__

Documentation:, if present.

__issue_tracker_url__

Issue tracker:, if present.

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.