diff options
author | Michal Minar <miminar@redhat.com> | 2013-07-13 08:16:06 +0200 |
---|---|---|
committer | Michal Minar <miminar@redhat.com> | 2013-07-19 14:32:58 +0200 |
commit | fc5fd553327ca5cc0a496f3793697f8f39d4d00c (patch) | |
tree | 22106808b1f0c92aef2724f764458d51c3365362 | |
parent | 29df6b80cdbd5f61911c58cf768e73ec8c088851 (diff) | |
download | openlmi-scripts-fc5fd553327ca5cc0a496f3793697f8f39d4d00c.tar.gz openlmi-scripts-fc5fd553327ca5cc0a496f3793697f8f39d4d00c.tar.xz openlmi-scripts-fc5fd553327ca5cc0a496f3793697f8f39d4d00c.zip |
initial commit
Working lmi metacommand based on python-cliff and python-docopt.
Dependency on python-cliff is only temporary - most of its functionality
is already overriden.
This commit does not contain any commands.
-rw-r--r-- | README.md | 80 | ||||
-rw-r--r-- | config/lmi.conf | 14 | ||||
-rw-r--r-- | lmi/__init__.py | 21 | ||||
-rw-r--r-- | lmi/scripts/__init__.py | 24 | ||||
-rw-r--r-- | lmi/scripts/_metacommand/__init__.py | 274 | ||||
-rw-r--r-- | lmi/scripts/common/__init__.py | 110 | ||||
-rw-r--r-- | lmi/scripts/common/command.py | 530 | ||||
-rw-r--r-- | lmi/scripts/common/configuration.py | 91 | ||||
-rw-r--r-- | lmi/scripts/common/errors.py | 72 | ||||
-rw-r--r-- | lmi/scripts/common/session.py | 131 | ||||
-rwxr-xr-x | scripts/lmi | 32 | ||||
-rw-r--r-- | setup.py | 53 |
12 files changed, 1431 insertions, 1 deletions
@@ -1,4 +1,82 @@ openlmi-scripts =============== +Client-side python modules and command line utilities. + +It comprise of one binary called `lmi` and a common library. `lmi` +meta-command allows to run commands on a set of OpenLMI providers. These +commands can be installed separatelly in a modular way. + +`lmi` is a command line application allowing to run single command on a set +of hosts with just a one statement from `shell` or it can run in an +interactive way. + +Structure +--------- +Following diagram depicts directory structure. + + openlmi-tools + ├── commands # base directory for lmi subcommands + │ ├── service # service provider comand (service) + │ │ └── lmi + │ │ └── scripts + │ │ └── service + │ └── software # software provider command (sw) + │ └── lmi + │ └── scripts + │ └── software + ├── config # configuration files for lmi meta-command + └── lmi # common client-side library + └── scripts + ├── common + └── _metacommand # functionality of lmi meta-command + +Each subdirectory of `commands/` contains library for interfacing with +particular set of OpenLMI providers. Each contains its own `setup.py` file, +that handles its installation and registration of command. They have one +command thing. Each such `setup.py` must pass `entry_points` dictionary to +the `setup()` function, wich associates commands defined in command library +with its name under `lmi` meta-command. + +Dependencies +------------ +Code base is written for `pythopn 2.7`. +There are following python dependencies: + + * openlmi-tools + * python-cliff + * python-docopt + +Installation +------------ +Use standard `setuptools` script for installation: + + $ cd openlmi-scripts + $ python setup.py install --user + +This installs just the *lmi meta-command* and client-side library. To install +subcommands, you need to do the same procedure for each particular command +under `commands/` directory. + +Usage +----- +To get a help and see available commands, run: + + $ lmi help + +To get a help for particular command, run: + + $ lmi help service + +To issue single command on a host, run: + + $ lmi --host ${hostname} service list + +To start the app in interactive mode: + + $ lmi --host ${hostname} + > service list --disabled + ... + > service start svnserve.service + ... + > quit -Client-side python modules and command line utilities diff --git a/config/lmi.conf b/config/lmi.conf new file mode 100644 index 0000000..4c8bd7d --- /dev/null +++ b/config/lmi.conf @@ -0,0 +1,14 @@ +# Sample configuration file for OpenLMI script metacommand. + +[CIM] +# To override default CIM namespace, uncomment the line below. +#namespace = root/cimv2 + +[Log] +# These options modify logging configuration of the main process spawned +# by CIMOM. + +# Level can be set to following values: +# DEBUG, TRACE_INFO, TRACE_WARNING, INFO, WARNING, ERROR, CRITICAL +# It does not have any effect, if file_config option is set. +#level = ERROR diff --git a/lmi/__init__.py b/lmi/__init__.py new file mode 100644 index 0000000..aa4170a --- /dev/null +++ b/lmi/__init__.py @@ -0,0 +1,21 @@ +# Software Management Providers +# +# Copyright (C) 2012-2013 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# +__import__('pkg_resources').declare_namespace(__name__) diff --git a/lmi/scripts/__init__.py b/lmi/scripts/__init__.py new file mode 100644 index 0000000..bf39403 --- /dev/null +++ b/lmi/scripts/__init__.py @@ -0,0 +1,24 @@ +# Software Management Providers +# +# Copyright (C) 2012-2013 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# +""" +Package with client-side python modules and command line utilities. +""" +__import__('pkg_resources').declare_namespace(__name__) diff --git a/lmi/scripts/_metacommand/__init__.py b/lmi/scripts/_metacommand/__init__.py new file mode 100644 index 0000000..e58036a --- /dev/null +++ b/lmi/scripts/_metacommand/__init__.py @@ -0,0 +1,274 @@ +# Copyright (C) 2012 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# -*- coding: utf-8 -*- +""" +The base module containing the main functionality of ``lmi`` metacommand. +""" + +import argparse +import logging +import pkg_resources +import sys + +from cliff.app import App +from cliff.commandmanager import CommandManager +from cliff.help import HelpCommand +from cliff.interactive import InteractiveApp +import lmi.lmi_client_base +from lmi.scripts import common +from lmi.scripts.common import errors +from lmi.scripts.common.command import LmiCommandMultiplexer, LmiBaseCommand +from lmi.scripts.common.configuration import Configuration +from lmi.scripts.common.session import Session + +PYTHON_EGG_NAME = "lmi-scripts" +#RE_COMMAND = re.compiler(r'^[a-z_]+(?:-[a-z_]+)*$') + +LOG = common.get_logger(__name__) + +# ignore any message before the logging is configured +logging.getLogger('').addHandler(logging.NullHandler()) + +class LmiHelpCommand(HelpCommand): + """print detailed help for another command + """ + + def take_action(self, parsed_args): + if parsed_args.cmd: + try: + the_cmd = self.app.command_manager.find_command( + parsed_args.cmd, + ) + cmd_factory, cmd_name, search_args = the_cmd + except ValueError: + # Did not find an exact match + cmd = parsed_args.cmd[0] + fuzzy_matches = [k[0] for k in self.app.command_manager + if k[0].startswith(cmd) + ] + if not fuzzy_matches: + raise + self.app.stdout.write('Command "%s" matches:\n' % cmd) + for fm in fuzzy_matches: + self.app.stdout.write(' %s\n' % fm) + return + cmd = cmd_factory(self.app, search_args) + full_name = (cmd_name + if self.app.interactive_mode + else ' '.join([self.app.NAME, cmd_name]) + ) + cmd_parser = cmd.get_parser(full_name) + self.app.stdout.write(cmd.__doc__) + return 0 + else: + cmd_parser = self.get_parser(' '.join([self.app.NAME, 'help'])) + cmd_parser.print_help(self.app.stdout) + return 0 + +def parse_hosts_file(hosts_file): + res = [] + for line in hosts_file.readlines(): + hostname = line.strip() + res.append(hostname) + return res + +class MetaCommand(App): + + def __init__(self): + lmi.lmi_client_base.LmiBaseClient._set_use_exceptions(True) + App.__init__(self, + "OpenLMI command line interface for CIM providers." + " It's functionality is composed of registered subcommands," + " operating on top of simple libraries, interfacing with" + " particular OpenLMI profile providers. Works also in interactive" + " mode.", + pkg_resources.get_distribution(PYTHON_EGG_NAME).version, + command_manager=CommandManager('lmi.scripts.cmd')) + self.command_manager.add_command('help', LmiHelpCommand) + self.session = None + + def build_option_parser(self, *args, **kwargs): + parser = App.build_option_parser(self, *args, **kwargs) + parser.add_argument('--config-file', '-c', action='store', + default=Configuration.USER_CONFIG_FILE_PATH, + help="Specify the user configuration file to use. Options in" + " this file override any settings of global configuration" + " file located in \"%s\"." % Configuration.config_file_path()) + parser.add_argument('--user', action='store', default="", + help="Supply a username used in each connection to target" + " host.") + parser.add_argument('--host', action='append', dest='hosts', + default=[], + help="Hostname of target system, where the command will be" + " applied.") + parser.add_argument('--hosts-file', type=file, + help="Supply a path to file containing target hostnames." + " Each hostname must be listed on single line.") + return parser + + def run(self, argv): + """Equivalent to the main program for the application. + + :param argv: input arguments and options + :paramtype argv: list of str + """ + def _debug(): + if hasattr(self, 'options'): + return self.options.debug + else: + return True + try: + self.options, remainder = self.parser.parse_known_args(argv) + self.configure_logging() + self.interactive_mode = not remainder + self.initialize_app(remainder) + except Exception as err: + if _debug(): + LOG().exception(err) + raise + else: + LOG().error(err) + return 1 + + result = 1 + if self.interactive_mode: + # Cmd does not provide a way to override arguments in some nice way + sys.argv = [sys.argv[0]] + remainder + result = self.interact() + else: + try: + result = self.run_subcommand(remainder) + except errors.LmiCommandError as exc: + getattr(LOG(), 'exception' if _debug() else 'critical')( + str(exc)) + result = 1 + return result + + def prepare_to_run_command(self, cmd): + if not isinstance(cmd, LmiHelpCommand): + if not self.options.hosts and not self.options.hosts_file: + self.parser.error( + "missing one of (host | hosts-file) arguments") + hosts = [] + if self.options.hosts_file: + hosts.extend(parse_hosts_file(self.options.hosts_file)) + hosts.extend(self.options.hosts) + credentials = {h: (self.options.user, '') for h in hosts} + if self.session is None: + self.session = Session(self, hosts, credentials) + + def run_subcommand(self, argv): + subcommand = self.command_manager.find_command(argv) + cmd_factory, cmd_name, sub_argv = subcommand + cmd = cmd_factory(self, self.options) + err = None + result = 1 + try: + self.prepare_to_run_command(cmd) + full_name = (cmd_name + if self.interactive_mode + else ' '.join([self.NAME, cmd_name]) + ) + cmd_parser = cmd.get_parser(full_name) + if isinstance(cmd, LmiBaseCommand): + if cmd.is_end_point(): + parsed_args = cmd_parser.parse_args(sub_argv) + result = cmd.run(self.session, parsed_args) + else: + parsed_args, unknown = cmd_parser.parse_known_args(sub_argv) + result = cmd.run(self.session, parsed_args, unknown) + else: + parsed_args = cmd_parser.parse_args(sub_argv) + result = cmd.run(parsed_args) + + except Exception as err: + if self.options.debug: + LOG().exception(err) + else: + LOG().error(err) + try: + self.clean_up(cmd, result, err) + except Exception as err2: + if self.options.debug: + LOG().exception(err2) + else: + LOG().error('Could not clean up: %s', err2) + if self.options.debug: + raise + else: + try: + self.clean_up(cmd, result, None) + except Exception as err3: + if self.options.debug: + LOG().exception(err3) + else: + LOG().error('Could not clean up: %s', err3) + return result + + def configure_logging(self): + # first instantiation of Configuration object + config = Configuration.get_instance(self.options.config_file) + config.verbosity = self.options.verbose_level + root_logger = logging.getLogger('') + # make a reference to null handlers (one should be installed) + null_handlers = [ h for h in root_logger.handlers + if isinstance(h, logging.NullHandler)] + try: + logging_level = getattr(logging, config.logging_level.upper()) + except KeyError: + logging_level = logging.ERROR + + # Set up logging to a file + log_file = self.options.log_file + if log_file is None: + log_file = config.get_safe('Log', 'OutputFile') + if log_file is not None: + file_handler = logging.FileHandler(filename=log_file) + formatter = logging.Formatter( + config.get_safe('Log', 'FileFormat', + fallback=self.LOG_FILE_MESSAGE_FORMAT, + raw=True)) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + # Always send higher-level messages to the console via stderr + console = logging.StreamHandler(self.stderr) + console_level_default = logging.ERROR if log_file else logging_level + console_level = { + Configuration.OUTPUT_SILENT : logging.CRITICAL, + Configuration.OUTPUT_WARNING : logging.WARNING, + Configuration.OUTPUT_INFO : logging.INFO, + Configuration.OUTPUT_DEBUG : logging.DEBUG, + }.get(config.verbosity, console_level_default) + console.setLevel(console_level) + formatter = logging.Formatter( + config.get_safe('Log', 'ConsoleFormat', + fallback=self.CONSOLE_MESSAGE_FORMAT)) + console.setFormatter(formatter) + root_logger.addHandler(console) + root_logger.setLevel(min(logging_level, console_level)) + + # remove all null_handlers + for handler in null_handlers: + root_logger.removeHandler(handler) + return + +def main(argv=sys.argv[1:]): + mc = MetaCommand() + return mc.run(argv) + diff --git a/lmi/scripts/common/__init__.py b/lmi/scripts/common/__init__.py new file mode 100644 index 0000000..b9e9d8d --- /dev/null +++ b/lmi/scripts/common/__init__.py @@ -0,0 +1,110 @@ +# Software Management Providers +# +# Copyright (C) 2012-2013 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# +""" +Package with client-side python modules and command line utilities. +""" + +import logging +from lmi.scripts.common.configuration import Configuration + +DEFAULT_LOGGING_CONFIG = { + "version" : 1, + 'disable_existing_loggers' : True, + "formatters": { + # this is a message format for logging function/method calls + # it's manually set up in YumWorker's init method + "default": { + "default" : "%(levelname)s:%(module)s:" + "%(funcName)s:%(lineno)d - %(message)s" + }, + }, + "handlers": { + "stderr" : { + "class" : "logging.StreamHandler", + "level" : "ERROR", + "formatter": "default", + }, + }, + "root": { + "level": "ERROR", + "handlers" : ["cmpi"], + }, + } + +def setup_logging(config): + """ + Set up the logging with options given by Configuration instance. + This should be called at process's startup before any message is sent to + log. + + :param config: (``BaseConfiguration``) Configuration with Log section + containing settings for logging. + """ + cp = config.config + logging_setup = False + try: + path = config.file_path('Log', 'FileConfig') + if not os.path.exists(path): + logging.getLogger(__name__).error('given FileConfig "%s" does' + ' not exist', path) + else: + logging.config.fileConfig(path) + logging_setup = True + except Exception: + if cp.has_option('Log', 'FileConfig'): + logging.getLogger(__name__).exception( + 'failed to setup logging from FileConfig') + if logging_setup is False: + defaults = DEFAULT_LOGGING_CONFIG.copy() + defaults["handlers"]["cmpi"]["cmpi_logger"] = env.get_logger() + if config.stderr: + defaults["root"]["handlers"] = ["stderr"] + level = config.logging_level + if not level in LOGGING_LEVELS: + logging.getLogger(__name__).error( + 'level name "%s" not supported', level) + else: + level = LOGGING_LEVELS[level] + for handler in defaults["handlers"].values(): + handler["level"] = level + defaults["root"]["level"] = level + logging.config.dictConfig(defaults) + +def get_logger(module_name): + """ + Convenience function for getting callable returning logger for particular + module name. It's supposed to be used at module's level to assign its + result to global variable like this: + + LOG = common.get_logger(__name__) + + This can be used in module's functions and classes like this: + + def module_function(param): + LOG().debug("this is debug statement logging param: %s", param) + + Thanks to ``LOG`` being a callable, it always returns valid logger object + with current configuration, which may change overtime. + """ + def _logger(): + """ Callable used to obtain current logger object. """ + return logging.getLogger(module_name) + return _logger diff --git a/lmi/scripts/common/command.py b/lmi/scripts/common/command.py new file mode 100644 index 0000000..e790985 --- /dev/null +++ b/lmi/scripts/common/command.py @@ -0,0 +1,530 @@ +# Copyright (C) 2012 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# -*- coding: utf-8 -*- +""" +Module with abstractions for representing subcommand of lmi meta-command. +""" + +import abc +import argparse +import inspect +import re +from cliff.command import Command +from cliff.lister import Lister +from docopt import docopt + +import lmi.lmi_client_base +from lmi.scripts.common import Configuration +from lmi.scripts.common import get_logger +from lmi.scripts.common import errors + +RE_CALLABLE = re.compile( + r'^(?P<module>[a-z_]+(?:\.[a-z_]+)*):(?P<func>[a-z_]+)$', + re.IGNORECASE) +RE_COMMAND_NAME = re.compile('^[a-z]+(-[a-z]+)*$') +RE_OPT_BRACKET_ARGUMENT = re.compile('^<(?P<name>[^>]+)>$') +RE_OPT_UPPER_ARGUMENT = re.compile('^(?P<name>[A-Z]+(?:[_-][A-Z]+)*)$') +RE_OPT_SHORT_OPTION = re.compile('^-(?P<name>[a-z])$', re.IGNORECASE) +RE_OPT_LONG_OPTION = re.compile('^--(?P<name>[a-z_-]+)$', re.IGNORECASE) + +LOG = get_logger(__name__) + +def _row_to_string(tup): + return tuple(str(a) for a in tup) + +def opt_name_sanitize(opt_name): + return re.sub(r'[^a-zA-Z]', '_', opt_name).lower() + +def options_dict2kwargs(options): + """ + Convert option name from resulting docopt dictionary to a valid python + identificator token used as function argument name. + + :param options: (``dict``) Dictionary return by docopt call. + :rtype: (``dict``) New dictionary with keys passeable to function + as argument names. + """ + # (new_name, value) for each pair in options dictionary + kwargs = {} + # (name + orig_names = {} + for name, value in options.items(): + for (reg, func) in ( + (RE_OPT_BRACKET_ARGUMENT, lambda m: m.group('name')), + (RE_OPT_UPPER_ARGUMENT, lambda m: m.group('name')), + (RE_OPT_SHORT_OPTION, lambda m: m.group(0)), + (RE_OPT_LONG_OPTION, lambda m: m.group(0))): + match = reg.match(name) + if match: + new_name = func(match) + break + else: + if RE_COMMAND_NAME: + LOG().warn('command "%s" is missing implementation' % name) + continue + raise errors.LmiError( + 'failed to convert argument "%s" to function option' + % name) + new_name = opt_name_sanitize(new_name) + if new_name in kwargs: + raise errors.LmiError('option clash for "%s" and "%s", which both' + ' translate to "%s"' % (name, orig_names[new_name], new_name)) + kwargs[new_name] = value + orig_names[new_name] = name + return kwargs + +class _EndPointCommandMetaClass(abc.ABCMeta): + + @classmethod + def _make_execute_method(mcs, bases, dcl, func): + if func is None: + for base in bases: + if hasattr(base, 'execute'): + # we already have abstract execute method defined + break + else: + # prevent instantiation of command without CALLABLE property + # specified + dcl['execute'] = abc.abstractmethod(lambda self: None) + else: + del dcl['CALLABLE'] + def _execute(_self, *args, **kwargs): + return func(*args, **kwargs) + _execute.dest = func + dcl['execute'] = _execute + + def __new__(mcs, name, bases, dcl): + try: + func = dcl.get('CALLABLE') + if isinstance(func, basestring): + match = RE_CALLABLE.match(func) + if not match: + raise errors.LmiCommandInvalidCallable( + dcl['__module__'], name, + 'Callable "%s" has invalid format (\':\' expected)' + % func) + mod_name = match.group('module') + try: + func = getattr(__import__(mod_name, globals(), locals(), + [match.group('func')], 0), + match.group('func')) + except (ImportError, AttributeError): + raise errors.LmiCommandImportFailed( + dcl['__module__'], name, func) + except KeyError: + mod = dcl['__module__'] + if not name.lower() in mod: + raise errors.LmiCommandMissingCallable( + 'Missing CALLABLE attribute for class "%s.%s".' % ( + mod.__name__, name)) + func = mod[name.lower()] + if func is not None and not callable(func): + raise errors.LmiCommandInvalidCallable( + '"%s" is not a callable object or function.' % ( + func.__module__ + '.' + func.__name__)) + + mcs._make_execute_method(bases, dcl, func) + return super(_EndPointCommandMetaClass, mcs).__new__( + mcs, name, bases, dcl) + +class _ListerMetaClass(_EndPointCommandMetaClass): + + def __new__(mcs, name, bases, dcl): + cols = dcl.get('COLUMNS', None) + if cols is not None: + cols = dcl['COLUMNS'] + if not isinstance(cols, (list, tuple)): + raise errors.LmiCommandInvalidProperty(dcl['__module__'], name, + 'COLUMNS class property must be either list or tuple') + if not all(isinstance(c, basestring) for c in cols): + raise errors.LmiCommandInvalidProperty(dcl['__module__'], name, + 'COLUMNS must contain just column names as strings') + def _new_get_columns(_cls): + return cols + del dcl['COLUMNS'] + dcl['get_columns'] = classmethod(_new_get_columns) + return super(_ListerMetaClass, mcs).__new__(mcs, name, bases, dcl) + +class _CheckResultMetaClass(_EndPointCommandMetaClass): + + def __new__(mcs, name, bases, dcl): + try: + expect = dcl['EXPECT'] + if callable(expect): + def _new_expect(self, options, result): + if isinstance(result, lmi.lmi_client_base._RValue): + result = result.rval + passed = expect(options, result) + self._result = result + return passed + else: + def _new_expect(self, _options, result): + if isinstance(result, lmi.lmi_client_base._RValue): + result = result.rval + passed = expect == result + self._result = result + return passed + _new_expect.expected = expect + del dcl['EXPECT'] + dcl['check_result'] = _new_expect + except KeyError: + # EXPECT might be defined in some subclass + pass + #raise errors.LmiCommandError(dcl['__module__'], name, + #'missing EXPECT property') + + return super(_CheckResultMetaClass, mcs).__new__(mcs, name, bases, dcl) + +class _MultiplexerMetaClass(abc.ABCMeta): + + @classmethod + def _is_root_multiplexer(mcs, bases): + for base in bases: + if issubclass(type(base), _MultiplexerMetaClass): + return False + return True + + def __new__(mcs, name, bases, dcl): + if not mcs._is_root_multiplexer(bases): + # check COMMANDS property and make it a classmethod + if not 'COMMANDS' in dcl: + raise errors.LmiCommandError('missing COMMANDS property') + cmds = dcl['COMMANDS'] + if not isinstance(cmds, dict): + raise errors.LmiCommandInvalidProperty(dcl['__module__'], name, + 'COMMANDS must be a dictionary') + if not all(isinstance(c, basestring) for c in cmds.keys()): + raise errors.LmiCommandInvalidProperty(dcl['__module__'], name, + 'keys of COMMANDS dictionary must contain command names' + ' as strings') + for cmd_name, cmd in cmds.items(): + if not RE_COMMAND_NAME.match(cmd_name): + raise errors.LmiCommandInvalidName(cmd, cmd_name) + if not issubclass(cmd, LmiBaseCommand): + raise errors.LmiCommandError(dcl['__module__'], cmd_name, + 'COMMANDS dictionary must be composed of' + ' LmiCommandBase subclasses, failed class: "%s"' + % cmd.__name__) + if issubclass(cmd, LmiCommandMultiplexer): + cmd.__doc__ = dcl['__doc__'] + def _new_get_commands(_cls): + return cmds + del dcl['COMMANDS'] + dcl['get_commands'] = classmethod(_new_get_commands) + + # check documentation + if dcl.get('__doc__', None) is None: + LOG().warn('Command "%s.%s" is missing description string.' % ( + dcl['__module__'], name)) + return super(_MultiplexerMetaClass, mcs).__new__(mcs, name, bases, dcl) + +class LmiBaseCommand(object): + + @classmethod + def is_end_point(cls): + return True + + def __init__(self, args, kwargs): + self._cmd_name_args = None + self.parent = kwargs.pop('parent', None) + + @property + def cmd_name(self): + """ Name of this subcommand without as a single word. """ + return self._cmd_name_args[-1] + + @property + def cmd_full_name(self): + """ + Name of this subcommand with all prior commands included. + It's the sequence of commands as given on command line up to this + subcommand without any options present. In interactive mode + this won't contain the name of binary (``sys.argv[0]``). + + :rtype: (``str``) Concatenation of all preceding commands with + ``cmd_name``. + """ + return ' '.join(self._cmd_name_args) + + @property + def cmd_name_args(self): + return self._cmd_name_args[:] + @cmd_name_args.setter + def cmd_name_args(self, args): + if isinstance(args, basestring): + args = args.split(' ') + else: + args = list(args) + if not isinstance(args, (list, tuple)): + raise TypeError("args must be a list") + self._cmd_name_args = args + + @property + def docopt_cmd_name_args(self): + """ + Arguments array for docopt parser. + + :rtype: (``list``) + """ + if self.app.interactive_mode: + return self._cmd_name_args[:] + return self._cmd_name_args[1:] + +class LmiCommandMultiplexer(LmiBaseCommand, Command): + __metaclass__ = _MultiplexerMetaClass + + @classmethod + def is_end_point(cls): + return False + + @classmethod + def get_commands(cls): + raise NotImplementedError("get_commands method must be overriden in" + " a subclass") + + def __init__(self, *args, **kwargs): + LmiBaseCommand.__init__(self, args, kwargs) + Command.__init__(self, *args, **kwargs) + + def get_parser(self, cmd_name_args): + self.cmd_name_args = cmd_name_args + parser = argparse.ArgumentParser( + description=self.get_description(), + prog=self.cmd_full_name, + add_help=False) + subparser = parser.add_subparsers(dest='command') + for cmd in self.get_commands(): + subparser.add_parser(cmd) + return parser + + def make_options(self, subcmd_name, unknown_args): + full_args = self.docopt_cmd_name_args + full_args.append(subcmd_name) + full_args.extend(unknown_args) + options = docopt(self.__doc__, full_args) + for scn in self.get_commands(): + try: + del options[scn] + except KeyError: + LOG().warn('doc string of "%s" command does not contain' + ' registered command "%s" command', + subcmd_name, scn) + # remove also this command from options + if self.cmd_name in options: + del options[self.cmd_name] + return options + + def take_action(self, session, args, unknown_args): + for cmd_name, cmd_cls in self.get_commands().items(): + if cmd_name == args.command: + cmd = cmd_cls(self.app, self.app_args, parent=self) + subcmd_args = self.cmd_name_args + [cmd_name] + cmd_parser = cmd.get_parser(subcmd_args) + parsed_args, remainder = cmd_parser.parse_known_args( + unknown_args) + if cmd.is_end_point(): + options = self.make_options(cmd_name, remainder) + return cmd.run(session, parsed_args, options) + else: + return cmd.run(session, parsed_args, remainder) + # this won't happen if checks are done correctly + LOG().critical('unexpected command "%s"', args.command) + raise errors.LmiCommandNotFound(args.command) + + def run(self, session, parsed_args, unknown_args): + self.take_action(session, parsed_args, unknown_args) + return 0 + +class LmiEndPointCommand(LmiBaseCommand): + + def verify_options(self, _options): + pass + + def transform_options(self, options): + return options + + @abc.abstractmethod + def process_session(self, session, cmd_args, options): + raise NotImplementedError("process_session must be overriden" + " in subclass") + + @abc.abstractmethod + def execute(self, *args, **kwargs): + raise NotImplementedError("execute method must be overriden" + " in subclass") + + def get_parser(self, cmd_name_args): + cls = self.__class__ + parser = None + self.cmd_name_args = cmd_name_args + while cls: + cmd_bases = tuple(c for c in cls.__bases__ + if issubclass(c, Command)) + not_end_point_bases = tuple(c for c in cmd_bases + if not issubclass(c, LmiEndPointCommand)) + if len(not_end_point_bases) > 0: + parser = not_end_point_bases[0].get_parser(self, + self.cmd_full_name) + break + cls = cmd_bases[0] + return parser + + def _make_end_point_args(self, options): + argspec = inspect.getargspec(self.execute.dest) + kwargs = options_dict2kwargs(options) + to_remove = [] + if argspec.keywords is None: + for opt_name in kwargs: + if opt_name not in argspec.args[1:]: + LOG().debug('option "%s" not handled in function "%s",' + ' ignoring', opt_name, self.cmd_name) + to_remove.append(opt_name) + for opt_name in to_remove: + del kwargs[opt_name] + args = [] + for arg_name in argspec.args[1:]: + if arg_name not in kwargs: + raise errors.LmiCommandError( + self.__module__, self.__class__.__name__, + 'registered command "%s" expects option "%s", which' + ' is not covered in usage string' + % (self.cmd_name, arg_name)) + args.append(kwargs.pop(arg_name)) + return args, kwargs + + def run(self, session, cmd_args, options): + self.verify_options(options) + options = self.transform_options(options) + args, kwargs = self._make_end_point_args(options) + return self.process_session(session, cmd_args, args, kwargs) + +class LmiLister(LmiEndPointCommand, Lister): + __metaclass__ = _ListerMetaClass + + def __init__(self, *args, **kwargs): + LmiEndPointCommand.__init__(self, args, kwargs) + Lister.__init__(self, *args, **kwargs) + + @classmethod + def get_columns(cls): + return None + + def take_action(self, connection, function_args, function_kwargs): + res = self.execute(connection, *function_args, **function_kwargs) + columns = self.get_columns() + if columns is None: + # let's get columns from the first row + columns = next(res) + return (_row_to_string(columns), res) + + def process_session(self, session, cmd_args, function_args, + function_kwargs): + self.formatter = self.formatters[cmd_args.formatter] + for connection in session: + if len(session) > 1: + self.app.stdout.write("="*79 + "\n") + self.app.stdout.write("Host: %s\n" % connection.hostname) + self.app.stdout.write("="*79 + "\n") + column_names, data = self.take_action( + connection, function_args, function_kwargs) + self.produce_output(cmd_args, column_names, data) + if len(session) > 1: + self.app.stdout.write("\n") + return 0 + +class LmiCheckResult(LmiEndPointCommand, Lister): + __metaclass__ = _CheckResultMetaClass + + def __init__(self, *args, **kwargs): + LmiEndPointCommand.__init__(self, args, kwargs) + Lister.__init__(self, *args, **kwargs) + + @abc.abstractmethod + def check_result(self, options, result): + raise NotImplementedError("check_result must be overriden in subclass") + + def take_action(self, connection, function_args): + try: + res = self.execute(connection, function_args) + return (self.check_result(function_args, res), None) + except Exception as exc: + return (False, exc) + + def process_session(self, session, cmd_args, function_args, + function_kwargs): + self.formatter = self.formatters[cmd_args.formatter] + # first list contain passed hosts, the second one failed ones + results = ([], []) + for connection in session: + passed, error = self.take_action( + connection, *function_args, **function_kwargs) + results[0 if passed else 1].append((connection.hostname, error)) + if not passed and error: + LOG().warn('invocation failed on host "%s": %s', + connection.hostname, error) + if Configuration.get_instance().verbosity >= \ + Configuration.OUTPUT_DEBUG: + self.app.stdout.write('invocation failed on host "%s":' + ' %s\n"' % (connection.hostname, str(error))) + if Configuration.get_instance().verbosity >= \ + Configuration.OUTPUT_INFO: + self.app.stdout.write('Successful runs: %d\n' % len(results[0])) + failed_runs = len(results[1]) + len(session.get_unconnected()) + if failed_runs: + self.app.stdout.write('There were %d unsuccessful runs on hosts:\n' + % failed_runs) + self.formatter = self.formatters['table'] + data = [] + for hostname in session.get_unconnected(): + data.append((hostname, 'failed to connect')) + for hostname, error in results[1]: + if error is None: + error = "failed" + if ( Configuration.get_instance().verbosity + >= Configuration.OUTPUT_INFO + and hasattr(self.check_result, 'expected')): + error = error + (" (%s != %s)" % ( + self.check_result.expected, self._result)) + data.append((hostname, error)) + self.produce_output(cmd_args, ('Name', 'Error'), data) + +def make_list_command(func, + name=None, + columns=None, + verify_func=None, + transform_func=None): + if name is None: + if isinstance(func, basestring): + name = func.split('.')[-1] + else: + name = func.__name__ + if not name.startswith('_'): + name = '_' + name.capitalize() + props = { 'COLUMNS' : columns } + if verify_func: + props['VERIFY'] = verify_func + if transform_func: + props['TRANSFORM'] = transform_func + return LmiLister.__metaclass__(name, (LmiLister, ), props) + +def register_subcommands(command_name, doc_string, command_map): + props = { 'COMMANDS' : command_map + , '__doc__' : doc_string } + return LmiCommandMultiplexer.__metaclass__(command_name, + (LmiCommandMultiplexer, ), props) + diff --git a/lmi/scripts/common/configuration.py b/lmi/scripts/common/configuration.py new file mode 100644 index 0000000..8a2a456 --- /dev/null +++ b/lmi/scripts/common/configuration.py @@ -0,0 +1,91 @@ +# Copyright (C) 2012 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# -*- coding: utf-8 -*- +""" +Module for SoftwareConfiguration class. + +SoftwareConfiguration +--------------------- + +.. autoclass:: SoftwareConfiguration + :members: + +""" + +import os +from lmi.common.BaseConfiguration import BaseConfiguration + +class Configuration(BaseConfiguration): + """ + Configuration class specific to software providers. + OpenLMI configuration file should reside in + /etc/openlmi/scripts/lmi.conf. + """ + + CONFIG_FILE_PATH_TEMPLATE = BaseConfiguration.CONFIG_DIRECTORY_TEMPLATE + \ + "lmi.conf" + USER_CONFIG_FILE_PATH = "~/.lmirc" + + OUTPUT_SILENT = -1 + OUTPUT_WARNING = 0 + OUTPUT_INFO = 1 + OUTPUT_DEBUG = 2 + + def __init__(self, user_config_file_path=USER_CONFIG_FILE_PATH, **kwargs): + """ + :param user_config_file_path: (``str``) Path to the user configuration + options. + """ + self._user_config_file_path = os.path.expanduser(user_config_file_path) + BaseConfiguration.__init__(self, **kwargs) + self._verbosity = self.OUTPUT_WARNING + + @classmethod + def provider_prefix(cls): + return "scripts" + + @classmethod + def default_options(cls): + """ :rtype: (``dict``) Dictionary of default values. """ + defaults = BaseConfiguration.default_options().copy() + defaults["Verbose"] = False + return defaults + + @property + def verbosity(self): + """ Return integer indicating verbosity level of output to console. """ + if self._verbosity is None: + return self.get_safe('Main', 'Verbose', bool, self.OUTPUT_WARNING) + return self._verbosity + + @verbosity.setter + def verbosity(self, level): + """ Allow to set verbosity without modifying configuration values. """ + if not isinstance(level, (long, int)): + raise TypeError("level must be integer") + if level < self.OUTPUT_SILENT: + level = self.OUTPUT_SILENT + elif level > self.OUTPUT_DEBUG: + level = self.OUTPUT_DEBUG + self._verbosity = level + + def load(self): + """ Read additional user configuration file if it exists. """ + BaseConfiguration.load(self) + self.config.read(self._user_config_file_path) + diff --git a/lmi/scripts/common/errors.py b/lmi/scripts/common/errors.py new file mode 100644 index 0000000..43fe55c --- /dev/null +++ b/lmi/scripts/common/errors.py @@ -0,0 +1,72 @@ +# Copyright (C) 2012 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# -*- coding: utf-8 -*- + +class LmiError(Exception): + pass + +class LmiFailed(LmiError): + pass + +class LmiCommandNotFound(LmiError): + def __init__(self, cmd_name): + LmiError.__init__(self, 'failed to find command "%s"' % cmd_name) + +class LmiNoConnections(LmiError): + pass + +class LmiMissingCommands(LmiError): + pass + +class LmiAlreadyExists(LmiError): + pass + +class LmiInvalidName(LmiError): + def __init__(self, name): + LmiError.__init__(self, 'invalid name of egg "%s"' % name) + +class LmiCommandError(LmiError): + def __init__(self, module_name, class_name, msg): + LmiError.__init__(self, 'wrong declaration of command "%s.%s": %s' + % (module_name, class_name, msg)) + +class LmiCommandAlreadyExists(LmiCommandError): + pass + +class LmiCommandInvalidName(LmiCommandError): + def __init__(self, module_name, class_name, cmd_name): + LmiCommandError.__init__(self, module_name, class_name, + 'invalid command name "%s"' % cmd_name) + +class LmiCommandMissingCallable(LmiCommandError): + def __init__(self, module_name, class_name): + LmiCommandError.__init__(self, module_name, class_name, + 'missing CALLABLE property') + +class LmiCommandInvalidProperty(LmiCommandError): + pass + +class LmiCommandImportFailed(LmiCommandInvalidProperty): + def __init__(self, module_name, class_name, callable_prop): + LmiCommandInvalidProperty.__init__(self, module_name, class_name, + 'failed to import callable "%s"' % callable_prop) + +class LmiCommandInvalidCallable(LmiCommandInvalidProperty): + def __init__(self, module_name, class_name, msg): + LmiCommandInvalidProperty.__init__(self, module_name, class_name, msg) + diff --git a/lmi/scripts/common/session.py b/lmi/scripts/common/session.py new file mode 100644 index 0000000..5959b1a --- /dev/null +++ b/lmi/scripts/common/session.py @@ -0,0 +1,131 @@ +# Copyright (C) 2012 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# -*- coding: utf-8 -*- + +from collections import defaultdict +import getpass +import os +import pywbem +import readline + +from lmi.lmi_client_base import LmiBaseClient +from lmi.lmi_client_shell import LmiConnection +from lmi.scripts.common import errors +from lmi.scripts.common import get_logger + +LOG = get_logger(__name__) + +class Session(object): + + def __init__(self, app, hosts, credentials=None): + self._app = app + self._connections = {h: None for h in hosts} + self._credentials = defaultdict(lambda: ('', '')) + if credentials is not None: + if not isinstance(credentials, dict): + raise TypeError("credentials must be a dictionary") + self._credentials.update(credentials) + + def __getitem__(self, hostname): + if self._connections[hostname] is None: + self._connections[hostname] = self._connect( + hostname, interactive=True) + return self._connections[hostname] + + def __len__(self): + return len(self._connections) + + def __iter__(self): + successful_connections = 0 + for h in self._connections: + try: + connection = self[h] + if connection is not None: + yield connection + successful_connections += 1 + except Exception as exc: + LOG().error('failed to make a connection to "%s": %s', + h, exc) + if successful_connections == 0: + raise errors.LmiNoConnections('no successful connection made') + + def _connect(self, hostname, interactive=False): + username, password = self.get_credentials(hostname) + prompt_prefix = '[%s] '%hostname if len(self) > 1 else '' + if not username: + while True: + try: + username = raw_input(prompt_prefix + "username: ") + if username: + break + except EOFError, e: + self._app.stdout.write("\n") + continue + except KeyboardInterrupt, e: + self._app.stdout.write("\n") + return None + if self._app.interactive_mode: + readline.remove_history_item( + readline.get_current_history_length() - 1) + if not password: + try: + password = getpass.getpass(prompt_prefix + 'password: ') + except EOFError, e: + password = "" + LOG().warn('End of File when reading password for "%s"', + hostname) + except KeyboardInterrupt, e: + LOG().warn('failed to get password for host "%s"', hostname) + return None + if self._app.interactive_mode: + readline.remove_history_item( + readline.get_current_history_length() - 1) + # Try to get some non-existing class as a login check + connection = LmiConnection(hostname, username, + password, interactive) + use_exceptions = LmiBaseClient._get_use_exceptions() + try: + LmiBaseClient._set_use_exceptions(True) + connection.root.cimv2.NonExistingClass + except pywbem.cim_operations.CIMError, e: + if e.args[0] == pywbem.cim_constants.CIM_ERR_NOT_FOUND: + return connection + LOG().error('failed to connect to host "%s"', hostname) + if use_exceptions: + raise + return None + except pywbem.cim_http.AuthError, e: + LOG().error('failed to authenticate against host "%s"', + hostname) + return None + finally: + LmiBaseClient._set_use_exceptions(use_exceptions) + LOG().debug('connection to host "%s" successfully created', + hostname) + return connection + + @property + def hostnames(self): + return self._connections.keys() + + def get_credentials(self, hostname): + return self._credentials[hostname] + + def get_unconnected(self): + return [h for h, c in self._connections.items() if c is None] + diff --git a/scripts/lmi b/scripts/lmi new file mode 100755 index 0000000..fb6a014 --- /dev/null +++ b/scripts/lmi @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# Software Management Providers +# +# Copyright (C) 2012-2013 Red Hat, Inc. All rights reserved. +# +# This library 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. +# +# This library 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 this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Authors: Michal Minar <miminar@redhat.com> +# +""" +Client-side command line utility for system management based on OpenLMI. +""" + +import sys + +from lmi.scripts._metacommand import MetaCommand + +if __name__ == '__main__': + sys.exit(MetaCommand().run()) + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0eab0c3 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +PROJECT = 'lmi-scripts' +VERSION = '0.1' + +# Bootstrap installation of Distribute +from setuptools import setup, find_packages + +try: + long_description = open('README.md', 'rt').read() +except IOError: + long_description = '' + +setup( + name=PROJECT, + version=VERSION, + description='Client-side library and command-line client', + long_description=long_description, + author='Michal Minar', + author_email='miminar@redhat.com', + url='https://github.com/openlmi/openlmi-scripts', + download_url='https://github.com/openlmi/openlmi-scripts/tarball/master', + platforms=['Any'], + license="LGPLv2+", + classifiers=[ + 'License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)', + 'Operating System :: POSIX :: Linux', + 'Topic :: System :: Systems Administration', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Intended Audience :: Developers', + 'Environment :: Console', + ], + + install_requires=['distribute', 'cliff'], + + namespace_packages=['lmi', 'lmi.scripts'], + packages=[ + 'lmi.scripts.common', + 'lmi.scripts._metacommand'], + include_package_data=True, + #data_files=[('/etc/openlmi/scripts', ['config/lmi.conf'])], + zip_safe=False, + entry_points={ + 'console_scripts': [ + 'lmi = lmi.scripts._metacommand:main' + ], + 'lmi.scripts.cmd': [], + }, + ) |