Building a Full-Featured Python CLI Tool With argparse

This post is the third out of four in a series of posts about automating everyday tasks using scripting languages. The first one was an introduction to the hypothetical problem of having to take images divided into subfolders and dump all of them in one folder while altering their name and adding text over them to know what folder they came from (hypotheticall revealing the location where the pictures were taken) to solve and a first simple solution using Bash and ImageMagick.

In the second post we switched to Python and solved the https://carmine.dev/posts/wandpython/ same problem in a better way. With the Python version we can specify input and output directories and the code is much easier to understand.

In this post we’re going to take that Python code and make it work within a proper CLI tool built with the aid of argparse.

What our Tool Will Be

We’re going to implement the tool in three files:

  • imageprocessor.py, which contains the code we wrote in the previous post and that provides an easy-to-use interface to Wand and ImageMagick in the form of a class called ImageProcessor, I’m not going to explain it line-by-line in this post, so you should read my previous post (linked at the start of this post) if you want to know how it came together;
  • actions.py, which processes the arguments provided by argparse and uses the ImageProcessor to process the pictures.
  • myclitool.py, which is the executable that configures argparse to make it do what we want it to do.

The Commands We Need to Implement

Our tool is going to respond to two subcommands:

  • generate dir save_dir which takes the pictures from dir, processes them in the way I talked about in the first two posts in the series, and saves the output to save_dir
  • copy dir save_dir, which does the same but without adding text over the images, it only renames them and dumps them in the save_dir.

Building the Tool

Let’s implement the tool starting from the code that actually performs the actions and then we’ll work our way through the abstraction layers and eventually get to the part that actually implements the CLI interface using argparse.

myclitool.py

An Introduction to argparse

As we learned in the previous post, it is possible to build a perfectly functional CLI tool in Python without having to use any third-party package.

Actually, that might not be completely true. It depends on your definition of perfectly functional. Without argparse, you need to have your own help and usage responses and you need to parse arguments one by one as they are provided to the tool. Doing anything that’s a bit more complex requires writing lots of code: having many subcommands, each with their own subcommands and usage strings, and perhaps different option would make the app very long and unreadable very quickly.

That’s where argparse helps: you define what you want your tool to respond to, write the functions that interact with the arguments in a logical and simple way, and it’s done in a tenth (or less) of the lines of code it would have taken in plain Python!

How to Use argparse

The first element in the chain of objects used to configure argparse to do what we want is the ArgumentParser, which is the top level object all other objects should report to.

It is initialized with something like:

parser = argparse.ArgumentParser(
  description="Improve the way you look at your picture collection with this simple CLI tool"
)

Further down the chain we’re going to have subparsers, the pre-requisite for which is an object you can generate like this:

subparsers = parser.add_subparsers()

and that you can then use to add subparsers that will be linked to the original parser, optionally with an help text that will be used if the user requests usage information:

generate = subparser.add_parser(
  "generate", help="Generate the pictures"
)

Arguments are very easy to add, we’ll see how we get them and how to use them later, and the help text is there for the same purpose as all other help text, as a way to give more information to the user when they request usage information:

generate.add_argument("dir", type=str, help="The directory where the tree of input picture starts")
generate.add_argument("save_dir", type=str, help="The directory where to save the output pictures")

Now we need to define a function to process the directories, which we’ll talk about when we come to the action.py file. Meanwhile, let’s say we’ll call it process_dir and let’s set it as the function to be called when the user calls the generate command:

generate.set_defaults(func=actions.process_dir)

Let’s do the same things we did for generate with copy:

copy = subparser.add_parser("copy", help="Copy the pictures to the output path without adding text")
copy.add_argument("dir", type=str, help="The directory where the tree of input picture starts")
copy.add_argument("save_dir", type=str, help="The directory where to save the output pictures")
copy.set_defaults(func=actions.copy)

The last two lines we need to add to myclitool.py are those that actually parse the arguments the user actually provided and call the corresponding function with the corresponding arguments:

args = parser.parse_args()

args.func(args)

actions.py

argparse passes its arguments to the functions as a single structure that contains the arguments passed by the user.

For example, if we are passing an argument called arg1 and another called arg2, an hypothetical handle_call function that prints the arguments as passed by argparse is

def handle_call(args):
  print(args.arg1) # prints arg1
  print(args.arg2) # prints arg2

Handling the generate Command

The function to handle the generate CLI subcommand is going to be called process_dir to further underline the fact it’s the one meant to process the pictures and not just copy them.

Given that we are getting arguments called dir and save_dir

def process_dir(args):
    image_processor = ImageProcessor(args.dir, args.save_dir)
    image_processor.process_dir()

Handling the copy Command

The difference between these two functions is very subtle because of the way we implemented the ImageProcessor’s constructor, which takes an optional boolean value that tells the process method whether or not it’s supposed to process the images.

def copy(args):
    image_processor = ImageProcessor(args.dir, args.save_dir, process=False)
    image_processor.process_dir()

In the end, the actions.py file is

imageprocessor.py

We also need the ImageProcessor code which, as I said, is explained in the previous post and you can find here with some useful comments so that you can work out how it’s put together without having to read that:

Using the Tool

If python3 is the name of the command you use to run Python scripts (depends on your environment, it may be just python) you can use

python3 myclitool.py generate /path/to/input /path/to/output

to run the generate command and have /path/to/input as the input path and /path/to/output as the output path.

The copy subcommand can be used in the same way.

You can see usage information by running

python3 myclitool.py -h

and you can have help text specific to the generate command with

python3 myclitool.py generate -h

The same applies to copy obviously.

Making the Script Executable

As with any script ran by a scripting language, it can be made executable on Linux/macOS/Unix with

chmod +x myclitool.py

and, after adding the shebang line

#!/usr/bin/env python3

to the top of the file, you can execute it with

./myclitool.py {command} [arguments]

On Windows you need to use the Python Launcher for Windows.

Wrapping Up and Where to Go From Here

We’re done! We’ve built a full CLI tool with usage strings, help commands and two subcommands each taking two arguments.

The next step is to go back to Bash and take advantage of its great completion infrastructure to provide a completion script that works on Linux and macOS. No such feature is available in Windows’s CMD. Even though there is PSReadline on Windows, I’m not going to write about it because I have no experience with it, as is the case woth most of the Windows-specific programming interfaces and tools. As always, remember to check out my Flutter book and follow me on Twitter.