瀏覽代碼

Add fish shell completions support (#686).

Merge pull request #70 from isaec/feat/fish-completions
Dan Helfman 2 年之前
父節點
當前提交
4aae7968b8

+ 6 - 0
borgmatic/commands/arguments.py

@@ -207,6 +207,12 @@ def make_parsers():
         action='store_true',
         help='Show bash completion script and exit',
     )
+    global_group.add_argument(
+        '--fish-completion',
+        default=False,
+        action='store_true',
+        help='Show fish completion script and exit',
+    )
     global_group.add_argument(
         '--version',
         dest='version',

+ 3 - 0
borgmatic/commands/borgmatic.py

@@ -715,6 +715,9 @@ def main():  # pragma: no cover
     if global_arguments.bash_completion:
         print(borgmatic.commands.completion.bash_completion())
         sys.exit(0)
+    if global_arguments.fish_completion:
+        print(borgmatic.commands.completion.fish_completion())
+        sys.exit(0)
 
     config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
     configs, parse_logs = load_configurations(

+ 184 - 5
borgmatic/commands/completion.py

@@ -1,12 +1,18 @@
+import shlex
+from argparse import Action
+from textwrap import dedent
+
 from borgmatic.commands import arguments
 
-UPGRADE_MESSAGE = '''
-Your bash completions script is from a different version of borgmatic than is
+
+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:
 
-    sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"
-    source $BASH_SOURCE
+    {upgrade_command}
+    source {completion_file}
 '''
 
 
@@ -34,7 +40,11 @@ def bash_completion():
             '    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}\nEOF',
+            f'''        then cat << EOF\n{upgrade_message(
+                    'bash',
+                    'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"',
+                    '$BASH_SOURCE',
+                )}\nEOF''',
             '    fi',
             '}',
             'complete_borgmatic() {',
@@ -55,3 +65,172 @@ def bash_completion():
             '\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
         )
     )
+
+
+# fish section
+
+
+def has_file_options(action: Action):
+    '''
+    Given an argparse.Action instance, return True if it takes a file argument.
+    '''
+    return action.metavar in (
+        'FILENAME',
+        'PATH',
+    ) or action.dest in ('config_paths',)
+
+
+def has_choice_options(action: Action):
+    '''
+    Given an argparse.Action instance, return True if it takes one of a predefined set of arguments.
+    '''
+    return action.choices is not None
+
+
+def has_unknown_required_param_options(action: Action):
+    '''
+    A catch-all for options that take a required parameter, but we don't know what the parameter is.
+    This should be used last. These are actions that take something like a glob, a list of numbers, or a string.
+
+    Actions that match this pattern should not show the normal arguments, because those are unlikely to be valid.
+    '''
+    return (
+        action.required is True
+        or action.nargs
+        in (
+            '+',
+            '*',
+        )
+        or action.metavar in ('PATTERN', 'KEYS', 'N')
+        or (action.type is not None and action.default is None)
+    )
+
+
+def has_exact_options(action: Action):
+    return (
+        has_file_options(action)
+        or has_choice_options(action)
+        or has_unknown_required_param_options(action)
+    )
+
+
+def exact_options_completion(action: Action):
+    '''
+    Given an argparse.Action instance, return a completion invocation that forces file completions, options completion,
+    or just that some value follow the action, if the action takes such an argument and was the last action on the
+    command line prior to the cursor.
+
+    Otherwise, return an empty string.
+    '''
+
+    if not has_exact_options(action):
+        return ''
+
+    args = ' '.join(action.option_strings)
+
+    if has_file_options(action):
+        return f'''\ncomplete -c borgmatic -Fr -n "__borgmatic_current_arg {args}"'''
+
+    if has_choice_options(action):
+        return f'''\ncomplete -c borgmatic -f -a '{' '.join(map(str, action.choices))}' -n "__borgmatic_current_arg {args}"'''
+
+    if has_unknown_required_param_options(action):
+        return f'''\ncomplete -c borgmatic -x -n "__borgmatic_current_arg {args}"'''
+
+    raise ValueError(
+        f'Unexpected action: {action} passes has_exact_options but has no choices produced'
+    )
+
+
+def dedent_strip_as_tuple(string: str):
+    '''
+    Dedent a string, then strip it to avoid requiring your first line to have content, then return a tuple of the string.
+    Makes it easier to write multiline strings for completions when you join them with a tuple.
+    '''
+    return (dedent(string).strip('\n'),)
+
+
+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()
+
+    all_subparsers = ' '.join(action for action in subparsers.choices.keys())
+
+    exact_option_args = tuple(
+        ' '.join(action.option_strings)
+        for subparser in subparsers.choices.values()
+        for action in subparser._actions
+        if has_exact_options(action)
+    ) + tuple(
+        ' '.join(action.option_strings)
+        for action in top_level_parser._actions
+        if len(action.option_strings) > 0
+        if has_exact_options(action)
+    )
+
+    # Avert your eyes.
+    return '\n'.join(
+        dedent_strip_as_tuple(
+            f'''
+            function __borgmatic_check_version
+                set -fx this_filename (status current-filename)
+                fish -c '
+                    if test -f "$this_filename"
+                        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(
+                            'fish',
+                            'borgmatic --fish-completion | sudo tee $this_filename',
+                            '$this_filename',
+                        )}"
+                        end
+                    end
+                ' &
+            end
+            __borgmatic_check_version
+
+            function __borgmatic_current_arg --description 'Check if any of the given arguments are the last on the command line before the cursor'
+                set -l all_args (commandline -poc)
+                # premature optimization to avoid iterating all args if there aren't enough
+                # to have a last arg beyond borgmatic
+                if [ (count $all_args) -lt 2 ]
+                    return 1
+                end
+                for arg in $argv
+                    if [ "$arg" = "$all_args[-1]" ]
+                        return 0
+                    end
+                end
+                return 1
+            end
+
+            set --local subparser_condition "not __fish_seen_subcommand_from {all_subparsers}"
+            set --local exact_option_condition "not __borgmatic_current_arg {' '.join(exact_option_args)}"
+            '''
+        )
+        + ('\n# subparser completions',)
+        + tuple(
+            f'''complete -c borgmatic -f -n "$subparser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(subparser.description)}'''
+            for action_name, subparser in subparsers.choices.items()
+        )
+        + ('\n# global flags',)
+        + tuple(
+            # -n is checked in order, so put faster / more likely to be true checks first
+            f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)}{exact_options_completion(action)}'''
+            for action in top_level_parser._actions
+            # ignore the noargs action, as this is an impossible completion for fish
+            if len(action.option_strings) > 0
+            if 'Deprecated' not in action.help
+        )
+        + ('\n# subparser flags',)
+        + tuple(
+            f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)} -n "__fish_seen_subcommand_from {action_name}"{exact_options_completion(action)}'''
+            for action_name, subparser in subparsers.choices.items()
+            for action in subparser._actions
+            if 'Deprecated' not in action.help
+        )
+    )

