Building a Full-Featured Image Editing Python CLI Tool With argparse
Tags: python, linux, tutorial, ubuntu
Published: 2019-11-18 17:42:23

This post is the third out of four in a series of posts about automating everyday tasks using scripting languages. The [first one](https://carmine.dev/posts/bashautomation/) 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](https://carmine.dev/posts/wandpython/) 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)
~~~

<script src="https://gist.github.com/carzacc/e1ce37a34c8b0c5ded3e3203c728264b.js"></script>

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

<script src="https://gist.github.com/carzacc/7e0f8be2541c701515a594154e0e6d44.js"></script>

## `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:

<script src="https://gist.github.com/carzacc/6bc3d60ebc7de597b5a66306687c308f.js"></script>

# 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](https://www.python.org/dev/peps/pep-0397/).

# 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](https://carmine.dev/programmingflutter/) and [follow me on Twitter](https://twitter.com/carminezacc).