Using the argparseDecorator

Install

The easiest way to install argparseDecorator is with pip:

$ pip import argparseDecorator

Alternativly the sources can be downloaded directly from the Github page.

Once argparseDecorator has been installed it can be used like this.

Importing argparseDecorator

First, if you are using a python version previous to 3.10 it is a good idea to add

from __future__ import annotations

at the top of your file. This ensures that the annotations used by the argparseDecorator are handled as strings and not as Python Types. This is not only faster, but some uses of annotations by the decorator will cause errors otherwise.

Next the argparseDecorator is imported. The best way is to

from argparsedecorator import *

to import all required types, including the annotation objects. Take a look at the __init__.py file to see which names are pulled into the namespace

Simple example

Now the decorator can be instantiated and its command decorator can be used to mark a function as a command. In this short example the command reverse is created, which takes a single argument named word:

cli = ArgParseDecorator()

@cli.command
def reverse(word):
     print(word[::-1])

With this a command can be executed like this:

cli.execute("reverse foobar")

raboof

The ArgParseDecorator class

To use the argparseDecorator an instance of the ArgParseDecorator class has to be created.

cli = ArgParseDecorator()

The two main methods of the ArgParseDecorator class are command() and execute().

command() is a Decorator that can mark any function or method as a command. There can be any number of decorated functions.

@cli.command
def foobar(word):
     ...

Any such decorated function is called by execute(cmdstring) when the cmdstring contains the command.

Note

The command decorator can be used with or without parenthesis.

Arguments

Take a look at the ArgParseDecorator API to see what optional arguments can be given when instantiating the class.

Note that any keyword argument that ArgParseDecorator does not handle itself will be passed onto the the underlying argparse.ArgumentParser class. Some options like formatter_class or allow_abbrev might be useful in some cases.

However some options of argparse.ArgumentParser are not useful and should not be used. Take a look at the Limitations chapter for more info on which options should be avoided.

Help

By default argparse.ArgumentParser adds a -h/–help argument to every command. This is somewhat ugly for a CLI with many commands and every one having the same, obvious help argument.

Instead the ArgParseDecorator by default adds a help command to the CLI which will provide a list of all supported commands when called by itself or a detailed command description when supplied with a command name argument.

To override this behaviour and instead use the -h/--help system of ArgumentParser set helpoption="-h" when instantiating the ArgParseDecorator

cli = ArgParseDecorator(helpoption="-h")

If no help is wanted set helpoption to None

cli = ArgParseDecorator(helpoption=None)

Subcommands

Sometimes it makes sense to split commands into multiple subcommands. This is supported by the argparseDecorator. To define a subcommand just add an underscore between the main command and the subcommand in the function name.

For example the commands to switch an LED on or off could be implemented like this

@cli.command
def led_on():
    ...

@cli.command
def led_off():
    ...

With this the argparseDecorator now understands the two commands led on and led off and the respective functions are called.

cli.execute("led on")

Commands with Hyphens

To create a command containing a hypen -, e.g. get-info ... a double underscore is used in the command name, e.g.

@cli.command
def get__info():
    ...

cli.execute("get-info")

Using ArgParseDecorator to Decorate Class Methods

When using this library to decorate methods within a class there is one caveat:

class MyCLI:

    cli = ArgParseDecorator()

    @cli.command
    def cmd(self, arg1, arg2, ...):
        ...

To mark methods as commands the ArgParseDecorator must be instantiated as a class variable. But as a class variable it does not have access to any data from a MyCLI instance, especially not to the self reference.

To correctly call the cmd function from execute() a reference to self must be given, e.g. like this:

class MyCLI:

    cli = ArgParseDecorator()

    @cli.command
    def cmd(self, arg1, arg2, ...):
        ...

    def execute(self, cmdline):
        cli.execute(cmdline, self)

Note how cli.execute() is wrapped in a method and how it passes a reference to self to the ArgParseDecorator.

An alternative method would be the use of inner functions like this:

class MyCLI:

    def __init__(self):
        self.setup_cli()

    def setup_cli(self):

        cli = ArgParseDecorator()
        self.cli = cli              # store as instance variable

        @cli.command
        def cmd(arg1, arg2, ...)
            self.do_something_with(arg1)

    def execute(self, cmdline)
        self.cli.execute(cmdline)

Function Signature

argparseDecorator makes heavy use of type annotaions to pass additional information to the ArgumentParser. This includes a number of custom types which are used to provide additional information about the arguments.

For example the following command will add up a list of numbers or, if --squared is added to the command, will calculate the sum of the squares.

@cli.command
def add(values: OneOrMore[float], squared: Option = False) -> None:
    if squared:
        values = [x*x for x in values]
    print sum(values)

OneOrMore[float] tells the decorator, that values must have at least one value and that it is accepting only valid numbers (int or float). Option = False marks squared as an option (starting with --) and that it has the the value True if set on the command line (overriding the default) or False (the default) otherwise.

