# 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 .
"""Helper functions to support command line tools providing more than
one command.
e.g called as "tool command [options] args..." where and are
command'specific
"""
from __future__ import print_function
__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 deprecated
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 not cls.name 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=]", end=" ")
print(" [options] ...")
if self.doc:
print("\n%s" % self.doc)
print(
"""
Type "%(pgm)s --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= / -C 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"""
@deprecated("use cls.register(cli)")
def register_commands(commands):
"""register existing commands"""
for command_klass in commands:
_COMMANDS.register(command_klass)
@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)
@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