Quick Start

ilcli is a Python library based on ArgumentParser that provides the following features:

  • Subcommand creation support so you can build a complex, well-structured, and standard CLI.

  • Support for including documentation (man pages, web links, etc.) so users can quickly open different doc directly from the your tool.

  • REST API automatic conversion: start serving your CLI as a REST API so it can be executed remotely.

  • Multi-project CLI: build a general CLI whose subcommands will be pulled from different projects so you can unify tools.

The most important bit of ilcli is the Command class. All commands you define should be children of this class.

The minimal example

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class mycmd(ilcli.Command):
    def _run(self, args):
        self.out('Hello World!')
        return 0


exit(mycmd().run())

This creates a command without options (just -h):

$ python examples/minimal.py
Hello World!
$ python examples/minimal.py -h
usage: mycmd [-h]

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

Note that the name of the class is taken as program name. You can tweak this by setting the class attribute name to whatever value you need.

Arguments and options

Arguments and options should be added by overriding _init_arguments() method. In this method, you can use self.add_argument() method for adding arguments in exactly the same way as you would do with ArgumentParser.add_argument() method sinc it accepts the same parameters.

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class mycmd(ilcli.Command):
    def _init_arguments(self):
        self.add_argument('message', help='your message')
        self.add_argument(
            '-u', '--upper', action='store_true', help='message in upper case'
        )

    def _run(self, args):
        msg = args.message
        if args.upper:
            msg = msg.upper()
        self.out(msg)
        return 0


cmd = mycmd()
cmd.run()  # sys.argv by default
cmd.run(['me too'])
cmd.run(['me too', '-u'])
cmd.run(['--upper', 'me too!!'])

Now our command has a positional argument and one option. If not provided, it will fail:

$ python examples/args_opts.py
usage: mycmd [-h] [--upper] message
mycmd: error: the following arguments are required: message

Note that the arguments and options can be provided as part of the run() method:

$ python examples/args_opts.py "I like command line interfaces!"
I like command line interfaces!
me too
ME TOO
ME TOO!!

Validate arguments

Although ArgumentParser provides some mechanisms for checking the parameters (such as type, choices, etc.), sometimes is useful to perform extra validation on the arguments which are application specific. For that, you can use _validate_arguments() method:

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class mycmd(ilcli.Command):
    def _init_arguments(self):
        self.add_argument('message', help='your message')
        self.add_argument(
            '-u', '--upper', action='store_true', help='message in upper case'
        )

    def _validate_arguments(self, args):
        msg = args.message.lower()
        if 'ilcli' in msg:
            self.err("Please do not take ilcli's name in vain")
            return 1
        if 'help' in msg:
            self.out('You say help? Sure!')
            self.parser.print_help()
            return 0

    def _run(self, args):
        msg = args.message
        if args.upper:
            msg = msg.upper()
        self.out(msg)
        return 0


exit(mycmd().run())
$ python examples/args_opts_validation.py -u 'Hello!'
HELLO!

Note that any extra argument will be treated as an error (the default ArgumentParser behaviour) which will make the program fail:

$ python examples/args_opts_validation.py -u 'Hello!' --something more
usage: mycmd [-h] [-u] message
mycmd: error: unrecognized arguments: --something more

It is worth to mention that self._run() will not be executed if self._validate_arguments() returns something, so we can make sure that once self._run() is executed, the arguments are validated at our semantics level:

$ python examples/args_opts_validation.py 'help me'
You say help? Sure!
usage: mycmd [-h] [-u] message

positional arguments:
  message      your message

optional arguments:
  -h, --help   show this help message and exit
  -u, --upper  message in upper case
help me
$ python examples/args_opts_validation.py 'ilcli is cool!'
Please do not take ilcli's name in vain

In certain cases, you might want to allow the user to provide multiple arguments at the command line interface that actually cannot be specified for multiple reasons. For example, too many possible argument combinations or your application needs to get values to be replaced in a template:

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class mycmd(ilcli.Command):

    extra_args = True

    def _init_arguments(self):
        self.add_argument('message', help='your message')

    def _validate_extra_arguments(self, args):
        self.kv = dict([args[0].split('=')])

    def _run(self, args):
        msg = args.message.format(**self.kv)
        self.out(msg)
        return 0


exit(mycmd().run())
$ python examples/args_extra_opts_validation.py '{name} is cool!' name=ilcli
ilcli is cool!

Subcommands

One interesting feature that ilcli.Command class provides is that it can be composed by other commands forming sub-commands in a similar way as tools like apt or Chef knife are structured. The result is a tree-like structure with two types of commands:

  • Node commands: commands that define subcommands. By default, their arguments will be passed to their children as common arguments.

  • Leaf commands: commands that do not define subcommands. They can define their own arguments and, by default, they will inherit the parent’s ones.

Subcommands are defined as a class attribute:

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class ssh(ilcli.Command):
    """
    ssh into a hostname
    """
    def _run(self, args):
        self.out('SSH into: %s', args.hostname)


