Terminal Application
This tutorial builds a command line program step by step: a program that emits categorized messages, honors
--verbose, --debug and --quiet, counts its own errors, prints version information, and reports an unhandled
exception instead of dumping a traceback.
See also the reference documentation of pyTooling.TerminalUI.
Hint
Every code example on this page is a complete, runnable program in tests/example/TerminalApplication, and
each one is imported and exercised by the unit tests.
Step 1 - The Application Class
An application derives from TerminalApplication and writes its messages with the
Write* method matching the message’s severity.
Running it prints the normal message and the warning; the verbose message is dropped, because the default log
level is Severity.Normal. The warning is also counted, which
step 4 makes use of.
Hint
TerminalApplication is a singleton: instantiating Application a second time
returns the same object, including its recorded messages and counters.
from typing import NoReturn
from pyTooling.TerminalUI import TerminalApplication
class Application(TerminalApplication):
HeadLine = "My Application"
def Run(self) -> None:
self._PrintHeadline()
self.WriteNormal("Reading the input file...")
self.WriteVerbose(" Line 1 of 4")
self.WriteWarning("The input file is empty.")
def main() -> NoReturn:
program = Application()
program.Run()
program.Exit()
if __name__ == "__main__":
main()
Step 2 - Verbosity Switches
Which severities are visible is decided by Configure(), usually
from the command line switches.
Now --verbose shows the verbose line, --debug additionally shows every WriteDebug message (debug
implies verbose), and --quiet reduces the output to errors and messages written with
WriteQuiet() - which is how a quiet program still prints its
result.
def main() -> NoReturn:
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)
)
program.Run()
program.Exit()
Expensive work can be skipped by asking the application whether it would print at all. Verbose, Debug and
Quiet answer without writing anything, so the statistics are only collected when they end up on screen.
def Run(self) -> None:
self._PrintHeadline()
self.WriteNormal("Reading the input file...")
self.WriteVerbose("Collecting statistics ...") # not computed unless it's printed
self.WriteWarning("The input file is empty.")
Step 3 - A Headline and a Version Command
_PrintHeadline() prints the class variable HeadLine centered
between two horizontal lines. _PrintVersion() prints copyright,
license, authors and version - read from the dunder variables of the module handed to it, which is why an
application overrides it with the one-liner naming its own package.
Passing the package name (second parameter) queries PyPI for the latest release, so the version line tells the
user whether an update is available. The query has a one second timeout and never raises - an unreachable index
prints (PyPI timeout).
class Application(TerminalApplication):
HeadLine = "My Application"
def Run(self) -> None:
self._PrintHeadline()
self._PrintVersion()
def _PrintVersion(self) -> None:
import myPackage as DunderModule
super()._PrintVersion(DunderModule, "myPackage")
Step 4 - Stopping on Errors
Errors, critical warnings and warnings are counted while they are written, even when the log level hides them. A processing step therefore ends by asking whether it may continue.
ExitOnPreviousErrors() writes a fatal message and exits with
FATAL_EXIT_CODE if anything was counted.
ExitOnPreviousCriticalWarnings() and
ExitOnPreviousWarnings() do the same for the other two counters.
def Run(self) -> None:
self.ReadInputFiles()
self.ExitOnPreviousErrors() # unreadable input: don't start processing
self.Process()
self.ExitOnPreviousWarnings() # stricter: a warning is enough to stop
Step 5 - Reporting Unhandled Exceptions
A user should not see a raw traceback. The entry point catches what escaped and hands it to the matching printer, which formats the exception, its notes, its cause and its traceback, and then exits with a distinct exit code.
The program’s own exceptions come first and are reported as ordinary error messages - a user who passed a wrong option should read one line, not a traceback. Only what nobody expected reaches the printers, which is why they are the last three clauses.
def main() -> NoReturn:
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)
)
try:
program.Run()
except MyPackageException as ex: # the program's own exceptions, reported as messages
program.WriteLineToStdErr(f"{{RED}}[ERROR] {ex}{{NOCOLOR}}".format(**Application.Foreground))
except ExceptionBase as ex:
program.PrintExceptionBase(ex) # exit code 241, a known exception
except NotImplementedError as ex:
program.PrintNotImplementedError(ex) # exit code 240, an unimplemented function was called
except MissingDependencyError as ex:
program.PrintMissingDependencyError(ex) # exit code 242, an installation problem
except Exception as ex:
program.PrintException(ex) # exit code 241, an unexpected exception
program.Exit()
Set ISSUE_TRACKER_URL, and each of these reports ends by
inviting the user to file a bug, with the URL - except the missing-dependency report, which names the package and
the command installing it instead: nothing is wrong with the program. The application connects the class variable
to its own dunder variable, because only the application knows which of its modules carries it.
class Application(TerminalApplication):
HeadLine: ClassVar[str] = "My Application"
ISSUE_TRACKER_URL: ClassVar[str] = __issue_tracker_url__
def Run(self) -> None:
self._PrintHeadline()
self.WriteNormal("Reading the input file...")
Step 6 - Commands and Options
The message handling is independent of argument parsing, but the two are designed to be combined: an application
deriving from TerminalApplication and
ArgParseHelperMixin gets commands and options as decorated methods - and
_PrintHelp() then prints the parser’s help page, or
the help page of a single command.
A command that takes a parameter declares it: help accepts an optional command name, so
StringArgument adds it to that command’s parser and
args.Command exists when the handler runs.
That is the full shape of a pyTooling-based command line program. See ArgParse for the argument parsing part, and Terminal for everything the terminal side offers.
from argparse import Namespace, RawDescriptionHelpFormatter
from typing import ClassVar, NoReturn
from pyTooling.Attributes.ArgParse import ArgParseHelperMixin, CommandHandler, DefaultHandler
from pyTooling.Attributes.ArgParse.Argument import StringArgument
from pyTooling.Decorators import export
from pyTooling.TerminalUI import TerminalApplication
from myPackage import __issue_tracker_url__
@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", formatter_class=RawDescriptionHelpFormatter, add_help=False)
def Run(self) -> None:
ArgParseHelperMixin.Run(self)
@DefaultHandler()
def HandleDefault(self, _: Namespace) -> None:
self._PrintHeadline()
self._PrintHelp()
@CommandHandler("help", help="Display help page(s) for the given command name.")
@StringArgument(dest="Command", metaName="Command", optional=True, help="Print help page(s) for a command.")
def HandleHelp(self, args: Namespace) -> None:
self._PrintHeadline()
self._PrintHelp(args.Command)
@CommandHandler("version", help="Display version information.")
def HandleVersion(self, _: Namespace) -> None:
self._PrintHeadline()
self._PrintVersion()
def _PrintVersion(self) -> None:
import myPackage as DunderModule
super()._PrintVersion(DunderModule, "myPackage")
def main() -> NoReturn:
program = Application()
program.Run()
program.Exit()
if __name__ == "__main__":
main()
Testing Such an Application
Every written message is recorded as a Line object in
Lines, so a testcase can check what an application reported
without capturing the terminal.
Two details make this work: a derived class per testcase, because
TerminalApplication is a singleton and would otherwise carry messages from one
testcase into the next, and the counters, which are incremented even when the log level suppresses the message
itself.
The testcase derives from Testcase - see Testcase - which is
unittest.TestCase plus the assertions newer Python versions added.
from pyTooling.TerminalUI import Severity
from pyTooling.Testing import Testcase
from Step1 import Application
class ApplicationTests(Testcase):
def test_AnEmptyInputFileIsReported(self) -> None:
class TestApplication(Application): # own class: the base class is a singleton
pass
program = TestApplication()
program.Run()
self.assertEqual(1, program.WarningCount)
self.assertIn(Severity.Warning, [line.Severity for line in program.Lines])