Source code for swh.core.cli

# Copyright (C) 2019-2023  The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information

import logging
import sys
import warnings

import click
import yaml

CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])

logger = logging.getLogger(__name__)


[docs] class SWHHelpFormatter(click.HelpFormatter): """A subclass of click's HelpFormatter which converts some :ref: to links to the SWH documentation """ _template = ( "{key} key: https://docs.softwareheritage.org/devel/configuration.html#{ref}" )
[docs] def write_text(self, text): import re text = re.sub( ":ref:`(?P<ref>cli-config-(?P<key>.*?))`", lambda m: self._template.format(**m.groupdict()), text, ) text = re.sub( ":mod:`(.*?)`", r"https://docs.softwareheritage.org/devel/apidoc/\1.html", text, ) return super().write_text(text)
[docs] class AliasedGroup(click.Group): """A simple Group that supports: * command aliases * notes related to options It will also ensure the --help option can always be handled by disabling the callback of the group when the tool is called with --help or -h """ # make this class the default Group, see # https://click.palletsprojects.com/en/stable/api/#click.Group.group_class group_class = type def __init__(self, name=None, commands=None, **attrs): self.option_notes = attrs.pop("option_notes", None) self.aliases = {} super().__init__(name, commands, **attrs)
[docs] def get_command(self, ctx, cmd_name): return super().get_command(ctx, self.aliases.get(cmd_name, cmd_name))
[docs] def add_alias(self, name, alias): if not isinstance(name, str): name = name.name self.aliases[alias] = name
[docs] def format_options(self, ctx, formatter): click.Command.format_options(self, ctx, formatter) if self.option_notes: with formatter.section("Notes"): formatter.write_text(self.option_notes) self.format_commands(ctx, formatter)
[docs] def invoke(self, ctx): # do NOT execute groups callback if this command is called for help; # group callbacks may try to do stuff (esp. loading and partially checking # configuration) that we do not want to fail when asking for help... # We need to restore the actual callback after execution for tests of # other sw packages not to break; they may test the behavior of their # cli's '--help' output, which would then prevent any further cli call # to fail... orig_cb = ctx.command.callback if set(ctx.help_option_names) & set(ctx.args): ctx.command.callback = None try: return super().invoke(ctx) finally: ctx.command.callback = orig_cb
[docs] def clean_exit_on_signal(signal, frame): """Raise a SystemExit exception to let command-line clients wind themselves down on exit""" raise SystemExit(0)
[docs] def validate_loglevel_params(ctx, param, values): """Validate the --log-level parameters, with multiple values.""" if values is None: return None from swh.core.logging import BadLogLevel, validate_loglevel try: module_log_levels = [validate_loglevel(value) for value in values] except BadLogLevel as e: raise click.BadParameter(e) return module_log_levels
[docs] def validate_options(ctx, param, values): if values is None: return values options = [] for option in values: kv = option.split("=", 1) if len(kv) != 2: raise ValueError("Invalid option") k, v = kv options.append((k.strip(), yaml.safe_load(v))) return options
[docs] def setup_config(ctx, config_file, options=None): from os import environ from swh.core.config import read as config_read ctx.ensure_object(dict) if config_file and ctx.info_name != "swh": logger.warn( f"Using --config-file on the {ctx.info_name} command group is deprecated" ) if "config" not in ctx.obj: if config_file is None: config_file = environ.get("SWH_CONFIG_FILENAME") ctx.obj["config"] = config_read(config_file) if options is not None: for k, v in options: kpath = k.split(".") cfg = ctx.obj["config"] for elt in kpath[:-1]: cfg = cfg.setdefault(elt, {}) cfg[kpath[-1]] = v
[docs] def show_versions( ctx: click.core.Context, param: click.core.Parameter, value: bool ) -> None: if not value or ctx.resilient_parsing: return from importlib import metadata as MD version = MD.version("swh.core") click.echo(f"swh.core, version {version}") click.echo("Installed swh components:") for pdist in sorted( (x for x in MD.distributions() if x.name.startswith("swh.")), key=lambda x: x.name, ): try: click.echo(f" {pdist.name}, version {pdist.version}") except MD.PackageNotFoundError: pass ctx.exit()
@click.group( context_settings=CONTEXT_SETTINGS, cls=AliasedGroup, option_notes="""\ If both options are present, --log-level values will override the configuration in --log-config. The --log-config YAML must conform to the logging.config.dictConfig schema documented at https://docs.python.org/3/library/logging.config.html. """, ) @click.option( "--log-level", "-l", "log_levels", envvar="SWH_LOG_LEVEL", default=None, callback=validate_loglevel_params, multiple=True, help=( "Log level (defaults to INFO). " "Can override the log level for a specific module, by using the " "``specific.module:LOGLEVEL`` syntax (e.g. ``--log-level swh.core:DEBUG`` " "will enable DEBUG logging for swh.core)." ), ) @click.option( "--log-config", default=None, type=click.Path(exists=True, readable=True), help="Python yaml logging configuration file.", ) @click.option( "--sentry-dsn", default=None, help="DSN of the Sentry instance to report to" ) @click.option( "--sentry-debug/--no-sentry-debug", default=False, hidden=True, help="Enable debugging of sentry", ) @click.option( "--config-file", "-C", default=None, type=click.Path(exists=True, dir_okay=False), help="Configuration file.", ) @click.option( "--option", "-o", "options", multiple=True, callback=validate_options, help=( "Configuration option; set or override a configuration " "entry from the configuration file" ), ) @click.option( "--versions", is_flag=True, expose_value=False, is_eager=True, callback=show_versions, help="Show versions of all loaded swh packages", ) @click.pass_context def swh(ctx, log_levels, log_config, sentry_dsn, sentry_debug, config_file, options): """Command line interface for Software Heritage.""" import signal from swh.core.logging import logging_configure signal.signal(signal.SIGTERM, clean_exit_on_signal) signal.signal(signal.SIGINT, clean_exit_on_signal) if sentry_dsn is not None: from ..sentry import init_sentry init_sentry(sentry_dsn=sentry_dsn, debug=sentry_debug) set_default_loglevel = logging_configure(log_levels, log_config) ctx.ensure_object(dict) ctx.obj["log_level"] = set_default_loglevel ctx.__class__.formatter_class = SWHHelpFormatter setup_config(ctx, config_file, options) # XXX find a decent way to dump the actual configuration in the logs # hiding credentials (including in connection string...)
[docs] def main(): # Even though swh() sets up logging, we need an earlier basic logging setup # for the next few logging statements logging.basicConfig() if sys.version_info < (3, 10): from backports.entry_points_selectable import entry_points else: from importlib.metadata import entry_points # load plugins that define cli sub commands for entry_point in entry_points(group="swh.cli.subcommands"): try: cmd = entry_point.load() if isinstance(cmd, click.BaseCommand): # for BW compat, auto add click commands warnings.warn( f"{entry_point.name}: automagic addition of click commands " f"to the main swh group is deprecated", DeprecationWarning, ) swh.add_command(cmd, name=entry_point.name) # otherwise it's expected to be a module which has been loaded # it's the responsibility of the click commands/groups in this # module to transitively have the main swh group as parent. except Exception as e: logger.warning("Could not load subcommand %s: %r", entry_point.name, e) return swh(auto_envvar_prefix="SWH")
if __name__ == "__main__": main()