class ping(ilcli.Command):
    """
    ping a hostname
    """
    def _run(self, args):
        self.out('Ping to: %s', args.hostname)


class net(ilcli.Command):
    """
    networking tools
    """
    subcommands = [ssh, ping]

    def _init_arguments(self):
        self.add_argument('hostname', help='target IP or hostname')


class ls(ilcli.Command):
    """
    list a file/directory
    """
    def _run(self, args):
        self.out('ls: %s', args.path)


class rm(ilcli.Command):
    """
    remove a file/directory
    """
    def _run(self, args):
        self.out('rm: %s', args.path)


class fs(ilcli.Command):
    """
    file-system tools
    """
    subcommands = [ls, rm]

    def _init_arguments(self):
        self.add_argument('path', help='path to file/dir')


class mytools(ilcli.Command):
    subcommands = [net, fs]

    def _init_arguments(self):
        self.add_argument(
            '-v', '--verbose', action='store_true', help='increase verbosity'
        )


exit(mytools().run())

ilcli will automatically build a tree hierarchy with the command that is firstly created as root and recursively creates the command tree. Note that each command can define its own set of arguments.

$ python examples/args_subcommands.py -h
usage: mytools [-h] {net,fs} ...

positional arguments:
  {net,fs}
    net       networking tools
    fs        file-system tools

optional arguments:
  -h, --help  show this help message and exit
$ python examples/args_subcommands.py net -h
usage: mytools net [-h] {ssh,ping} ...

positional arguments:
  {ssh,ping}
    ssh       ssh into a hostname
    ping      ping a hostname

optional arguments:
  -h, --help  show this help message and exit
$ python examples/args_subcommands.py net ssh -h
usage: mytools net ssh [-h] [--verbose] hostname

positional arguments:
  hostname       target IP or hostname

optional arguments:
  -h, --help     show this help message and exit
  --verbose, -v  increase verbosity

Argument inheritance

As we have seen in the previous example, it can also define arguments that will be passed to the children. In general, when a command defines arguments, then they will propagated to all leaf commands under that command. This is ilcli default behaviour although it might be tweaked using the following mechanisms:

  • inherit_arguments = False: if it is defined as a class attribute in the command, no arguments will be passed to this command.

    #! /usr/bin/env python
    # -*- coding:utf-8; mode:python -*-
    
    import ilcli
    
    
    class ls(ilcli.Command):
        """
        list a file/directory
        """
        inherit_arguments = False
    
        def _init_arguments(self):
            self.add_argument(
                '-v', '--version', action='store_true', help='show version'
            )
    
        def _validate_arguments(self, args):
            print('validate arguments at "ls"')
    
        def _run(self, args):
            print('Running ls')
    
    
    class rm(ilcli.Command):
        """
        rm a file/directory
        """
    
        def _validate_arguments(self, args):
            print('validate arguments at "rm"')
    
        def _run(self, args):
            print('Running rm')
    
    
    class fs(ilcli.Command):
        subcommands = [ls, rm]
    
        def _init_arguments(self):
            self.add_argument(
                '-v', '--verbose', action='store_true', help='increase verbosity'
            )
    
        def _validate_arguments(self, args):
            print('validate arguments at "fs"')
    
    
    exit(fs().run())
    

    Thus, subcommands could define their own arguments without clashing with parent’s:

    $ python examples/not_inherit_args.py ls -h
    usage: fs ls [-h] [-v]
    
    optional arguments:
      -h, --help     show this help message and exit
      -v, --version  show version
    
    $ python examples/not_inherit_args.py rm -h
    usage: fs rm [-h] [-v]
    
    optional arguments:
      -h, --help     show this help message and exit
      -v, --verbose  increase verbosity
    

    This is very useful in situations when you want to have a subcommand within a specific command (because makes sense there or it is related to it) but all the previous arguments do not apply to it. For exmplate, you my have:

    $ mytool members list --org myorg
    $ mytool members create --org myorg new-member
    $ mytool members request --url https://api.server.com
    

    In this case, list and create share the argument --org that comes from members. However, request a new member requires just the URL (from where you probably get the org parameter) and still makes sense having request under members because it is related to it.

    An important aspect when you use this feature is that the parents’ validation code is not executed at all. Using this flag you will need to implement a new validation argument functions, if needed. See the following execution examples:

    $ python examples/not_inherit_args.py rm -v
    validate arguments at "fs"
    validate arguments at "rm"
    Running rm
    
    $ python examples/not_inherit_args.py ls -v
    validate arguments at "ls"
    Running ls
    
  • _init_arguments(): the parent could decide what arguments pass to its children by implementing it in the _init_arguments() method. The parent keeps a list of subcommands at self._subcommands

The argument inheritance mechanisms implies a few limitations:

  • By default, since the arguments actually reside on leaf command parsers, there can not be conflicted names. That is, 2 argument names can not be defined at different levels of the same path of the hierarchy. However, you can change this default behaviour and allow argument overriding. See Argument overriding.

  • Commands that define sub-commands should not define _run(). By default, it will never be executed as only leaf commands are taking into account. However, since children has a reference to the parent command they might run parent commands as part as their run.

