DEV Community

Cover image for Command Line Tools in Python with Typer and pytest: Type Hints Are Useful
Jonathan Bowman
Jonathan Bowman

Posted on

Command Line Tools in Python with Typer and pytest: Type Hints Are Useful

With Typer, you can write command line tools in Python, intuitively and easily. At the same time, Typer is flexible enough to handle the complexity thrown at it.

Type hints

Among command line helper libraries for Python (such as argparse and Click), Typer is unique in its use of type hints.

Python is a programming language that does not require static type declarations. In other words, I can write my_variable = 2 without needing to specify that my_variable is indeed an integer, not a string.

While optional, Python does support static type hints. In other words, I can write my_variable: int = 2 to hint that my_variable should always be an integer. How does one use this? The tool mypy can be run on the Python modules and packages to ferret out problems, or confirm your awesomeness.

The end result? I am continually impressed how, even on small projects, mypy and type hints identify problems in my code that would have caused significant diagnostic headaches.

If you are writing type hints anyway, using Typer to build a command line interface makes a lot of sense. Let's walk through a simple example of what that could look like.

Create the project and add a module

To manage the project, I am using Poetry. Poetry is a mature and modern tool for managing a Python project and its dependencies. You might enjoy reading my introduction to Poetry as well as a brief explanation of using Poetry to expose command line scripts in your project.

You certainly don't need to use Poetry to construct and maintain a project. I do recommend treating your project as a Python package, and using virtual environments. Feel free to browse my demonstration and tool overview for managing virtual environments. Regarding packaging a Python project the traditional way, using setup.py, not Poetry, you might like my Python dev environment intro.

To create the project with Poetry, something like this should work:

poetry new --name greet --src typergreet
cd typergreet
Enter fullscreen mode Exit fullscreen mode

Note that in this case I name the internal package greet but the project directory is named typergreet.

You do not need to use the src structure for your package, but I use it for reasons.

I then add a file called greet.py in the src/greet subdirectory, with the following contents:

"""Send greetings."""

import time

import arrow  # type: ignore


def greet(tz: str, repeat: int = 1, interval: int = 3) -> None:
    """Parse a timezone and greet a location a number of times."""
    for i in range(repeat):
        if i > 0:  # no delay needed on first round
            time.sleep(interval)
        now = arrow.now(tz)
        friendly_time = now.format("h:mm a")
        seconds = now.format("s")
        location = tz.split("/")[-1].replace("_", " ")
        print(f"Hello, {location}!")
        print(f"The time is {friendly_time} and {seconds} seconds.\n")
Enter fullscreen mode Exit fullscreen mode

Note the type hints on each function parameter, as well as the return value. Because there is no return in this function (the shame!) the return type is None.

Install dependencies

We need arrow, and will be using Typer, so they should be added now:

poetry add arrow typer[all]
Enter fullscreen mode Exit fullscreen mode

That will give us Arrow, plus Typer with all the bells and whistles. If you don't want colorized output and shell detection, you can omit the [all].

We are using type hints, so we also want mypy, but as a development dependency. With poetry add, that means passing the -D flag:

poetry add -D mypy
Enter fullscreen mode Exit fullscreen mode

Check the type hints

To check to make sure we have the type hints correct, and the code honors them, run mypy:

poetry run mypy src/greet/greet.py
Enter fullscreen mode Exit fullscreen mode

No issues found? Excellent.

When importing this package, even when making the tests later, we will want to know that it is type hinted. To do that, create an empty py.typed file in the package. There are many ways to do that (with your text editor, even). This works in Bash and Powershell:

echo "" | tee ./src/greet/py.typed
Enter fullscreen mode Exit fullscreen mode

Add a script end point in pyproject.toml

To expose the greet function as a command line script, add a tool.poetry.scripts section to pyproject.toml.

[tool.poetry.scripts]
greet = "greet.greet:greet"
Enter fullscreen mode Exit fullscreen mode

That sets greet (the script) to look in greet (the package) for greet (the module) and use greet (the function). If you have more creativity for naming, go for it.

Now that the script is set up, install the package and script with

