DEV Community

Cover image for Build and Test a Command Line Interface with Poetry, Python's argparse, and pytest
Jonathan Bowman
Jonathan Bowman

Posted on

Build and Test a Command Line Interface with Poetry, Python's argparse, and pytest

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 as well as a brief explanation of using Poetry to expose command line scripts in your project.

I have also documented using Poetry with Click, and with Python Fire. 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

poetry new --name greet --src argparsegreet
cd argparsegreet
Enter fullscreen mode Exit fullscreen mode

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

"""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")
Enter fullscreen mode Exit fullscreen mode

You are also welcome to instead copy my greet.py gist to the appropriate directory, with something like this:

curl https://gist.githubusercontent.com/bowmanjd/e34dbd88af7e7a718c129b6676f1ba5e/raw > src/greet/greet.py
Enter fullscreen mode Exit fullscreen mode

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

Install dependencies

We need Arrow, so that should be added now:

poetry add arrow
Enter fullscreen mode Exit fullscreen mode

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.

[tool.poetry.scripts]
greet = "greet.greet:greet"
Enter fullscreen mode Exit fullscreen mode

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

poetry install
Enter fullscreen mode Exit fullscreen mode

Running the newly installed script...

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

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:

"""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)
Enter fullscreen mode Exit fullscreen mode

Compared to Click and Fire, 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:

[tool.poetry.scripts]
greet = "greet.greet:cli"
Enter fullscreen mode Exit fullscreen mode

Now, try poetry run greet --help

Helpful.

$ 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.
Enter fullscreen mode Exit fullscreen mode

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:

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)
Enter fullscreen mode Exit fullscreen mode

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 will use an argument list if specified (it uses sys.argv if not).

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

from greet.greet import cli


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

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

I used pytest's capsys fixture to capture the output.

Does poetry run pytest pass?

Satisfying.

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

Top comments (3)

Collapse
 
flxvctr profile image
Felix Victor Münch

Looks like a neat solution. However, maybe I am overlooking something in my implementation in my code, but it does not seem to go well with running pytest itself with CL arguments (e.g. the super-handy '--lf'). Seems to try to parse those then as well and throws an exception.

What speaks again simply using pytest-console-scripts (pypi.org/project/pytest-console-sc...) instead? Worked out of the box for my case.

Collapse
 
bowmanjd profile image
Jonathan Bowman

So sorry to miss your comment so long ago! Thank you so much for the pytest-console-scripts recommendation. I may incorporate that back into the article, if that is OK with you.

Collapse
 
flxvctr profile image
Felix Victor Münch

Sure! No need to even ask if it will help people :)