13 minute read

Introduction

A lot of developers are bad at writing command line applications. Interacting with terminals is something that most developers do every day, but not many get the opportunity to (properly) learn how those interactions should work from the other side. Implementing a command line application is nothing like using one.

When we teach people to code, we often encourage them to learn by writing small, simple CLI applications. These apps typically perform some action and print out the results to the terminal. The best example is the classic ‘hello world’. A key aspect that is usually missing from these toy problems is… what’s the next step? How should terminal applications be structured when they start to grow beyond the examples used to teach the more basic concepts? How should terminal applications best take user input?

When new developers get their first industry jobs, they might still not know the answers to those questions. What’s more, it’s unlikely that they’ll get an opportunity to learn them at work: there aren’t many organisations which give junior developers the long leash required to build lots of new tools and learn what works. Most developers cut their teeth maintaining code that’s already been written, and might never need to think about that kind of structure.

This is a critical problem, since presumably at some point all of those engineers will be interviewees: if a technical test requires you to write a slightly less trivial CLI app, how do you structure your code to impress the interviewers?

This article is going to take a ‘trivial’ example we use to teach developers to code, implement a naive solution, and address issues that make this naive solution behave differently from a normal terminal app. I’ll be writing a follow-up article that implements a key-value store, to focus on how to build applications which need to take user input at an interactive prompt.

A guessing game

Let’s start by building a guessing game. This is a pretty straightforward problem, and is a common ‘starter’ program.


Prompt:

Write a program which asks the user to guess a number from one to 10. The program should tell the user whether the guess was successful, and feed back the correct number.


from random import randint

random_number = randint(1, 10)
guess = int(input("Guess a number between 1 and 10: "))

if guess == random_number:
    print("Congratulations, you guessed the right number!")
else:
    print("Sorry, you guessed wrong.")

print("The correct number was:", random_number)

Issues

The first implementation may have been straightforward, but let’s think about how it differs from terminal applications that we use on a day to day basis:

  • The game’s exit code always indicates success. There’s no way for us to tell the guess was successful without parsing human language. This could be bad: if we change the strings later (e.g. to add localisation) users’ code could break.
  • We show our users a nasty error if they pass in something that isn’t a number. Maybe our users are technical and know what invalid literal for int() with base 10 means, but they might not be.
  • The human readable output, which is information for the user, is written to the same stream as the expected number, which we might want to pass on. If we were doing something like modelling how random the program actually is, we’d want to distinguish between these two outputs. In general, content that’s for users to read should probably be written to stderr instead of stdout, and content that might be acted upon by another program should be written to stdout. If you only have one type of information, stdout is fine.
  • Most terminal apps don’t prompt you for input. It’s good practice for terminal applications to take input from command-line arguments instead of prompting the user.

Second solution: Exit codes

To change the exit code, we can manually raise a SystemExit exception at the end instead of allowing Python to decide. Most exit codes are integers between 0 and 127 (inclusive), and anything other than zero indicates an error. What specific codes mean differs from program to program, and should be included somewhere in your app’s documentation.


Prompt:

Write a program which asks the user to guess a number from one to 10. The program should tell the user whether the guess was successful, and feed back the correct number. The program should return an error code of 1 if the guess is incorrect, and 0 if it is correct.


from random import randint

random_number = randint(1, 10)
guess = int(input("Guess a number between 1 and 10: "))

if guess == random_number:
    print("Congratulations, you guessed the right number!")
    exit_code = 0  # Added successful exit code here.
else:
    print("Sorry, you guessed wrong.")
    exit_code = 1  # And an exit code for a generic error here.

print("The correct number was:", random_number)
raise SystemExit(exit_code)  # Raising the new exit code.

Third solution: Catching an error

It’s generally a bad idea to let users see tracebacks for errors we can reasonably predict. If we take user input, and expect an integer, it’s reasonable to expect that the user might enter something that isn’t a number. We should print something helpful when this happens, rather than bubbling up a cryptic error message. What we shouldn’t (normally) do is try to hide errors that we don’t expect. This prevents users from raising effective bug reports.


Prompt:

Write a program which asks the user to guess a number from one to 10. The program should tell the user whether the guess was successful, and feed back the correct number. The program should return an error code of 1 if the guess is incorrect, and 0 if it is correct. If the user’s guess is faulty (i.e. not an integer between one and ten), an error code of 2 should be returned.


from random import randint

random_number = randint(1, 10)
try:
    guess = int(input("Guess a number between 1 and 10: "))
except ValueError as err:  # Catching the int casting error
    print("Guess should be an integer between 1 and 10")
    # Raising a system exit with a new exit code.
    raise SystemExit(2) from err

