Browse Source

Fix Bash completion for sub-actions like "borgmatic config bootstrap" (#697 follow-on work).

Dan Helfman 2 years ago
parent
commit
bbc7f0596c

+ 1 - 1
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,

+ 4 - 3
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))

+ 0 - 0
borgmatic/commands/completion/__init__.py


+ 44 - 0
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)

+ 62 - 0
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',
+        )
+    )

+ 4 - 68
borgmatic/commands/completion.py → 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',

+ 0 - 0
tests/integration/commands/completion/__init__.py


+ 20 - 0
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

+ 5 - 0
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()

+ 5 - 0
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()

+ 0 - 9
tests/integration/commands/test_completion.py

@@ -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()

+ 0 - 0
tests/unit/commands/completion/__init__.py


+ 7 - 0
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'
+    )

+ 17 - 0
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'
+    )

+ 1 - 1
tests/unit/commands/test_completions.py → 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]