CLI Tools in Python

Sometimes, I get curious about a solved problem. Mundane things that we tend to think about from time to time - but not often enough to usually do anything about. When I get an itch about things I have a tendency to quickly prototype a tool to help with my understanding. This has two practical benefits:

  1. I learn about whatever it is I'm trying to solve by doing
  2. I get to practice coding on a problem that isn't related to work

Admittedly, the problems are rarely difficult; however, they are usually satisfying to solve. I have come up with a decent design pattern (read: unoriginal) that helps me implement these tools.

Anatomy of a CLI application

There's three parts to these little toy tools that I create.

  1. infrastructure
  2. parser
  3. driver

Infrastructure

This is a collection of parts that is general good practice for Python development. These are things that I would implement for Python projects in general.

  1. pipx
  2. pipenv
  3. black

pipx is a tool to install and run Python applications in isolated environments (sort of like brew, npx, or apt). If you're familiar with the Python ecosphere then you've used pip to install packages before. pipx utilizes pip but it installs and manages packages that can be run from the command line (packages like cowsay, black, or pipenv for a few examples).

pipenv creates and manages virtual environments in order to assist with dependency management in your projects. A typical Python project would use a combination of: pip, venv, and requirements.txt in order to manage dependencies and environment. pipenv distills all of those components down into a single tool. A major benefit is that you don't pollute your system Python with the packages and requirements of some arbitrary project. A virtual environment is created specifically for the project you're working on. To top it off, you get the benefit of dependency management so you know exactly what's going in to the build.

black is a very opinionated formatter for Python. I'm going to be honest: I think some of its opinions are ugly; however, I value the formatting consistency over my personal preferences.

How do we use them together? Like so:

Install pipx on your system

# Example is for Ubuntu/Debian
$ sudo apt-get install python3-pip
$ python3 -m pip install --user pipx

# Add the following line to your shell configuration file
$ export PATH=$PATH:~/.local/bin

Install pipenv and black via pipx

# Install packages with `pipx` to isolate it from the system python
$ pipx install pipenv
$ pipx install black

Install packages into the project environment (pipenv reads a file called Pipfile)

>$ pipenv install

An example Pipfile:

[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"

[requires]
python_version = "3.8"

[dev-packages]
pylint = "*"

That's it. Then when you want to run your project in your virtual environment that has all its dependencies already installed you might do the following:

>$ pipenv run foo.py

Parser

The CLI parsing library that I have a preference for in Python is argparse. I typically implement a file that mirrors the name of the tool I'm implementing and that really only contains the implementation of the CLI itself. So, we might have a file called tool.py that's implemented as follows:

#!/usr/bin/env python3
"""
Some CLI tool
"""
from argparse import ArgumentParser
from driver import Driver

__author__ = "agraham"
__name__ = "main"


def process_arguments():
    """
    Process command line arguments
    :return: parsed_arguments
    :rtype: obj
    """
    parser = ArgumentParser(prog="tool")

    parser.add_argument(
        "-v",
        "--version",
        action="version",
        version="%(prog)s 0.1.0",
        help="Show application version",
    )
    parser.add_argument(
        "-u",
        "--user",
        action="store",
        dest="user",
        type=str,
        required=True,
        help="Enter your username!"
    )

    parsed_args = parser.parse_args()

    return parsed_args

if __name__ == "__main__":
    # Receive arguments from the parser
    ARGS = process_arguments()

    # Instantiate the tool
    DRIVER = Driver(ARGS)

    # Execute the tool
    DRIVER.tool_driver()

The script takes the results it gets from argparse. We can see it defines two arguments (in short and long forms) for version and user. A neat bonus to using a library like argparse is the addition of a built-in help:

$ pipenv run tool.py -h

usage: tool [-h] [-v] -u USER

required arguments:
  -u, --user            Enter your username!

optional arguments:
  -h, --help            show this help message and exit
  -v, --version         Show application version

This part parses and bundles up the arguments received by the parser and passes them on to the next component, which is something I call the driver.

Driver

The driver is where the the actual implementation of the tool lives. It carries out its tasks based on the information it receives from the parser. So, for our token example, we might have a file called driver.py that looks like so:

"""
Implements the main functionality of tool
"""

__author__ = "agraham"


class Driver:
    """
    This class implements tool
    """

    def __init__(self, args):
        self.username = args.user

    def pytax_driver(self):
        """
        Main driver for pytax
        :return: None
        """
        self.hello()

    def hello(self):
        """
        Say hello to the user
        """
        print(f"Hello {self.user}!")

Running it together we'd get something like:

$ pipenv run tool.py -u agraham

Hello agraham!

Contrived, yeah?

Examples

The following projects, pymort and pytax, are two simple CLI tools that I wrote that utilize the paradigm described above. You can check them out on my gitlab.

pymort

pymort is a tool to help visualize a debt amortization schedule.

pytax

pytax is a tool to help visualize and calculate your marginal tax schedule.