Logo

Build an intuitive CLI app with Python argparse

Learn how to use Python's built-in argparse library to build an intuitive CLI app

Published: 10 Oct, 2024


A while ago, I used Python and the argparse library to build an app for managing my own mail server. That’s when I realized that argparse is not only flexible and powerful, but also easy to use.

I always reach for argparse when I need to build a CLI tool because it’s also included in the standard library.

I’ll show you how to build a CLI tool that mimics the docker command because I find the interface intuitive and would like to show you how to replicate the same user experience with argparse. I won’t be implementing the behavior but you’ll be able to see how you can use argparse to build any kind of easy to use CLI app.

See a real example of such a tool in this file.

Docker commands

I would like the CLI to provide commands such as:

  • docker container ls
  • docker container start
  • docker volume ls
  • docker volume rm
  • docker network ls
  • docker network create

Notice how the commands are grouped into seperate categories. In the example above, we have container, volume, and network. Docker ships with many more categories. Type docker --help in your terminal to see all of them.

Type docker container --help to see subcommands that the container group accepts. docker container ls is such a sub command. Type docker container ls —help to see flags that the ls sub command accepts.

The docker CLI tool is so intuitive to use because you can easily find any command for performing a task thanks to this kind of grouping. By relying on the built-in —help flag, you don’t even need to read the documentation.

Let’s build a CLI similar to the docker CLI tool command above.

I’m assuming you already read the

argparse tutorial

.

Subparsers and handlers

I use a specific pattern to build this kind of tool where I have a bunch of subparsers and a handler for each. Let’s build the docker container create command to get a better idea. According to the docs, the command syntax is docker container create [OPTIONS] IMAGE [COMMAND] [ARG...].

from argparse import ArgumentParser

def add_container_parser(parent):
  parser = parent.add_parser("container", help="Commands to deal with containers.")
  parser.set_defaults(handler=container_parser.print_help)

def main():
  parser = ArgumentParser(description="A clone of the docker command.")
  subparsers = parser.add_subparsers()

  add_container_parser(subparsers)

  args = parser.parse_args()

  if getattr(args, "handler", None):
    args.handler()
  else:
    parser.print_help()


if __name__ == "__main__":
  main()

Here, I’m creating a main parser, then adding subparsers to it. The first subparser is called container. Type python app.py container and you’ll see a help messaged printed out. That’s because of the set_default method. I’m using it to set an attribute called handler to the object that will be returned after argparse parses the container argument. I’m calling it handler here but you can call it anything you want because it’s not part of the argparse library.

Next, I want the container command to accept a create command:

...
def add_container_create_parser(parent):
  parser = parent.add_parser("create", help="Create a container without starting it.")
  parser.set_defaults(handler=parser.print_help)

def add_container_parser(parent):
  parser = parser.add_parser("container", help="Commands to deal with containers.")
  parser.set_defaults(handler=container_parser.print_help)

  subparsers = parser.add_subparsers()

  add_container_create_parser(subparsers)
...

Type python app.py container create to see a help message printed again. You can continue iterating on this pattern to add as many sub commands as you need.

The create command accepts a number of flags. In the documentation, they’re called options. The docker CLI help page shows them as [OPTIONS]. With argparse, we’re simply going to add them as optional arguments. Add the -a or —attach flag like so:

...
def add_container_create_parser(parent):
  parser = parent.add_parser("create", help="Create a container without starting it.")
  parser.set_defaults(handler=parser.print_help)

  parser.add_argument("-a", "--attach", action="store_true", default=False, help="Attach to STDIN, STDOUT or STDERR")
...

Type python app.py container create again and you’ll see that it contains help for the -a flag. I’m not going to add all flags, so next, add the [IMAGE] positional argument.

...
def add_container_create_parser(parent):
  parser = parent.add_parser("create", help="Create a container without starting it.")
  parser.set_defaults(handler=parser.print_help)

  parser.add_argument("-a", "--attach", action="store_true", default=False, help="Attach to STDIN, STDOUT or STDERR")
  parser.add_argument("image", metavar="[IMAGE]", help="Name of the image to use for creating this container.")
...

