Python CLI application

In this section, you'll see how to implement a CLI application using Python features, instead of relying on shell features. First, you'll learn how to work with command line arguments using the sys module. Followed by argparse module, which is specifically designed for creating CLI applications.

sys.argv

Command line arguments passed when executing a Python program can be accessed using the sys.argv list. The first element (index 0) contains the name of the Python script or -c or empty string, depending on how the interpreter was called. See docs.python: sys.argv for details.

Rest of the elements will have the command line arguments, if any were passed along the script to be executed. The data type of sys.argv elements is str class. The eval() function allows you to execute a string as a Python instruction. Here's an example:

$ python3.9 -c 'import sys; print(eval(sys.argv[1]))' '23 ** 2'
529

# bash shortcut
$ pc() { python3.9 -c 'import sys; print(eval(sys.argv[1]))' "$1" ; }
$ pc '23 ** 2'
529
$ pc '0x2F'
47

warning warning Using eval() function isn't recommended if the input passed to it isn't under your control, for example an input typed by a user from a website application. The arbitrary code execution issue would apply to the bash shortcuts seen in previous section as well, because the input argument is interpreted without any sanity check.

However, for the purpose of this calculator project, it is assumed that you are the sole user of the application. See stackoverflow: evaluating a mathematical expression for more details about the dangers of using eval() function and alternate ways to evaluate a string as mathematical expression.

argparse

Quoting from docs.python: argparse:

The argparse module makes it easy to write user-friendly command-line interfaces. The program defines what arguments it requires, and argparse will figure out how to parse those out of sys.argv. The argparse module also automatically generates help and usage messages and issues errors when users give the program invalid arguments.

argparse initialization

If this is your first time using the argparse module, it is recommended to understand the initialization instructions and see the effect they provide by default. Quoting from docs.python: argparse:

The ArgumentParser object will hold all the information necessary to parse the command line into Python data types.

ArgumentParser parses arguments through the parse_args() method. This will inspect the command line, convert each argument to the appropriate type and then invoke the appropriate action.

# arg_help.py
import argparse

parser = argparse.ArgumentParser()
args = parser.parse_args()

The documentation for the CLI application is generated automatically based on the information passed to the parser. You can use help options (which is added automatically too) to view the content, as shown below:

$ python3.9 arg_help.py -h
usage: arg_help.py [-h]

optional arguments:
  -h, --help  show this help message and exit

In addition, any option or argument that are not defined will generate an error.

$ python3.9 arg_help.py -c
usage: arg_help.py [-h]
arg_help.py: error: unrecognized arguments: -c

$ python3.9 arg_help.py '2 + 3'
usage: arg_help.py [-h]
arg_help.py: error: unrecognized arguments: 2 + 3

A required argument wasn't declared in this program, so there's no error for the below usage.

$ python3.9 arg_help.py

info See also docs.python: Argparse Tutorial.

Accepting an input expression

# single_arg.py
import argparse, sys

parser = argparse.ArgumentParser()
parser.add_argument('ip_expr',
                    help="input expression to be evaluated")
args = parser.parse_args()

try:
    result = eval(args.ip_expr)
    print(result)
except (NameError, SyntaxError):
    sys.exit("Error: Not a valid input expression")

The add_argument() method allows you to add details about an option/argument for the CLI application. The first parameter names an argument or options (starts with -). The optional help parameter lets you add documentation for that particular option/argument. See docs.python: add_argument for documentation and details about other parameters.

The value for ip_expr passed by the user will be available as an attribute of args, which stores the object returned by the parse_args() method. The default data type for arguments is str, which is good enough here for eval().

The help documentation for this script is shown below:

$ python3.9 single_arg.py -h
usage: single_arg.py [-h] ip_expr

positional arguments:
  ip_expr     input expression to be evaluated

optional arguments:
  -h, --help  show this help message and exit

Note that the script uses try-except block to give user friendly feedback for some of the common issues. Passing a string to sys.exit() gets printed to the stderr stream and sets the exit status as 1 to indicate something has gone wrong. See docs.python: sys.exit for documentation. Here's some usage examples:

