Source code for logilab.common.clcommands

# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of logilab-common.
#
# logilab-common is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option) any
# later version.
#
# logilab-common is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with logilab-common.  If not, see <http://www.gnu.org/licenses/>.
"""Helper functions to support command line tools providing more than
one command.

e.g called as "tool command [options] args..." where <options> and <args> are
command'specific
"""


__docformat__ = "restructuredtext en"

import sys
import logging
from os.path import basename

from logilab.common.configuration import Configuration
from logilab.common.logging_ext import init_log, get_threshold
from logilab.common.deprecation import callable_deprecated


[docs]class BadCommandUsage(Exception): """Raised when an unknown command is used or when a command is not correctly used (bad options, too much / missing arguments...). Trigger display of command usage. """
class CommandError(Exception): """Raised when a command can't be processed and we want to display it and exit, without traceback nor usage displayed. """ # command line access point #################################################### class CommandLine(dict): """Usage: >>> LDI = cli.CommandLine('ldi', doc='Logilab debian installer', version=version, rcfile=RCFILE) >>> LDI.register(MyCommandClass) >>> LDI.register(MyOtherCommandClass) >>> LDI.run(sys.argv[1:]) Arguments: * `pgm`, the program name, default to `basename(sys.argv[0])` * `doc`, a short description of the command line tool * `copyright`, additional doc string that will be appended to the generated doc * `version`, version number of string of the tool. If specified, global --version option will be available. * `rcfile`, path to a configuration file. If specified, global --C/--rc-file option will be available? self.rcfile = rcfile * `logger`, logger to propagate to commands, default to `logging.getLogger(self.pgm))` """ def __init__( self, pgm=None, doc=None, copyright=None, version=None, rcfile=None, logthreshold=logging.ERROR, check_duplicated_command=True, ): if pgm is None: pgm = basename(sys.argv[0]) self.pgm = pgm self.doc = doc self.copyright = copyright self.version = version self.rcfile = rcfile self.logger = None self.logthreshold = logthreshold self.check_duplicated_command = check_duplicated_command def register(self, cls, force=False): """register the given :class:`Command` subclass""" assert not self.check_duplicated_command or force or cls.name not in self, ( "a command %s is already defined" % cls.name ) self[cls.name] = cls return cls def run(self, args): """main command line access point: * init logging * handle global options (-h/--help, --version, -C/--rc-file) * check command * run command Terminate by :exc:`SystemExit` """ init_log( debug=True, # so that we use StreamHandler logthreshold=self.logthreshold, logformat="%(levelname)s: %(message)s", ) try: arg = args.pop(0) except IndexError: self.usage_and_exit(1) if arg in ("-h", "--help"): self.usage_and_exit(0) if self.version is not None and arg in ("--version"): print(self.version) sys.exit(0) rcfile = self.rcfile if rcfile is not None and arg in ("-C", "--rc-file"): try: rcfile = args.pop(0) arg = args.pop(0) except IndexError: self.usage_and_exit(1) try: command = self.get_command(arg) except KeyError: print("ERROR: no %s command" % arg) print() self.usage_and_exit(1) try: sys.exit(command.main_run(args, rcfile)) except KeyboardInterrupt as exc: print("Interrupted", end=" ") if str(exc): print(": %s" % exc, end=" ") print() sys.exit(4) except BadCommandUsage as err: print("ERROR:", err) print() print(command.help()) sys.exit(1) def create_logger(self, handler, logthreshold=None): logger = logging.Logger(self.pgm) logger.handlers = [handler] if logthreshold is None: logthreshold = get_threshold(self.logthreshold) logger.setLevel(logthreshold) return logger def get_command(self, cmd, logger=None): if logger is None: logger = self.logger if logger is None: logger = self.logger = logging.getLogger(self.pgm) logger.setLevel(get_threshold(self.logthreshold)) return self[cmd](logger) def usage(self): """display usage for the main program (i.e. when no command supplied) and exit """ print("usage:", self.pgm, end=" ") if self.rcfile: print("[--rc-file=<configuration file>]", end=" ") print("<command> [options] <command argument>...") if self.doc: print("\n%s" % self.doc) print( """ Type "%(pgm)s <command> --help" for more information about a specific command. Available commands are :\n""" % self.__dict__ ) max_len = max([len(cmd) for cmd in self]) padding = " " * max_len for cmdname, cmd in sorted(self.items()): if not cmd.hidden: print(" ", (cmdname + padding)[:max_len], cmd.short_description()) if self.rcfile: print( """ Use --rc-file=<configuration file> / -C <configuration file> before the command to specify a configuration file. Default to %s. """ % self.rcfile ) print( """%(pgm)s -h/--help display this usage information and exit""" % self.__dict__ ) if self.version: print( """%(pgm)s -v/--version display version configuration and exit""" % self.__dict__ ) if self.copyright: print("\n", self.copyright) def usage_and_exit(self, status): self.usage() sys.exit(status) # base command classes ######################################################### class Command(Configuration): """Base class for command line commands. Class attributes: * `name`, the name of the command * `min_args`, minimum number of arguments, None if unspecified * `max_args`, maximum number of arguments, None if unspecified * `arguments`, string describing arguments, used in command usage * `hidden`, boolean flag telling if the command should be hidden, e.g. does not appear in help's commands list * `options`, options list, as allowed by :mod:configuration """ arguments = "" name = "" # hidden from help ? hidden = False # max/min args, None meaning unspecified min_args = None max_args = None @classmethod def description(cls): return cls.__doc__.replace(" ", "") @classmethod def short_description(cls): return cls.description().split(".")[0] def __init__(self, logger): usage = "%%prog %s %s\n\n%s" % (self.name, self.arguments, self.description()) Configuration.__init__(self, usage=usage) self.logger = logger def check_args(self, args): """check command's arguments are provided""" if self.min_args is not None and len(args) < self.min_args: raise BadCommandUsage("missing argument") if self.max_args is not None and len(args) > self.max_args: raise BadCommandUsage("too many arguments") def main_run(self, args, rcfile=None): """Run the command and return status 0 if everything went fine. If :exc:`CommandError` is raised by the underlying command, simply log the error and return status 2. Any other exceptions, including :exc:`BadCommandUsage` will be propagated. """ if rcfile: self.load_file_configuration(rcfile) args = self.load_command_line_configuration(args) try: self.check_args(args) self.run(args) except CommandError as err: self.logger.error(err) return 2 return 0 def run(self, args): """run the command with its specific arguments""" raise NotImplementedError() class ListCommandsCommand(Command): """list available commands, useful for bash completion.""" name = "listcommands" arguments = "[command]" hidden = True def run(self, args): """run the command with its specific arguments""" if args: command = args.pop() cmd = _COMMANDS[command] for optname, optdict in cmd.options: print("--help") print("--" + optname) else: commands = sorted(_COMMANDS.keys()) for command in commands: cmd = _COMMANDS[command] if not cmd.hidden: print(command) # deprecated stuff ############################################################# _COMMANDS = CommandLine() DEFAULT_COPYRIGHT = """\ Copyright (c) 2004-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. http://www.logilab.fr/ -- mailto:contact@logilab.fr""" @callable_deprecated("use cls.register(cli)") def register_commands(commands): """register existing commands""" for command_klass in commands: _COMMANDS.register(command_klass) @callable_deprecated("use args.pop(0)") def main_run(args, doc=None, copyright=None, version=None): """command line tool: run command specified by argument list (without the program name). Raise SystemExit with status 0 if everything went fine. >>> main_run(sys.argv[1:]) """ _COMMANDS.doc = doc _COMMANDS.copyright = copyright _COMMANDS.version = version _COMMANDS.run(args) @callable_deprecated("use args.pop(0)") def pop_arg(args_list, expected_size_after=None, msg="Missing argument"): """helper function to get and check command line arguments""" try: value = args_list.pop(0) except IndexError: raise BadCommandUsage(msg) if expected_size_after is not None and len(args_list) > expected_size_after: raise BadCommandUsage("too many arguments") return value