The help page will now container information about the [IMAGE] command. Next, the user can specify a command that the container will execute on boot. They can also supply extra arguments that will be passed to this command.

from argparse import REMAINDER

...
def add_container_create_parser(parent):
  parser = parent.add_parser("create", help="Create a container without starting it.")
  parser.set_defaults(handler=parser.print_help)

  parser.add_argument("-a", "--attach", action="store_true", default=False, help="Attach to STDIN, STDOUT or STDERR")
  parser.add_argument("image", metavar="IMAGE [COMMAND] [ARG...]", help="Name of the image to use for creating this container. Optionall supply a command to run by default and any argumentsd the command must receive.")
...

What about the default command and arguments that the user can pass to the container when it starts? Recall that we used the parse_args method in our main function:

def main():
...
  args = parser.parse_args()
...

Change it to use parse_known_args instead:

def main():
  parser = ArgumentParser(description="A clone of the docker command.")
  subparsers = parser.add_subparsers()

  add_container_parser(subparsers)

  known_args, remaining_args = parser.parse_known_args()

  if getattr(known_args, "handler", None):
    known_args.handler()
  else:
    parser.print_help()

This will allow argparse to capture any arguments that aren’t for our main CLI in a list (called remaining_args here) that we can use to pass them along when the user executes the container create animage command.

Now that we have the interface ready, it’s time to build the actual behavior in the form of a handler.

Handling commands

Like I said, I won’t be implementing behavior but I still want you to see how to do it.

Earlier, you used set_defaults in your add_container_create_parser function:

  parser = parent.add_parser("create", help="Create a container without starting it.")
  parser.set_defaults(handler=parser.print_help)
  ...

Instead of printing help, you will call another function called a handler. Create the handler now:

def handle_container_create(args):
    known_args, remaining_args = args
    print(
        f"Created container. image={known_args.image} command_and_args={' '.join(remaining_args) if len(remaining_args) > 0 else 'None'}"
    )

It will simply print the arguments and pretend that a container was created. Next, change the call to set_defaults:

  parser = parent.add_parser("create", help="Create a container without starting it.")
  parser.set_defaults(handler=handle_container_create, handler_args=True)
  ...

Notice that I’m also passing a handler_args argument. That’s because I want my main function to know whether the handler needs access to the command line arguments or not. In this case, it does. Change main to be as follows now:

def main():
    parser = ArgumentParser(description="A clone of the docker command.")
    subparsers = parser.add_subparsers()

    add_container_parser(subparsers)

    known_args, remaining_args = parser.parse_known_args()

    if getattr(known_args, "handler", None):
        if getattr(known_args, "handler_args", None):
            known_args.handler((known_args, remaining_args))
        else:
            known_args.handler()
    else:
        parser.print_help()

Notice that I added the following:

...
if getattr(known_args, "handler_args", None):
    known_args.handler((known_args, remaining_args))
else:
    known_args.handler()

If handler_args is True, I’ll call the handler and pass all arguments to it.

Use the command now and you’ll see that everything works as expected:

python app.py container create myimage
# Created container. image=myimage command_and_args=None

python app.py container create myimage bash
# Created container. image=myimage command_and_args=bash

python app.py container create myimage bash -c
# Created container. image=myimage command_and_args=bash -c

When implementing real behavior, you’ll simply use the arguments in your logic.

Now that you implemented the container create command, let’s implement another one under the same category - docker container stop.

Add a second command

Add the following parser and handler:

def handle_container_stop(args):
    known_args = args[0]
    print(f"Stopped containers {' '.join(known_args.containers)}")


def add_container_stop_parser(parent):
    parser = parent.add_parser("stop", help="Stop containers.")
    parser.add_argument("containers", nargs="+")

    parser.add_argument("-f", "--force", help="Force the containers to stop.")
    parser.set_defaults(handler=handle_container_stop, handler_args=True)

Update your add_container_parser function to use this parser:

def add_container_parser(parent):
    parser = parent.add_parser("container", help="Commands to deal with containers.")
    parser.set_defaults(handler=parser.print_help)

    subparsers = parser.add_subparsers()

    add_container_create_parser(subparsers)
    add_container_stop_parser(subparsers)

