Build and Test a Command Line Interface with Poetry, Python's argparse, and pytest
Tags: python, commandline, pytest, pythonpoetry
Published: 2020-08-08 11:56:36

The Python Standard Library's own [argparse] package is the officially recommended way to construct a command line interface (CLI) in Python.

[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. 

I have also [documented using Poetry with Click][PoetryClick], and [with Python Fire][PoetryFire]. These are convenient and flexible third-party Python packages for CLI generation. Where it fits, the Poetry setup information in this article is quite similar to the Poetry setup in those articles.

## Create the project and add a module

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

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

You are also welcome to instead copy [my `greet.py` gist](https://gist.github.com/bowmanjd/e34dbd88af7e7a718c129b6676f1ba5e) to the appropriate directory, with something like this:

```bash
curl https://gist.githubusercontent.com/bowmanjd/e34dbd88af7e7a718c129b6676f1ba5e/raw > src/greet/greet.py
```

Because downloading arbitrary code from the Internet seems like a pretty good move.

## Install dependencies

We need [Arrow](https://pypi.org/project/arrow), so that should be added now:

```bash
poetry add arrow
```

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

I do like to repeat my redundancies over and over multiple times.

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

```bash
poetry install
```

Running 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'
```

It seemed like a good idea at the time.

## Using [argparse] to parse command line arguments

To make this work, we need to define command line arguments and pass those as parameters to the `greet` function.

So we turn to the Python Standard Library's own [argparse].

First, `import argparse` and add a command processing function to the code, defining each argument and option. The result should read something like this:

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

import argparse
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")


def cli():
    parser = argparse.ArgumentParser()
    parser.add_argument("tz", help="The timezone")
    parser.add_argument(
        "-r",
        "--repeat",
        help="number of times to repeat the greeting",
        default=1,
        type=int,
    )
    parser.add_argument(
        "-i",
        "--interval",
        help="time in seconds between iterations",
        default=3,
        type=int,
    )
    args = parser.parse_args()
    greet(args.tz, args.repeat, args.interval)
```

Compared to [Click][PoetryClick] and [Fire][PoetryFire], the command processing function is a bit more complex, but not prohibitively so.

We first create the parser with `argparse.ArgumentParser()` then add each argument with a separate `add_argument()`, specifying the name of the argument or option, and, if necessary, the default value and type (if not string). Note that the bare name, such as `"tz"` designates a positional argument, while prefixing it with `-` or `--` makes it a named option, such as `"--repeat"`. In the above example, I specified both short and long options (e.g. `-r` and `--repeat`).

The `cli()` function, not `greet()`, needs to be the script end point, so `pyproject.toml` should have a minor adjustment:

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

Now, try `poetry run greet --help`

Helpful.

```bash
$ poetry run greet -r 2 -i 1 Asia/Damascus
Hello, Damascus!
The time is 2:23 pm and 56 seconds.

Hello, Damascus!
The time is 2:23 pm and 57 seconds.
```

It works! Need some tests to continually verify that, though. Thank you, [pytest].

## Testing [argparse] interfaces with [pytest]

Testing command line interfaces deserves some pondering.

While other CLI libraries provide helper functions, argparse does not, out of the box. However, by restructuring the code slightly, it becomes easier to test.

By modifying the `cli()` function to accept args, we can send a list of arguments in our tests. If args are not specified, `sys.argv` should be used. So the `cli()` function could read:

```python
def cli(args=None):
    if not args:
        args = sys.argv[1:]
    parser = argparse.ArgumentParser()
    parser.add_argument("tz", help="The timezone")
    parser.add_argument(
        "-r",
        "--repeat",
        help="number of times to repeat the greeting",
        default=1,
        type=int,
    )
    parser.add_argument(
        "-i",
        "--interval",
        help="time in seconds between iterations",
        default=3,
        type=int,
    )
    args = parser.parse_args(args)
    greet(args.tz, args.repeat, args.interval)
```

Other than accepting and testing for the existence of `args`, the key difference here is that we pass `args` to `parse_args()`. In other words [the `parse_args()` function](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.parse_args) will use an argument list if specified (it uses `sys.argv` if not).

Now we can put the following in tests/test_greet.py:

```python
from greet.greet import cli


def test_greet_cli(capsys):
    cli(["Asia/Jakarta"])
    captured = capsys.readouterr()
    result = captured.out
    assert "Hello, Jakarta!" in result
```

As seen above, the `cli()` function now accepts a list of command line arguments.

I used [pytest's `capsys` fixture](https://docs.pytest.org/en/stable/capture.html) to capture the output.

Does `poetry run pytest` pass?

Satisfying.

Again, take a look at a [similar tutorial involving Click][PoetryClick] and another [about Python Fire][PoetryFire] if interested in exploring those tools.

[PoetryFire]: https://dev.to/bowmanjd/build-a-command-line-interface-with-poetry-and-python-fire-c8b
[argparse]: https://docs.python.org/3/library/argparse.html
[PoetryClick]: https://dev.to/bowmanjd/build-a-command-line-interface-with-python-poetry-and-click-1f5k/
[pytest]: https://docs.pytest.org/
[Click]: https://palletsprojects.com/p/click/
[Python Fire]: https://google.github.io/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/