diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index ee860288..30035f9c 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -7,6 +7,10 @@ import borgmatic.actions.browse.logs class Browse_app(textual.app.App): + ''' + The main app / entry point for the browse action UI. + ''' + BINDINGS = ( textual.binding.Binding(key='q', action='quit', description='quit'), textual.binding.Binding(key='v', action='toggle_logs', description='view logs'), @@ -36,6 +40,14 @@ class Browse_app(textual.app.App): super().__init__() def compose(self): + ''' + Compose a UI consisting of: + + * a header with the application name + * a carousel container that contains the main UI panels + * a logs panel where Python logs show up (panel hidden by default) + * a footer with available keys listed + ''' yield textual.widgets.Header() yield borgmatic.actions.browse.carousel.Carousel( [borgmatic.actions.browse.panels.Configuration_files_list(self.configs)] @@ -45,17 +57,21 @@ class Browse_app(textual.app.App): ] ) - logs_widget = borgmatic.actions.browse.panels.Logs() - yield logs_widget + logs_panel = borgmatic.actions.browse.panels.Logs() + yield logs_panel yield textual.widgets.Footer() - borgmatic.actions.browse.logs.log_to_widget(logs_widget) + borgmatic.actions.browse.logs.log_to_widget(logs_panel) def on_mount(self): + ''' + Set the application title, which ends up in the header. + ''' self.title = 'borgmatic browse' def action_toggle_logs(self): - logs_container = self.query_one('#logs') - logs_container.styles.display = ( - 'none' if logs_container.styles.display == 'block' else 'block' - ) + ''' + Toggle the show/hide status of the logs panel. + ''' + logs_panel = self.query_one('#logs') + logs_panel.styles.display = 'none' if logs_panel.styles.display == 'block' else 'block' diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 5b4b3849..acbc29c8 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -62,6 +62,9 @@ class Carousel(textual.containers.Horizontal): super().__init__() def compose(self): + ''' + Compose with each of the contained panels and focus the first one. + ''' yield from self.panels self.focused_panel.focus() @@ -85,17 +88,22 @@ class Carousel(textual.containers.Horizontal): ''' Hide the current focused panel and create the next one. ''' - self.focused_panel.styles.display = 'none' next_panel_index = self.panels.index(self.focused_panel) + 1 if next_panel_index < len(self.panels): - self.focused_panel = self.panels[next_panel_index] - self.focused_panel.styles.display = 'block' + next_panel = self.panels[next_panel_index] + next_panel.styles.display = 'block' else: - self.focused_panel = make_next_panel(self.focused_panel, option_id) - self.panels.append(self.focused_panel) - self.focused_panel.highlighted = 0 + next_panel = make_next_panel(self.focused_panel, option_id) + if next_panel is None: + return + + self.panels.append(next_panel) + next_panel.highlighted = 0 + + self.focused_panel.styles.display = 'none' + self.focused_panel = next_panel self.focused_panel.focus() self.refresh(recompose=True) diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index 024cde1c..d09ccf2b 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -31,7 +31,7 @@ class Configuration_files_list(textual.widgets.OptionList): super().__init__( *( - textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) + textual.widgets.option_list.Option(unexpanded_path, id=config_path) for config_path in configs for unexpanded_path in (config_path.replace(home_directory, '~'),) ), diff --git a/borgmatic/actions/browse/run.py b/borgmatic/actions/browse/run.py index 984cb4e2..26786120 100644 --- a/borgmatic/actions/browse/run.py +++ b/borgmatic/actions/browse/run.py @@ -7,7 +7,9 @@ def run_browse( configs, ): ''' - Run the "browse" action for the given repository. + Run the "browse" action for the given borgmatic configurations. This launches a console UI. + + Raise ValueError if the Textual library (a prerequisite for this action) can't be imported. ''' if not configs: return diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 09f014b2..4bd2117c 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1131,8 +1131,9 @@ properties: - info - break-lock - key - - borg - diff + - browse + - borg description: | List of one or more actions to skip running for this configuration file, even if specified on the command-line (explicitly or @@ -1348,8 +1349,9 @@ properties: - info - break-lock - key - - borg - diff + - browse + - borg description: | List of actions for which the commands will be run. Defaults to running for all actions. @@ -1414,8 +1416,9 @@ properties: - info - break-lock - key - - borg - diff + - browse + - borg description: | Only trigger the hook when borgmatic is run with particular actions listed here. Defaults to diff --git a/pyproject.toml b/pyproject.toml index b9a4de86..48ec4590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ namespaces = false [tool.pytest.ini_options] testpaths = "tests" -addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120" +addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120 --asyncio-mode=auto" [tool.ruff] line-length = 100 diff --git a/test_requirements.txt b/test_requirements.txt index ec4e11e3..a28e167d 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -18,6 +18,7 @@ packaging==26.2 # via pytest, -r test_requirements.in pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in pygments==2.20.0 # via pytest, -r test_requirements.in pytest==9.0.3 # via pytest-cov, pytest-timeout, -r test_requirements.in +pytest-asyncio===1.3.0 pytest-cov==7.1.0 # via -r test_requirements.in pytest-timeout==2.4.0 # via -r test_requirements.in pyyaml>5.0.0 diff --git a/tests/integration/actions/browse/__init__.py b/tests/integration/actions/browse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py new file mode 100644 index 00000000..e2542003 --- /dev/null +++ b/tests/integration/actions/browse/test_app.py @@ -0,0 +1,60 @@ +import borgmatic.actions.browse.app +import borgmatic.actions.browse.panels + + +async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + header = app.query_one(selector='Header') + header.name == 'borgmatic browse' + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].configs == app.configs + + app.query_one(selector='Logs') + app.query_one(selector='Footer') + + +async def test_browse_app_with_one_config_uses_repositories_list(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + + async with app.run_test() as pilot: + header = app.query_one(selector='Header') + header.name == 'borgmatic browse' + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert carousel.panels[0].config == app.configs['test1.yaml'] + + app.query_one(selector='Logs') + app.query_one(selector='Footer') + + +async def test_browse_app_key_toggles_logs_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + + async with app.run_test() as pilot: + logs_panel = app.query_one('#logs') + + await pilot.press('v') + await pilot.pause() + assert logs_panel.styles.display == 'block' diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py new file mode 100644 index 00000000..44a6a290 --- /dev/null +++ b/tests/integration/actions/browse/test_carousel.py @@ -0,0 +1,265 @@ +import borgmatic.actions.browse.app +import borgmatic.actions.browse.carousel +import borgmatic.actions.browse.panels +import borgmatic.actions.browse.workers + +from flexmock import flexmock + +import textual.widgets.option_list + + +async def test_carousel_previous_action_with_multiple_configs_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('left') + + +async def test_carousel_previous_action_with_one_config_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('left') + + +async def test_carousel_next_action_with_multiple_configs_advances_panels(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'none' + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'block' + assert carousel.panels[1].highlighted == 0 + assert app.focused == carousel.panels[1] + + +async def test_carousel_next_action_with_one_config_advances_to_next_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[0].styles.display == 'none' + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Archives_list + ) + assert carousel.panels[1].styles.display == 'block' + assert carousel.panels[1].highlighted == 0 + assert app.focused == carousel.panels[1] + + +async def test_carousel_next_action_and_previous_action_returns_to_original_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + await pilot.press('left') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 0 + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'none' + assert app.focused == carousel.panels[0] + + +async def test_carousel_next_action_and_previous_action_and_next_action_reuses_next_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.carousel).should_call('make_next_panel').once() + + async with app.run_test() as pilot: + await pilot.press('enter') + await pilot.press('left') + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'none' + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'block' + assert carousel.panels[1].highlighted == 0 + assert app.focused == carousel.panels[1] + + +async def test_carousel_next_action_with_no_next_panel_does_not_advance(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.carousel).should_receive('make_next_panel').and_return(None) + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 0 + assert app.focused == carousel.panels[0] + + +async def test_carousel_next_action_and_previous_action_and_down_truncates_next_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + await pilot.press('left') + await pilot.press('down') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 1 + assert app.focused == carousel.panels[0] + + +async def test_carousel_down_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('down') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 1 + assert app.focused == carousel.panels[0] + + +async def test_carousel_up_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('down') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 1 + assert app.focused == carousel.panels[0] + + +async def test_carousel_next_action_and_select_dot_dot_returns_to_original_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + carousel.panels[1].options[0] = textual.widgets.option_list.Option('..', id='..') + + await pilot.press('enter') + + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 0 + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'none' + assert app.focused == carousel.panels[0] diff --git a/tests/unit/actions/browse/__init__.py b/tests/unit/actions/browse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/actions/browse/test_run.py b/tests/unit/actions/browse/test_run.py new file mode 100644 index 00000000..0febc13f --- /dev/null +++ b/tests/unit/actions/browse/test_run.py @@ -0,0 +1,29 @@ +from flexmock import flexmock + +from borgmatic.actions.browse import run as module +import borgmatic.actions.browse.app + + +def test_run_browse_without_configs_bails(): + app = flexmock() + app.should_receive('run').never() + + flexmock(borgmatic.actions.browse.app).should_receive('Browse_app').and_return(app) + + module.run_browse( + diff_arguments=flexmock(), + global_arguments=flexmock(), + configs=(), + ) + + +def test_run_browse_with_configs_does_not_raise(): + flexmock(borgmatic.actions.browse.app).should_receive('Browse_app').and_return( + flexmock(run=lambda: None) + ) + + module.run_browse( + diff_arguments=flexmock(), + global_arguments=flexmock(), + configs=(flexmock(), flexmock()), + )