Build and Test a Command Line Interface with Poetry, Python Fire, and pytest
Tags: python, pytest, pythonpoetry, pythonfire
Published: 2020-08-07 11:08:58

The [Python Fire] package provides a way to develop a command line interface (CLI) in Python, automatically, with very minimal extra code. The package comes from Google but is ["not an official Google product"](https://google.github.io/python-fire/#disclaimer).

[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], another Python package for CLI generation. Where appropriate, the Poetry setup information in this article is quite similar to the Poetry setup in that one.

## Create the project and add a module

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

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 [Python Fire], so they both should be added now:

```bash
poetry add arrow fire
```

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

Once I find a good name, I use it for everything. That what my son Darryl says, anyway. His brother Darryl agrees.

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

```bash
poetry install
```

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

The best laid plans...

## Using [Fire] 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.

This is exactly what [Python Fire] can do automatically. However, we do need to adjust our strategy.

First, `import fire` and add a simple command processing function to the code. The result should read something like this:

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

import time

import arrow
import fire


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():
    fire.Fire(greet)
```

That `cli()` function is all we added, other than `import fire`.

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`

Yeah. All that for almost no work. This is how [Python Fire] shines: laziness yields such elegance.

```bash
$ poetry run greet -r 2 -i 1 Canada/Saskatchewan
Hello, Saskatchewan!
The time is 4:11 am and 43 seconds.

Hello, Saskatchewan!
The time is 4:11 am and 44 seconds.
```

Now that we have tested it, we know it works. Wait... that was not real testing. Enter [pytest].

## Testing [Python Fire] interfaces with [pytest]

Testing command line interfaces takes a bit of thought. Not surprisingly, with [pytest] and [Python Fire], I can continue to be lazy.

The `fire.Fire()` function is also used in testing. We can put the following in tests/test_greet.py:

```python
import fire

from greet.greet import greet


def test_greet_cli(capsys):
    fire.Fire(greet, ["Egypt"])
    captured = capsys.readouterr()
    result = captured.out
    assert "Hello, Egypt!" in result
```

As seen above, the `fire.Fire()` function can accept 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] if interested in comparing the tools.
 

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