Removing arguments

A Command might inherit an argument you don’t want, either from a parent class or being a sub-command of another Command. These can be ignored via ignore_arguments, though if you are ignoring lots of things you may want to reevaluate your class/command heirarchy. Arguments are ignored by their switch (-f/--foo) or the name given for positional arguments.

  • ignore_arguments = []: list of switches/positional arguments to ignore, identified by their switch (both -f and --foo will work) or the name given to positional arguments.

$ python examples/removing_arguments.py -h
usage: parent [-h] {firstdemocommand,seconddemocommand,thirddemocommand} ...

positional arguments:
  {firstdemocommand,seconddemocommand,thirddemocommand}
    firstdemocommand
    seconddemocommand
    thirddemocommand

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

firstdemocommand ignores the -b/--bar switch, and adds the --foo switch

$ python examples/removing_arguments.py firstdemocommand -h
usage: parent firstdemocommand [-h] [--foo FOO] bat

positional arguments:
  bat

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

seconddemocommand inherits from firstdemocommand and both ignores -b/--bar switch and the --foo switch.

$ python examples/removing_arguments.py seconddemocommand -h
usage: parent seconddemocommand [-h] bat

positional arguments:
  bat

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

thirddemocommand inherits from firstdemocommand and ignores the bat positional argument.

$ python examples/removing_arguments.py thirddemocommand -h
usage: parent thirddemocommand [-h] [--foo FOO] [-b BAR]

optional arguments:
  -h, --help         show this help message and exit
  --foo FOO
  -b BAR, --bar BAR

Documentation support

Although using -h in your commands there is plenty of information to know how to use your tool, sometimes is also good to provide extended information.

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class ls(ilcli.Command):
    """
    list a file/directory
    """
    man_page = 'ls'
    man_section = 1


exit(ls().run())

This automatically add an option --doc in the command where it class attributes has been defined:

$ python examples/doc.py net -h
usage: ls [-h] [--doc]

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

In this case, if --doc is used it will open the man page specified. Note that this is an action that, once is finished, nothing else is executed. Also note that the class attributes might be defined at any point of the command hierarchy, so you could have more general documentation in top-level commands, and more concrete one in the leaf commands:

$ mycmd [--doc] subcommand1 [--doc] ...

Argument overriding

ilcli will fail if you define the same argument on a single inheritance path. However, ArgumentParser allows to resolve this conflicts by setting conflict_handler=resolve. You can set custom arguments to be used in all parsers created by ilcli internally by using the class property parser_args.

Here it is an example of how to allow argument overriding:

#! /usr/bin/env python
# -*- coding:utf-8; mode:python -*-

import ilcli


class subcommand1(ilcli.Command):
    """
    --foo does different things
    """

    def _init_arguments(self):
        self.add_argument('-o', '--foo', help='new foo help')

    def _run(self, args):
        self.out('called with: {}'.format(args))


class subcommand2(ilcli.Command):
    """
    --foo does different things
    """

    def _init_arguments(self):
        self.add_argument('-f', '--far', help='new far help')

    def _run(self, args):
        self.out('called with: {}'.format(args))


class subcommand3(ilcli.Command):
    """
    This command replaces the parent definition of -f/--foo entirely
    """

    def _init_arguments(self):
        self.add_argument('-f', '--foo', help='new foo help')

    def _run(self, args):
        self.out('called with: {}'.format(args))


class toplevel(ilcli.Command):
    subcommands = [subcommand1, subcommand2, subcommand3]
    parser_args = {'conflict_handler': 'resolve'}

    def _init_arguments(self):
        self.add_argument(
            '-f', '--foo', help='old foo help'
        )
        self.add_argument(
            '-b', '--bar', help='old bar help'
        )


exit(toplevel().run())

Note that toplevel subcommands defines parser_args. Now ilcli will not fail and, as ArgumentParser does, subcommands will override conflicts:

$ python examples/args_conflict_subcommands.py subcommand1 -h
usage: toplevel subcommand1 [-h] [--foo FOO] [-f F] [--bar BAR]

optional arguments:
  -h, --help         show this help message and exit
  --foo FOO, -o FOO  new foo help
  -f F               old foo help
  --bar BAR, -b BAR  old bar help
$ python examples/args_conflict_subcommands.py subcommand2 -h
usage: toplevel subcommand2 [-h] [-f FAR] [--foo FOO] [-b BAR]

optional arguments:
  -h, --help         show this help message and exit
  -f FAR, --far FAR  new far help
  --foo FOO          old foo help
  -b BAR, --bar BAR  old bar help
$ python examples/args_conflict_subcommands.py subcommand3 -h
usage: toplevel subcommand3 [-h] [-f FOO] [-b BAR]

optional arguments:
  -h, --help         show this help message and exit
  -f FOO, --foo FOO  new foo help
  -b BAR, --bar BAR  old bar help

REST API support

TBD

Multi-project CLI

TBD