diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index a7f8280e..40f4ae6d 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -9,6 +9,11 @@ import borgmatic.logger class Rich_color_formatter(logging.Formatter): + ''' + A Python logging formatter that formats log records with Rich-compatible color markup according + to their levels. + ''' + def __init__(self, *args, **kwargs): self.prefix = None super().__init__( @@ -19,6 +24,10 @@ class Rich_color_formatter(logging.Formatter): ) def format(self, record): + ''' + Given a log record, format it with Rich-compatibe color markup correponding to its log + level. + ''' borgmatic.logger.add_custom_log_levels() color = { @@ -35,12 +44,23 @@ class Rich_color_formatter(logging.Formatter): class Browse_log_handler(logging.Handler): + ''' + A Python log handler that writes any log records to a logging widget. + ''' + def __init__(self, logs_widget): + ''' + Given a logs widget, save it for use below. + ''' self.logs_widget = logs_widget super().__init__() def emit(self, record): + ''' + Given a log record, format it and log it to the logs widgets. This works whether or not the + logging is happening in the main thread. + ''' message = self.format(record) try: @@ -72,6 +92,11 @@ def log_to_widget(logs_widget): class Logs(textual.widgets.RichLog): + ''' + A widget for viewing borgmatic logs in realtime. The log level is determined by borgmatic's + current verbosity level. + ''' + def __init__(self): super().__init__(markup=True, id='logs', classes='panel') self.border_title = '🪵 logs' diff --git a/tests/integration/actions/browse/test_directory_list.py b/tests/integration/actions/browse/test_directory_list.py index 19f506a0..a6d29cca 100644 --- a/tests/integration/actions/browse/test_directory_list.py +++ b/tests/integration/actions/browse/test_directory_list.py @@ -200,7 +200,9 @@ def test_directory_list_on_mount_with_non_root_directory_adds_archive_paths(): ).and_return(flexmock()) flexmock(module).should_receive('add_archive_paths').once() directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -216,7 +218,9 @@ def test_on_archive_path_loaded_with_loading_done_signal_removes_loading_indicat ).and_return(flexmock()) flexmock(module).should_receive('add_archive_paths').never() directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -233,7 +237,9 @@ def test_on_archive_path_loaded_with_path_loaded_signal_adds_archive_path(): ).and_return(flexmock()) flexmock(module).should_receive('add_archive_paths').once() directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -248,7 +254,9 @@ def test_directory_list_on_option_list_option_highlighted_with_highlighted_none_ 'app' ).and_return(flexmock()) directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -265,7 +273,9 @@ def test_directory_list_on_option_list_option_highlighted_with_highlighted_zero_ 'app' ).and_return(flexmock()) directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -283,7 +293,9 @@ def test_directory_list_on_option_list_option_highlighted_with_highlighted_non_z 'app' ).and_return(flexmock()) directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) diff --git a/tests/integration/actions/browse/test_loading.py b/tests/integration/actions/browse/test_loading.py index e1117b73..0d4d12d9 100644 --- a/tests/integration/actions/browse/test_loading.py +++ b/tests/integration/actions/browse/test_loading.py @@ -8,9 +8,7 @@ import textual.widgets def test_update_inline_loading_indicator_with_option_list_adds_a_dot(): widget = textual.widgets.OptionList() - widget.add_option( - textual.widgets.option_list.Option('HOLD.', id='loading-indicator') - ) + widget.add_option(textual.widgets.option_list.Option('HOLD.', id='loading-indicator')) module.update_inline_loading_indicator(widget) @@ -20,9 +18,7 @@ def test_update_inline_loading_indicator_with_option_list_adds_a_dot(): def test_update_inline_loading_indicator_with_option_list_wraps_dots_beyond_three(): widget = textual.widgets.OptionList() - widget.add_option( - textual.widgets.option_list.Option('HOLD...', id='loading-indicator') - ) + widget.add_option(textual.widgets.option_list.Option('HOLD...', id='loading-indicator')) module.update_inline_loading_indicator(widget) diff --git a/tests/integration/actions/browse/test_logs.py b/tests/integration/actions/browse/test_logs.py new file mode 100644 index 00000000..beffade5 --- /dev/null +++ b/tests/integration/actions/browse/test_logs.py @@ -0,0 +1,36 @@ +import contextlib + +from borgmatic.actions.browse import logs as module + +from flexmock import flexmock + + +def test_log_to_widget_adds_our_handler_and_removes_default_handler(): + default_handler = module.borgmatic.logger.Multi_stream_handler({}) + root_logger = module.logging.getLogger() + root_logger.addHandler(default_handler) + browse_log_handler = None + + try: + module.log_to_widget(flexmock()) + + with contextlib.suppress(StopIteration): + browse_log_handler = next( + handler for handler in root_logger.handlers + if isinstance(handler, module.Browse_log_handler) + ) + + assert browse_log_handler + finally: + if browse_log_handler: + root_logger.removeHandler(browse_log_handler) + + assert not any( + handler for handler in root_logger.handlers + if isinstance(handler, module.borgmatic.logger.Multi_stream_handler) + ) + + + +def test_logs_does_not_raise(): + module.Logs() diff --git a/tests/unit/actions/browse/test_logs.py b/tests/unit/actions/browse/test_logs.py new file mode 100644 index 00000000..b59526f7 --- /dev/null +++ b/tests/unit/actions/browse/test_logs.py @@ -0,0 +1,87 @@ +from borgmatic.actions.browse import logs as module + +from flexmock import flexmock + + +def test_rich_color_formatter_format_colors_log_record_based_on_level(): + formatted = module.Rich_color_formatter().format( + module.logging.makeLogRecord( + dict( + levelno=module.logging.ERROR, + levelname='ERROR', + msg='oh no', + ) + ), + ) + + assert formatted == '[bright_red]oh no[/bright_red]' + + +def test_rich_color_formatter_format_includes_prefix(): + formatter = module.Rich_color_formatter() + formatter.prefix = 'sup' + formatted = formatter.format( + module.logging.makeLogRecord( + dict( + levelno=module.logging.ERROR, + levelname='ERROR', + msg='oh no', + ), + ), + ) + + assert formatted == '[bright_red]sup: oh no[/bright_red]' + + +def test_browse_log_handler_emit_from_worker_thread_calls_write_in_main_thread(): + flexmock(module.textual.worker).should_receive('get_current_worker') + logs_widget = flexmock(write=lambda message: None, app=flexmock()) + flexmock(logs_widget.app).should_receive('call_from_thread').with_args( + logs_widget.write, 'hi' + ).once() + + module.Browse_log_handler(logs_widget).emit( + module.logging.makeLogRecord( + dict( + levelno=module.logging.DEBUG, + levelname='DEBUG', + msg='hi', + ), + ), + ) + + +def test_browse_log_handler_emit_from_main_thread_calls_write_directly(): + flexmock(module.textual.worker).should_receive('get_current_worker').and_raise(RuntimeError) + logs_widget = flexmock(write=lambda message: None, app=flexmock()) + flexmock(logs_widget.app).should_receive('call_from_thread').never() + flexmock(logs_widget).should_receive('write').with_args('hi').once() + + module.Browse_log_handler(logs_widget).emit( + module.logging.makeLogRecord( + dict( + levelno=module.logging.DEBUG, + levelname='DEBUG', + msg='hi', + ), + ), + ) + + +def test_browse_log_handler_emit_from_main_thread_with_no_active_app_does_not_raise(): + flexmock(module.textual.worker).should_receive('get_current_worker').and_raise(RuntimeError) + logs_widget = flexmock(write=lambda message: None, app=flexmock()) + flexmock(logs_widget.app).should_receive('call_from_thread').never() + flexmock(logs_widget).should_receive('write').with_args('hi').and_raise( + module.textual._context.NoActiveAppError + ) + + module.Browse_log_handler(logs_widget).emit( + module.logging.makeLogRecord( + dict( + levelno=module.logging.DEBUG, + levelname='DEBUG', + msg='hi', + ), + ), + )