diff --git a/requirements.txt b/requirements.txt index d08d15b..7c16399 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Click Deprecated PyYAML +sentry-sdk diff --git a/swh/core/cli/__init__.py b/swh/core/cli/__init__.py index e60d92b..bb10f2b 100644 --- a/swh/core/cli/__init__.py +++ b/swh/core/cli/__init__.py @@ -1,106 +1,115 @@ # Copyright (C) 2019 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 logging.config import signal import click import pkg_resources +import sentry_sdk import yaml LOG_LEVEL_NAMES = ['NOTSET', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) logger = logging.getLogger(__name__) class AliasedGroup(click.Group): '''A simple Group that supports command aliases, as well as notes related to options''' def __init__(self, name=None, commands=None, **attrs): self.option_notes = attrs.pop('option_notes', None) self.aliases = {} super().__init__(name, commands, **attrs) def get_command(self, ctx, cmd_name): return super().get_command(ctx, self.aliases.get(cmd_name, cmd_name)) def add_alias(self, name, alias): if not isinstance(name, str): name = name.name self.aliases[alias] = name 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) def clean_exit_on_signal(signal, frame): """Raise a SystemExit exception to let command-line clients wind themselves down on exit""" raise SystemExit(0) @click.group( context_settings=CONTEXT_SETTINGS, cls=AliasedGroup, option_notes='''\ If both options are present, --log-level will override the root logger configuration set 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', default=None, type=click.Choice(LOG_LEVEL_NAMES), help="Log level (defaults to INFO).") @click.option('--log-config', default=None, type=click.File('r'), 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.pass_context -def swh(ctx, log_level, log_config): +def swh(ctx, log_level, log_config, sentry_dsn, sentry_debug): """Command line interface for Software Heritage. """ signal.signal(signal.SIGTERM, clean_exit_on_signal) signal.signal(signal.SIGINT, clean_exit_on_signal) + if sentry_dsn: + sentry_sdk.init(dsn=sentry_dsn, debug=sentry_debug) + if log_level is None and log_config is None: log_level = 'INFO' if log_config: logging.config.dictConfig(yaml.safe_load(log_config.read())) if log_level: log_level = logging.getLevelName(log_level) logging.root.setLevel(log_level) ctx.ensure_object(dict) ctx.obj['log_level'] = log_level def main(): # Even though swh() sets up logging, we need an earlier basic logging setup # for the next few logging statements logging.basicConfig() # load plugins that define cli sub commands for entry_point in pkg_resources.iter_entry_points('swh.cli.subcommands'): try: cmd = entry_point.load() swh.add_command(cmd, name=entry_point.name) except Exception as e: logger.warning('Could not load subcommand %s: %s', entry_point.name, str(e)) return swh(auto_envvar_prefix='SWH') if __name__ == '__main__': main() diff --git a/swh/core/db/tests/test_cli.py b/swh/core/db/tests/test_cli.py index 9f153a0..118ff7d 100644 --- a/swh/core/db/tests/test_cli.py +++ b/swh/core/db/tests/test_cli.py @@ -1,56 +1,57 @@ # from click.testing import CliRunner from swh.core.cli.db import db as swhdb help_msg = '''Usage: swh [OPTIONS] COMMAND [ARGS]... Command line interface for Software Heritage. Options: -l, --log-level [NOTSET|DEBUG|INFO|WARNING|ERROR|CRITICAL] Log level (defaults to INFO). --log-config FILENAME Python yaml logging configuration file. + --sentry-dsn TEXT DSN of the Sentry instance to report to -h, --help Show this message and exit. Notes: If both options are present, --log-level will override the root logger configuration set 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. Commands: db Software Heritage database generic tools. ''' def test_swh_help(swhmain): swhmain.add_command(swhdb) runner = CliRunner() result = runner.invoke(swhmain, ['-h']) assert result.exit_code == 0 assert result.output == help_msg help_db_msg = '''Usage: swh db [OPTIONS] COMMAND [ARGS]... Software Heritage database generic tools. Options: -C, --config-file FILE Configuration file. -h, --help Show this message and exit. Commands: init Initialize the database for every Software Heritage module found in... ''' def test_swh_db_help(swhmain): swhmain.add_command(swhdb) runner = CliRunner() result = runner.invoke(swhmain, ['db', '-h']) assert result.exit_code == 0 assert result.output == help_db_msg diff --git a/swh/core/tests/test_cli.py b/swh/core/tests/test_cli.py index bdb985d..7faed10 100644 --- a/swh/core/tests/test_cli.py +++ b/swh/core/tests/test_cli.py @@ -1,195 +1,259 @@ -# +# Copyright (C) 2019 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 textwrap +from unittest.mock import patch import click from click.testing import CliRunner import pytest help_msg = '''Usage: swh [OPTIONS] COMMAND [ARGS]... Command line interface for Software Heritage. Options: -l, --log-level [NOTSET|DEBUG|INFO|WARNING|ERROR|CRITICAL] Log level (defaults to INFO). --log-config FILENAME Python yaml logging configuration file. + --sentry-dsn TEXT DSN of the Sentry instance to report to -h, --help Show this message and exit. Notes: If both options are present, --log-level will override the root logger configuration set 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. ''' def test_swh_help(swhmain): runner = CliRunner() result = runner.invoke(swhmain, ['-h']) assert result.exit_code == 0 assert result.output.startswith(help_msg) result = runner.invoke(swhmain, ['--help']) assert result.exit_code == 0 assert result.output.startswith(help_msg) def test_command(swhmain): @swhmain.command(name='test') @click.pass_context def swhtest(ctx): click.echo('Hello SWH!') runner = CliRunner() - result = runner.invoke(swhmain, ['test']) + with patch('sentry_sdk.init') as sentry_sdk_init: + result = runner.invoke(swhmain, ['test']) + sentry_sdk_init.assert_not_called() assert result.exit_code == 0 assert result.output.strip() == 'Hello SWH!' def test_loglevel_default(caplog, swhmain): @swhmain.command(name='test') @click.pass_context def swhtest(ctx): assert logging.root.level == 20 click.echo('Hello SWH!') runner = CliRunner() result = runner.invoke(swhmain, ['test']) assert result.exit_code == 0 print(result.output) assert result.output.strip() == '''Hello SWH!''' def test_loglevel_error(caplog, swhmain): @swhmain.command(name='test') @click.pass_context def swhtest(ctx): assert logging.root.level == 40 click.echo('Hello SWH!') runner = CliRunner() result = runner.invoke(swhmain, ['-l', 'ERROR', 'test']) assert result.exit_code == 0 assert result.output.strip() == '''Hello SWH!''' def test_loglevel_debug(caplog, swhmain): @swhmain.command(name='test') @click.pass_context def swhtest(ctx): assert logging.root.level == 10 click.echo('Hello SWH!') runner = CliRunner() result = runner.invoke(swhmain, ['-l', 'DEBUG', 'test']) assert result.exit_code == 0 assert result.output.strip() == '''Hello SWH!''' +def test_sentry(swhmain): + @swhmain.command(name='test') + @click.pass_context + def swhtest(ctx): + click.echo('Hello SWH!') + + runner = CliRunner() + with patch('sentry_sdk.init') as sentry_sdk_init: + result = runner.invoke(swhmain, ['--sentry-dsn', 'test_dsn', 'test']) + assert result.exit_code == 0 + assert result.output.strip() == '''Hello SWH!''' + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + debug=False, + ) + + +def test_sentry_debug(swhmain): + @swhmain.command(name='test') + @click.pass_context + def swhtest(ctx): + click.echo('Hello SWH!') + + runner = CliRunner() + with patch('sentry_sdk.init') as sentry_sdk_init: + result = runner.invoke( + swhmain, ['--sentry-dsn', 'test_dsn', '--sentry-debug', 'test']) + assert result.exit_code == 0 + assert result.output.strip() == '''Hello SWH!''' + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + debug=True, + ) + + +def test_sentry_env(swhmain): + @swhmain.command(name='test') + @click.pass_context + def swhtest(ctx): + click.echo('Hello SWH!') + + runner = CliRunner() + with patch('sentry_sdk.init') as sentry_sdk_init: + env = { + 'SWH_SENTRY_DSN': 'test_dsn', + 'SWH_SENTRY_DEBUG': '1', + } + result = runner.invoke( + swhmain, ['test'], env=env, auto_envvar_prefix='SWH') + assert result.exit_code == 0 + assert result.output.strip() == '''Hello SWH!''' + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + debug=True, + ) + + @pytest.fixture def log_config_path(tmp_path): log_config = textwrap.dedent('''\ --- version: 1 formatters: formatter: format: 'custom format:%(name)s:%(levelname)s:%(message)s' handlers: console: class: logging.StreamHandler stream: ext://sys.stdout formatter: formatter level: DEBUG root: level: DEBUG handlers: - console loggers: dontshowdebug: level: INFO ''') (tmp_path / 'log_config.yml').write_text(log_config) yield str(tmp_path / 'log_config.yml') def test_log_config(caplog, log_config_path, swhmain): @swhmain.command(name='test') @click.pass_context def swhtest(ctx): logging.debug('Root log debug') logging.info('Root log info') logging.getLogger('dontshowdebug').debug('Not shown') logging.getLogger('dontshowdebug').info('Shown') runner = CliRunner() result = runner.invoke( swhmain, [ '--log-config', log_config_path, 'test', ], ) assert result.exit_code == 0 assert result.output.strip() == '\n'.join([ 'custom format:root:DEBUG:Root log debug', 'custom format:root:INFO:Root log info', 'custom format:dontshowdebug:INFO:Shown', ]) def test_log_config_log_level_interaction(caplog, log_config_path, swhmain): @swhmain.command(name='test') @click.pass_context def swhtest(ctx): logging.debug('Root log debug') logging.info('Root log info') logging.getLogger('dontshowdebug').debug('Not shown') logging.getLogger('dontshowdebug').info('Shown') runner = CliRunner() result = runner.invoke( swhmain, [ '--log-config', log_config_path, '--log-level', 'INFO', 'test', ], ) assert result.exit_code == 0 assert result.output.strip() == '\n'.join([ 'custom format:root:INFO:Root log info', 'custom format:dontshowdebug:INFO:Shown', ]) def test_aliased_command(swhmain): @swhmain.command(name='canonical-test') @click.pass_context def swhtest(ctx): 'A test command.' click.echo('Hello SWH!') swhmain.add_alias(swhtest, 'othername') runner = CliRunner() # check we have only 'canonical-test' listed in the usage help msg result = runner.invoke(swhmain, ['-h']) assert result.exit_code == 0 assert 'canonical-test A test command.' in result.output assert 'othername' not in result.output # check we can execute the cmd with 'canonical-test' result = runner.invoke(swhmain, ['canonical-test']) assert result.exit_code == 0 assert result.output.strip() == '''Hello SWH!''' # check we can also execute the cmd with the alias 'othername' result = runner.invoke(swhmain, ['othername']) assert result.exit_code == 0 assert result.output.strip() == '''Hello SWH!'''