Use the command now:

python app.py container stop abcd def ijkl
# Stopped containers abcd def ijkl

Perfect! Now let’s create another category - docker volume

Create another category

Repeat the same step as above to create as many categories as you want:

def add_volume_parser(parent):
  parser = parent.add_parser("volume", help="Commands for handling volumes")
  parser.set_defaults(handler=parser.print_help)

Let’s implement the ls command like in docker volume ls:

def volume_ls_handler():
  print("Volumes available:\n1. vol1\n2. vol2")

def add_volume_ls_parser(parent):
  parser = parent.add_parser("ls", help="List volumes")
  parser.set_defaults(handler=volume_ls_handler)

def add_volume_parser(parent):
  ...
  subparsers = parser.add_subparsers()
  add_volume_ls_parser(subparsers)

Notice how I’m not passing any arguments to the volume_ls_handler, thus not adding the handler_args option. Try it out now:

python app.py volume ls
#Volumes available:
#1. vol1
#2. vol2

Excellent, everything works as expected.

As you can see, building user friendly CLIs is simply with argparse. All you have to do is create nested subparsers for any commands that will need their own arguments and options. Some commands like docker container create are more involved than docker volume ls because they accept their own arguments but everything can be implemented using argparse without having to bring in any external library.

Here’s a full example of what we implemented so far:

from argparse import ArgumentParser


def handle_container_create(args):
    known_args, remaining_args = args
    print(
        f"Created container. image={known_args.image} command_and_args={' '.join(remaining_args) if len(remaining_args) > 0 else 'None'}"
    )


def add_container_create_parser(parent):
    parser = parent.add_parser("create", help="Create a container without starting it.")

    parser.add_argument(
        "-a",
        "--attach",
        action="store_true",
        default=False,
        help="Attach to STDIN, STDOUT or STDERR",
    )
    parser.add_argument(
        "image",
        metavar="IMAGE",
        help="Name of the image to use for creating this container.",
    )
    parser.add_argument(
        "--image-command", help="The command to run when the container boots up."
    )
    parser.add_argument(
        "--image-command-args",
        help="Arguments passed to the image's default command.",
        nargs="*",
    )

    parser.set_defaults(handler=handle_container_create, handler_args=True)


def handle_container_stop(args):
    known_args = args[0]
    print(f"Stopped containers {' '.join(known_args.containers)}")


def add_container_stop_parser(parent):
    parser = parent.add_parser("stop", help="Stop containers.")
    parser.add_argument("containers", nargs="+")

    parser.add_argument("-f", "--force", help="Force the containers to stop.")
    parser.set_defaults(handler=handle_container_stop, handler_args=True)


def add_container_parser(parent):
    parser = parent.add_parser("container", help="Commands to deal with containers.")
    parser.set_defaults(handler=parser.print_help)

    subparsers = parser.add_subparsers()

    add_container_create_parser(subparsers)
    add_container_stop_parser(subparsers)


def volume_ls_handler():
    print("Volumes available:\n1. vol1\n2. vol2")


def add_volume_ls_parser(parent):
    parser = parent.add_parser("ls", help="List volumes")
    parser.set_defaults(handler=volume_ls_handler)


def add_volume_parser(parent):
    parser = parent.add_parser("volume", help="Commands for handling volumes")
    parser.set_defaults(handler=parser.print_help)

    subparsers = parser.add_subparsers()
    add_volume_ls_parser(subparsers)


def main():
    parser = ArgumentParser(description="A clone of the docker command.")
    subparsers = parser.add_subparsers()

    add_container_parser(subparsers)
    add_volume_parser(subparsers)

    known_args, remaining_args = parser.parse_known_args()

    if getattr(known_args, "handler", None):
        if getattr(known_args, "handler_args", None):
            known_args.handler((known_args, remaining_args))
        else:
            known_args.handler()
    else:
        parser.print_help()


if __name__ == "__main__":
    main()

Continue to play around with this and you’ll be amazed at how powerful argparse is.


Email me if you have any questions about this post.

Subscribe to the RSS feed to keep updated.