clcommands.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Tue, 17 Jul 2012 17:10:51 +0200
branchstable
changeset 1491 8724116a446a
parent 1422 b0e29b5dc97c
child 1639 183eeb6fe1be
permissions -rw-r--r--
fix debian rev
     1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
     3 #
     4 # This file is part of logilab-common.
     5 #
     6 # logilab-common is free software: you can redistribute it and/or modify it under
     7 # the terms of the GNU Lesser General Public License as published by the Free
     8 # Software Foundation, either version 2.1 of the License, or (at your option) any
     9 # later version.
    10 #
    11 # logilab-common is distributed in the hope that it will be useful, but WITHOUT
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    14 # details.
    15 #
    16 # You should have received a copy of the GNU Lesser General Public License along
    17 # with logilab-common.  If not, see <http://www.gnu.org/licenses/>.
    18 """Helper functions to support command line tools providing more than
    19 one command.
    20 
    21 e.g called as "tool command [options] args..." where <options> and <args> are
    22 command'specific
    23 """
    24 
    25 __docformat__ = "restructuredtext en"
    26 
    27 import sys
    28 import logging
    29 from os.path import basename
    30 
    31 from logilab.common.configuration import Configuration
    32 from logilab.common.logging_ext import init_log, get_threshold
    33 from logilab.common.deprecation import deprecated
    34 
    35 
    36 class BadCommandUsage(Exception):
    37     """Raised when an unknown command is used or when a command is not
    38     correctly used (bad options, too much / missing arguments...).
    39 
    40     Trigger display of command usage.
    41     """
    42 
    43 class CommandError(Exception):
    44     """Raised when a command can't be processed and we want to display it and
    45     exit, without traceback nor usage displayed.
    46     """
    47 
    48 
    49 # command line access point ####################################################
    50 
    51 class CommandLine(dict):
    52     """Usage:
    53 
    54     >>> LDI = cli.CommandLine('ldi', doc='Logilab debian installer',
    55                               version=version, rcfile=RCFILE)
    56     >>> LDI.register(MyCommandClass)
    57     >>> LDI.register(MyOtherCommandClass)
    58     >>> LDI.run(sys.argv[1:])
    59 
    60     Arguments:
    61 
    62     * `pgm`, the program name, default to `basename(sys.argv[0])`
    63 
    64     * `doc`, a short description of the command line tool
    65 
    66     * `copyright`, additional doc string that will be appended to the generated
    67       doc
    68 
    69     * `version`, version number of string of the tool. If specified, global
    70       --version option will be available.
    71 
    72     * `rcfile`, path to a configuration file. If specified, global --C/--rc-file
    73       option will be available?  self.rcfile = rcfile
    74 
    75     * `logger`, logger to propagate to commands, default to
    76       `logging.getLogger(self.pgm))`
    77     """
    78     def __init__(self, pgm=None, doc=None, copyright=None, version=None,
    79                  rcfile=None, logthreshold=logging.ERROR,
    80                  check_duplicated_command=True):
    81         if pgm is None:
    82             pgm = basename(sys.argv[0])
    83         self.pgm = pgm
    84         self.doc = doc
    85         self.copyright = copyright
    86         self.version = version
    87         self.rcfile = rcfile
    88         self.logger = None
    89         self.logthreshold = logthreshold
    90         self.check_duplicated_command = check_duplicated_command
    91 
    92     def register(self, cls, force=False):
    93         """register the given :class:`Command` subclass"""
    94         assert not self.check_duplicated_command or force or not cls.name in self, \
    95                'a command %s is already defined' % cls.name
    96         self[cls.name] = cls
    97         return cls
    98 
    99     def run(self, args):
   100         """main command line access point:
   101         * init logging
   102         * handle global options (-h/--help, --version, -C/--rc-file)
   103         * check command
   104         * run command
   105 
   106         Terminate by :exc:`SystemExit`
   107         """
   108         init_log(debug=True, # so that we use StreamHandler
   109                  logthreshold=self.logthreshold,
   110                  logformat='%(levelname)s: %(message)s')
   111         try:
   112             arg = args.pop(0)
   113         except IndexError:
   114             self.usage_and_exit(1)
   115         if arg in ('-h', '--help'):
   116             self.usage_and_exit(0)
   117         if self.version is not None and arg in ('--version'):
   118             print self.version
   119             sys.exit(0)
   120         rcfile = self.rcfile
   121         if rcfile is not None and arg in ('-C', '--rc-file'):
   122             try:
   123                 rcfile = args.pop(0)
   124                 arg = args.pop(0)
   125             except IndexError:
   126                 self.usage_and_exit(1)
   127         try:
   128             command = self.get_command(arg)
   129         except KeyError:
   130             print 'ERROR: no %s command' % arg
   131             print
   132             self.usage_and_exit(1)
   133         try:
   134             sys.exit(command.main_run(args, rcfile))
   135         except KeyboardInterrupt, exc:
   136             print 'Interrupted',
   137             if str(exc):
   138                 print ': %s' % exc,
   139             print
   140             sys.exit(4)
   141         except BadCommandUsage, err:
   142             print 'ERROR:', err
   143             print
   144             print command.help()
   145             sys.exit(1)
   146 
   147     def create_logger(self, handler, logthreshold=None):
   148         logger = logging.Logger(self.pgm)
   149         logger.handlers = [handler]
   150         if logthreshold is None:
   151             logthreshold = get_threshold(self.logthreshold)
   152         logger.setLevel(logthreshold)
   153         return logger
   154 
   155     def get_command(self, cmd, logger=None):
   156         if logger is None:
   157             logger = self.logger
   158         if logger is None:
   159             logger = self.logger = logging.getLogger(self.pgm)
   160             logger.setLevel(get_threshold(self.logthreshold))
   161         return self[cmd](logger)
   162 
   163     def usage(self):
   164         """display usage for the main program (i.e. when no command supplied)
   165         and exit
   166         """
   167         print 'usage:', self.pgm,
   168         if self.rcfile:
   169             print '[--rc-file=<configuration file>]',
   170         print '<command> [options] <command argument>...'
   171         if self.doc:
   172             print '\n%s' % self.doc
   173         print  '''
   174 Type "%(pgm)s <command> --help" for more information about a specific
   175 command. Available commands are :\n''' % self.__dict__
   176         max_len = max([len(cmd) for cmd in self])
   177         padding = ' ' * max_len
   178         for cmdname, cmd in sorted(self.items()):
   179             if not cmd.hidden:
   180                 print ' ', (cmdname + padding)[:max_len], cmd.short_description()
   181         if self.rcfile:
   182             print '''
   183 Use --rc-file=<configuration file> / -C <configuration file> before the command
   184 to specify a configuration file. Default to %s.
   185 ''' % self.rcfile
   186         print  '''%(pgm)s -h/--help
   187       display this usage information and exit''' % self.__dict__
   188         if self.version:
   189             print  '''%(pgm)s -v/--version
   190       display version configuration and exit''' % self.__dict__
   191         if self.copyright:
   192             print '\n', self.copyright
   193 
   194     def usage_and_exit(self, status):
   195         self.usage()
   196         sys.exit(status)
   197 
   198 
   199 # base command classes #########################################################
   200 
   201 class Command(Configuration):
   202     """Base class for command line commands.
   203 
   204     Class attributes:
   205 
   206     * `name`, the name of the command
   207 
   208     * `min_args`, minimum number of arguments, None if unspecified
   209 
   210     * `max_args`, maximum number of arguments, None if unspecified
   211 
   212     * `arguments`, string describing arguments, used in command usage
   213 
   214     * `hidden`, boolean flag telling if the command should be hidden, e.g. does
   215       not appear in help's commands list
   216 
   217     * `options`, options list, as allowed by :mod:configuration
   218     """
   219 
   220     arguments = ''
   221     name = ''
   222     # hidden from help ?
   223     hidden = False
   224     # max/min args, None meaning unspecified
   225     min_args = None
   226     max_args = None
   227 
   228     @classmethod
   229     def description(cls):
   230         return cls.__doc__.replace('    ', '')
   231 
   232     @classmethod
   233     def short_description(cls):
   234         return cls.description().split('.')[0]
   235 
   236     def __init__(self, logger):
   237         usage = '%%prog %s %s\n\n%s' % (self.name, self.arguments,
   238                                         self.description())
   239         Configuration.__init__(self, usage=usage)
   240         self.logger = logger
   241 
   242     def check_args(self, args):
   243         """check command's arguments are provided"""
   244         if self.min_args is not None and len(args) < self.min_args:
   245             raise BadCommandUsage('missing argument')
   246         if self.max_args is not None and len(args) > self.max_args:
   247             raise BadCommandUsage('too many arguments')
   248 
   249     def main_run(self, args, rcfile=None):
   250         """Run the command and return status 0 if everything went fine.
   251 
   252         If :exc:`CommandError` is raised by the underlying command, simply log
   253         the error and return status 2.
   254 
   255         Any other exceptions, including :exc:`BadCommandUsage` will be
   256         propagated.
   257         """
   258         if rcfile:
   259             self.load_file_configuration(rcfile)
   260         args = self.load_command_line_configuration(args)
   261         try:
   262             self.check_args(args)
   263             self.run(args)
   264         except CommandError, err:
   265             self.logger.error(err)
   266             return 2
   267         return 0
   268 
   269     def run(self, args):
   270         """run the command with its specific arguments"""
   271         raise NotImplementedError()
   272 
   273 
   274 class ListCommandsCommand(Command):
   275     """list available commands, useful for bash completion."""
   276     name = 'listcommands'
   277     arguments = '[command]'
   278     hidden = True
   279 
   280     def run(self, args):
   281         """run the command with its specific arguments"""
   282         if args:
   283             command = args.pop()
   284             cmd = _COMMANDS[command]
   285             for optname, optdict in cmd.options:
   286                 print '--help'
   287                 print '--' + optname
   288         else:
   289             commands = sorted(_COMMANDS.keys())
   290             for command in commands:
   291                 cmd = _COMMANDS[command]
   292                 if not cmd.hidden:
   293                     print command
   294 
   295 
   296 # deprecated stuff #############################################################
   297 
   298 _COMMANDS = CommandLine()
   299 
   300 DEFAULT_COPYRIGHT = '''\
   301 Copyright (c) 2004-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
   302 http://www.logilab.fr/ -- mailto:contact@logilab.fr'''
   303 
   304 @deprecated('use cls.register(cli)')
   305 def register_commands(commands):
   306     """register existing commands"""
   307     for command_klass in commands:
   308         _COMMANDS.register(command_klass)
   309 
   310 @deprecated('use args.pop(0)')
   311 def main_run(args, doc=None, copyright=None, version=None):
   312     """command line tool: run command specified by argument list (without the
   313     program name). Raise SystemExit with status 0 if everything went fine.
   314 
   315     >>> main_run(sys.argv[1:])
   316     """
   317     _COMMANDS.doc = doc
   318     _COMMANDS.copyright = copyright
   319     _COMMANDS.version = version
   320     _COMMANDS.run(args)
   321 
   322 @deprecated('use args.pop(0)')
   323 def pop_arg(args_list, expected_size_after=None, msg="Missing argument"):
   324     """helper function to get and check command line arguments"""
   325     try:
   326         value = args_list.pop(0)
   327     except IndexError:
   328         raise BadCommandUsage(msg)
   329     if expected_size_after is not None and len(args_list) > expected_size_after:
   330         raise BadCommandUsage('too many arguments')
   331     return value
   332