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...]
.
```python
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:
```python
...
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:
```python
...
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.
```python
...
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.
```python
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:
python
def main():
...
args = parser.parse_args()
...
Change it to use parse_known_args instead:
```python
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:
python
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:
python
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:
python
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:
```python
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:
python
...
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:
```shell
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:
```python
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:
```python
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:
```shell
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:
python
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:
```python
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:
```shell
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:
```python
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.
I originally posted this on my blog.
Visit me if you're interested in similar topics.