From bbc7f0596c13b79e6930ffa1a2b4381e8d2df766 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 15 Jun 2023 10:55:31 -0700 Subject: [PATCH] Fix Bash completion for sub-actions like "borgmatic config bootstrap" (#697 follow-on work). --- NEWS | 2 +- borgmatic/commands/borgmatic.py | 7 +- borgmatic/commands/completion/__init__.py | 0 borgmatic/commands/completion/actions.py | 44 ++++++++++++ borgmatic/commands/completion/bash.py | 62 ++++++++++++++++ .../{completion.py => completion/fish.py} | 72 ++----------------- .../commands/completion/__init__.py | 0 .../commands/completion/test_actions.py | 20 ++++++ .../commands/completion/test_bash.py | 5 ++ .../commands/completion/test_fish.py | 5 ++ tests/integration/commands/test_completion.py | 9 --- tests/unit/commands/completion/__init__.py | 0 .../unit/commands/completion/test_actions.py | 7 ++ tests/unit/commands/completion/test_bash.py | 17 +++++ .../test_fish.py} | 2 +- 15 files changed, 170 insertions(+), 82 deletions(-) create mode 100644 borgmatic/commands/completion/__init__.py create mode 100644 borgmatic/commands/completion/actions.py create mode 100644 borgmatic/commands/completion/bash.py rename borgmatic/commands/{completion.py => completion/fish.py} (72%) create mode 100644 tests/integration/commands/completion/__init__.py create mode 100644 tests/integration/commands/completion/test_actions.py create mode 100644 tests/integration/commands/completion/test_bash.py create mode 100644 tests/integration/commands/completion/test_fish.py delete mode 100644 tests/integration/commands/test_completion.py create mode 100644 tests/unit/commands/completion/__init__.py create mode 100644 tests/unit/commands/completion/test_actions.py create mode 100644 tests/unit/commands/completion/test_bash.py rename tests/unit/commands/{test_completions.py => completion/test_fish.py} (98%) diff --git a/NEWS b/NEWS index a5a1014e..8c5480fe 100644 --- a/NEWS +++ b/NEWS @@ -1,7 +1,7 @@ 1.7.15.dev0 * #697: Extract borgmatic configuration from backup via "bootstrap" action—even when borgmatic has no configuration yet! - * #669: Add sample systemd user serivce for running borgmatic as a non-root user. + * #669: Add sample systemd user service for running borgmatic as a non-root user. 1.7.14 * #484: Add a new verbosity level (-2) to disable output entirely (for console, syslog, log file, diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 6523ebbc..79aae78b 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -31,7 +31,8 @@ import borgmatic.actions.restore import borgmatic.actions.rinfo import borgmatic.actions.rlist import borgmatic.actions.transfer -import borgmatic.commands.completion +import borgmatic.commands.completion.bash +import borgmatic.commands.completion.fish from borgmatic.borg import umount as borg_umount from borgmatic.borg import version as borg_version from borgmatic.commands.arguments import parse_arguments @@ -751,10 +752,10 @@ def main(): # pragma: no cover print(importlib_metadata.version('borgmatic')) sys.exit(0) if global_arguments.bash_completion: - print(borgmatic.commands.completion.bash_completion()) + print(borgmatic.commands.completion.bash.bash_completion()) sys.exit(0) if global_arguments.fish_completion: - print(borgmatic.commands.completion.fish_completion()) + print(borgmatic.commands.completion.fish.fish_completion()) sys.exit(0) config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) diff --git a/borgmatic/commands/completion/__init__.py b/borgmatic/commands/completion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/borgmatic/commands/completion/actions.py b/borgmatic/commands/completion/actions.py new file mode 100644 index 00000000..903670da --- /dev/null +++ b/borgmatic/commands/completion/actions.py @@ -0,0 +1,44 @@ +import argparse + + +def upgrade_message(language: str, upgrade_command: str, completion_file: str): + return f''' +Your {language} completions script is from a different version of borgmatic than is +currently installed. Please upgrade your script so your completions match the +command-line flags in your installed borgmatic! Try this to upgrade: + + {upgrade_command} + source {completion_file} +''' + + +def available_actions(subparsers, current_action=None): + ''' + Given subparsers as an argparse._SubParsersAction instance and a current action name (if + any), return the actions names that can follow the current action on a command-line. + + This takes into account which sub-actions that the current action supports. For instance, if + "bootstrap" is a sub-action for "config", then "bootstrap" should be able to follow a current + action of "config" but not "list". + ''' + # Make a map from action name to the names of contained sub-actions. + actions_to_subactions = { + action: tuple( + subaction_name + for subaction in subparser._actions + if isinstance(subaction, argparse._SubParsersAction) + for subaction_name in subaction.choices.keys() + ) + for action, subparser in subparsers.choices.items() + } + + current_subactions = actions_to_subactions.get(current_action) + + if current_subactions: + return current_subactions + + all_subactions = set( + subaction for subactions in actions_to_subactions.values() for subaction in subactions + ) + + return tuple(action for action in subparsers.choices.keys() if action not in all_subactions) diff --git a/borgmatic/commands/completion/bash.py b/borgmatic/commands/completion/bash.py new file mode 100644 index 00000000..d20eca42 --- /dev/null +++ b/borgmatic/commands/completion/bash.py @@ -0,0 +1,62 @@ +import borgmatic.commands.arguments +import borgmatic.commands.completion.actions + + +def parser_flags(parser): + ''' + Given an argparse.ArgumentParser instance, return its argument flags in a space-separated + string. + ''' + return ' '.join(option for action in parser._actions for option in action.option_strings) + + +def bash_completion(): + ''' + Return a bash completion script for the borgmatic command. Produce this by introspecting + borgmatic's command-line argument parsers. + ''' + top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() + global_flags = parser_flags(top_level_parser) + + # Avert your eyes. + return '\n'.join( + ( + 'check_version() {', + ' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"', + ' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"', + ' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];' + f''' then cat << EOF\n{borgmatic.commands.completion.actions.upgrade_message( + 'bash', + 'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"', + '$BASH_SOURCE', + )}\nEOF''', + ' fi', + '}', + 'complete_borgmatic() {', + ) + + tuple( + ''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then + COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}")) + return 0 + fi''' + % ( + action, + parser_flags(subparser), + ' '.join( + borgmatic.commands.completion.actions.available_actions(subparsers, action) + ), + global_flags, + ) + for action, subparser in reversed(subparsers.choices.items()) + ) + + ( + ' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003 + % ( + ' '.join(borgmatic.commands.completion.actions.available_actions(subparsers)), + global_flags, + ), + ' (check_version &)', + '}', + '\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic', + ) + ) diff --git a/borgmatic/commands/completion.py b/borgmatic/commands/completion/fish.py similarity index 72% rename from borgmatic/commands/completion.py rename to borgmatic/commands/completion/fish.py index 4b2f17f3..306de195 100644 --- a/borgmatic/commands/completion.py +++ b/borgmatic/commands/completion/fish.py @@ -2,72 +2,8 @@ import shlex from argparse import Action from textwrap import dedent -from borgmatic.commands import arguments - - -def upgrade_message(language: str, upgrade_command: str, completion_file: str): - return f''' -Your {language} completions script is from a different version of borgmatic than is -currently installed. Please upgrade your script so your completions match the -command-line flags in your installed borgmatic! Try this to upgrade: - - {upgrade_command} - source {completion_file} -''' - - -def parser_flags(parser): - ''' - Given an argparse.ArgumentParser instance, return its argument flags in a space-separated - string. - ''' - return ' '.join(option for action in parser._actions for option in action.option_strings) - - -def bash_completion(): - ''' - Return a bash completion script for the borgmatic command. Produce this by introspecting - borgmatic's command-line argument parsers. - ''' - top_level_parser, subparsers = arguments.make_parsers() - global_flags = parser_flags(top_level_parser) - actions = ' '.join(subparsers.choices.keys()) - - # Avert your eyes. - return '\n'.join( - ( - 'check_version() {', - ' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"', - ' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"', - ' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];' - f''' then cat << EOF\n{upgrade_message( - 'bash', - 'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"', - '$BASH_SOURCE', - )}\nEOF''', - ' fi', - '}', - 'complete_borgmatic() {', - ) - + tuple( - ''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then - COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}")) - return 0 - fi''' - % (action, parser_flags(subparser), actions, global_flags) - for action, subparser in subparsers.choices.items() - ) - + ( - ' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003 - % (actions, global_flags), - ' (check_version &)', - '}', - '\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic', - ) - ) - - -# fish section +import borgmatic.commands.arguments +import borgmatic.commands.completion.actions def has_file_options(action: Action): @@ -155,7 +91,7 @@ def fish_completion(): Return a fish completion script for the borgmatic command. Produce this by introspecting borgmatic's command-line argument parsers. ''' - top_level_parser, subparsers = arguments.make_parsers() + top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() all_subparsers = ' '.join(action for action in subparsers.choices.keys()) @@ -182,7 +118,7 @@ def fish_completion(): set this_script (cat $this_filename 2> /dev/null) set installed_script (borgmatic --fish-completion 2> /dev/null) if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ] - echo "{upgrade_message( + echo "{borgmatic.commands.completion.actions.upgrade_message( 'fish', 'borgmatic --fish-completion | sudo tee $this_filename', '$this_filename', diff --git a/tests/integration/commands/completion/__init__.py b/tests/integration/commands/completion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/commands/completion/test_actions.py b/tests/integration/commands/completion/test_actions.py new file mode 100644 index 00000000..1a8d69e6 --- /dev/null +++ b/tests/integration/commands/completion/test_actions.py @@ -0,0 +1,20 @@ +import borgmatic.commands.arguments +from borgmatic.commands.completion import actions as module + + +def test_available_actions_uses_only_subactions_for_action_with_subactions(): + unused_top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() + + actions = module.available_actions(subparsers, 'config') + + assert 'bootstrap' in actions + assert 'list' not in actions + + +def test_available_actions_omits_subactions_for_action_without_subactions(): + unused_top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() + + actions = module.available_actions(subparsers, 'list') + + assert 'bootstrap' not in actions + assert 'config' in actions diff --git a/tests/integration/commands/completion/test_bash.py b/tests/integration/commands/completion/test_bash.py new file mode 100644 index 00000000..ac3cff63 --- /dev/null +++ b/tests/integration/commands/completion/test_bash.py @@ -0,0 +1,5 @@ +from borgmatic.commands.completion import bash as module + + +def test_bash_completion_does_not_raise(): + assert module.bash_completion() diff --git a/tests/integration/commands/completion/test_fish.py b/tests/integration/commands/completion/test_fish.py new file mode 100644 index 00000000..3b7d3dd8 --- /dev/null +++ b/tests/integration/commands/completion/test_fish.py @@ -0,0 +1,5 @@ +from borgmatic.commands.completion import fish as module + + +def test_fish_completion_does_not_raise(): + assert module.fish_completion() diff --git a/tests/integration/commands/test_completion.py b/tests/integration/commands/test_completion.py deleted file mode 100644 index 9a118abf..00000000 --- a/tests/integration/commands/test_completion.py +++ /dev/null @@ -1,9 +0,0 @@ -from borgmatic.commands import completion as module - - -def test_bash_completion_does_not_raise(): - assert module.bash_completion() - - -def test_fish_completion_does_not_raise(): - assert module.fish_completion() diff --git a/tests/unit/commands/completion/__init__.py b/tests/unit/commands/completion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/commands/completion/test_actions.py b/tests/unit/commands/completion/test_actions.py new file mode 100644 index 00000000..75f3dead --- /dev/null +++ b/tests/unit/commands/completion/test_actions.py @@ -0,0 +1,7 @@ +from borgmatic.commands.completion import actions as module + + +def test_upgrade_message_does_not_raise(): + module.upgrade_message( + language='English', upgrade_command='read a lot', completion_file='your brain' + ) diff --git a/tests/unit/commands/completion/test_bash.py b/tests/unit/commands/completion/test_bash.py new file mode 100644 index 00000000..3f4265bd --- /dev/null +++ b/tests/unit/commands/completion/test_bash.py @@ -0,0 +1,17 @@ +from flexmock import flexmock + +from borgmatic.commands.completion import bash as module + + +def test_parser_flags_flattens_and_joins_flags(): + assert ( + module.parser_flags( + flexmock( + _actions=[ + flexmock(option_strings=['--foo', '--bar']), + flexmock(option_strings=['--baz']), + ] + ) + ) + == '--foo --bar --baz' + ) diff --git a/tests/unit/commands/test_completions.py b/tests/unit/commands/completion/test_fish.py similarity index 98% rename from tests/unit/commands/test_completions.py rename to tests/unit/commands/completion/test_fish.py index 12829d5f..35d53ed8 100644 --- a/tests/unit/commands/test_completions.py +++ b/tests/unit/commands/completion/test_fish.py @@ -5,7 +5,7 @@ from typing import Tuple import pytest from flexmock import flexmock -from borgmatic.commands import completion as module +from borgmatic.commands.completion import fish as module OptionType = namedtuple('OptionType', ['file', 'choice', 'unknown_required']) TestCase = Tuple[Action, OptionType]