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
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")
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
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
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"
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
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'
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)
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"
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.
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)
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
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)
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.
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.
Sure! No need to even ask if it will help people :)