Custom subcommands

The pas commands can be grouped into 4 areas of interest:

Meta management
Allows to execute all actions to setup and manage a testing environment and its configuration.
Jobmgr management
Provides a simple interface to manage remote jobmgr processes (start, stop, kill,...).
Measuring
Collection of commands to manage the measuring process, such as starting or stopping a measure.
Processing
Different tools to act upon al already acquired measure, to transform, simplify, filter, etc. the different assets resulting from a measuring process.

A fifth group, the derived commands group can also be added and contains all custom defined workflows resulting from the chaining of different “primitive” commands. This form of custom command creation was already described in the command composition section of the first chapter.

Architecture

The various available commands are gathered at runtime by the pas binary (oh, well… actually it is a python script) when executed. The entry point recursively scans the pas.commands package to retrieve the various commands.

This mechanism allows for great flexibility in the definition of parsers for single commands and makes the addition of new commands relatively easy.

The next section will show you how to create a new command by adding it directly to the pas package.

A simple example, the unreported subcommand

A pas subcommand is built for each module found in the pas.commands package. For this operation to succeed, some conventions have to be respected; suppose we want to add a new command to the pas utility (i.e. a command to list all collected measures with no report), then we want to proceed as follows:

  1. Create a new python module inside the pas.commands package. Name it as you want to name your command. As we want our command to be named unreported, we create the pas/commands/unreported.py file.

    Note: Filenames starting with underscores are automatically filtered out.

    If you run the pas utility with the unreported subcommand, then an error is reported indicating that you don’t have implemented the command correctly:

    $ pas unreported
         ERROR: No command found in module pas.commands.unreported
    usage: pas [-h] [--version] [-v] [-q] [--settings SETTINGS_MODULE]
             {authorize,execute,jobmgr,compile,init,measure} ...
    pas: error: invalid choice: 'unreported'
  2. To provide an implementation for a command, simply define a callable named command inside your unreported module. The callable shall accept one positional argument (which will be set to the command line options) and return a non-true value if the command succeeded:

    # Inside pas/commands/unreported.py
    
    def command(options):
       pass
    

    If you run the command now, it should exit cleanly without any message, but not much will be done. Let’s write a simple implementation:

    import os
    import glob
    from pas.conf import settings
    
    def command(options):
        a = '{0}/*'.format(settings.PATHS['shared-measures'][0])
        r = '{0}/*/report'.format(settings.PATHS['shared-measures'][0])
    
        all_reports = set(glob.glob(a))
        already_reported = set([os.path.dirname(d) for d in glob.glob(r)])
    
        not_reported = all_reports - already_reported
    
        if not not_reported:
            print "No unreported measures found ({0} total measures)".format(
                    len(all_reports))
        elif len(not_reported) == 1:
            print "One unreported measure found ({0} total measures)".format(
                    len(all_reports))
        else:
            print "{0} unreported measures found ({1} total measures)".format(
                    len(not_reported), len(all_reports))
    
        for i, path in enumerate(not_reported):
            print "{0:2d}: {1}".format(i+1, os.path.basename(path))
    
  3. Suppose we want now to allow the newly created subcommand to accept some optional (or required) flags and arguments; how can we add support for additional command line parsing to the current implementation?

    Fortunately the whole pas commands subsystem is based around the argparse module and adding options and arguments is straightforward. The pas command line parser builder looks if the module containing the command also contains a callable named getparser and, if it is the case, it calls it during the parser construction by passing the subcommand specific subparser instance to it.

    If we want to add a --no-list flag allowing to turn off the list of unreported measures (thus only showing the total counts), we can proceed as follows:

    import os
    import glob
    from pas.conf import settings
    
    def getparser(parser):
        parser.add_argument('-n', '--no-list', dest='show_list', default=True,
            action='store_false')
    
    def command(options):
        # [...]
    
        if options.show_list:
            for i, path in enumerate(not_reported):
                print "{0:2d}: {1}".format(i+1, os.path.basename(path))
    

    Refer to the argparse documentation for the syntax and the usage of the module (just remember that the parser argument of the getparser function is an argparse.ArgumentParser instance and the options argument passed to the command is an argparse.Namespace instance).

The example illustrated above covers the basics of creating a new subcommand for the pas command line utility, but some more techniques allows to achieve an higher degree of flexibility, namely Recursive subcommands and External directory scanning.

Recursive subcommands

As stated in the introduction to the Architecture section, the pas entry point recursively scans the pas.commands package for commands. This allows to define pas subcommands which have themselves subcommands.

Take as an example the jobmgr subcommand. It defines different actions to be taken upon the different instances, such as start, stop or kill. These three actions are defined as subcommands of the jobmgr subcommand.

To create a similar grouping structure for your commands collection, it suffices to define your actions as modules inside a package named after the commands collection name.

To reproduce a structure as the one implemented by the jobmgr command, the following directory structure may be set up:

+ pas/
 \
  + commands/
   \
    + __init__.py
    + command1.py
    + command2.py
    |
    + jobmgr/
    |\
    | + __init__.py
    | + start.py
    | + stop.py
    | + kill.py
    |
    + command3.py

