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
.
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.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
.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
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!
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
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()
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:
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.
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.
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.