Figuring out Python Subcommands with ArgParse

1. Overview

While watching The Taggart Institute's mttaggart stream on programming a KASM manager with python, he ran into a question on how subcommands with argparse work. This has haunted me before, and if answered would certainly help me with other projects. Taggart eventually used click, a package for "beautiful command line interfaces", to handle his subcommand and argument parsing needs.

Currently I have a python project I'd like to use subcommands of either "load" or "unload". With which each would have different arguments associated with the subcommand. This example, rant, and hopeful proof-of-concept will help in utilizing subcommands using argparse. Specifically, this is for adversary emulation within a devops environment that is confirmed to have the python docker sdk but no other non-standard module or library assumptions.

2. The Setup

This is just utilizing the argparse module, to create the program's global variable values. The global program arguments act as regular arguments in argparse, can have default values, actions, help menu context, and typing. In my case below, I need to pass some information about the docker registry, repository authentication, and dockerclient regardless if I'm utilizing the load or unload subcommands.

from argparse import ArgumentParser

parser = ArgumentParser()

parser.add_argument('--registry', help="The location of the registry to login", default="docker.io")
parser.add_argument('--repository', help="Where to push your image")
parser.add_argument('--username')
parser.add_argument('--password')
parser.add_argument('--DockerClient', help="The docker socket to use that you have permissiosn for", default="unix:///var/run/docker.sock/")

3. Subparsers, subcommands, and their subarguments

Next we want to start the subparsers pointing to the subcommand, note that this is what python will use when describing if you print the object. I also use the title parameter so that it shows up in --help. We do this with the parser object from above and running the add_subparsers method on it. Once the add_subparsers method has ran, we need to define the new parsers that will be the "subcommands", in this case using the add_parser method on the returned add_subparsers object stored in subcommand_parser.

subcommand_parser = parser.add_subparsers(dest='subcommand', title="subcommand", help="additonal subcommand help here")

load_parser = subcommand_parser.add_parser('load')
unload_parser = subcommand_parser.add_parser('unload')

After these subcommand parsers are defined, we can continue by adding arguments to the subparsers, like we did with the global arguments. Next is adding arguments specific to our subcommands with the add_argument method of the specific subcommand. We can see that unload doesn't have any additional arguments and load has two additional arguments.

# Load First
load_parser.add_argument('--dataLoc', help="Provide the file location of the data to load into the container")
load_parser.add_argument('--dockerFile', help="Specify a specific dockerfile location. Warning! this assumes you load the data into the container manually with the dockerfile")

# Unload doesn't have additonal arguments, just executes different functions

4. Conclusion

  • I'm definitely more comfortable with subcommands now and pretty much finished my initial use case in this post.
  • If you wanted to add further subcommands, it would be as simple as adding another layer of add_subparsers to the subcommand_parser.add_parser objects (e.g. load-subcommand_parse = load.parser.add_subparsers('subcommand for load').
  • Also found this stackoverflow post from > 10 years ago with python2.x that helped piece this together with the documentation.

4.1. Results from parsing arguments

print("The default --help results")
print(parser.parse_args(['--help']))
print("\n\n\n---\n\n\n")
print("The default load --help results")
print(parser.parse_args(['load', '--help']))


The default --help results
usage:  [-h] [--global-thing] [--global-value GLOBAL_VALUE]
	[--registry REGISTRY] [--repository REPOSITORY] [--username USERNAME]
	[--password PASSWORD] [--DockerClient DOCKERCLIENT]
	{load,unload} ...

options:
  -h, --help            show this help message and exit
  --global-thing
  --global-value GLOBAL_VALUE
  --registry REGISTRY   The location of the registry to login
  --repository REPOSITORY
			Where to push your image
  --username USERNAME
  --password PASSWORD
  --DockerClient DOCKERCLIENT
			The docker socket to use that you have permissiosn for

subcommand:
  {load,unload}         additonal subcommand help here


---



The default load --help results
usage:  load [-h] [--dataLoc DATALOC] [--dockerFile DOCKERFILE]

options:
  -h, --help            show this help message and exit
  --dataLoc DATALOC     Provide the file location of the data to load into the
			container
  --dockerFile DOCKERFILE
			Specify a specific dockerfile location. Warning! this
			assumes you load the data into the container manually
			with the dockerfile

4.2. Tangled code

from argparse import ArgumentParser

parser = ArgumentParser()

parser.add_argument('--global-thing', action = 'store_true')
parser.add_argument('--global-value', default = 42)
parser.add_argument('--registry', help="The location of the registry to login", default="docker.io")
parser.add_argument('--repository', help="Where to push your image")
parser.add_argument('--username')
parser.add_argument('--password')
parser.add_argument('--DockerClient', help="The docker socket to use that you have permissiosn for", default="unix:///var/run/docker.sock/")

subcommand_parser = parser.add_subparsers(dest='subcommand', title="subcommand", help="additonal subcommand help here")

load_parser = subcommand_parser.add_parser('load')
unload_parser = subcommand_parser.add_parser('unload')

# Load First
load_parser.add_argument('--dataLoc', help="Provide the file location of the data to load into the container")
load_parser.add_argument('--dockerFile', help="Specify a specific dockerfile location. Warning! this assumes you load the data into the container manually with the dockerfile")

# Unload doesn't have additonal arguments, just executes different functions

print("The default --help results")
print(parser.parse_args(['--help']))

print("The default load --help results")
print(parser.parse_args(['load', '--help']))

Date: 2023-02-08 Wed 00:00

Author: Russell Brinson

Created: 2023-02-09 Thu 12:32