Tutorial

Single-command tools

A simple CLI tool can be written as a subclass of rjgtoys.cli.Command, in which you provide at least three things:

  1. A description class attribute, that describes what your command does;

  2. An add_arguments method that adds arguments to a provided argparse.ArgumentParser;

  3. A run method that accepts parsed arguments and performs the action you want to do.

Here’s a simple example:


from rjgtoys.cli import Command

class HelloCommand(Command):

    description = "Says hello"

    DEFAULT_NAME = "you"

    def add_arguments(self, p):
        p.add_argument(
            '--name',
            type=str,
            help="Name of the person to greet",
            default=self.DEFAULT_NAME
        )

    def run(self, args):
        print(f"Hello {args.name}!")

if __name__ == "__main__":

    import sys

    cmd = HelloCommand()
    sys.exit(cmd.main())


This creates a script with a simple command-line interface:

$ python rjgtoys/cli/examples/hello.py -h
usage: hello.py [-h] [--name NAME]

Says hello

optional arguments:
  -h, --help   show this help message and exit
  --name NAME  Name of the person to greet (default: you)
$ python rjgtoys/cli/examples/hello.py
Hello you!
$ python rjgtoys/cli/examples/hello.py --name Bob
Hello Bob!

Composing commands into languages

You can compose multiple Command classes into a single script that defines a rjgtoys.cli.Tool.

A Tool defines a ‘command language’ by listing a set of command names (or phrases) along with the name of the Command class that implements each.

Imagine you have another Command implementation, similar to the HelloCommand, called GoodbyeCommand, and you wanted to provide both in a single ‘greeter’ script.

It might look like this:

"""Example tool"""

from rjgtoys.cli import Tool

tool = Tool(
    (
        ('say hello', 'rjgtoys.cli.examples.hello.HelloCommand'),
        ('say goodbye', 'rjgtoys.cli.examples.goodbye.GoodbyeCommand')
    )
)

if __name__ == "__main__":
    import sys
    sys.exit(tool.main())

The Tool constructor accepts a list of (command phrase, class path) pairs; the ‘command phrase’ is simply the list of command line tokens that will select a function, and the class path is a dotted path to the Command class that implements that function.

The Tool class handles parsing of the command phrases themselves, generating help about the available commands, and dealing with parsing errors, until a complete phrase is recognised, at which point the corresponding Command is invoked, and parses the rest of the command line.

Here is some example output from the above greeter1 script:

$ python rjgtoys/cli/examples/greeter1.py
Incomplete command, could be one of:
  say goodbye - Says goodbye
  say hello   - Says hello
$ python rjgtoys/cli/examples/greeter1.py say hello
Hello you!
$ python rjgtoys/cli/examples/greeter1.py say goodbye -h
usage: say goodbye [-h] [--name NAME]

Says goodbye

optional arguments:
  -h, --help   show this help message and exit
  --name NAME  Name of the person to greet (default: you)

Constructing a Tool from YAML

The ‘pure Python’ syntax for describing a tool language can be fiddly and hard to read, when the list becomes large.

It’s also possible to describe a Tool in YAML:

"""Example tool"""

from rjgtoys.cli import Tool

tool = Tool.from_yaml("""
say hello: rjgtoys.cli.examples.hello.HelloCommand
say goodbye: rjgtoys.cli.examples.goodbye.GoodbyeCommand
"""
)

if __name__ == "__main__":
    import sys
    sys.exit(tool.main())

Furthermore, to avoid repeating a package prefix many times, the YAML form allows setting a ‘default’ package:

"""Example tool"""

from rjgtoys.cli import Tool

tool = Tool.from_yaml("""
_package: rjgtoys.cli.examples
say hello: hello.HelloCommand
say goodbye: goodbye.GoodbyeCommand
"""
)

if __name__ == "__main__":
    import sys
    sys.exit(tool.main())

It is also possible to load the YAML from a file, which makes it easy to consider putting the command language definition in the hands of your users: provide them with a set of command implementations, and a default bit of YAML to be starting with, and they can pick and choose which commands they need available, and what they’d like to call them.

Sharing option definitions amongst commands

In the example above, the two commands, HelloCommand and GoodbyeCommand are very similar, and in particular they both accept a --name option.

Repetition of the code to parse the option should be avoided, and in this case it’s pretty easy to do that by creating a common base class which both command classes inherit.

But that approach really only works if both commands accept the same set of options.

rjgtoys.cli provides another mechanism that allows parts of the command line parser to be defined as reusable functions, and selected for use by command classes, so they can ‘cherry pick’ the arguments that are appropriate, but always get a consistent definition of each argument that they use.

The process starts with a base class, which defines method(s) to build the parser. Each method has a name like _arg_FOO, where FOO is the name by which the method will be referenced by subclasses. Each such method may add any number of arguments, subparsers, or anything else to the parser.

Here’s a possible superclass for new versions of the ‘hello’ and ‘goodbye’ commands:


from rjgtoys.cli import Command

class GreeterBase(Command):

    DEFAULT_NAME = "you"

    def _arg_name(self, p):

        p.add_argument(
            '--name',
            type=str,
            help="Name of the person to greet",
            default=self.DEFAULT_NAME
        )

    def run(self, args):
        print(f"Hello {args.name}!")

Each subclass can declare the list of parser building methods to call, by setting an attribute (usually a class attribute) called arguments.

The value of arguments may be either a string, in which it is expected to contain a comma-separated list of the argument generating methods to be called, or it may be any other kind of iterable that produces a sequence of method names.

Here is the new tool script, using the arguments mechanism:


from greetbase import GreeterBase

from rjgtoys.cli import Tool

class HelloCommand(GreeterBase):

    description = "Says hello"

    arguments = "name"

    DEFAULT_NAME = "me"

    def run(self, args):
        print(f"Hello from {args.name}!")

class GoodbyeCommand(GreeterBase):

    description = "Says goodbye"

    arguments = "name"

    DEFAULT_NAME = "him"

    def run(self, args):
        print(f"Goodbye from {args.name}")

tool = Tool.from_yaml(f"""
_package: {__name__}
say hello: HelloCommand
say goodbye: GoodbyeCommand
""")

if __name__ == "__main__":
    import sys
    sys.exit(tool.main())


Appendix: The rjgtoys.cli.examples.goodbye.GoodbyeCommand code

Here is the code for the ‘say goodbye’ command:


from rjgtoys.cli import Command

class GoodbyeCommand(Command):

    description = "Says goodbye"

    DEFAULT_NAME = "you"

    def add_arguments(self, p):
        p.add_argument(
            '--name',
            type=str,
            help="Name of the person to greet",
            default=self.DEFAULT_NAME
        )

    def run(self, args):
        print(f"Goodbye {args.name}!")

if __name__ == "__main__":

    import sys

    cmd = GoodbyeCommand()
    sys.exit(cmd.main())