# A bounds check is always good idea. Our guess should be between
# 1 and 10.
if not 1 <= guess <= 10:
    print("Guess must be between 1 and 10")
    raise SystemExit(2)

if guess == random_number:
    print("Congratulations, you guessed the right number!")
    exit_code = 0
else:
    print("Sorry, you guessed wrong.")
    exit_code = 1

print("The correct number was:", random_number)
raise SystemExit(exit_code)

Fourth solution: Crossing the streams

By default, the print statement writes to sys.stdout, but we can import sys and write to sys.stderr too. A significant annoyance here is input(...), which doesn’t allow us to change what stream is used.


Prompt:

Write a program which asks the user (via stderr) to guess a number from one to 10. The program should tell the user (via stderr) whether the guess was successful, and feed back the correct number to stdout. The program should return an error code of 1 if the guess is incorrect, and 0 if it is correct. If the user’s guess is faulty (i.e. not an integer between one and ten), an error code of 2 should be returned.


import sys  # Need to add `sys` import
from random import randint

random_number = randint(1, 10)
# Since input doesn't allow us to change the stream, let's abandon
# the prompt and use print. With `end=""` we can omit the normal
# newline.
print("Guess a number between 1 and 10: ", end="", file=sys.stderr)
try:
    guess = int(input())  # Removing prompt
except ValueError as err:
    # Every other print statement gets a `file=sys.stderr` argument
    print("Guess should be an integer between 1 and 10", file=sys.stderr)
    raise SystemExit(2) from err

if not 1 <= guess <= 10:
    print("Guess must be between 1 and 10", file=sys.stderr)
    raise SystemExit(2)

if guess == random_number:
    print("Congratulations, you guessed the right number!", file=sys.stderr)
    exit_code = 0
else:
    print("Sorry, you guessed wrong.", file=sys.stderr)
    exit_code = 1

print("The correct number was:", file=sys.stderr)
# Except the last, which we still write to stdout.
print(random_number)
raise SystemExit(exit_code)

Alternative fourth solution

As an alternative to changing all of our print statements to stderr… we could just not print them if the user is piping the output to another application. Some terminal programs like ls make good use of this behaviour: in some implementations, file listings are coloured when using a terminal, and black and white if being piped to another program.

import sys
from random import randint
from typing import TextIO

# Introducing a new function to print only to terminals.
def print_if_terminal(*to_print, end: str = "\n", file: TextIO = sys.stdout):
    """Print the output, if the output file is a terminal."""
    if file.isatty():
        print(*to_print, end=end, file=file)


random_number = randint(1, 10)
print_if_terminal("Guess a number between 1 and 10: ", end="")
try:
    guess = int(input())
except ValueError as err:
    # We still want to write our error messages to stderr.
    print("Guess should be an integer between 1 and 10", file=sys.stderr)
    raise SystemExit(2) from err

if not 1 <= guess <= 10:
    print("Guess must be between 1 and 10", file=sys.stderr)
    raise SystemExit(2)

if guess == random_number:
    print_if_terminal("Congratulations, you guessed the right number!")
    exit_code = 0
else:
    print_if_terminal("Sorry, you guessed wrong.")
    exit_code = 1

print_if_terminal("The correct number was:")
print(random_number)
raise SystemExit(exit_code)

Fifth solution: Arguments

Most terminal applications should take input from command line arguments. Prompting the user for input makes applications awkward to use in an automated context and makes them more difficult to use for experienced terminal users (since they expect terminal applications to behave in a certain way).

There are two main ways to work with command line arguments in Python:

  1. Using a proper argument parser, like argparse (in the standard library) or click.
  2. Taking arguments from sys.argv. The first argument is the name of the file being run, the rest are arguments for the program.

For the sake of not introducing another library, we’re going to take the second option, but there’s an example using the first in the repository.

One thing to keep in mind is that you should not interact with the terminal in more than one place in your code. This includes printing, taking input, and exiting. To do so would complicate reasoning, make your code harder to write unit tests for, and lock in your structure. Instead, raise exceptions to indicate errors and take action from the ‘main’ part of the code.


Prompt:

Write a program which requires a user to guess a number between one and 10. This program should take the user’s guess via a positional command line argument. The program should tell the user (via stderr) whether the guess was successful, and feed back the correct number to stdout. The program should return an error code of 1 if the guess is incorrect, and 0 if it is correct. If the user’s guess is faulty (i.e. not an integer between one and ten), an error code of 2 should be returned. If the user fails to provide a guess, an error code of 3 should be returned.


import sys
from random import randint
from typing import List


