Build Command Line Tools with Python Poetry
Tags: python, poetry
Published: 2020-08-04 10:51:51

[Poetry] is a robust and convenient tool for building [Python] projects. The article [Getting Started with Python Poetry][PoetryIntro] demonstrated this in simple terms.

Now, let's add another layer: using Poetry to develop a simple command line interface.

## Review: the project so far

The project structure looks like this:

```bash
pygreet/
├── README.rst
├── poetry.lock
├── pyproject.toml
├── src
│   └── greet
│       ├── __init__.py
│       └── location.py
└── tests
    ├── __init__.py
    └── test_greet.py
```

## Add command line processing

In `src/greet/location.py`, add a function that processes command line arguments.

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

import arrow

import sys


def greet(tz):
    """Greet a location."""
    now = arrow.now(tz)
    friendly_time = now.format("h:mm a")
    location = tz.split("/")[-1].replace("_"," ") 
    return f"Hello, {location}! The time is {friendly_time}."


def cli(args=None):
    """Process command line arguments."""
    if not args:
        args = sys.argv[1:]    
    tz = args[0]
    print(greet(tz))
```

In this case, I just called the function "cli," but that name is not sacred.

Why not just read `sys.argv` in the body of the function? Why pass it as a default parameter?

Because testing, which we will see in a bit.

## Add script to `pyproject.toml`

In the `pyproject.toml` file, we will utilize a [`[tool.poetry.scripts]` section](https://python-poetry.org/docs/pyproject/#scripts). Add it now if it is not there already, and add a script variable pointing to the function we just wrote.

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

Does the nomenclature make sense? Basically, it can be read `package.submodule:function`. We have a package "greet" with a submodule (Python file) "location" with the command-processing function "cli".

## Re-install project

I had to install the project again to update the command entrypoints.

```bash
poetry install
```

## Execute shell and run command

Enter the Python virtual environment with

```bash
poetry shell
```

then try out the command we just built:

```bash
(pygreet-abcd1234-py3.8) $ greet Africa/Addis_Ababa
Hello, Addis Ababa! The time is 1:49 pm.
```

If you do not want to start a new shell, as above, you can also just run

```bash
poetry run greet Africa/Addis_Ababa
```

## Test the command processor with pytest

Always write tests. Because we wrote the `cli()` command processing function to accept an arbitrary list as arguments, this makes testing easy.

Let's add a `test_cli.py` file in the `tests` directory:

```python
from greet.location import cli


def test_greet_cli(capsys):
    args = ["America/Argentina/San_Juan"]
    cli(args)
    captured = capsys.readouterr()
    result = captured.out
    assert "San Juan!" in result
``` 

The `args` list is basically a fabrication of the list normally provided by `sys.argv`.

The [pytest](https://docs.pytest.org/) fixture `capsys` allows capturing stdout, so we can test the output, even though the function had no return value (it only used `print()`).

Does it work?

```bash
poetry run pytest
```

Note that we could write a test using `subprocess.run()`, and that appears to work as well.

```python
import subprocess

def test_greet_cli2():
    result = subprocess.run(["greet", "America/Costa_Rica"], capture_output=True)
    assert b"Costa Rica!" in result.stdout
```

The command line parsing in this article is rudimentary at best. For a more flexible and robust interface, consider using packages such as [Click], [Fire], and the Python Standard Library's  own [argparse].

You may appreciate [my article on using Poetry with Click](https://dev.to/bowmanjd/build-a-command-line-interface-with-python-poetry-and-click-1f5k).

Happy developing.

[PoetryIntro]: https://dev.to/bowmanjd/getting-started-with-python-poetry-3ica
[Python]: https://www.python.org/
[Poetry]: https://python-poetry.org/
[PyPI]: https://pypi.org/
[Click]: https://palletsprojects.com/p/click/
[Fire]: https://github.com/google/python-fire
[argparse]: https://docs.python.org/3/library/argparse.html