Then, you can invoke the jobmgr‘s start, stop and kill subcommands simply by typing this command:

$ pas jobmgr start

All other conventions (command callable, getparser callable, argument types,…) presented in the plain command example still hold for nested commands.

External directory scanning

The main weak point of the architecture as it was presented until now is certainly the fact that for the parser to recognize a new command, the command itself has to be placed inside the pas.commands package which, depending on the installation, is buried deeply somewhere in the file system and is easily overridden by a reinstallation.

Fortunately, a mechanism has been put in place to allow arbitrary directories to be scanned for commands: the COMMAND_DIRECTORIES settings directive contains a list of directories to be scanned for commands in addition to the pas.commands package.

By adding your custom commands directory to the list you can have them automatically included in the pas utility without the need to modify the pas source tree directly.

This is useful if you want to add some commands tied to a particular testing environment or – if it is the case – to a particular measure case.

Note

You can override built-in commands simply by specifying a directory containing a command with the same name. Note although that no recursive merge is done, thus you can’t override a single command of a commands collection while retaining all other collection subcommands.

Command utilities

The pas.commands package provides some useful utilities to facilitate common tasks when working with command definitions.

The API for these utilities is presented below. For each utility its use case is described and a usage example is provided. To use them in your custom commands definition simply import them from the pas.commands package.

Command decorators

The following group of utilities can be used as decorators for the command callable:

pas.commands.nosettings(func)[source]

Instructs the command dispatcher to not try to load the settings when executing this command.

Normally the command invoker will try to load the settings and will terminate the execution if not able to do so, but some commands don’t need the settings module and are specifically crafted to operate outside of an active testing environment.

To instruct the invoker not to try to load the settings, a nosettings attribute with a non-false value can be set on the command function; this decorator does exactly this.

You can use it like this:

from pas.commands import nosettings

@nosettings
def command(options):
    # Do something without the settings
    pass
Parameters:func – The command function for which the settings don’t have to be loaded.
pas.commands.select_case(func)[source]

Adds a case attribute to the Namespace instance passed to the command, containing a tuple of (full-path-on-disk-to-the-measure-case, measure-case-basename).

The case attribute is populated by calling the pas.case.select() function.

If a case attribute is already set on the Namespace its value will be passed to the the pas.case.select() function as default value for the selection.

You can use it like this:

from pas.commands import select_case

@select_case
def command(options):
    # Do something with the measure case
    print options.case
Parameters:func – The command function for which the a case has to be selected.
pas.commands.select_measure(func)[source]

Adds a measure attribute to the Namespace instance passed to the command, containing a tuple of (full-path-on-disk-to-the-measure, measure-basename).

The measure attribute is populated by calling the pas.measure.select() function.

If a measure attribute is already set on the Namespace its value will be passed to the the pas.measure.select() function as default value for the selection.

You can use it like this:

from pas.commands import select_measure

@select_measure
def command(options):
    # Do something with the measure
    print options.measure
Parameters:func – The command function for which the a case has to be selected.

Argument shortcuts

The following group of utilities can be used as factories for common/complex additional arguments/option to bind to a parser instance for a custom subcommand:

pas.commands.host_option(parser, argument=False)[source]

Adds an optional --host option to the parser argument.

The given hosts can be read using the hosts attribute of the Namespace instance passed to the command to be executed.

The resulting hosts attribute will always be a list of the hosts specified on the command line or an empty list if no hosts were specified.

If the optional argument flag is set to true, then this decorators adds an argument insted of an option.

Multiple hosts can be specified using multiple times the --host option or by giving multiple HOST arguments as appropriate.

Use it like this:

from pas.commands import host_option

def getparser(parser):
    host_option(parser)

def command(options):
    # Do something with it
    print options.hosts
Parameters:
  • parser – The ArgumentParser instance on which the --host option shall be attached.
  • argument – A flag indicating if the hosts shall be parsed as arguments instead.
pas.commands.case_argument(parser)[source]

Adds an optional case argument to the given parser.

No validations are done on the parsed value as the settings have to be loaded to obtain the local measure-cases path and they can’t be loaded before the full command-line parsing process is completed.

To bypass this issue, the pas.commands.select_case() command decorator can be used which looks at the Namespace instance and does all the necessary steps to provide the command with a valid measure case path.

Use it like this:

from pas.commands import case_argument, select_case

def getparser(parser):
    case_argument(parser)

@select_case   # Optional
def command(options):
    # Do something with it
    print options.case
Parameters:parser – The ArgumentParser instance on which the case argument shall be attached.
pas.commands.measure_argument(parser)[source]

Adds an optional measure argument to the given parser.

No validations are done on the parsed value as the settings have to be loaded to obtain the local measures path and they can’t be loaded before the full command-line parsing process is completed.

To bypass this issue, the pas.commands.select_measure() command decorator can be used which looks at the Namespace instance and does all the necessary steps to provide the command with a valid measure path.

Use it like this:

from pas.commands import measure_argument, select_measure

def getparser(parser):
    measure_argument(parser)

@select_measure   # Optional
def command(options):
    # Do something with it
    print options.measure
Parameters:parser – The ArgumentParser instance on which the measure argument shall be attached.