CLI Commands

Adding a new subcommand

Currently, all TIRA CLI subcommands live within python-client/tira/tira_cli.py. For this tutorial we will create a new subcommand tira-cli repeat <number> --text <text> that is supposed to print the given text, <text>, <number> times to the console and exit. We already implemented this functionality in the following function

def repeat_command(number: int, text: str, **kwargs) -> int:
    for _ in range(number):
        print(text)
    return 0

Note

We return 0 to indicate success. Any return code other than 0 should indicate failure and, if you like, you can use the return code to communicate different kinds of failures to the user.

Attention

The **kwargs parameter is important! For ease of use, we forward all command line arguments into the command. Try and see what happens if you remove the **kwargs or print them to console if you want to understand it better.

Now, we only need to register this command with TIRA and tell it the arguments we need. We do this by creating a new method within python-client/tira/tira_run.py like this:

def setup_repeat_command(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("number", type=int, help="The number of times the chosen text should be printed")
    parser.add_argument("-t", "--text", required=False, default="Hi", type=str, help="The text that should be printed")

    # Register the repeat_command method as the "main"-method
    # This is important as it tells TIRA what command should be run.
    parser.set_defaults(executable=repeat_command)

Note

The naming scheme of using setup_{commandname}_command to register the command {commandname}_command is “just” a guideline. But, for consistency, please adhere to it.

Attention

The argument names are important. To see why, try and remove "--text" such that the text-argument can only be invoked via -t. Now, repeat_command won’t have its text parameter set correctly if you supply the argument. You should use the dest parameter of add_argument to specify the parameter-name the argument should be stored in when calling your command implementation. In this case, it should read:

parser.add_argument("-t", required=False, default="Hi", dest="text", type=str, help="The text that should be printed")

The only thing left to do now is to call this setup function in TIRA’s setup code and we are done! To do so, find the following line of code within python-client/tira/tira_run.py.

subparsers = parser.add_subparsers()

We use the lines below to register our subcommands. Register your subcommand where appropriate (e.g., in alphabetical order). It should now look something like this:

subparsers = parser.add_subparsers()
setup_a_thing_command(subparsers.add_parser('a-thing', help="I do stuff"))
setup_repeat_command(subparsers.add_parser('repeat', help="Repeat the provided text any number of times"))
setup_something_else_command(subparsers.add_parser('something-else', help="I do other stuff"))

We are done! To test your brand new command, set the PYTHONPATH environment variable to the path to python-client. This overrides where the tira-cli command is loaded from. For example, if you have cloned the repository to /home/me/repos/tira, you can set the PYTHONPATH by running export PYTHONPATH=/home/me/repos/tira/python-client from the terminal. Now you can call the help page for your command and should get the following output:

$ tira-cli repeat --help

usage: tira-cli repeat [-h] [-t,--text T,__TEXT] number

positional arguments:
number              The number of times the chosen text should be printed

options:
-h, --help          show this help message and exit
-t,--text T,__TEXT  The text that should be printed

Make sure that it works by also trying out

$ tira-cli repeat 3
$ tira-cli repeat 3 --text "I like TIRA"
$ tira-cli repeat 5 -t "I like TIRA"
$ tira-cli repeat
$ tira-cli repeat not-a-number

What would you expect should happen? Does it work?

Important

Please, do not forget to add documentation of your new subcommand to the TIRA CLI documentation.

Adding Logging to our Subcommand

For consistency, some arguments should look and feel the same across subcommands. A good example may be the logging arguments (--quiet and --verbose). These argument definitions should be defined in separate functions (e.g., setup_logging_args for logging) such that they can be reused. In effect this means that adding such functionality to our subcommand is as simple as calling these setup-functions. Concretely, for logging, this means that we can configure logging for our Subcommand simply by modifying the setup_repeat_command method such that it reads

def setup_repeat_command(parser: argparse.ArgumentParser) -> None:
    setup_logging_args(parser)
    parser.add_argument("number", type=int, help="The number of times the chosen text should be printed")
    parser.add_argument("-t", "--text", required=False, default="Hi", type=str, help="The text that should be printed")

    # Register the repeat_command method as the "main"-method
    # This is important as it tells TIRA what command should be run.
    parser.set_defaults(executable=repeat_command)

Test it by adding logs to your subcommand (logging.debug, logging.info, logging.warning, logging.error, logging.critical) and append -v, -q and -vv to your command. What do you expect and what happens? Consider that the default log-level is INFO.

Note

Keep in mind that logging can be disabled by the user, redirected to a file or formatted arbitrarily (e.g., JSON logs for deployment but simple text logs for development). This means that anything that semantically should or must be printed to the console should not be logged but printed instead.

Typing Arguments (A more complex example)

Not all arguments are primitives like str or int. While we could encode all arguments this way and let our code check if the argument follows the correct format, this does not follow the philosphy behind Python’s argparse API and should be avoided (unless it is more readable). To illustrate this, we want to create the CLI for the following function:

def writestuff_command(input: TextIO, output: TextIO, approach: Approach, **kwargs) -> None:
    """
    Creates a new file at the specified output path and writes the following lines to it:
    <first line of the input file>\\n
    <approach.taskid>\\n
    <approach.userid>\\n
    <approach.name>\\n
    """
    output.write(f"{input.readline()}\n{approach.taskid}\n{approach.userid}\n{approach.name}\n")

Here, Approach is a named tuple:

class Approach(NamedTuple):
    taskid: str
    userid: str
    name: str

which the user should provide on the CLI as --approach <task-id>/<user-id>/<approach-name>. To enforce this behavior, we generally do the same as for repeat_command but we use the type argument to convert the arguments from strings and enforce constraints (e.g., that the input-path must exist). This may look like this:

# Please don't place me with the setup_..._command methods but somewhere else to keep tira_cli.py clutter free :)
def approach_from_str(text: str) -> Approach:
    split = text.split('/')
    if len(split) != 3:
        raise argparse.ArgumentTypeError("Approaches MUST be encoded as '<task-id>/<user-id>/<approach-name>'")
    return Approach(taskid=split[0], userid=split[1], name=split[2])

def setup_writestuff_command(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("input", type=argparse.FileType('r'), help="...")
    parser.add_argument("-o", type=argparse.FileType('w'), dest="output", required=True, help="...")
    parser.add_argument("--approach", type=approach_from_str, required=True, help="...")
    parser.set_defaults(executable=writestuff_command)

Don’t forget to register the subcommand and check with tira-cli writestuff --help that it works. Now play around with different arguments. For example:

$ tira-cli writestuff <non-existent-file> -o <file> --approach task/user/name
$ tira-cli writestuff <existing-file> -o <file> --approach task/user/name
$ tira-cli writestuff <existing-file> -o <file> --approach task/user

Does it do what you expect?

Hint

argparse.FileType has the neat feature that it can also pass stdin and stdout to your command if you set the parameter value to - (you may already know this notation, e.g., from wget), like so:

$ echo "Hello World" | tira-cli writestuff - -o- --approach task/user/name

Implementation Details and Hints

  • For argument parsing, we use Python’s argparse library. If you want to find more information on how to properly configure your command line arguments, their documentation is a good start.

  • Please keep in mind that some arguments are already in use by TIRA and could cause problems if you are not careful. These are command and executable. If you want to use such a name for your argument (e.g., --command), you can do so but you have to set the dest argument to something other than the protected names.