The add command can now be used like this

cli.execute("add 1 2 3 4")

10

cli.execute("add --squared 1 2 3 4")

30

Take a look at the annotations API for all supported annotations and more examples.

Flags and Options

The argparse library only destinguishes between position arguments and flags. Flags are all arguments starting with either a single or a double hyphen -.

As python identifiers must not start with a hyphen there must be a way to tell the argparseDecorator that the argument of a command is a flag.

This is done with the Flag and Option annotations. The Flag tells the the decorator to internally add a single - to the argument. Option does the same, but with a double hyphen --.

If an Flag or Option should have multiple names, e.g. a long Option name like --foobar and a short Flag name like -f an :alias --foobar: -f must be added to the docstring of the command function. See Aliases below for details.

Argument Type

The argparse.ArgumentParser reads command-line arguments as simple strings. However it is often usefull to interpret the input as another type, such as int or float.

This can be done by just annotating the argument with the required type in the normal Python fashion:

@cli.command
def add(value1: float, value2: float):
    print(value1 + value2)

@cli.execute("add 1 2.5")   # output "3.5"
@cli.execute("add apple banana) # causes ValueError

Some of the special annotations of argparseDecorator can also specify the type in brackets to make the code more readable:

@cli.command
def sum(values: OneOrMore[float]):
    print(sum(values)

is almost equivalent to

@cli.command
def sum(values: float | OneOrMore):
    print(sum(values)

but it is nicer to read and it also tells any type-checker that values is a List of floats and not a union of a float and a generic List.

Internally, when analyzing the annotations, the argparseDecorator will take anything that is not one of the built-in annotations, call eval() on it and uses the result as the type.

Take a look at the argparse documentation for more info what types are possible and how to implement custom types.

Number of Values

annotations has a number of Annotation Types to tell the ArgParseDecorator (and the arparse.ArgumentParser) how many values a command argument expects. If nothing is specified a single value is expected for the argument.

These annotations are supported:

Docstring

The argparseDecorator also uses the docstring of a decorated function to get a description of the command that is used for help and some additional meta information about arguments that can not be easily written as annotations.

argparseDecorator uses the docstring of a decorated function for description of the command and its arguments, as well as some additional data that can not be set via the signature and its annotations.

Command Description

If a decorated function has a docstring its content is used as the help text for the command:

@cli.command
def foo(bar):
    """The foo command will foo a bar."""
    ...

cli.execute("help foo")

will create the output:

usage:  foo bar

The foo command will foo a bar.

positional arguments:
  bar

Argument Help

The docstring can be used add small help strings to arguments. For this a line in the format

:param argname: short description

is added to the docstring. Example:

@cli.command
def foo(bar):
    """
    The foo command will foo a bar.
    :param bar: Which bar to foo
    """
    ...

cli.execute("help foo")

will generate:

...
positional arguments:
  bar   Which bar to foo

If the help for an argument starts with SUPPRESS, then this argument is hidden in the help. This might be usefull to hide some unofficial options used for example for debugging.

Aliases

ArgumentParser allows for flags (arguments starting with - or --) to have multiple names, e.g. --flag and -f. To support multiple names for the same argument the :alias directive can be used in the docstring. It has the format

:alias argname: -name1, --name2

Here is an example on how this can be used:

@cli.command
def foobar(flag: Option = False):
    """
    :alias flag: -f
    """
    print(flag)

cli.execute("foobar --flag")
cli.execute("foobar -f")

the last two lines are identical and will print True.

Note

While the argname given to :alias will work with or without leading hyphens, the actual alias(es) must have either one or two leading hyphens.

Choices

ArgParseDecorator supports the Choices[] annotation in the signature to restrict the value of an argument to a list of predefined values. As the syntax somewhat ugly for a list of strings (they have to be encapsuled in a Literal[] annotation to keep type checkers happy) there is an alternative using a docstring with the format:

:choices argname: opt1, opt2, ...

Example:

@cli.command
def foobar(value):
    """
    Only allow values foo, bar, 1 or 2
    :choices value: 'foo', 'bar', 1, 2
    """
    print(flag)

cli.execute("foobar foo")
cli.execute("foobar 2")
cli.execute("foobar baz")    # this will raise an Exception

Note

The list of choices is parsed using the python eval() function. It can be anything that returns a sequence of items, e.g. range(1,4) would be a valid value for choices.

Metavar

When ArgumentParser generates help messages, it needs some way to refer to each expected argument. By default, ArgumentParser objects use name of the argument as the name of each object. By default, for positional argument actions, the dest value is used directly, and for optional argument actions, the dest value is uppercased. For example

def foobar(datetime: Option | Exactly2[str]):

will have a help output of

usage:  foobar [--datetime DATETIME DATETIME]

optional arguments:
  --datetime DATETIME DATETIME

which does look ugly and is not as descriptive. Here the :metavar directive can be used to assign more descriptive names to the arguments of --datetime, e.g.:

def foobar(datetime: Option | Exactly2[str]):
    """
    :metavar datetime: DATE, TIME

will have a help output of

usage:  foobar [--datetime DATE TIME]
optional arguments:
  --datetime DATE TIME

Note

The number of metavar names must match the number of parameters an argument takes.

Executing a Command Line

Once the ArgParseDecorator has been set up with all decorated functions or methods it can be used to execute arbitrary command lines.

This is done by calling the execute() method with a command line string. The command line can come directly from the prompt like in the example below, or it could come for example from a ssh connection.

cli = ArgParseDecorator()

...

cmdline = input()
cli.execute(cmdline)

Internally the command line is parsed by the underlying argparse.ArgumentParser instance and, if there are no errors, the command function (the first word of the command line) is called with all arguments.

Execute Async Code

A typical use case for a command line interface is via a remote ssh connection. These are usually implemented with asyncio code. ArgParseDecorator supports this with the execute_async() method which is functionally equivalent to execute(), but is implemented as a coroutine which can be awaited.

To make full use of this the command functions should be coroutines as well. After parsing the given command line input, execute_async() will then await the command coroutine.

Here is a simple example for a sleep command that will pause the cli while other stuff could continue to run:

import asyncio

from argparsedecorator import *

cli = ArgParseDecorator()

@cli.command
async def sleep(n: float):
    await asyncio.sleep(n)

async def runner():
    await cli.execute_async("sleep 1.5")

if __name__ == "__main__":
    asyncio.run(runner())

Take a look at the ssh_cli.py demo for a more complex module using argparseDecorator in an asyncio application.

Using Quotes on the Command Line

The execute method needs to first split the command line into a list of strings containing the command itself and all seperate arguments to make it usable for the parse_args() method. In doing so it will treat any text in double quotes " as a single entry and pass the string in quotes but without the quotes to parse_args().

For example

cli.execute('cmd foo bar')          # -> Split into ['cmd', 'foo', 'bar']
cli.execute('cmd "foo bar"')        # -> Split into ['cmd', 'foo bar']
cli.execute('cmd "\'foo bar\'"')    # -> Split into ['cmd', "'foo bar'"]

Note

There is currently no way to pass an argument value including the quotes from the commandline input to the command. However single quotes ' can be used inside a quoted argument.

Error Handling

If there is an error parsing the command line (e.g. invalid commands, illegal arguments etc.) an error message is written to sys.stderr.

If a more involved error handling is required, e.g. to translate the error messages or to do some formatting on them, a special error handler function can be given to execute() that is called whenever an error occurs.

The error handler function is called with one argument , an argparse.ArgumentError exception object. The string representation of the exception contains the full error message.

def my_error_handler(err: argparse.ArgumentError):
    print(str(err))     # output the error message to stdout instead of stderr

cli = ArgParseDecorator()

cli.execute("command", error_handler=my_error_handler)  # "command" does not exist causing an error message

The error_handler can be explicitly set to None. In this case no error message is output but instead an argparse.ArgumentError is raised which can be caught and acted upon.

while True:
    try:
        cmdline = input()
        cli.execute(cmdline, error_handler=None)
    except ArgumentError as err:
        print(str(err))

Redirecting Output

When executing a command line all output (e.g. help messages) is written by default to the sys.stdout stream and any error message (e.g. invalid syntax) is written to the sys.stderr stream. These are usually the stdout and stderr streams of the shell from where python was started.

As the typical use case for a CLI implemented with ArgParseDecorator is some kind of remote connection, for example a ssh server implementation, there must be a way to redirect the output of the ArgumentParser to the remote connection.

This can be done by passing TextIO Streams for stdout and stderr to the execute() method. This method will then redirect sys.stdout and sys.sterr to the given stream(s) before calling argparse.ArgumentParser and the command function. After the command has been called and before returning to the caller sys.stdout and sys.stderr are restored to their original values.

cli = ArgParseDecorator()

my_stdout = BufferedWriter()

@cli.command
def echo(text: str):
    print(text)

cli.execute("echo foobar", stdout=my_stdout)
print(stdout.getvalue())    # prints 'foobar'

Note

When running in an

Redirecting Input

If any commands require further user input, e.g. for confirmation checks, the sys.stdin can also be redirected to a different stream:

cli = ArgParseDecorator()
my_stdin = io.StringIO("yes")

@cli.command
def delete():
    print("type 'yes' to confirm that you want to delete everything")
    result = input()
    if result == "yes":
        print("you have chosen 'yes'")

cli.execute("delete", stdin=my_stdin)   # will output "you have chosen 'yes'" immediatly