+ 14 - 3
docs/how-to/set-up-backups.md

@@ -334,10 +334,13 @@ Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293).
 
 ### Shell completion
 
-borgmatic includes a shell completion script (currently only for Bash) to
+borgmatic includes a shell completion script (currently only for Bash and Fish) to
 support tab-completing borgmatic command-line actions and flags. Depending on
-how you installed borgmatic, this may be enabled by default. But if it's not,
-start by installing the `bash-completion` Linux package or the
+how you installed borgmatic, this may be enabled by default.
+
+#### Bash
+
+If completions aren't enabled, start by installing the `bash-completion` Linux package or the
 [`bash-completion@2`](https://formulae.brew.sh/formula/bash-completion@2)
 macOS Homebrew formula. Then, install the shell completion script globally:
 
@@ -362,6 +365,14 @@ borgmatic --bash-completion > ~/.local/share/bash-completion/completions/borgmat
 Finally, restart your shell (`exit` and open a new shell) so the completions
 take effect.
 
+#### fish
+
+To add completions for fish, install the completions file globally:
+
+```fish
+borgmatic --fish-completion | sudo tee /usr/share/fish/vendor_completions.d/borgmatic.fish
+source /usr/share/fish/vendor_completions.d/borgmatic.fish
+```
 
 ### Colored output
 

+ 3 - 3
scripts/run-full-tests

@@ -10,7 +10,7 @@
 
 set -e
 
-if [ -z "$TEST_CONTAINER" ] ; then
+if [ -z "$TEST_CONTAINER" ]; then
     echo "This script is designed to work inside a test container and is not intended to"
     echo "be run manually. If you're trying to run borgmatic's end-to-end tests, execute"
     echo "scripts/run-end-to-end-dev-tests instead."
@@ -18,14 +18,14 @@ if [ -z "$TEST_CONTAINER" ] ; then
 fi
 
 apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \
-    py3-ruamel.yaml py3-ruamel.yaml.clib bash sqlite
+    py3-ruamel.yaml py3-ruamel.yaml.clib bash sqlite fish
 # If certain dependencies of black are available in this version of Alpine, install them.
 apk add --no-cache py3-typed-ast py3-regex || true
 python3 -m pip install --no-cache --upgrade pip==22.2.2 setuptools==64.0.1
 pip3 install --ignore-installed tox==3.25.1
 export COVERAGE_FILE=/tmp/.coverage
 
-if [ "$1" != "--end-to-end-only" ] ; then
+if [ "$1" != "--end-to-end-only" ]; then
     tox --workdir /tmp/.tox --sitepackages
 fi
 

+ 4 - 0
tests/end-to-end/test_completion.py

@@ -3,3 +3,7 @@ import subprocess
 
 def test_bash_completion_runs_without_error():
     subprocess.check_call('borgmatic --bash-completion | bash', shell=True)
+
+
+def test_fish_completion_runs_without_error():
+    subprocess.check_call('borgmatic --fish-completion | fish', shell=True)

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

@@ -3,3 +3,7 @@ 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()

+ 124 - 0
tests/unit/commands/test_completions.py

@@ -0,0 +1,124 @@
+from argparse import Action
+from collections import namedtuple
+from typing import Tuple
+
+import pytest
+
+from borgmatic.commands import completion as module
+
+OptionType = namedtuple('OptionType', ['file', 'choice', 'unknown_required'])
+TestCase = Tuple[Action, OptionType]
+
+test_data: list[TestCase] = [
+    (Action('--flag', 'flag'), OptionType(file=False, choice=False, unknown_required=False)),
+    *(
+        (
+            Action('--flag', 'flag', metavar=metavar),
+            OptionType(file=True, choice=False, unknown_required=False),
+        )
+        for metavar in ('FILENAME', 'PATH')
+    ),
+    (
+        Action('--flag', dest='config_paths'),
+        OptionType(file=True, choice=False, unknown_required=False),
+    ),
+    (
+        Action('--flag', 'flag', metavar='OTHER'),
+        OptionType(file=False, choice=False, unknown_required=False),
+    ),
+    (
+        Action('--flag', 'flag', choices=['a', 'b']),
+        OptionType(file=False, choice=True, unknown_required=False),
+    ),
+    (
+        Action('--flag', 'flag', choices=['a', 'b'], type=str),
+        OptionType(file=False, choice=True, unknown_required=True),
+    ),
+    (
+        Action('--flag', 'flag', choices=None),
+        OptionType(file=False, choice=False, unknown_required=False),
+    ),
+    (
+        Action('--flag', 'flag', required=True),
+        OptionType(file=False, choice=False, unknown_required=True),
+    ),
+    *(
+        (
+            Action('--flag', 'flag', nargs=nargs),
+            OptionType(file=False, choice=False, unknown_required=True),
+        )
+        for nargs in ('+', '*')
+    ),
+    *(
+        (
+            Action('--flag', 'flag', metavar=metavar),
+            OptionType(file=False, choice=False, unknown_required=True),
+        )
+        for metavar in ('PATTERN', 'KEYS', 'N')
+    ),
+    *(
+        (
+            Action('--flag', 'flag', type=type, default=None),
+            OptionType(file=False, choice=False, unknown_required=True),
+        )
+        for type in (int, str)
+    ),
+    (
+        Action('--flag', 'flag', type=int, default=1),
+        OptionType(file=False, choice=False, unknown_required=False),
+    ),
+    (
+        Action('--flag', 'flag', type=str, required=True, metavar='PATH'),
+        OptionType(file=True, choice=False, unknown_required=True),
+    ),
+    (
+        Action('--flag', 'flag', type=str, required=True, metavar='PATH', default='/dev/null'),
+        OptionType(file=True, choice=False, unknown_required=True),
+    ),
+    (
+        Action('--flag', 'flag', type=str, required=False, metavar='PATH', default='/dev/null'),
+        OptionType(file=True, choice=False, unknown_required=False),
+    ),
+]
+
+
+@pytest.mark.parametrize('action, option_type', test_data)
+def test_has_file_options_detects_file_options(action: Action, option_type: OptionType):
+    assert module.has_file_options(action) == option_type.file
+
+
+@pytest.mark.parametrize('action, option_type', test_data)
+def test_has_choice_options_detects_choice_options(action: Action, option_type: OptionType):
+    assert module.has_choice_options(action) == option_type.choice
+
+
+@pytest.mark.parametrize('action, option_type', test_data)
+def test_has_unknown_required_param_options_detects_unknown_required_param_options(
+    action: Action, option_type: OptionType
+):
+    assert module.has_unknown_required_param_options(action) == option_type.unknown_required
+
+
+@pytest.mark.parametrize('action, option_type', test_data)
+def test_has_exact_options_detects_exact_options(action: Action, option_type: OptionType):
+    assert module.has_exact_options(action) == (True in option_type)
+
+
+@pytest.mark.parametrize('action, option_type', test_data)
+def test_exact_options_completion_produces_reasonable_completions(
+    action: Action, option_type: OptionType
+):
+    completion = module.exact_options_completion(action)
+    if True in option_type:
+        assert completion.startswith('\ncomplete -c borgmatic')
+    else:
+        assert completion == ''
+
+
+def test_dedent_strip_as_tuple_does_not_raise():
+    module.dedent_strip_as_tuple(
+        '''
+        a
+        b
+    '''
+    )