poetry install
Enter fullscreen mode Exit fullscreen mode

Now let's run the newly installed script:

$ poetry run greet
Traceback (most recent call last):
  File "<string>", line 1, in <module>
TypeError: greet() missing 1 required positional argument: 'tz'
Enter fullscreen mode Exit fullscreen mode

Failure is the path to learning. And we are learning.

Using Typer to parse command line arguments

We need a way to parse command line arguments and pass those as parameters to the function. Typer detects the type hints and figures out command line arguments and options accordingly.

Here is the original function, with Typer added:

"""Send greetings."""

import time

import arrow  # type: ignore
import typer

app = typer.Typer()


@app.command()
def greet(tz: str, repeat: int = 1, interval: int = 3) -> None:
    """Parse a timezone and greet a location a number of times."""
    for i in range(repeat):
        if i > 0:  # no delay needed on first round
            time.sleep(interval)
        now = arrow.now(tz)
        friendly_time = now.format("h:mm a")
        seconds = now.format("s")
        location = tz.split("/")[-1].replace("_", " ")
        print(f"Hello, {location}!")
        print(f"The time is {friendly_time} and {seconds} seconds.\n")


def run() -> None:
    """Run commands."""
    app()
Enter fullscreen mode Exit fullscreen mode

What has changed? We instantiated the Typer app globally with app = typer.Typer(). That way, we can decorate any function we want to call from the command line, using the @app.command() decorator.

Then, we needed to add a command runner to execute the Typer app. That is what the run() function does.

Given that we have changed the script entry point from greet to run, this should be clarified in pyproject.toml. So, the following change is needed:

[tool.poetry.scripts]
greet = "greet.greet:run"
Enter fullscreen mode Exit fullscreen mode

Now run is the function that the greet command will point to, not greet.

With all this in place, we have a working command line interface.

$ poetry run greet --help
Usage: greet [OPTIONS] TZ

  Parse a timezone and greet a location a number of times.

Arguments:
  TZ  [required]

Options:
  --repeat INTEGER      [default: 1]
  --interval INTEGER    [default: 3]
  --install-completion  Install completion for the current shell.
  --show-completion     Show completion for the current shell, to copy it or
                        customize the installation.

  --help                Show this message and exit.
$
$ poetry run greet --repeat 2 --interval 1 Africa/Johannesburg
Hello, Johannesburg!
The time is 1:06 pm and 38 seconds.

Hello, Johannesburg!
The time is 1:06 pm and 39 seconds.
Enter fullscreen mode Exit fullscreen mode

What a polite and informed command line tool we have.

Note the extra features Typer adds by default: the --help with descriptions detailing the arguments and options, and the ability to add shell completion should the user desire.

Testing Typer interfaces with pytest

Testing command line interfaces can demand a bit of creativity. Thankfully, Typer provides CliRunner, a command line runner for testing. If you are familiar with Click, it is very similar to Click's CliRunner. (Not surprising, as Click is a dependency of Typer.)

Place the following in the file tests/test_greet.py

from typer.testing import CliRunner

from greet.greet import app


def test_greet_cli():
    runner = CliRunner()
    result = runner.invoke(app, ["Europe/Madrid"])
    assert result.exit_code == 0
    assert "Hello, Madrid!" in result.output
Enter fullscreen mode Exit fullscreen mode

Run the above with poetry run pytest.

Does the test pass?

Happiness.

Alternatives and further reading

Python is a logical choice when building command line tools, so it is no surprise that many options exist for facilitating the process.

  • It is possible to parse your own command line arguments through the list provided by sys.argv, as we did in the previously mentioned Poetry/CLI article.
  • However, the Python Standard Library's own argparse provides much better functionality, simplicity, and efficiency. I wrote an article very similar to this one about using Poetry, argparse, and pytest.
  • I have also written brief introductions to the following command line libraries:
  • I have heard great reviews of Plumbum and Cleo, but have yet to try them
  • For an entirely different way of defining command line interfaces, try docopt. Define interfaces in your docstrings. It is not frequently maintained, though, so there is a docopt-ng fork.

Enjoy the command line!

Top comments (0)