Build and Test a Command Line Interface with Python, Poetry, Click, and pytest
Tags: python, pytest, pythonpoetry, pythonclick
Published: 2020-08-07 01:18:20

The [Click] package makes it easy to develop a pretty command line interface (CLI) for your [Python] project.

[Poetry] is a mature and modern way to manage 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.

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, packages like [Click], [Fire], and the Python Standard Library's  own [argparse] provide such simplicity and efficiency, a roll-your-own solution simply doesn't make sense.

I have also [documented using Poetry with Python Fire][PoetryFire], another Python package for CLI generation. The Poetry setup has similarities.

Let's choose [Click] for this exercise, and pair it with [Poetry] for project maintenance.

## Create the project and add a module

```bash
poetry new --name greet --src clickgreet
cd clickgreet
```

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

```python
"""Send greetings."""

import time

import arrow

def greet(tz, repeat=1, interval=3):
    """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")
```

## Install dependencies

We need [arrow](https://pypi.org/project/arrow), and will be using [click], so they should be added now:

```bash
poetry add arrow click
```

## 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. Creative naming is not my strong point.

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

```bash
poetry install
```

Now let's run the newly installed script:

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

I thought that went well.

## Using [Click] to parse command line arguments

We need a way to parse command line arguments and pass those as parameters to the function. [Click] uses decorators to do this easily.

Here is the original function, now with appropriate decorators added:

```python
"""Send greetings."""

import time

import arrow
import click


@click.command()
@click.argument("tz")
@click.option("--repeat", "-r", default=1, type=int)
@click.option("--interval", "-i", default=3, type=int)
def greet(tz, repeat=1, interval=3):
    """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")
```

The `click.command` make the function a command line tool, while `click.argument` specifies a positional argument `tz`. Even though specified first in the above code, syntactically it will come after the options in the command itself. These options are specified with the two `click.option` decorators. The `type=int` arguments are not strictly necessary because the default values are integers which in effect infers the type. One would want them, however, if the default was not specified and the desired type was not string.

With these decorators, we have a working command line interface.

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

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

Options:
  -r, --repeat INTEGER
  -i, --interval INTEGER
  --help                  Show this message and exit.
$
$ poetry run greet -r 2 -i 1 Europe/Luxembourg
Hello, Luxembourg!
The time is 3:00 am and 36 seconds.

Hello, Luxembourg!
The time is 3:00 am and 37 seconds.
```

We have ourselves an incredibly useful and elegant command line tool.

## Testing [Click] interfaces with [pytest]

Testing command line interfaces can demand a bit of creativity. Thankfully, Click provides `CliRunner`, a command line runner for testing.

```python
from click.testing import CliRunner

from greet.greet import greet


def test_greet_cli():
    runner = CliRunner()
    result = runner.invoke(greet, ["Europe/Istanbul"])
    assert result.exit_code == 0
    assert "Hello, Istanbul!" in result.output
```

Run the above with `poetry run pytest`.

Does the test pass?

Happiness.

Again, take a look at a [similar tutorial involving Python Fire][PoetryFire] if interested in comparing the tools.

[PoetryFire]: https://dev.to/bowmanjd/build-a-command-line-interface-with-poetry-and-python-fire-c8b
[pytest]: https://docs.pytest.org/
[Click]: https://palletsprojects.com/p/click/
[Fire]: https://github.com/google/python-fire
[argparse]: https://docs.python.org/3/library/argparse.html
[PoetryIntro]: https://dev.to/bowmanjd/getting-started-with-python-poetry-3ica
[PoetryCLI]: https://dev.to/bowmanjd/build-command-line-tools-with-python-poetry-4mnc
[Python]: https://www.python.org/
[Poetry]: https://python-poetry.org/