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.

Step1.py
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.

Step2.py
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.

Step2.py
  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).

Step3.py
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.

Step4.py
  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.

Step5.py
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.

Step5.py
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.

Step6.py
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.

Testing.py
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])