$ python3.9 single_arg.py '40 + 2'
42

# if no argument is passed to the script
$ python3.9 single_arg.py
usage: single_arg.py [-h] ip_expr
single_arg.py: error: the following arguments are required: ip_expr
$ echo $?
2

# SyntaxError
$ python3.9 single_arg.py '5 \ 2'
Error: Not a valid input expression
$ echo $?
1

# NameError
$ python3.9 single_arg.py '5 + num'
Error: Not a valid input expression

Adding optional flags

To add an option, use -<char> for short option and --<name> for long option. You can add both as well, '-v', '--verbose' for example. If you use both short and long options, the attribute name will be whichever option is the latest. For the CLI application, five short options have been added, as shown below.

# options.py
import argparse, sys

parser = argparse.ArgumentParser()
parser.add_argument('ip_expr',
                    help="input expression to be evaluated")
parser.add_argument('-f', type=int,
                    help="specify floating point output precision")
parser.add_argument('-b', action="store_true",
                    help="output in binary format")
parser.add_argument('-o', action="store_true",
                    help="output in octal format")
parser.add_argument('-x', action="store_true",
                    help="output in hexadecimal format")
parser.add_argument('-v', action="store_true",
                    help="verbose mode, shows both input and output")
args = parser.parse_args()

try:
    result = eval(args.ip_expr)

    if args.f:
        result = f'{result:.{args.f}f}'
    elif args.b:
        result = f'{int(result):#b}'
    elif args.o:
        result = f'{int(result):#o}'
    elif args.x:
        result = f'{int(result):#x}'

    if args.v:
        print(f'{args.ip_expr} = {result}')
    else:
        print(result)
except (NameError, SyntaxError):
    sys.exit("Error: Not a valid input expression")

The type parameter for add_argument() method allows you to specify what data type should be applied for that option. The -f option is used here to set the precision for floating-point output. The code doesn't actually check if the output is floating-point type, that is left as an exercise for you.

The -b, -o, -x and -v options are intended as boolean data types. Using action="store_true" indicates that the associated attribute should be set to False as their default value. When the option is used from the command line, their value will be set to True. The -b, -o and -x options are used here to get the output in binary, octal and hexadecimal formats respectively. The -v option will print both the input expression and the evaluated result.

The help documentation for this script is shown below. By default, uppercase of the option name will be used to describe the value expected for that option. Which is why you see -f F here. You can use metavar='precision' to change it to -f precision instead.

$ python3.9 options.py -h
usage: options.py [-h] [-f F] [-b] [-o] [-x] [-v] ip_expr

positional arguments:
  ip_expr     input expression to be evaluated

optional arguments:
  -h, --help  show this help message and exit
  -f F        specify floating point output precision
  -b          output in binary format
  -o          output in octal format
  -x          output in hexadecimal format
  -v          verbose mode, shows both input and output

Here's some usage examples:

$ python3.9 options.py '22 / 7'
3.142857142857143
$ python3.9 options.py -f3 '22 / 7'
3.143
$ python3.9 options.py -f2 '32 ** 2'
1024.00

$ python3.9 options.py -bv '543 * 2'
543 * 2 = 0b10000111110

$ python3.9 options.py -x '0x1F * 0xA'
0x136

$ python3.9 options.py -o '0xdeadbeef'
0o33653337357

Since -f option expects an int value, you'll get an error if you don't pass a value or if the value passed isn't a valid integer.

$ python3.9 options.py -fa '22 / 7'
usage: options.py [-h] [-f F] [-b] [-o] [-x] [-v] ip_expr
options.py: error: argument -f: invalid int value: 'a'

$ python3.9 options.py -f
usage: options.py [-h] [-f F] [-b] [-o] [-x] [-v] ip_expr
options.py: error: argument -f: expected one argument

$ python3.9 options.py -f '22 / 7'
usage: options.py [-h] [-f F] [-b] [-o] [-x] [-v] ip_expr
options.py: error: argument -f: invalid int value: '22 / 7'