# A base exception. This isn't intended to be used directly, but has
# an exit code which can be returned for the output.
class GuessingGameException(Exception):
    """A generic guessing game exception."""
    EXIT_CODE: int
    """An exit code to be returned if this exception is raised."""


# Two more specific error subclasses.
class InvalidValueException(GuessingGameException):
    """
    An exception to be raised if the value provided for the guess
    was invalid.

    """
    EXIT_CODE = 2


class NoGuessException(GuessingGameException):
    """An exception to be raised if no guess has been provided."""
    EXIT_CODE = 3


def get_user_guess(arguments: List[str]) -> int:
    """Parse the user's guess from the command line arguments."""
    # Note that we don't exit from this function - we raise an
    # exception which is handled elsewhere.
    try:
        user_guess = int(arguments[0])
    except IndexError as err:
        raise NoGuessException("No guess provided") from err
    except ValueError as err:
        message = "Guess should be an integer between 1 and 10"
        raise InvalidValueException(message) from err
    
    if not 1 <= user_guess <= 10:
        raise InvalidValueException("Guess must be between 1 and 10")
    return user_guess

random_number = randint(1, 10)

# The first item in this list is usually the path to the file, which
# we don't want to pass to our function.
cli_args = sys.argv[1:]
try:
    # Get the guess from the CLI args.
    guess = get_user_guess(cli_args)
except GuessingGameException as err:
    # If something about the guess is wrong, raise a system exit with the
    # exit code. Catching only our custom exceptions means if users
    # manage to trigger something we haven't anticipated, they can make
    # effective bug reports.
    print(str(err), file=sys.stderr)
    raise SystemExit(err.EXIT_CODE) from err

if guess == random_number:
    print("Congratulations, you guessed the right number!", file=sys.stderr)
    exit_code = 0
else:
    print("Sorry, you guessed wrong.", file=sys.stderr)
    exit_code = 1

print("The correct number was:", file=sys.stderr)
print(random_number)
raise SystemExit(exit_code)

Good practice

A serious omission from our implementation is that we haven’t made use of an entry point guard to enable us to import code from our guessing game (e.g. to write unit tests) without actually running the game.

import sys
from random import randint
from typing import List


class GuessingGameException(Exception):
    """A generic guessing game exception."""

    EXIT_CODE: int
    """An exit code to be returned if this exception is raised."""


class InvalidValueException(GuessingGameException):
    """
    An exception to be raised if the value provided for the guess
    was invalid.

    """

    EXIT_CODE = 2


class NoGuessException(GuessingGameException):
    """An exception to be raised if no guess has been provided."""

    EXIT_CODE = 3


def get_user_guess(arguments: List[str]) -> int:
    """Parse the user's guess from the command line arguments."""
    try:
        user_guess = int(arguments[0])
    except IndexError as err:
        raise NoGuessException("No guess provided") from err
    except ValueError as err:
        message = "Guess should be an integer between 1 and 10"
        raise InvalidValueException(message) from err

    if not 1 <= user_guess <= 10:
        raise InvalidValueException("Guess must be between 1 and 10")
    return user_guess


# The 'main' function contains all of our core terminal interactions.
def main(cli_args: List[str]) -> int:
    """The main entry point for the guessing game."""
    random_number = randint(1, 10)

    try:
        guess = get_user_guess(cli_args)
    except GuessingGameException as err:
        print(str(err), file=sys.stderr)
        return err.EXIT_CODE

    if guess == random_number:
        print("Congratulations, you guessed the right number!", file=sys.stderr)
        exit_code = 0
    else:
        print("Sorry, you guessed wrong.", file=sys.stderr)
        exit_code = 1

    print("The correct number was:", file=sys.stderr)
    print(random_number)
    return exit_code


# Only run the main function if the file is being run, rather than being
# imported from.
if __name__ == "__main__":
    # Args should be taken at this point, so we can pass in different args
    # for testing.
    exit_code = main(sys.argv[1:])
    raise SystemExit(exit_code)

Was it all worth it?

Our final guessing game is much more complex than the simple example we started off with. Sometimes, complexity is a necessary evil - for the price of complexity, we gain modularity and (mostly as a result) testability. Minimisation of complexity should be a key aim of any development work - it’s always a good idea to consider whether additional code brings any benefits.

Having said that, in general you are unlikely to regret:

  • Avoiding interactive prompts in favour of taking arguments
  • Restricting your printing/prompting/quitting to a single location (be that file or function)
  • Providing effective feedback for known errors, rather than bubbling up errors.

Use your better judgement, and try to think about whether the tool you’re building is following the principle of least astonishment.

Code Examples

The code examples are available at https://github.com/thesketh/guessing_game

Further Reading