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 first split into separate tokens using the shlex
lexer
library (in POSIX mode). These tokens are then passed to the internal
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 sys.argv as Input
Instead of a single string the execute and execute_async methods can also take a list of strings (or any
string Iterator
), where the first item is the name of the command and all following items
are the arguments.
This is useful if you - instead of implementing a full CLI - just want to parse the command line arguments of a Python
script. A Python script has all its arguments in the system parameter sys.argv
with
sys.argv[0]
containing the script name. This can be passed directly to execute/execute_async as the
commandline argument. For example, the following script will implement a --verbose
argument for the script:
# testverbose.py
import sys
from argparsedecorator import *
# use helpoption='-h' as the default "help" option does not
# work when parsing script arguments.
argparser = ArgParseDecorator(helpoption="-h")
@argparser.command
def testverbose(v: Flag = False): # must be the same name as the script.
"""
Sample to show script argument parsing.
:param v: switch on verbose mode.
:alias v: --verbose
"""
if v:
print("chatty mode activated")
if __name__ == "__main__":
argparser.execute(sys.argv)
# python testverbose.py --verbose
chatty mode activated
# python testverbose.py --help
usage: testverbose [-h] [-v]
Sample to show script argument parsing.
options:
-h, --help show this help message and exit
-v, --verbose switch on verbose mode.
Process finished with exit code 0
Using the name of the script as the name of the command function allows for the same script to behave differently depending on the name of the script, e.g. by using differently named links to the same Python script.
Using Quotes on the Command Line
ArgParseDecorator uses the shlex
lexer library (in POSIX mode) to split a given
commandline into seperate tokens for the command and the arguments. Arguments containing spaces can be encapsulated
in single or double quotemarks to prevent splitting them into seperate arguments.
However these quotemarks will be removed by shlex. If an argument requires quotes to be preserved they need to
be escaped by a backslash character \
. If a backslash character is part of an argument it has to be escaped
as well like \\
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 "a \'quote\' "') # -> Split into ['cmd', "a 'quote' "]
cli.execute('cmd path\\to\\file') # -> Split into ['cmd', 'path\to\file']
If this behaviour is not desired, e.g. when working with lots of Windows paths, then the caller can implement its own lexer (e.g. shlex in the default non-POSIX mode) and pass its result to the execute method (note: shlex implements the Iterator methods and can be passed to execute directly).
See shlex parsing rules for more details on how shlex works in the different modes.
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'
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