From b0a95f51c460cb64db5bde2dd5f72903e96d5c8c Mon Sep 17 00:00:00 2001 From: Andreas Maier Date: Thu, 13 May 2021 06:18:23 +0200 Subject: [PATCH] Added pywbemlistener command Details: * Added a new 'pywbemlistener' command that manages WBEM indication listeners. (see issue #430) * Removed Python 3.4 on Windows from GitHub Actions tests, because this environment does not have the Microsoft Visual C++ 10.0 compiler needed for building the 'psutils' package. Signed-off-by: Andreas Maier --- .github/workflows/test.yml | 14 +- Makefile | 12 + docs/index.rst | 11 +- docs/pywbemlistener/cmdshelp.rst | 257 ++++ docs/pywbemlistener/index.rst | 50 + minimum-constraints.txt | 5 +- pywbemtools/pywbemlistener/__init__.py | 28 + pywbemtools/pywbemlistener/_cmd_listener.py | 1178 ++++++++++++++++++ pywbemtools/pywbemlistener/_config.py | 37 + pywbemtools/pywbemlistener/_context_obj.py | 175 +++ pywbemtools/pywbemlistener/pywbemlistener.py | 145 +++ requirements.txt | 6 +- setup.py | 1 + 13 files changed, 1910 insertions(+), 9 deletions(-) create mode 100644 docs/pywbemlistener/cmdshelp.rst create mode 100644 docs/pywbemlistener/index.rst create mode 100644 pywbemtools/pywbemlistener/__init__.py create mode 100644 pywbemtools/pywbemlistener/_cmd_listener.py create mode 100644 pywbemtools/pywbemlistener/_config.py create mode 100644 pywbemtools/pywbemlistener/_context_obj.py create mode 100644 pywbemtools/pywbemlistener/pywbemlistener.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86a4028fe..b6d93b0f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,6 +60,16 @@ jobs: \"os\": \"macos-latest\", \ \"python-version\": \"3.4\", \ \"package_level\": \"latest\" \ + }, \ + { \ + \"os\": \"windows-latest\", \ + \"python-version\": \"3.4\", \ + \"package_level\": \"minimum\" \ + }, \ + { \ + \"os\": \"windows-latest\", \ + \"python-version\": \"3.4\", \ + \"package_level\": \"latest\" \ } \ ], \ \"include\": [ \ @@ -113,12 +123,12 @@ jobs: }, \ { \ \"os\": \"windows-latest\", \ - \"python-version\": \"3.4\", \ + \"python-version\": \"3.5\", \ \"package_level\": \"minimum\" \ }, \ { \ \"os\": \"windows-latest\", \ - \"python-version\": \"3.4\", \ + \"python-version\": \"3.5\", \ \"package_level\": \"latest\" \ } \ ] \ diff --git a/Makefile b/Makefile index 906ac85e0..f5b3935a2 100644 --- a/Makefile +++ b/Makefile @@ -144,6 +144,7 @@ package_name := pywbemtools # Names of the commands in the package. The command names are used in # certain directory names. command1 := pywbemcli +command2 := pywbemlistener # Determine if coverage details report generated # The variable can be passed in as either an environment variable or @@ -207,6 +208,7 @@ doc_opts := -v -d $(doc_build_dir)/doctrees -c $(doc_conf_dir) -D latex_elements # File names of automatically generated utility help message text output doc_utility_help_files := \ $(doc_conf_dir)/$(command1)/cmdshelp.rst \ + $(doc_conf_dir)/$(command2)/cmdshelp.rst \ # Dependents for Sphinx documentation build doc_dependent_files := \ @@ -450,6 +452,7 @@ install: install_$(pymn).done install_$(pymn).done: Makefile install_basic_$(pymn).done install_$(package_name)_$(pymn).done -$(call RM_FUNC,$@) $(PYTHON_CMD) -c "import $(package_name).$(command1)" + $(PYTHON_CMD) -c "import $(package_name).$(command2)" echo "done" >$@ # The following target is supposed to install any prerequisite OS-level packages @@ -708,3 +711,12 @@ else PYWBEMTOOLS_TERMWIDTH=$(pywbemtools_termwidth) $(PYTHON_CMD) -u tools/click_help_capture.py $(command1) >$@ endif @echo 'Done: Created help command info for cmds: $@' + +$(doc_conf_dir)/$(command2)/cmdshelp.rst: $(package_name)/$(command2)/$(command2).py install_$(pymn).done tools/click_help_capture.py $(doc_help_source_files) + @echo 'makefile: Creating $@ for documentation' +ifeq ($(PLATFORM),Windows_native) + cmd /c "set PYWBEMTOOLS_TERMWIDTH=$(pywbemtools_termwidth) & $(PYTHON_CMD) -u tools/click_help_capture.py $(command2) >$@" +else + PYWBEMTOOLS_TERMWIDTH=$(pywbemtools_termwidth) $(PYTHON_CMD) -u tools/click_help_capture.py $(command2) >$@ +endif + @echo 'Done: Created help command info for cmds: $@' diff --git a/docs/index.rst b/docs/index.rst index 1bdc3e2b1..526e7c438 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,11 @@ -Pywbemtools - A set of tools using pywbem -***************************************** +Pywbemtools - WBEM command line tools +************************************* -This package contains tools that provide a command line interface to interact -with WBEM servers +This package contains WBEM command line tools: + +* pywbemcli - a WBEM client CLI for interacting with WBEM servers +* pywbemlistener - a tool that runs and manages WBEM listeners The pywbemtools github page is: `https://github.com/pywbem/pywbemtools `_. @@ -15,6 +17,7 @@ The pywbemtools github page is: `https://github.com/pywbem/pywbemtools = '3.5' yamlloader==0.5.5 mock==3.0.0 toposort==1.6 +psutil==5.5.0 # Virtualenv virtualenv==14.0.0; python_version < '3.5' diff --git a/pywbemtools/pywbemlistener/__init__.py b/pywbemtools/pywbemlistener/__init__.py new file mode 100644 index 000000000..de1d6bda0 --- /dev/null +++ b/pywbemtools/pywbemlistener/__init__.py @@ -0,0 +1,28 @@ +# (C) Copyright 2021 Inova Development Inc. +# All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Pywbemlistener is a WBEM listener command that uses pywbem as its communication +interface. It is written in pure Python and supports Python 2 and Python 3. +""" + +from __future__ import absolute_import, print_function + +from .._version import __version__ # noqa: F401 +from .._utils import * # noqa: F403,F401 +from .._click_extensions import * # noqa: F403,F401 +from ._context_obj import * # noqa: F403,F401 +from ._cmd_listener import * # noqa: F403,F401 +from .pywbemlistener import * # noqa: F403,F401 diff --git a/pywbemtools/pywbemlistener/_cmd_listener.py b/pywbemtools/pywbemlistener/_cmd_listener.py new file mode 100644 index 000000000..de4640daa --- /dev/null +++ b/pywbemtools/pywbemlistener/_cmd_listener.py @@ -0,0 +1,1178 @@ +# (C) Copyright 2021 Inova Development Inc. +# All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Click definitions for the pywbemlistener commands. + +NOTE: Commands are ordered in help display by their order in this file. +""" + +from __future__ import absolute_import, print_function + +import sys +import os +import subprocess +import signal +import atexit +import threading +import argparse +import importlib +from time import sleep +from datetime import datetime +import click +import psutil +import six +from six.moves import shlex_quote +from nocasedict import NocaseDict + +from pywbem import WBEMListener, ListenerError + +from .._click_extensions import PywbemtoolsCommand, CMD_OPTS_TXT +from .._options import add_options, help_option, validate_required_arg +from .._output_formatting import format_table + +from . import _config +from .pywbemlistener import cli + +# Signals used for having the 'run' command signal startup completion +# back to its parent 'start' process. +# The default handlers for these signals are replaced. +# The success signal can be any signal that can be handled. +# In order to handle keyboard interrupts during password prompt correctly, +# the failure signal must be SIGINT. +try: + # Unix/Linux/macOS + # pylint: disable=no-member + SIGNAL_RUN_STARTUP_SUCCESS = signal.SIGUSR1 +except AttributeError: + # native Windows + # pylint: disable=no-member + SIGNAL_RUN_STARTUP_SUCCESS = signal.SIGBREAK +SIGNAL_RUN_STARTUP_FAILURE = signal.SIGINT + +# Status and condition used to communicate the startup completion status of the +# 'run' process between the signal handlers and other functions in the +# 'start' process. +RUN_STARTUP_STATUS = None +RUN_STARTUP_COND = threading.Condition() + +# Timeout in seconds for the 'run' command starting up. This timeout +# also ends a possible prompt for the password of the private key file. +RUN_STARTUP_TIMEOUT = 60 + +DEFAULT_LISTENER_PORT = 25989 +DEFAULT_LISTENER_PROTOCOL = 'https' +DEFAULT_INDI_FORMAT = '{dt} {h} {c} {p}' + +LISTEN_OPTIONS = [ + click.option('--port', type=int, metavar='PORT', + required=False, default=DEFAULT_LISTENER_PORT, + help=u'The port number the listener will open to receive ' + 'indications. This can be any available port. ' + 'Default: {}'.format(DEFAULT_LISTENER_PORT)), + click.option('--protocol', type=click.Choice(['http', 'https']), + metavar='PROTOCOL', + required=False, default=DEFAULT_LISTENER_PROTOCOL, + help=u'The protocol used by the listener (http, https). ' + 'Default: {}'.format(DEFAULT_LISTENER_PROTOCOL)), + click.option('-c', '--certfile', type=str, metavar='FILE', + required=False, default=None, + envvar=_config.PYWBEMLISTENER_CERTFILE_ENVVAR, + help=u'Path name of a PEM file containing the certificate ' + 'that will be presented as a server certificate during ' + 'SSL/TLS handshake. Required when using https. ' + 'The file may in addition contain the private key of the ' + 'certificate. ' + 'Default: EnvVar {ev}, or no certificate file.'. + format(ev=_config.PYWBEMLISTENER_CERTFILE_ENVVAR)), + click.option('-k', '--keyfile', type=str, metavar='FILE', + required=False, default=None, + envvar=_config.PYWBEMLISTENER_KEYFILE_ENVVAR, + help=u'Path name of a PEM file containing the private key ' + 'of the server certificate. ' + 'Required when using https and when the certificate file ' + 'does not contain the private key. ' + 'Default: EnvVar {ev}, or no key file.'. + format(ev=_config.PYWBEMLISTENER_KEYFILE_ENVVAR)), + click.option('--indi-call', type=str, metavar='MODULE.FUNCTION', + required=False, default=None, + help=u'Call a Python function for each received indication. ' + 'Invoke with --help-call for details on the function ' + 'interface. ' + 'Default: No function is called.'), + click.option('--indi-display', is_flag=True, + required=False, default=False, + help=u'Display received indications on stdout. ' + 'The format can be modified using the --indi-format option. ' + 'Default: Not displayed.'), + click.option('--indi-file', type=str, metavar='FILE', + required=False, default=None, + help=u'Append received indications to a file. ' + 'The format can be modified using the --indi-format option. ' + 'Default: Not appended.'), + click.option('--indi-format', type=str, metavar='FORMAT', + required=False, default=DEFAULT_INDI_FORMAT, + help=u'Sets the format to be used when displaying received ' + 'indications. ' + 'Invoke with --help-format for details on the format ' + 'specification. ' + 'Default: "{dif}".'.format(dif=DEFAULT_INDI_FORMAT)), + click.option('--help-format', is_flag=True, + required=False, default=False, is_eager=True, + help=u'Show help message for the format specification used ' + 'with the --indi-format option.'), + click.option('--help-call', is_flag=True, + required=False, default=False, is_eager=True, + help=u'Show help message for calling a Python function for ' + 'each received indication when using the --indi-call option.'), +] + + +class ListenerProperties(object): + """ + The properties of a running named listener. + """ + + def __init__(self, name, port, protocol, certfile, keyfile, + indi_call, indi_display, indi_file, indi_format, + logfile, pid, created): + self._name = name + self._port = port + self._protocol = protocol + self._certfile = certfile + self._keyfile = keyfile + self._indi_call = indi_call + self._indi_display = indi_display + self._indi_file = indi_file + self._indi_format = indi_format + self._logfile = logfile + self._pid = pid + self._created = created + + def show_row(self): + """Return a tuple of the properties for 'show' command""" + return ( + self.name, + str(self.port), + self.protocol, + self.certfile, + self.keyfile, + self.indi_call, + self.indi_display, + self.indi_file, + self.logfile, + str(self.pid), + self.created.strftime("%Y-%m-%d %H:%M:%S"), + ) + + @staticmethod + def show_headers(): + """Return a tuple of the header labels for 'show' command""" + return ( + 'Name', + 'Port', + 'Protocol', + 'Certificate file', + 'Key file', + 'Indication call', + 'Indication display', + 'Indication file', + 'Log file', + 'PID', + 'Created', + ) + + def list_row(self): + """Return a tuple of the properties for 'list' command""" + return ( + self.name, + str(self.port), + self.protocol, + str(self.pid), + self.created.strftime("%Y-%m-%d %H:%M:%S"), + ) + + @staticmethod + def list_headers(): + """Return a tuple of the header labels for 'list' command""" + return ( + 'Name', + 'Port', + 'Protocol', + 'PID', + 'Created', + ) + + @property + def name(self): + """string: Name of the listener""" + return self._name + + @property + def port(self): + """int: Port number of the listener""" + return self._port + + @property + def protocol(self): + """string: Protocol of the listener""" + return self._protocol + + @property + def certfile(self): + """string: Path name of certificate file of the listener""" + return self._certfile + + @property + def keyfile(self): + """string: Path name of key file of the listener""" + return self._keyfile + + @property + def indi_call(self): + """string: Call function MODULE.FUNCTION for each received indication""" + return self._indi_call + + @property + def indi_display(self): + """bool: Display each received indication in a format""" + return self._indi_display + + @property + def indi_file(self): + """string: Append each received indication to a file in a format""" + return self._indi_file + + @property + def indi_format(self): + """string: Set format of indication""" + return self._indi_format + + @property + def logfile(self): + """string: Path name of log file""" + return self._logfile + + @property + def pid(self): + """int: Process ID of the listener""" + return self._pid + + @property + def created(self): + """datetime: Point in time when the listener process was created""" + return self._created + + +@cli.command('run', cls=PywbemtoolsCommand, options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=False) +@add_options(LISTEN_OPTIONS) +@add_options(help_option) +@click.pass_obj +def listener_run(context, name, **options): + """ + Run as a named WBEM indication listener. + + Run this command as a named WBEM indication listener until it gets + terminated, e.g. by a keyboard interrupt, break signal (e.g. kill), or the + `pywbemlistener stop` command. + + A listener with that name must not be running, otherwise the command fails. + + Examples: + + pywbemlistener run lis1 + """ + if show_help_options(options): + return + validate_required_arg(name, 'NAME') + context.execute_cmd(lambda: cmd_listener_run(context, name, options)) + + +@cli.command('start', cls=PywbemtoolsCommand, options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=False) +@add_options(LISTEN_OPTIONS) +@add_options(help_option) +@click.pass_obj +def listener_start(context, name, **options): + """ + Start a named WBEM indication listener in the background. + + A listener with that name must not be running, otherwise the command fails. + + A listener is identified by its hostname or IP address and a port number. + It can be started with any free port. + + Examples: + + pywbemlistener start lis1 + """ + if show_help_options(options): + return + validate_required_arg(name, 'NAME') + context.execute_cmd(lambda: cmd_listener_start(context, name, options)) + + +@cli.command('stop', cls=PywbemtoolsCommand, options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=True) +@add_options(help_option) +@click.pass_obj +def listener_stop(context, name): + """ + Stop a named WBEM indication listener. + + The listener will shut down gracefully. + + A listener with that name must be running, otherwise the command fails. + + Examples: + + pywbemlistener stop lis1 + """ + context.execute_cmd(lambda: cmd_listener_stop(context, name)) + + +@cli.command('show', cls=PywbemtoolsCommand, options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=True) +@add_options(help_option) +@click.pass_obj +def listener_show(context, name): + """ + Show a named WBEM indication listener. + + A listener with that name must be running, otherwise the command fails. + + Examples: + + pywbemlistener stop lis1 + """ + context.execute_cmd(lambda: cmd_listener_show(context, name)) + + +@cli.command('list', cls=PywbemtoolsCommand, options_metavar=CMD_OPTS_TXT) +@add_options(help_option) +@click.pass_obj +def listener_list(context): + """ + List the currently running named WBEM indication listeners. + + This is done by listing the currently running `pywbemlistener run` + commands. + """ + context.execute_cmd(lambda: cmd_listener_list(context)) + + +################################################################ +# +# Common methods for The action functions for the listener click group +# +############################################################### + + +def get_logfile(logdir, name): + """ + Return path name of run log file, or None if no log directory is specified. + + Parameters: + logdir (string): Path name of log directors, or None. + name (string): Listener name. + """ + if logdir is None: + return None + return os.path.join(logdir, 'pywbemlistener_{}.log'.format(name)) + + +def get_listeners(name=None): + """ + List the running listener processes, or the running listener process(es) + with the specified name. + + Note that in case of the 'run' command, it is possible that this + function is called with a name and finds two listener processes with that + name: A previosly started one, and the one that is about to run now. + Both will be returned so this situation can be handled by the caller. + + Returns: + list of ListenerProperties + """ + ret = [] + for p in psutil.process_iter(): + try: + cmdline = p.cmdline() + except (psutil.AccessDenied, psutil.ZombieProcess): + # Ignore processes we cannot access + continue + for i, item in enumerate(cmdline): + if item.endswith('pywbemlistener'): + listener_index = i + break + else: + # Ignore processes that are not 'pywbemlistener' + continue + listener_args = cmdline[listener_index + 1:] # After 'pywbemlistener' + args = parse_listener_args(listener_args) + if args: + if name is None or args.name == name: + logfile = get_logfile(args.logdir, args.name) + lis = ListenerProperties( + name=args.name, port=args.port, protocol=args.protocol, + certfile=args.certfile, keyfile=args.keyfile, + indi_call=args.indi_call, indi_display=args.indi_display, + indi_file=args.indi_file, indi_format=args.indi_format, + logfile=logfile, + pid=p.pid, created=datetime.fromtimestamp(p.create_time())) + ret.append(lis) + return ret + + +def is_parent_start(): + """ + Determine whether the parent process is a 'start' command, and + return its PID if so. Otherwise, return None. + + This is used by the 'run' command to find out whether it is + executed directly by a user, vs. launched by the 'start' command, + so it can signal startup completion to the 'start' command. + + Returns: + int: PID of parent process, if it is 'start', otherwise None. + """ + ppid = os.getppid() + pps = psutil.Process(ppid) + + try: + cmdline = pps.cmdline() + except (psutil.AccessDenied, psutil.ZombieProcess): + # Ignore processes we cannot access + return None + + seen_pywbemlistener = False + for item in cmdline: + if item.endswith('pywbemlistener'): + seen_pywbemlistener = True + continue + if seen_pywbemlistener and item == 'start': + break + else: + # Ignore processes that are not 'pywbemlistener [opts] start' + return None + + return ppid + + +def prepare_startup_completion(): + """ + In the 'start' command, prepare for a later use of + wait_startup_completion() by setting up the necessary signal handlers. + """ + signal.signal(SIGNAL_RUN_STARTUP_SUCCESS, success_signal_handler) + signal.signal(SIGNAL_RUN_STARTUP_FAILURE, failure_signal_handler) + + +def success_signal_handler(sig, frame): + # pylint: disable=unused-argument + """ + Signal handler in 'start' process for the signal indicating + success of startup completion of the 'run' child process. + """ + # pylint: disable=global-statement + global RUN_STARTUP_STATUS, RUN_STARTUP_COND + + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Handling success signal ({}) from run process". + format(sig)) + + RUN_STARTUP_STATUS = 'success' + with RUN_STARTUP_COND: + RUN_STARTUP_COND.notify() + + +def failure_signal_handler(sig, frame): + # pylint: disable=unused-argument + """ + Signal handler in 'start' process for the signal indicating + failure of startup completion of the 'run' child process. + """ + # pylint: disable=global-statement + global RUN_STARTUP_STATUS, RUN_STARTUP_COND + + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Handling failure signal ({}) from run process". + format(sig)) + + RUN_STARTUP_STATUS = 'failure' + with RUN_STARTUP_COND: + RUN_STARTUP_COND.notify() + + +def wait_startup_completion(child_pid): + """ + In the 'start' command, wait for the 'run' child process + to either successfully complete its startup or to fail its startup. + + Returns: + int: Return code indicating whether the child started up successfully (0) + or failed its startup (1). + """ + # pylint: disable=global-statement + global RUN_STARTUP_STATUS, RUN_STARTUP_COND + + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Waiting for run process {} to complete startup". + format(child_pid)) + + RUN_STARTUP_STATUS = 'failure' + with RUN_STARTUP_COND: + rc = RUN_STARTUP_COND.wait(RUN_STARTUP_TIMEOUT) + + # Before Python 3.2, wait() always returns None. Since 3.2, it returns + # a boolean indicating whether the timeout expired (False) or the + # condition was triggered (True). + if rc is None or rc is True: + status = RUN_STARTUP_STATUS + else: + # Only since Python 3.2 + status = 'timeout' + + if status == 'success': + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Startup of run process {} succeeded". + format(child_pid)) + return 0 + + if status == 'timeout': + click.echo("Timeout") + + # The 'run' child process may still be running, or already a + # zombie, or no longer exist. If it still is running, the likely cause is + # that it was in a password prompt for the keyfile password that was not + # entered. + + sleep(0.5) # Give it some time to finish by itself before we clean it up + + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Startup of run process {} failed". + format(child_pid)) + child_exists = False + try: + child_ps = psutil.Process(child_pid) + child_status = child_ps.status() + if child_status != psutil.STATUS_ZOMBIE: + child_exists = True + except (psutil.NoSuchProcess, psutil.ZombieProcess): + # No need to clean up anything in these cases. + pass + + if child_exists: + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Cleaning up run process {} and status {}". + format(child_pid, child_status)) + try: + child_ps.terminate() + child_ps.wait() + except (IOError, OSError) as exc: + raise click.ClickException( + "Cannot clean up 'run' child process with PID {}: {}: {}". + format(child_pid, type(exc), exc)) + return 1 + + +def run_exit_handler(start_pid, log_fp): + """ + Exit handler that gets etablished for the 'run' command. + + This exit handler signals a failed startup of the 'run' command + to the 'start' process, if it still exists. If the 'start' + process no longer exists, this means that the startup of the 'run' + command succeeded earlier, and it is now terminated by some means. + + In addition, it closes the log_fp log file. + """ + if _config.VERBOSE_PROCESSES_ENABLED: + print("Run process: Exit handler sends failure signal ({}) to start " + "process {}". + format(SIGNAL_RUN_STARTUP_FAILURE, start_pid)) + try: + os.kill(start_pid, SIGNAL_RUN_STARTUP_FAILURE) # Sends the signal + except OSError: + # The original start parent no longer exists -> the earlier startup + # succeeded. + pass + + if log_fp: + print("Closing 'run' output log file at {}".format(datetime.now())) + log_fp.close() + + +class DisplayDict(NocaseDict): + # pylint: disable=too-many-ancestors + """ + Dictionary with a string representation that uses name=value format. + """ + + def __str__(self): + items = ['{}={!r}'.format(k, v) for k, v in self.items()] + return ' '.join(items) + + +def format_indication(indication, host, indi_format=None): + """ + Return a string that contains the indication formatted according to the + given format. + """ + dt = datetime.now() + try: + dt = dt.astimezone() + except TypeError: + # Below Python 3.6, it cannot determine the local timezone, and its + # tzinfo argument is required. + pass + p_dict = DisplayDict() + for pn, p in indication.properties.items(): + p_dict[pn] = p.value + format_kwargs = dict( + dt=dt, + dt_tzname=dt.tzname() or '', + h=host, + i=indication, + c=indication.classname, + p=p_dict, + ) + if indi_format is None: + indi_format = DEFAULT_INDI_FORMAT + indi_str = indi_format.format(**format_kwargs) + return indi_str + + +def show_help_format(): + """ + Display help for the format specification used with the --indi-format + option. + """ + print(""" +Help for the format specification with option: --indi-format FORMAT + +FORMAT is a new-style format string that can use the following keyword args: + +* 'dt' - datetime object of the time the listener received the indication, in + local time. The object is timezone-aware on Python 3.6 or higher. +* 'dt_tzname' - timezone name of the datetime object if timezone-aware, else + the empty string. +* 'h' - Host name or IP address of the host that sent the indication +* 'i' - pywbem.CIMInstance object with the indication instance +* 'c' - CIM classname of the indication instance +* 'p' - Case-insensitive dictionary of the indication properties, displayed + as blank-separated name=value items + +Examples: + +--indi-format '{dt} {c} {p}' +2021-05-13 17:51:05.831117+02:00 CIM_AlertIndication Severity='high' \ +SequenceNumber='0' + +--indi-format '{dt} {h} {c}: Severity={p[severity]}' +2021-05-13 17:51:05.831117+02:00 127.0.0.1 CIM_AlertIndication: Severity=high +""") + + +def show_help_call(): + """ + Display help for calling a Python function for each received indication + when using the --indi-call option. + """ + print(""" +Help for calling a Python function with option: --indi-call MODULE.FUNCTION + +MODULE must be a module name or a dotted package name in the module search +path, e.g. 'mymodule' or 'mypackage.mymodule'. + +The current directory is added to the front of the Python module search path, +if needed. Thus, the module can be a single module file in the current +directory, for example: + + ./mymodule.py + +or a module in a package in the current directory, for example: + + ./mypackage/__init__.py + ./mypackage/mymodule.py + +FUNCTION must be a function in that module with the following interface: + + def func(indication, host) + +Parameters: + +* 'indication' is a 'pywbem.CIMInstance' object representing the CIM indication + that has been received. Its 'path' attribute is None. + +* 'host' is a string with the host name or IP address of the indication sender + (typically a WBEM server). + +The return value of the function will be ignored. + +Exceptions raised when importing the module cause the 'pywbemlistener run' +command to terminate with an error. Exceptions raised by the function when +it is called cause an error message to be displayed. +""") + + +def show_help_options(options): + """ + Show the help messages for the --help-... options, if specified. + + Returns: + bool: Indicates whether help was shown. + """ + ret = False + if options['help_call']: + show_help_call() + ret = True + if options['help_format']: + show_help_format() + ret = True + return ret + + +class SilentArgumentParser(argparse.ArgumentParser): + """ + argparse.ArgumentParser subclass that silences any errors and exit and + just raises them as SystemExit. + """ + + def error(self, message=None): + """Called for usage errors detected by the parser""" + raise SystemExit(2) + + def exit(self, status=0, message=None): + """Not sure when this is called""" + raise SystemExit(status) + + +def parse_listener_args(listener_args): + """ + Parse the command line arguments of a process. If it is a listener process + return its parsed arguments (after the 'pywbemlistener' command); otherwise + return None. + """ + + parser = SilentArgumentParser() + + # Note: The following options must ne in sync with the Click general options + parser.add_argument('--output-format', '-o', type=str, default=None) + parser.add_argument('--logdir', '-l', type=str, default=None) + parser.add_argument('--verbose', '-v', action='count', default=0) + parser.add_argument('--pdb', action='store_true', default=False) + parser.add_argument('--warn', default=False, action='store_true') + + parser.add_argument('run', type=str, default=None) + parser.add_argument('name', type=str, default=None) + + # Note: The following options must ne in sync with the Click command options + parser.add_argument('--port', type=int, default=DEFAULT_LISTENER_PORT) + parser.add_argument('--protocol', type=str, + default=DEFAULT_LISTENER_PROTOCOL) + parser.add_argument('--certfile', type=str, default=None) + parser.add_argument('--keyfile', type=str, default=None) + parser.add_argument('--indi-call', type=str, default=None) + parser.add_argument('--indi-display', action='store_true', default=False) + parser.add_argument('--indi-file', type=str, default=None) + parser.add_argument('--indi-format', type=str, default=None) + + try: + parsed_args = parser.parse_args(listener_args) + except SystemExit: + # Invalid arguments + return None + + if parsed_args.run != 'run': + return None + + return parsed_args + + +def run_term_signal_handler(sig, frame): + # pylint: disable=unused-argument + """ + Signal handler for the 'run' command that gets called for the + SIGTERM signal, i.e. when the 'run' process gets terminated by + some means. + + Ths handler ensures that the main loop of the the 'run' command + gets control and can gracefully stop the listener. + """ + if _config.VERBOSE_PROCESSES_ENABLED: + print("Run process: Received termination signal ({})".format(sig)) + raise SystemExit(1) + + +def transpose(headers, rows): + """ + Return transposed headers and rows (i.e. switch columns and rows). + """ + ret_headers = ['Attribute', 'Value'] + ret_rows = [] + for header in headers: + ret_row = [header] + ret_rows.append(ret_row) + for row in rows: + assert len(headers) == len(row) + for i, col in enumerate(row): + ret_rows[i].append(col) + return ret_headers, ret_rows + + +def display_show_listener(listener, table_format): + """ + Display a listener for the 'show' command. + """ + headers = ListenerProperties.show_headers() + rows = [listener.show_row()] + headers, rows = transpose(headers, rows) + table = format_table( + rows, headers, table_format=table_format, + sort_columns=None, hide_empty_cols=None, float_fmt=None) + click.echo(table) + + +def display_list_listeners(listeners, table_format): + """ + Display listeners for the 'list' command. + """ + headers = ListenerProperties.list_headers() + rows = [] + for lis in listeners: + rows.append(lis.list_row()) + table = format_table( + rows, headers, table_format=table_format, + sort_columns=None, hide_empty_cols=None, float_fmt=None) + click.echo(table) + + +################################################################ +# +# Common methods for The action functions for the listener click group +# +############################################################### + + +def cmd_listener_run(context, name, options): + """ + Run as a listener. + """ + port = options['port'] + protocol = options['protocol'] + host = 'localhost' + + logfile = get_logfile(context.logdir, name) + if logfile: + print("Logging 'run' output to: {}".format(logfile)) + log_fp = open(logfile, 'a') # pylint: disable=consider-using-with + # The log file will be closed in run_exit_handler() + sys.stdout = log_fp + sys.stderr = log_fp + print("Opening 'run' output log file at {}".format(datetime.now())) + else: + log_fp = None + + # Register a termination signal handler that causes the loop further down + # to get control via SystemExit. + signal.signal(signal.SIGTERM, run_term_signal_handler) + + # If this run process is started from a start process, register a Python + # atexit handler to make sure we get control when Click exceptions terminate + # the process. The exit handler signals a failed startup to the start + # process. + start_pid = is_parent_start() + if start_pid: + atexit.register(run_exit_handler, start_pid, log_fp) + + listeners = get_listeners(name) + if len(listeners) > 1: # This upcoming listener and a previous one + lis = listeners[0] + url = '{}://{}:{}'.format(lis.protocol, host, lis.port) + raise click.ClickException( + "Listener {} already running at {}".format(name, url)) + + if protocol == 'http': + http_port = port + https_port = None + certfile = None + keyfile = None + else: + assert protocol == 'https' + https_port = port + http_port = None + certfile = options['certfile'] + keyfile = options['keyfile'] or certfile + url = '{}://{}:{}'.format(protocol, host, port) + + context.spinner_stop() + + try: + listener = WBEMListener( + host=host, http_port=http_port, https_port=https_port, + certfile=certfile, keyfile=keyfile) + except ValueError as exc: + raise click.ClickException( + "Cannot create listener {}: {}".format(name, exc)) + try: + listener.start() + except (IOError, OSError, ListenerError) as exc: + raise click.ClickException( + "Cannot start listener {}: {}".format(name, exc)) + + indi_call = options['indi_call'] + indi_file = options['indi_file'] + indi_display = options['indi_display'] + indi_format = options['indi_format'] or DEFAULT_INDI_FORMAT + + def display_func(indication, host): + """ + Indication callback function that displays the indication on stdout + using the specified format. + """ + try: + display_str = format_indication(indication, host, indi_format) + except Exception as exc: # pylint: disable=broad-except + display_str = ("Error: Cannot format indication using format {!r}: " + "{}: {}". + format(indi_format, exc.__class__.__name__, exc)) + print(display_str) + sys.stdout.flush() + + def file_func(indication, host): + """ + Indication callback function that appends the indication to a file + using the specified format. + """ + try: + display_str = format_indication(indication, host, indi_format) + except Exception as exc: # pylint: disable=broad-except + display_str = ("Error: Cannot format indication using format {!r}: " + "{}: {}". + format(indi_format, exc.__class__.__name__, exc)) + with open(indi_file, 'a') as fp: + fp.write(display_str) + fp.write('\n') + + if indi_call: + mod_func = indi_call.rsplit('.', maxsplit=1) + if len(mod_func) < 2: + raise click.ClickException( + "The --indi-call option does not specify MODULE.FUNCTION: {}". + format(indi_call)) + mod_name = mod_func[0] + func_name = mod_func[1] + + curdir = os.getcwd() + if sys.path[0] != curdir: + if context.verbose >= _config.VERBOSE_SETTINGS: + click.echo("Inserting current directory into front of Python " + "module search path: {}".format(curdir)) + sys.path.insert(0, curdir) + + try: + module = importlib.import_module(mod_name) + except ImportError as exc: + raise click.ClickException( + "Cannot import module {}: {}". + format(mod_name, exc)) + except SyntaxError as exc: + raise click.ClickException( + "Cannot import module {}: SyntaxError: {}". + format(mod_name, exc)) + try: + func = getattr(module, func_name) + except AttributeError: + raise click.ClickException( + "Function {}() not found in module {}". + format(func_name, mod_name)) + listener.add_callback(func) + if context.verbose >= _config.VERBOSE_SETTINGS: + click.echo("Added indication handler for calling function {}() " + "in module {}".format(func_name, mod_name)) + + if indi_display: + listener.add_callback(display_func) + if context.verbose >= _config.VERBOSE_SETTINGS: + click.echo("Added indication handler for displaying on stdout " + "with format {!r}".format(indi_format)) + + if indi_file: + listener.add_callback(file_func) + if context.verbose >= _config.VERBOSE_SETTINGS: + click.echo("Added indication handler for appending to file {} " + "with format {!r}".format(indi_file, indi_format)) + + click.echo("Running listener {} at {}".format(name, url)) + + # Signal successful startup completion to the parent 'start' + # process. + start_pid = is_parent_start() + if start_pid: + if _config.VERBOSE_PROCESSES_ENABLED: + print("Run process: Sending success signal ({}) to " + "start process {}". + format(SIGNAL_RUN_STARTUP_SUCCESS, start_pid)) + os.kill(start_pid, SIGNAL_RUN_STARTUP_SUCCESS) # Sends the signal + + try: + while True: + sleep(60) + except (KeyboardInterrupt, SystemExit) as exc: + if _config.VERBOSE_PROCESSES_ENABLED: + print("Run process: Caught exception {}: {}". + format(type(exc), exc)) + # SystemExit occurs only due to being raised in the signal handler + # that was registered. + listener.stop() + click.echo("Shut down listener {} running at {}".format(name, url)) + + # Signal failure to the parent 'start' process if it still + # exists. + start_pid = is_parent_start() + if start_pid: + if _config.VERBOSE_PROCESSES_ENABLED: + print("Run process: Sending failure signal ({}) to start " + "process {}". + format(SIGNAL_RUN_STARTUP_FAILURE, start_pid)) + os.kill(start_pid, SIGNAL_RUN_STARTUP_FAILURE) # Sends the signal + + +def cmd_listener_start(context, name, options): + """ + Start a named listener. + """ + port = options['port'] + protocol = options['protocol'] + certfile = options['certfile'] + keyfile = options['keyfile'] + indi_call = options['indi_call'] + indi_display = options['indi_display'] + indi_file = options['indi_file'] + indi_format = options['indi_format'] + host = 'localhost' + + listeners = get_listeners(name) + if listeners: + lis = listeners[0] + url = '{}://{}:{}'.format(lis.protocol, host, lis.port) + raise click.ClickException( + "Listener {} already running at {}".format(name, url)) + + run_args = [ + 'pywbemlistener', + ] + if context.verbose: + run_args.append('-{}'.format('v' * context.verbose)) + if context.logdir: + run_args.extend(['--logdir', shlex_quote(context.logdir)]) + run_args.extend([ + 'run', name, + '--port', str(port), + '--protocol', shlex_quote(protocol), + ]) + if certfile: + run_args.extend(['--certfile', shlex_quote(certfile)]) + if keyfile: + run_args.extend(['--keyfile', shlex_quote(keyfile)]) + if indi_call: + run_args.extend(['--indi-call', shlex_quote(indi_call)]) + if indi_display: + run_args.extend(['--indi-display']) + if indi_file: + run_args.extend(['--indi-file', shlex_quote(indi_file)]) + if indi_format: + run_args.extend(['--indi-format', shlex_quote(indi_format)]) + + # While we stop the spinner of this 'start' command, the spinner of the + # invoked 'run' command will still be spinning until its startup/exit + # completion is detected. When the output of the 'start'command is + # redirected, the spinner of the child process will also be suppressed, + # so this behavior is consistent and should be fine. + context.spinner_stop() + + prepare_startup_completion() + + if six.PY2: + popen_kwargs = {} + else: + popen_kwargs = dict(start_new_session=True) + + if _config.VERBOSE_PROCESSES_ENABLED: + print("Start process: Starting run process as: {}". + format(' '.join(run_args))) + + # pylint: disable=consider-using-with + p = subprocess.Popen(run_args, **popen_kwargs) + + # Wait for startup completion or for error exit + try: + rc = wait_startup_completion(p.pid) + except KeyboardInterrupt: + raise click.ClickException( + "Keyboard interrupt while waiting for listener to start up") + if rc != 0: + # Error has already been displayed + raise SystemExit(rc) + + # A message about the successful startup has already been displayed by + # the child process. + + +def cmd_listener_stop(context, name): + """ + Stop a named listener. + """ + listeners = get_listeners(name) + if not listeners: + raise click.ClickException( + "No running listener found with name {}".format(name)) + listener = listeners[0] + + context.spinner_stop() + + p = psutil.Process(listener.pid) + p.terminate() + p.wait() + + # A message about the successful shutdown has already been displayed by + # the child process. + + +def cmd_listener_show(context, name): + """ + Show a named listener. + """ + listeners = get_listeners(name) + if not listeners: + raise click.ClickException( + "No running listener found with name {}".format(name)) + + context.spinner_stop() + display_show_listener(listeners[0], table_format=context.output_format) + + +def cmd_listener_list(context): + """ + List all named listeners. + """ + listeners = get_listeners() + context.spinner_stop() + if not listeners: + click.echo("No running listeners") + else: + display_list_listeners(listeners, table_format=context.output_format) diff --git a/pywbemtools/pywbemlistener/_config.py b/pywbemtools/pywbemlistener/_config.py new file mode 100644 index 000000000..879147edc --- /dev/null +++ b/pywbemtools/pywbemlistener/_config.py @@ -0,0 +1,37 @@ +# (C) Copyright 2021 Inova Development Inc. +# All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Common constants for pywbemlistener. +""" + +# Verbosity levels that enable certain types of messages +VERBOSE_SETTINGS = 1 +VERBOSE_PROCESSES = 2 + +# Verbosity help text, by level +VERBOSE_1_HELP = u'Display indication processing settings' +VERBOSE_2_HELP = u'Display interactions between start and run commands' + +# Global flag that is set if verbosity >= VERBOSE_PROCESSES +VERBOSE_PROCESSES_ENABLED = False + +# Environment variables that influence the behavior of the pywbemlistener +# command, mostly they define defaults that can be overridden by general +# command line options. +PYWBEMLISTENER_KEYFILE_ENVVAR = 'PYWBEMLISTENER_KEYFILE' +PYWBEMLISTENER_CERTFILE_ENVVAR = 'PYWBEMLISTENER_CERTFILE' +PYWBEMLISTENER_LOGDIR_ENVVAR = 'PYWBEMLISTENER_LOGDIR' +PYWBEMLISTENER_PDB_ENVVAR = 'PYWBEMLISTENER_PDB' diff --git a/pywbemtools/pywbemlistener/_context_obj.py b/pywbemtools/pywbemlistener/_context_obj.py new file mode 100644 index 000000000..1ed1b0b3f --- /dev/null +++ b/pywbemtools/pywbemlistener/_context_obj.py @@ -0,0 +1,175 @@ +# (C) Copyright 2021 Inova Development Inc. +# All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Click context object for the pybemlistener command. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import os +import click_spinner + + +class ContextObj(object): + # pylint: disable=useless-object-inheritance, too-many-instance-attributes + """ + Click context object for the pybemlistener command. + + This object is attached to the Click context, and is used as follows: + - Contains all general options for use by command functions. + - Serves as the central object for executing command functions. + - Has support for starting and stopping the Click spinner. + """ + + spinner_envvar = 'PYWBEMLISTENER_SPINNER' + + def __init__(self, output_format, logdir, verbose, pdb, warn): + """ + Parameters: + + output_format (:term:`string` or `None`): + Value of --output-format general option, or `None` if not specified. + + logdir (:term:`string` or `None`): + Value of --logdir general option, or `None` if not specified. + + verbose (int): + Verbosity. See VERBOSE_* constants for a definition. + + pdb (:class:`py:bool`): + Indicates whether the --pdb general option was specified. + + warn (:class:`py:bool`): + Indicates whether the --warn general option was specified. + """ + + self._output_format = output_format + self._logdir = logdir + self._verbose = verbose + self._pdb = pdb + self._warn = warn + + self._spinner_enabled = None # Deferred init in getter + self._spinner_obj = click_spinner.Spinner() + + def __repr__(self): + return 'ContextObj(at {:08x}, output_format={s.output_format}, ' \ + 'logdir={s.logdir}, verbose={s.verbose}, pdb={s.pdb}, ' \ + 'warn={s.warn}, spinner_enabled={s.spinner_enabled}' \ + .format(id(self), s=self) + + @property + def output_format(self): + """ + :term:`string`: String defining the output format requested. This may + be `None` meaning that the default format should be used or may be + one of the values in the TABLE_FORMATS variable. + """ + return self._output_format + + @property + def logdir(self): + """ + :term:`string`: Path name of log directory for the 'run' command, + or `None` for no logging. + """ + return self._logdir + + @property + def verbose(self): + """ + int: Verbosity. See VERBOSE_* constants for a definition. + """ + return self._verbose + + @property + def pdb(self): + """ + bool: Indicates whether to break in the debugger. + """ + return self._pdb + + @property + def warn(self): + """ + bool: Indicates whether to enable Python warnings. + """ + return self._warn + + @property + def spinner_enabled(self): + """ + :class:`py:bool`: Indicates and controls whether the spinner is enabled. + + If the spinner is enabled, subcommands will display a spinning wheel + while waiting for completion. + + This attribute can be modified. + + The initial state of the spinner is enabled, but it can be disabled by + setting the {0} environment variable to 'false', '0', or the empty + value. + """.format(self.spinner_envvar) + + # Deferred initialization + if self._spinner_enabled is None: + value = os.environ.get(self.spinner_envvar, None) + if value is None: + # Default if not set + self._spinner_enabled = True + elif value == '0' or value == '' or value.lower() == 'false': + self._spinner_enabled = False + else: + self._spinner_enabled = True + + return self._spinner_enabled + + @spinner_enabled.setter + def spinner_enabled(self, enabled): + """Setter method; for a description see the getter method.""" + self._spinner_enabled = enabled + + def spinner_start(self): + """ + Start the spinner, if the spinner is enabled. + """ + if self.spinner_enabled: + self._spinner_obj.start() + + def spinner_stop(self): + """ + Stop the spinner, if the spinner is enabled. + """ + if self.spinner_enabled: + self._spinner_obj.stop() + + def execute_cmd(self, cmd): + """ + Call the command function for a command, after enabling the spinner + (except when in debug mode) and after entering debug mode if desired. + """ + if not self.pdb: + self.spinner_start() + try: + if self.pdb: + import pdb # pylint: disable=import-outside-toplevel + pdb.set_trace() # pylint: disable=no-member + + cmd() # The command function for the pywbemlistener command + + finally: + if not self.pdb: + self.spinner_stop() diff --git a/pywbemtools/pywbemlistener/pywbemlistener.py b/pywbemtools/pywbemlistener/pywbemlistener.py new file mode 100644 index 000000000..c026c0dbe --- /dev/null +++ b/pywbemtools/pywbemlistener/pywbemlistener.py @@ -0,0 +1,145 @@ +# (C) Copyright 2021 Inova Development Inc. +# All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The main function of the pywbemlistener command. +""" + +from __future__ import absolute_import, print_function + +import sys +import warnings +import click + +from pywbem import __version__ as pywbem_version + +from ._context_obj import ContextObj +from . import _config +from .._click_extensions import PywbemtoolsTopGroup, GENERAL_OPTS_TXT, \ + SUBCMD_HELP_TXT +from .._utils import pywbemtools_warn, get_terminal_width +from .._options import add_options, help_option +from .._output_formatting import OUTPUT_FORMAT_GROUPS + +__all__ = ['cli'] + + +# +# Context variables passed to click +# +CONTEXT_SETTINGS = dict( + + # Enable -h as additional help option: + help_option_names=['-h', '--help'], + + # Default the output width properly: + terminal_width=get_terminal_width(), +) + + +############################################################################ +# +# cli command (main entry point) and definition of all of the pywbemlistener +# general options. +# +############################################################################ + + +# pylint: disable=bad-continuation +# PywbemtoolsTopGroup sets order commands listed in help output +@click.group(invoke_without_command=False, cls=PywbemtoolsTopGroup, + context_settings=CONTEXT_SETTINGS, + options_metavar=GENERAL_OPTS_TXT, + subcommand_metavar=SUBCMD_HELP_TXT) +@click.option('-o', '--output-format', metavar='FORMAT', + default=None, + help=u'Output format for the command result. ' + u'FORMAT is one of the table formats: [{tb}].'. + format(tb='|'.join(OUTPUT_FORMAT_GROUPS['TABLE'][0]))) +@click.option('-l', '--logdir', type=str, metavar='DIR', + default=None, + envvar=_config.PYWBEMLISTENER_LOGDIR_ENVVAR, + help=u"Enable logging of the 'pywbemlistener run' command output " + u"to a file in a log directory. The file will be named " + u"'pywbemlistener_NAME.log' where NAME is the listener " + u"name. Default: EnvVar {ev}, or no logging.". + format(ev=_config.PYWBEMLISTENER_LOGDIR_ENVVAR)) +@click.option('-v', '--verbose', count=True, + help=u'Verbosity level. Can be specified multiple times: ' + u'-v: {}; -vv: {}.'. + format(_config.VERBOSE_1_HELP, _config.VERBOSE_2_HELP)) +@click.option('--pdb', is_flag=True, + default=False, + envvar=_config.PYWBEMLISTENER_PDB_ENVVAR, + help=u'Pause execution in the built-in pdb debugger just before ' + u'executing the command within pywbemlistener. ' + u'Default: EnvVar {ev}, or no debugger.'. + format(ev=_config.PYWBEMLISTENER_PDB_ENVVAR)) +@click.option('--warn', is_flag=True, + default=False, + help=u'Enable display of all Python warnings. ' + u'Default: Leave warning control to the PYTHONWARNINGS ' + u'EnvVar, which by default displays no warnings.') +@click.version_option( + message='%(prog)s, version %(version)s\npywbem, version {}'.format( + pywbem_version), + help=u'Show the version of this command and the pywbem package.') +@add_options(help_option) +@click.pass_context +def cli(ctx, output_format, logdir, verbose, pdb, warn): + """ + The pywbemlistener command can run and manage WBEM listeners. + + Each listener is a process that executes the 'pywbemlistener run' + command to receive WBEM indications sent from a WBEM server. + + A listener process can be started with the 'pywbemlistener start' + command and stopped with the 'pywbemlistener stop' command. + + There is no central registration of the currently running listeners. + Instead, the currently running processes executing the + 'pywbemlistener run' command are by definition the currently running + listeners. Because of this, there is no notion of a stopped listener nor + does a listener have an operational status. + + The general options shown below can also be specified on any of the + commands, positioned right after the 'pywbemlistener' command name. + + The width of help texts of this command can be set with the + PYWBEMTOOLS_TERMWIDTH environment variable. + + For more detailed documentation, see: + + https://pywbemtools.readthedocs.io/en/stable/ + """ + + if warn: + warnings.simplefilter('once') + # else: Leave warning control to the PYTHONWARNINGS env var. + + if verbose >= _config.VERBOSE_PROCESSES: + _config.VERBOSE_PROCESSES_ENABLED = True + + # Since there is no interactive mode, there is never a context object. + assert ctx.obj is None + ctx.obj = ContextObj(output_format, logdir, verbose, pdb, warn) + + _python_nm = sys.version_info[0:2] + if _python_nm in ((2, 7), (3, 4)): + pywbemtools_warn( + "Pywbemlistener support for Python {}.{} is deprecated and will be " + "removed in a future version". + format(_python_nm[0], _python_nm[1]), + DeprecationWarning) diff --git a/requirements.txt b/requirements.txt index b36abe31f..264a4c64e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,12 +8,13 @@ # Direct dependencies (except pip, setuptools, wheel): -pywbem>=1.2.0 +# TODO: Enable pywbem 1.3.0 once released, before releasing pywbemtools +# pywbem>=1.3.0 # When using the GitHub master branch of pywbem, comment out the line above, # activate the GitHub link based dependency below. # In that case, some of the install tests need to be disabled by setting # the 'PYWBEM_FROM_REPO' variable in in tests/install/test_install.sh. -# git+https://github.com/pywbem/pywbem.git@master#egg=pywbem +git+https://github.com/pywbem/pywbem.git@master#egg=pywbem nocaselist>=1.0.3 nocasedict>=1.0.1 @@ -33,6 +34,7 @@ click-repl>=0.1.6 asciitree>=0.3.3 tabulate>=0.8.2 toposort>=1.6 +psutil>=5.5.0 # prompt-toolkit>=2.0 failed on py27 (issue #192), so it was pinned to <2.0. # Later, the fix for issue #224 allowed to lift that pinning. diff --git a/setup.py b/setup.py index 0d7065fd8..87ab0f2aa 100644 --- a/setup.py +++ b/setup.py @@ -88,6 +88,7 @@ def read_file(a_file): entry_points={ 'console_scripts': [ 'pywbemcli = pywbemtools.pywbemcli.pywbemcli:cli', + 'pywbemlistener = pywbemtools.pywbemlistener.pywbemlistener:cli', ], }, include_package_data=True, # as specified in MANIFEST.in