Command-Line Interfaces are the most straightforward interfaces, with their use reaching back decades around when the first modern computers were built.
Although Graphical User Interfaces have become commonplace, CLIs continue to be used today. They facilitate several Python operations, such as systems administration, development, and data science.
If you're building a command-line app, you will also need a user-friendly CLI to facilitate interaction with the app. The nice thing about Python is that the argparse module in its standard library enables developers to build full-featured CLIs.
In this guide, we'll walk you through using argparse to build a CLI.
It's important to note that you will need to know the basics of how your terminal or a command line interface works besides understanding concepts such as OOP, packages, and modules.
How To Use Argparse to Write Command Line Programs
First appearing in Python 3.2, argparse can accept a variable number of parameters and parse command-line arguments and options.
Using the module is as simple as importing it, making an argument parser, getting arguments and options into the picture, and calling .parse_args() to get the Namespace of arguments from the parser.
Let's see an example to understand how argparse works.
Here's a program that lists the files in a directory, like the ls command would on Linux:
# ls.py v1 import argparse from pathlib import Path parser = argparse.ArgumentParser() parser.add_argument("path") args = parser.parse_args() target_dir = Path(args.path) if not target_dir.exists(): print("Target directory doesn't exist") raise SystemExit(1) for entry in target_dir.iterdir(): print(entry.name)
As you can see, after importing the module, we create a parser with the ArgumentParser class. Then, the "path" argument is defined, fetching the target directory.
Next, the program calls .parse_args(), parsing the input arguments and getting the Namespace object with all the user's arguments.
Before we move on to dissecting the argparse module deeply, you must note that it recognizes two kinds of command-line arguments.
The first kind is the positional argument, or simply the argument. It is termed so because its purpose is defined by its relative position in the command construct. The "path" argument in the above example is a positional argument.
The second kind is the optional argument. It is also called an option, switch, or flag. This type of argument enables you to modify how your command works. As the name indicates, it doesn't need to be used for the command to work.
Writing an Argument Parser
The argument parser is an essential component of any CLI built with argparse since it processes every argument and option passed.
You must write one by instantiating the ArgumentParser class:
>>> from argparse import ArgumentParser >>> parser = ArgumentParser() >>> parser ArgumentParser( prog='', usage=None, description=None, formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True )
The ArgumentParser constructor accepts several arguments with which you can tweak the feature set of your CLI.
The arguments it accepts are optional, so if you instantiate the ArgumentParser class without any arguments, you will get a no-frills parser.
Adding Arguments and Options
Adding arguments and options to your CLI is as simple as using the .add_argument() method on the ArgumentParser instance you created.
The first argument you add to the .add_argument() method contrasts the difference between options and arguments. This first argument is termed either "name" or "flag," depending on whether you're defining an argument or option.
Let's add a "-l" option to the CLI created above:
# ls.py version2 import argparse import datetime from pathlib import Path parser = argparse.ArgumentParser() parser.add_argument("path") parser.add_argument("-l", "--long", action="store_true") #Creating an option with flags -l and --long args = parser.parse_args() target_dir = Path(args.path) if not target_dir.exists(): print("The target directory doesn't exist") raise SystemExit(1) def build_output(entry, long=False): if long: size = entry.stat().st_size date = datetime.datetime.fromtimestamp( entry.stat().st_mtime).strftime( "%b %d %H:%M:%S" ) return f"{size:>6d} {date} {entry.name}" return entry.name for entry in target_dir.iterdir(): print(build_output(entry, long=args.long))
When creating the option in line 11 (highlighted yellow), we also set the "action" argument to "store_true." This instructs Python to store the Boolean value "True" when the option is supplied in the command line.
As you'd expect, if the option is not supplied, the value will remain "False."
Later in the program, we define the build_output() function using tools such as Path.stat() and a datetime.datetime object.
It produces a detailed output when "long" is "True." This output includes the name of every item in the directory, its modification date, and size.
When the value is "False," it generates a basic output.
Parsing the Arguments and Options
The argument supplied needs to be parsed so your CLI can use the actions accordingly. In the previous program, this happens in the line where the "args" variable is first defined.
That statement calls the .parse.args() method. Then, its return value is assigned to the args variable, a Namespace object holding all the arguments and options supplied at the command line. The object also stores its corresponding values via the dot notation.
This Namespace object comes in handy in your app's primary code – like in the for loop in the previously mentioned program.
Setting Descriptions, Epilog Messages, and Grouped Help Messages with Argparse
Defining the Description and Epilog Message
Supplying a description of your CLI app is a great way to make the app's function easier to understand. An ending message or epilog is typically used to thank the user.
You can use the "description" and "epilog" arguments to accomplish these things. Let's continue with our custom ls command example to see how the arguments work:
# ls.py version3 import argparse import datetime from pathlib import Path parser = argparse.ArgumentParser( prog="ls", # This argument sets the program's name description=" This program lists a directory's content", epilog="Thank you for using %(prog)s", ) # ... for entry in target_dir.iterdir(): print(build_output(entry, long=args.long))
The description we have set will appear at the beginning of the help message.
Take a close look at the epilog argument, and you will see that the "%" format specifier is used. It might surprise you, but it is true that the help message supports this format specifier.
Since the f-strings specifiers replace the names with values as they run, they are not supported. So, if you try putting prog into epilog and using an f-string when calling ArgumentParser, the app will throw a NameError.
Executing the app will give you the following output:
$ python ls.py -h usage: ls [-h] [-l] path This program lists a directory's content positional arguments: path options: -h, --help show this help message and exit -l, --long Thank you for using ls |
Displaying Grouped Help Messages
The argparse module boasts the help group feature, allowing you to organize related commands and arguments in groups and make the help message easier to read.
Creating a help group involves using ArgumentParser's.add_argument_group() method, like so:
# ls.py version4 # ... parser = argparse.ArgumentParser( prog="ls", # This argument sets the program's name description=" This program lists a directory's content", epilog="Thank you for using %(prog)s", ) general = arg_parser.add_argument_group("general output") general.add_argument("path") detailed = arg_parser.add_argument_group("detailed output") detailed.add_argument("-l", "--long", action="store_true") args = arg_parser.parse_args() # ... for entry in target_dir.iterdir(): print(build_output(entry, long=args.long))
Though grouping the arguments seems unnecessary in an example as basic as this one, the idea is for you to improve your app's user experience following it.
Grouping help messages this way can be especially impactful to UX if your app has several arguments and options.
Executing the app with the -h option will provide the following results:
python ls.py -h usage: ls [-h] [-l] path This program lists a directory's content options: -h, --help show this help message and exit general output: path detailed output: -l, --long Thank you for using ls |
Improving Your Command-Line Arguments and Options
Setting the Action Behind an Option
When adding a flag or option to a CLI, in most cases, you will have to define how the option's value must be stored in its corresponding Namespace object.
Doing this involves using the "action" argument in the .add_argument() method. The argument in question stores the value supplied for the option as it is in the Namespace. This is because the argument has the value "store" by default.
However, the argument can have other values. Let's take a look at all the values the action argument can store and their meanings.
Allowed Value |
Description |
append |
Appends the current value to a list when option is supplied. |
append_const |
Appends a constant value to a list when option is supplied. |
count |
Counts the number of times the current option was supplied and stores the value. |
store |
Puts the input into the Namespace object. |
store_const |
Stores a constant value if the option is specified. |
store_false |
The default value is "True." It stores "False" if the option is specified. |
store_true |
The default value is "False." It stores "True" if the option is specified. |
version |
The app's version is displayed, and then the app is terminated. |
If you supply any values with the "_const" suffix, you will need to supply the constant value. Doing this is as simple as using the const argument when you call .add_argument().
By the same token, you will need to provide the app's version to the "version" action with the version argument in your call to .add_argument()
Here's an example offering a closer look at how these actions work:
# Toy app # actions.py import argparse parser = argparse.ArgumentParser() parser.add_argument( "--name", action="store" ) # This option will store the passed value without any considerations parser.add_argument("--pi", action="store_const", const=3.14) # When the option is supplied, this option will automatically store the target constant parser.add_argument("--is-valid", action="store_true") # This option will store True when supplied and False otherwise parser.add_argument("--is-invalid", action="store_false") # This option will store False when supplied and True otherwise parser.add_argument("--item", action="append") # This option will help you create a list of all values, but you will need to repeat the option for every value. The argparse method will append the supplied items to a list with the same name as the option. parser.add_argument("--repeated", action="append_const", const=42) # This option works similarly to the "--item" option. The only difference being it'll append the same constant value you provide with the const argument. parser.add_argument("--add-one", action="count") # It will count the frequency of the option's total usage in the command line. parser.add_argument( "--version", action="version", version="%(prog)s 0.1.0" ) # It'll show the app's version before terminating it. For this option to work, you will need to supply the version number beforehand. args = parser.parse_args() print(args)
As you can see, we've used all the possible values for the action argument in the program above. We've also commented on how every defined option will function.
When you run it, it'll print the Namespace object of all the action arguments used.
Though there are no deficiencies in the set of default actions argparse supplies, it's worth noting that you can create custom actions by subclassing the argparse. Action class.
Customizing Input Values for your Command-Line App
Sometimes, your app may demand that the supplied argument accept a string, list of values, or another type of value. The command line treats supplied arguments as strings by default.
But the argparse module features mechanisms that allow it to check whether an argument is a valid list, string, integer, etc. Let's take a look at the different ways of customizing input values.
Defining the Type of Input Values
You can use the "type" argument of the .add_argument() method to define the inputs you want to store in the Namespace object.
Let's say you're making a CLI app that divides two numbers. For it to work as intended, it'll accept two options: --dividend and --divisor.
For these options to work correctly, they must only accept integer numbers at the command line. Here's how you could approach it:
# divide.py import argparse parser = argparse.ArgumentParser() parser.add_argument("--dividend", type=int)# Setting "int" as the acceptable input type parser.add_argument("--divisor", type=int) # Setting "int" as the acceptable input type args = parser.parse_args() print(args.dividend / args.divisor)
This CLI will now only accept integers when the options are used. It will attempt to convert the supplied values into integers but fail if text strings and floating values are supplied.
Accepting Multiple Input Values
The argparse module assumes you'll accept individual values for every option and argument. To change this behavior, you can use the "nargs" argument.
This argument indicates to the module that the argument (or option) in question will take can accept zero or several input values depending on the specific value you assign to nargs.
The nargs argument can accept the following values:
Allowed Value |
Meaning |
* |
Accepts zero or more values and stores them in a list |
? |
Accepts zero or one value |
+ |
Accepts one or more values and stores them in a list |
argparse.REMAINDER |
Gathers all the values leftover in the command line |
Let's see how the allowed values work with an example. Let's say you create a CLI with a command-line option "--coordinates" that accepts two values. As you'd assume, the idea is to accept the x and y coordinates for a Cartesian plane.
Here's what the code would look like:
# point.py import argparse parser = argparse.ArgumentParser() parser.add_argument("--coordinates", nargs=2) # The option will only accept two arguments args = parser.parse_args() print(args)
The final statement in the program instructs Python to print the Namespace object. If you supply it with two values, you will see expected results like so:
$ python point.py --coordinates 2 3 Namespace(coordinates=['2', '3']) |
But if zero, one, or more than two arguments are supplied, the program will throw an error.
Now, let's go over an example using the * value of nargs. Let's say you build a CLI app that accepts numbers and returns their sum. It would look like this:
# sum.py import argparse parser = argparse.ArgumentParser() parser.add_argument("numbers", nargs="*", type=float)# The numbers argument will only accept floating point numbers args = parser.parse_args() print(sum(args.numbers))
Since we've used the * value, the program will return the sum regardless of how many values you pass (or don't pass any).
In contrast, assigning the + value to nargs will only force the argument to accept a minimum of one value. Here's an example of a CLI app that accepts one or more files and prints their names.
# files.py import argparse parser = argparse.ArgumentParser() parser.add_argument("files", nargs="+") # The files argument needs a minimum of one value args = parser.parse_args() print(args)
If you supply this program with one or more files, it will return a Namespace with the file names, like so:
$ python files.py helloWorld.txt Namespace(files=['helloWorld.txt']) |
However, if no files are supplied, it'll throw an error like so:
$ python files.py usage: files.py [-h] files [files ...] files.py: error: the following arguments are required: files |
The REMAINDER value enables nargs to capture the leftover input values at the command line. Let's see how it works:
import argparse parser = argparse.ArgumentParser() parser.add_argument('value') parser.add_argument('remaining', nargs=argparse.REMAINDER) args = parser.parse_args() print(f'Initial value: {args.value}') print(f'Leftover values: {args.remaining}')
Passing some strings to this program will lead to the first string being captured by the first argument. The second argument will capture the remaining strings. Here's what the output looks like when you run it:
>python nargs.py good day to you reader First value: good Other values: ['day', 'to', 'you', 'reader'] |
The nargs argument supplies a lot of flexibility, but it can be difficult to use when your app has multiple command-line options and arguments.
You might have trouble combining options and arguments when different nargs values are involved. Let's see how with an example:
# cooking.py import argparse parser = argparse.ArgumentParser() parser.add_argument("vegetables", nargs="+") parser.add_argument("spices", nargs="*") args = parser.parse_args() print(args)
You would expect "vegetables" to accept one or more items and "spices" to accept zero or more items. But this is not what happens when you run the program:
$ python cooking.py eggplant chili cucumber Namespace(vegetables=['eggplant', 'chili', 'cucumber'], spices=[]) |
We get this output because the parser doesn't have any means to determine which value needs to be assigned to which argument or option.
In this example, you can fix this issue by converting both arguments into options. Doing this is as simple as adding two hyphens behind the arguments.
You can then run the following for the correct output:
$ python cooking.py --vegetables eggplant cucumber --spices chilli Namespace(vegetables=['eggplant', 'cucumber'], spices=['chilli']) |
This example shows that you must be careful when combining arguments and options set with nargs.
Setting Default Values
Using the "default" argument with the .add_argument() method, you can supply appropriate default values to arguments and options.
Using this argument can be extremely helpful when your target option or argument needs a valid value, even if the user doesn't provide input.
Let's go back to the custom ls command we wrote earlier in this post. Let's say the app now needs to make the command list the current directory's content if the user doesn't supply a target directory.
Here's what the program would look like:
# ls.py version5 import argparse import datetime from pathlib import Path # ... general = parser.add_argument_group("general output") general.add_argument("path", nargs="?", default=".") # ...
As you can see, we have set the "default" argument to the "." string, representing the current directory. Also, nargs is set to "?" which removes the constraint for an input value and accepts only a single value if any are supplied.
Run the program and see how it works!
Mentioning a List of Allowed Input Values
The argparse module offers the possibility to supply a list of values and only allows those values to work with specific arguments and options.
The module makes this possible with the "choices" argument you can provide to the .add_argument() method. You will need to supply your list of acceptable values to this argument.
Let's say you're building a CLI app that needs to accept T-shirt sizes. Here's a program that would define a "--size" option and some acceptable values:
# size.py import argparse parser = argparse.ArgumentParser() parser.add_argument("--size", choices=["S", "M", "L", "XL"], default="M") args = parser.parse_args() print(args)
As you can see, we have used the "choices" argument to supply a list of acceptable values. As you'd expect, the CLI app will only accept these values.
Here's what happens if a user tries to provide a value that's not on this list:
$ python choices.py --size A usage: choices.py [-h] [--size {S,M,L,XL}] choices.py: error: argument --size: invalid choice: 'A' (choose from 'S', 'M', 'L', 'XL') |
But if a valid value is supplied, the program will print the Namespace object just like in the previously discussed examples.
What's more interesting is that the "choices" argument can accept values of different data types. So, if you needed to accept integer values, you could define a range of acceptable values.
Let's see how you can use range() to do this:
# weekdays.py import argparse my_parser = argparse.ArgumentParser() my_parser.add_argument("--weekday", type=int, choices=range(1, 8)) args = my_parser.parse_args() print(args)
This app will automatically check any value the user supplies to the command line against the range object in the form of the "choices" argument.
If the number supplied is out of the range, the app will throw an error like so:
$ python days.py --weekday 9 usage: days.py [-h] [--weekday {1,2,3,4,5,6,7}] days.py: error: argument --weekday: invalid choice: 9 (choose from 1, 2, 3, 4, 5, 6, 7) |
Creating and Customizing Help Messages in Argparse
One of the nicest features of the argparse module is its generation of help messages and automatic usage messages for your applications.
The user can access these messages using the -h or the --help flag. These flags are integrated into all argparse CLIs by default.
In the early parts of this post, you saw how you could integrate descriptions and ending messages into your CLI app.
Now, let's continue with our custom ls command example and look at how you can provide enhanced messages for individual arguments and options with the "help" and "metavar" arguments.
# ls.py version6 import argparse import datetime from pathlib import Path # ... general = parser.add_argument_group("general output") general.add_argument( "path", nargs="?", default=".", help="take the path to the target directory (default: %(default)s)", ) detailed = parser.add_argument_group("detailed output") detailed.add_argument( "-l", "--long", action="store_true", help="display detailed directory content", ) # ...
We've already discussed that format specifiers such as %(prog)s work without any problems in argparse. But at this stage, it's also worth noting that you can use most of the arguments to add_argument() as a format specifier. These include but are not limited to %(type)s and %(default)s.
Executing the app we've now redesigned gives us the following output:
$ python ls.py -h usage: ls [-h] [-l] [path] This program lists a directory's content options: -h, --help show this help message and exit general output: path take the path to the target directory (default: .) detailed output: -l, --long display detailed directory content Thank you for using ls |
As you can see, when running the app with the -h flag, both -l and path now show descriptive help messages. Though "path" has its default value in its help message, it's still helpful.
The default usage message in argparse is good enough, but you can improve it if you wish with the metavar argument.
The argument becomes especially handy when an option or argument accepts input values. You can assign descriptive names to the input values the parser will use to generate the help message.
Let's take a step back and run the point.py example from a few sections ago with the -h switch. Here's the output:
$ python point.py -h usage: point.py [-h] [--coordinates COORDINATES COORDINATES] options: -h, --help show this help message and exit --coordinates COORDINATES COORDINATES |
The argparse module uses the original names of the options to designate the corresponding input values in the help and usage messages.
But in the output above, "COORDINATES" appears two times, which can confuse a user into thinking that the coordinates need to be supplied two times.
Here is how you would deal with this ambiguity:
# point.py import argparse parser = argparse.ArgumentParser() parser.add_argument( "--coordinates", nargs=2, metavar=("X", "Y"), help="take the Cartesian coordinates %(metavar)s", ) args = parser.parse_args() print(args)
A tuple value with two coordinate names is assigned to metavar in the code above. Plus, we've also added a custom message for the appropriate option.
Running the code above will give the following output:
$ python coordinates.py -h usage: coordinates.py [-h] [--coordinates X Y] options: -h, --help show this help message and exit --coordinates X Y take the Cartesian coordinates ('X', 'Y') |
Handling How Your CLI App's Execution Terminates
CLI applications must be terminated in certain circumstances, such as when errors and exceptions appear.
The most common way of dealing with exceptions and errors is to exit the app and display an exit status or error code. The status or code indicates to the operating system or other apps that the app terminated due to some execution error.
Generally speaking, when a command exits with a zero code, it succeeds in its task before its termination. On the other hand, a non-zero code indicates that the command fails to complete its task.
This system of indicating success is quite simple. However, it complicates the task of indicating the command's failure. There are no definitive standards for exit statuses and error codes.
An operating system or programming language may use simple decimals, hexadecimal, alphanumeric strings, or full phrases to describe the error.
Integer values are used in Python to describe the system exit status of a CLI app. If the "None" exit status is returned, it means the exit status is zero, and the termination was successful.
As you'd expect, non-zero values indicate abnormal termination. Most systems require an exit code ranging between 0 to 127. If the value is outside the range, the results are undefined.
You don't have to think of returning exit codes for successful operations and command syntax errors when building CLI apps with argparse.
However, you will need to return appropriate exit codes when the app terminates abruptly due to other errors.
The ArgumentParser class supplies two methods for terminating an app when something goes wrong.
You can use the .exit(status=0, message=None) method, which ends the app and returns a specified status and message. Or, you can use the .error(message) method, which prints the supplied message and terminates the app with a status code "2."
Regardless of which method you use, the status will print to the standard error stream, a dedicated stream for error reporting.
Using the .exit() method is the right way to go when you want to specify the status code yourself. You can use the .error() method in any circumstance.
Let's edit our custom ls command program a little to see how to exit:
# ls.py version7 import argparse import datetime from pathlib import Path # ... target_dir = Path(args.path) if not target_dir.exists(): parser.exit(1, message="The target directory doesn't exist") # ...
The conditional statement towards the end of the code checks whether the target dictionary exists. As you can see, rather than using "raise SystemExit(1)," we've used "ArgumentParser.exit()."
This simple decision makes the code more focused on the argparse framework.
When you run the program, you will find that the program will terminate when the target directory doesn't exist.
If you're using Linux or macOS, you can inspect the $? Shell variable and confirm that your app has returned "1," signaling an error in the execution. On Windows, you will need to check the contents of the $LASTEXITCODE variable.
Keeping your status codes consistent across the CLI apps you build is an excellent way to ensure you and your users comfortably integrate your apps in command pipes and shell scripts.