$ python3.9 options.py -f '22'
usage: options.py [-h] [-f F] [-b] [-o] [-x] [-v] ip_expr
options.py: error: the following arguments are required: ip_expr

Accepting stdin

The final feature to be added is the ability to accept both stdin and argument value as the input expression. The sys.stdin filehandle can be used to read stdin data. The modified script is shown below.

# py_calc.py
import argparse, sys

parser = argparse.ArgumentParser()
parser.add_argument('ip_expr', nargs='?',
                    help="input expression to be evaluated")
parser.add_argument('-f', type=int,
                    help="specify floating point output precision")
parser.add_argument('-b', action="store_true",
                    help="output in binary format")
parser.add_argument('-o', action="store_true",
                    help="output in octal format")
parser.add_argument('-x', action="store_true",
                    help="output in hexadecimal format")
parser.add_argument('-v', action="store_true",
                    help="verbose mode, shows both input and output")
args = parser.parse_args()

if args.ip_expr in (None, '-'):
    args.ip_expr = sys.stdin.readline().strip()

try:
    result = eval(args.ip_expr)

    if args.f:
        result = f'{result:.{args.f}f}'
    elif args.b:
        result = f'{int(result):#b}'
    elif args.o:
        result = f'{int(result):#o}'
    elif args.x:
        result = f'{int(result):#x}'

    if args.v:
        print(f'{args.ip_expr} = {result}')
    else:
        print(result)
except (NameError, SyntaxError):
    sys.exit("Error: Not a valid input expression")

The nargs parameter allows to specify how many arguments can be accepted with a single action. You can use an integer value to get that many arguments as a list or use specific regular expression like metacharacters to indicate varying number of arguments. The ip_expr argument is made optional here by setting nargs to ?.

If ip_expr isn't passed as an argument by the user, the attribute will get None as the value. The - character is often used to indicate stdin as the input data. So, if ip_expr is None or -, the code will try to read a line from stdin as the input expression. The strip() string method is applied to the stdin data mainly to prevent newline from messing up the output for -v option. Rest of the code is the same as seen before.

The help documentation for this script is shown below. The only difference is that the input expression is now optional as indicated by [ip_expr].

$ python3.9 py_calc.py -h
usage: py_calc.py [-h] [-f F] [-b] [-o] [-x] [-v] [ip_expr]

positional arguments:
  ip_expr     input expression to be evaluated

optional arguments:
  -h, --help  show this help message and exit
  -f F        specify floating point output precision
  -b          output in binary format
  -o          output in octal format
  -x          output in hexadecimal format
  -v          verbose mode, shows both input and output

Here's some usage examples:

# stdin from output of another command
$ echo '40 + 2' | python3.9 py_calc.py
42

# manual stdin data after pressing enter key
$ python3.9 py_calc.py
43 / 5
8.6

# strip() will remove whitespace from start/end of string
$ echo ' 0b101 + 3' | python3.9 py_calc.py -vx
0b101 + 3 = 0x8

$ echo '0b101 + 3' | python3.9 py_calc.py -vx -
0b101 + 3 = 0x8

# expression passed as argument, works the same as seen before
$ python3.9 py_calc.py '5 % 2'
1

Shortcuts

To simplify calling the Python CLI calculator, you can create an alias or an executable Python script.

Use absolute path of the script to create the alias and add it to .bashrc, so that it will work from any working directory. The path used below would differ for you.

alias pc='python3.9 /home/learnbyexample/python_projs/py_calc.py'

To create an executable, you'll have to first add a shebang as the first line of the Python script. You can use type built-in command to get the path of the Python interpreter.

$ type python3.9
python3.9 is /usr/local/bin/python3.9

So, the shebang for this case will be #!/usr/local/bin/python3.9. After adding execute permission, copy the file to one of the PATH directories. I have ~/cbin/ as one of the paths. See unix.stackexchange: How to correctly modify PATH variable for more details about the PATH environment variable.

$ chmod +x py_calc.py

$ cp py_calc.py ~/cbin/pc

$ pc '40 + 2'
42

With that, the lessons for this project comes to an end. Solve the practice problems given in the exercises section to test your understanding.