Package csb :: Package apps |
[frames] | no frames]

# Source Code for Package csb.apps

      1  """
      2  Root package for all executable CSB client programs.
      3
      4  Introduction
      5  ============
      6
      7  There are roughly three types of CSB apps:
      8
      9      1. protocols: client applications, which make use of the core library
     10         to perform some action
     11      2. wrappers: these provide python bindings for external programs
     12      3. mixtures of (1) and (2).
     13
     14  The main design goal of this framework is to provide a way for writing
     15  executable code with minimal effort, without the hassle of repeating yourself
     16  over and over again. Creating a professional-grade CLI, validating and
     17  consuming the command line arguments is therefore really straightforward.
     18  On the other hand, one frequently feels the need to reuse some apps or their
     19  components in other apps. For such reasons, a CSB L{Application} is just a
     20  regular, importable python object, which never communicates directly with the
     21  command line interface or calls sys.exit(). The app's associated L{AppRunner}
     22  will take care of those things.
     23
     24  Getting Started
     25  ===============
     26
     27  Follow these simple steps to write a new CSB app:
     28
     29      1. Create the app module in the C{csb.apps} package.
     30
     31      2. Create a main class and derive it from L{csb.apps.Application}. You need
     32      to implement the L{csb.apps.Application.main()} abstract method - this is
     33      the app's entry point. You have the L{csb.apps.Application.args} object at
     34      your disposal.
     35
     36      3. Create an AppRunner class, derived from csb.apps.AppRunner. You need to
     37      implement the following methods and properties:
     38
     39          - property L{csb.apps.AppRunner.target} -- just return YourApp's class
     40          - method L{csb.apps.AppRunner.command_line()} -- make an instance of
     41          L{csb.apps.ArgHandler}, define your command line parameters on that
     42          instance and return it
     43          - optionally, override L{csb.apps.AppRunner.initapp(args)} if you need
     44          to customize the instantiation of the main app class, or to perform
     45          additional checks on the parsed application C{args} and eventually call
     46          C{YourApp.exit()}. Return an instance of your app at the end
     47
     48      4. Make it executable::
     49          if __name__ == '__main__':
     50              MyAppRunner().run()
     51
     52  See L{csb.apps.helloworld} for a sample implementation.
     53  """
     54
     55  import os
     56  import re
     57  import sys
     58  import argparse
     59  import traceback
     60
     61  from abc import ABCMeta, abstractmethod, abstractproperty
     62
     63
     64 -class ExitCodes(object):
     65      """
     66      Exit code constants.
     67      """
     68      CLEAN = 0
     69      USAGE_ERROR = 1
     70      CRASH = 99
     71
     72 -class AppExit(Exception):
     73      """
     74      Used to signal an immediate application exit condition (e.g. a fatal error),
     75      that propagates down to the client, instead of forcing the interpreter to
     76      close via C{sys.exit()}.
     77
     78      @param message: exit message
     79      @type message: str
     80      @param code: exit code (see L{ExitCodes} for common constants)
     81      @type code: int
     82      @param usage: ask the app runner to print also the app's usage line
     83      @type usage: bool
     84      """
     85
     86 -    def __init__(self, message='', code=0, usage=False):
     87
     88          self.message = message
     89          self.code = code
     90          self.usage = usage
     91
     92          super(AppExit, self).__init__(message, code, usage)
     93
     94 -class Application(object):
     95      """
     96      Base CSB application class.
     97
     98      @param args: an object containing the application arguments
     99      @type args: argparse.Namespace
    100      """
    101      __metaclass__ = ABCMeta
    102
    103 -    def __init__(self, args, log=sys.stdout):
    104
    105          self.__args = None
    106          self._log = log
    107
    108          self.args = args
    109
    110      @property
    111 -    def args(self):
    112          """
    113          The object containing application's arguments, as returned by the
    114          command line parser.
    115          """
    116          return self.__args
    117      @args.setter
    118 -    def args(self, args):
    119          self.__args = args
    120
    121      @abstractmethod
    122 -    def main(self):
    123          """
    124          The main application hook.
    125          """
    126          pass
    127
    128 -    def log(self, message, ending='\n'):
    129          """
    130          Write C{message} to the logging stream and flush it.
    131
    132          @param message: message
    133          @type message: str
    134          """
    135
    136          self._log.write(message)
    137          self._log.write(ending)
    138          self._log.flush()
    139
    140      @staticmethod
    141 -    def exit(message, code=0, usage=False):
    142          """
    143          Notify the app runner about an application exit.
    144
    145          @param message: exit message
    146          @type message: str
    147          @param code: exit code (see L{ExitCodes} for common constants)
    148          @type code: int
    149          @param usage: advise the client to show the usage line
    150          @type usage: bool
    151
    152          @note: you re not supposed to use C{sys.exit()} for the same purpose.
    153                 It is L{AppRunner}'s responsibility to handle the real system
    154                 exit, if the application has been started as an executable.
    155                 Think about your app being executed by some Python client as a
    156                 regular Python class, imported from a module -- in that case you
    157                 only want to ask the client to terminate the app, not to kill
    158                 the whole interpreter.
    159          """
    160          raise AppExit(message, code, usage)
    161
    162 -class AppRunner(object):
    163      """
    164      A base abstract class for all application runners. Concrete sub-classes
    165      must define their corresponding L{Application} using the L{self.target}
    166      property and must customize the L{Application}'s command line parser using
    167      L{self.command_line()}.
    168
    169      @param argv: the list of command line arguments passed to the program. By
    170                   default this is C{sys.argv}.
    171      @type argv: tuple of str
    172      """
    173      __metaclass__ = ABCMeta
    174
    175 -    def __init__(self, argv=sys.argv):
    176
    177          self._module = argv[0]
    178          self._program = os.path.basename(self.module)
    179          self._args = argv[1:]
    180
    181      @property
    182 -    def module(self):
    183          return self._module
    184
    185      @property
    186 -    def program(self):
    187          return self._program
    188
    189      @property
    190 -    def args(self):
    191          return self._args
    192
    193      @abstractproperty
    194 -    def target(self):
    195          """
    196          Reference to the concrete L{Application} class to run. This is
    197          an abstract property that couples the current C{AppRunner} to its
    198          corresponding L{Application}.
    199
    200          @rtype: type (class reference)
    201          """
    202          return Application
    203
    204      @abstractmethod
    205 -    def command_line(self):
    206          """
    207          Command line factory: build a command line parser suitable for the
    208          application.
    209          This is a hook method that each concrete AppRunner must implement.
    210
    211          @return: a command line parser object which knows how to handle
    212          C{sys.argv} in the context of the concrete application. See the
    213          documentation of L{ArgHandler} for more info on how to define command
    214          line arguments.
    215
    216          @rtype: L{ArgHandler}
    217          """
    218          # null implementation (no cmd arguments):
    219          return ArgHandler(self.program)
    220
    221 -    def initapp(self, args):
    222          """
    223          Hook method that controls the instantiation of the main app class.
    224          If the application has a custom constructor, you can adjust the
    225          app initialization by overriding this method.
    226
    227          @param args: an object containing the application arguments
    228          @type args: argparse.Namespace
    229
    230          @return: the application instance
    231          @rtype: L{Application}
    232          """
    233          app = self.target
    234          return app(args)
    235
    236 -    def run(self):
    237          """
    238          Get the L{self.command_line()} and run L{self.target}. Ensure clean
    239          system exit.
    240          """
    241          try:
    242              app = self.target
    243              cmd = self.command_line()
    244
    245              try:
    246                  assert issubclass(app, Application)
    247                  assert isinstance(cmd, ArgHandler)
    248
    249                  args = cmd.parse(self.args)
    250                  app.USAGE = cmd.usage
    251                  app.HELP = cmd.help
    252
    253                  self.initapp(args).main()
    254
    255              except AppExit as ae:
    256                  if ae.usage:
    257                      AppRunner.exit(ae.message, code=ae.code, usage=cmd.usage)
    258                  else:
    259                      AppRunner.exit(ae.message, code=ae.code)
    260
    261              except SystemExit as se:                            # this should never happen, but just in case
    262                  AppRunner.exit(se.message, code=se.code)
    263
    264          except Exception:
    265              message = '{0} has crashed. Details: \n{1}'.format(self.program, traceback.format_exc())
    266              AppRunner.exit(message, code=ExitCodes.CRASH)
    267
    268          AppRunner.exit(code=ExitCodes.CLEAN)
    269
    270      @staticmethod
    271 -    def exit(message='', code=0, usage='', ending='\n'):
    272          """
    273          Perform system exit. If the exit C{code} is 0, print all messages to
    274          STDOUT, else write to STDERR.
    275
    276          @param message: message to print
    277          @type message: str
    278          @param code: application exit code
    279          @type code: int
    280          """
    281
    282          ending = str(ending or '')
    283          message = str(message or '')
    284          stream = sys.stdout
    285
    286          if code > 0:
    287              message = 'E#{0} {1}'.format(code, message)
    288              stream = sys.stderr
    289
    290          if usage:
    291              stream.write(usage.rstrip(ending))
    292              stream.write(ending)
    293          if message:
    294              stream.write(message)
    295              stream.write(ending)
    296
    297          sys.exit(code)
    298
    299 -class ArgHandler(object):
    300      """
    301      Command line argument handler.
    302
    303      @param program: (file)name of the program, usually sys.argv[0]
    304      @type program: str
    305      @param description: long description of the application, shown in help
    306                          pages. The usage line and the parameter lists are
    307                          generated automatically, so no need to put them here.
    308      @type description: str
    309
    310      @note: a help argument (-h) is provided automatically.
    311      """
    312
    313      SHORT_PREFIX = '-'
    314      LONG_PREFIX = '--'
    315
    316 -    class Type(object):
    317
    318          POSITIONAL = 1
    319          NAMED = 2
    320
    321 -    def __init__(self, program, description=''):
    322
    323          self._argformat = re.compile('^[a-z][a-z0-9_-]*$', re.IGNORECASE)
    324          self._optformat = re.compile('^[a-z0-9]$', re.IGNORECASE)
    325
    326          self._program = program
    327          self._description = description
    328
    329          self._parser = argparse.ArgumentParser(prog=program, description=description)
    330
    331 -    def _add(self, kind, name, shortname, help="", *a, **k):
    332
    333          args = []
    334          kargs = dict(k)
    335
    336          if shortname is not None:
    337              if not re.match(self._optformat, shortname):
    338                  raise ValueError('Invalid short option name: {0}.'.format(shortname))
    339
    340              if kind == ArgHandler.Type.POSITIONAL:
    341                  args.append(shortname)
    342              else:
    343                  args.append(ArgHandler.SHORT_PREFIX + shortname)
    344
    345          if name is not None or kind == ArgHandler.Type.POSITIONAL:
    346              if not re.match(self._argformat, name):
    347                  raise ValueError('Malformed argument name: {0}.'.format(name))
    348
    349              if kind == ArgHandler.Type.POSITIONAL:
    350                  args.append(name)
    351              else:
    352                  args.append(ArgHandler.LONG_PREFIX + name)
    353
    354          assert len(args) in (1, 2)
    355          args.extend(a)
    356          kargs["help"] = help.replace("%", "%%")     # workaround for a bug in argparse
    357
    358          self.parser.add_argument(*args, **kargs)
    359
    360 -    def _format_help(self, help, default):
    361
    362          if not help:
    363              help = ''
    364          if default is not None:
    365              help = '{0} (default={1})'.format(help, default)
    366
    367          return help
    368
    369 -    def add_positional_argument(self, name, type, help, choices=None):
    370          """
    371          Define a mandatory positional argument (an argument without a dash).
    372
    373          @param name: name of the argument (used in help only)
    374          @type name: str
    375          @param type: argument data type
    376          @type type: type (type factory callable)
    377          @param help: help text
    378          @type help: str
    379          @param choices: list of allowed argument values
    380          @type choices: tuple
    381          """
    382          self._add(ArgHandler.Type.POSITIONAL, name, None,
    383                    type=type, help=help, choices=choices)
    384
    385 -    def add_array_argument(self, name, type, help, choices=None):
    386          """
    387          Same as L{self.add_positional_argument()}, but allow unlimited number
    388          of values to be specified on the command line.
    389
    390          @param name: name of the argument (used in help only)
    391          @type name: str
    392          @param type: argument data type
    393          @type type: type (type factory callable)
    394          @param help: help text
    395          @type help: str
    396          @param choices: list of allowed argument values
    397          @type choices: tuple
    398          """
    399          self._add(ArgHandler.Type.POSITIONAL, name, None,
    400                    type=type, help=help, choices=choices, nargs=argparse.ONE_OR_MORE)
    401
    402 -    def add_boolean_option(self, name, shortname, help, default=False):
    403          """
    404          Define an optional switch (a dashed argument with no value).
    405
    406          @param name: long name of the option (or None)
    407          @type name: str, None
    408          @param shortname: short (single character) name of the option (or None)
    409          @type shortname:str, None
    410          @param help: help text
    411          @type help: str
    412          @param default: default value, assigned when the option is omitted.
    413                          If the option is specified on the command line, the
    414                          inverse value is assigned
    415          @type default: bool
    416          """
    417          if not default:
    418              default = False
    419          help = self._format_help(help, default)
    420
    421          if default:
    422              action = 'store_false'
    423          else:
    424              action = 'store_true'
    425
    426          self._add(ArgHandler.Type.NAMED, name, shortname,
    427                    help=help, action=action, default=bool(default))
    428
    429 -    def add_scalar_option(self, name, shortname, type, help, default=None, choices=None, required=False):
    430          """
    431          Define a scalar option (a dashed argument that accepts a single value).
    432
    433          @param name: long name of the option (or None)
    434          @type name: str, None
    435          @param shortname: short (single character) name of the option (or None)
    436          @type shortname: str, None
    437          @param type: argument data type
    438          @type type: type (type factory callable)
    439          @param help: help text
    440          @type help: str
    441          @param default: default value, assigned when the option is omitted
    442          @param choices: list of allowed argument values
    443          @type choices: tuple
    444          @param required: make this option a named mandatory argument
    445          @type required: bool
    446          """
    447          help = self._format_help(help, default)
    448
    449          self._add(ArgHandler.Type.NAMED, name, shortname,
    450                    type=type, help=help, default=default, choices=choices, required=required)
    451
    452 -    def add_array_option(self, name, shortname, type, help, default=None, choices=None, required=False):
    453          """
    454          Define an array option (a dashed argument that may receive one
    455          or multiple values on the command line, separated with spaces).
    456
    457          @param name: long name of the option (or None)
    458          @type name: str, None
    459          @param shortname: short (single character) name of the option (or None)
    460          @type shortname: str, None
    461          @param type: argument data type
    462          @type type: type (type factory callable)
    463          @param help: help text
    464          @type help: str
    465          @param choices: list of allowed argument values
    466          @type choices: tuple
    467          @param required: make this option a named mandatory argument
    468          @type required: bool
    469          """
    470          help = self._format_help(help, default)
    471
    472          self._add(ArgHandler.Type.NAMED, name, shortname,
    473                    nargs=argparse.ZERO_OR_MORE, type=type, help=help, default=default,
    474                    choices=choices, required=required)
    475
    476 -    def parse(self, args):
    477          """
    478          Parse the command line arguments.
    479
    480          @param args: the list of user-provided command line arguments --
    481                       normally sys.argv[1:]
    482          @type args: tuple of str
    483
    484          @return: an object initialized with the parsed arguments
    485          @rtype: argparse.Namespace
    486          """
    487          try:
    488              return self.parser.parse_args(args)
    489          except SystemExit as se:
    490              if se.code > 0:
    491                  raise AppExit('Bad command line', ExitCodes.USAGE_ERROR)
    492              else:
    493                  raise AppExit(code=ExitCodes.CLEAN)
    494
    495      @property
    496 -    def parser(self):
    497          return self._parser
    498
    499      @property
    500 -    def usage(self):
    501          return self.parser.format_usage()
    502
    503      @property
    504 -    def help(self):
    505          return self.parser.format_help()
    506