Command Line Tools in Python with Typer and pytest: Type Hints Are Useful
Tags: python, pytest, typer, commandline
Published: 2020-09-03 12:03:03

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](https://docs.python.org/3/library/typing.html).

[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][poetryintro] as well as a brief [explanation of using Poetry to expose command line scripts][poetrycli] 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](https://dev.to/bowmanjd/python-tools-for-managing-virtual-environments-3bko). Regarding packaging a Python project the traditional way, using `setup.py`, not Poetry, you might like [my Python dev environment intro](https://dev.to/bowmanjd/python-dev-environment-part-1-setup-py-venv-and-pip-22gd).

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

```console
poetry new --name greet --src typergreet
cd typergreet
```

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](https://blog.ionelmc.ro/2014/05/25/python-packaging/).

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

```python
"""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")
```

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](https://pypi.org/project/arrow), and will be using [Typer], so they should be added now:

```console
poetry add arrow typer[all]
```

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:

```console
poetry add -D mypy
```

## Check the type hints

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

```console
poetry run mypy src/greet/greet.py
```

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:

```console
echo "" | tee ./src/greet/py.typed
```

## 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`.

```toml
[tool.poetry.scripts]
greet = "greet.greet:greet"
```

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

```console
poetry install
```

Now let's run the newly installed script:

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

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:

```python
"""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()
```

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:

```toml
[tool.poetry.scripts]
greet = "greet.greet:run"
```

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.

```console
$ 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.
```

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`

```python
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
```

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`](https://docs.python.org/3/library/sys.html#sys.argv), as we did in the previously mentioned [Poetry/CLI article][poetrycli].
- 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](https://dev.to/bowmanjd/build-and-test-a-command-line-interface-with-poetry-python-s-argparse-and-pytest-4gab).
- I have also written brief introductions to the following command line libraries:
  - [Python Fire][poetryfire]
  - [Click](https://dev.to/bowmanjd/build-a-command-line-interface-with-python-poetry-and-click-1f5k)
- I have heard great reviews of [Plumbum](https://plumbum.readthedocs.io/) and [Cleo](https://github.com/sdispater/cleo), but have yet to try them
- For an entirely different way of defining command line interfaces, try [docopt](https://github.com/docopt/docopt). Define interfaces in your docstrings. It is not frequently maintained, though, so [there is a docopt-ng fork](https://github.com/bazaar-projects/docopt-ng).

Enjoy the command line!

[argparse]: https://docs.python.org/3/library/argparse.html
[click]: https://palletsprojects.com/p/click/
[fire]: https://github.com/google/python-fire
[mypy]: https://mypy.readthedocs.io/
[poetry]: https://python-poetry.org/
[poetrycli]: https://dev.to/bowmanjd/build-command-line-tools-with-python-poetry-4mnc
[poetryfire]: https://dev.to/bowmanjd/build-a-command-line-interface-with-poetry-and-python-fire-c8b
[poetryintro]: https://dev.to/bowmanjd/getting-started-with-python-poetry-3ica
[pytest]: https://docs.pytest.org/
[python]: https://www.python.org/
[typer]: https://typer.tiangolo.com/