Add fish shell completions support (#686).

Merge pull request #70 from isaec/feat/fish-completions
This commit is contained in:
Dan Helfman 2023-05-06 16:00:25 -07:00 committed by GitHub
commit 4aae7968b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 342 additions and 11 deletions

View file

@ -207,6 +207,12 @@ def make_parsers():
action='store_true', action='store_true',
help='Show bash completion script and exit', 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( global_group.add_argument(
'--version', '--version',
dest='version', dest='version',

View file

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

View file

@ -1,12 +1,18 @@
import shlex
from argparse import Action
from textwrap import dedent
from borgmatic.commands import arguments 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 currently installed. Please upgrade your script so your completions match the
command-line flags in your installed borgmatic! Try this to upgrade: command-line flags in your installed borgmatic! Try this to upgrade:
sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE" {upgrade_command}
source $BASH_SOURCE source {completion_file}
''' '''
@ -34,7 +40,11 @@ def bash_completion():
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"', ' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"', ' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];' ' 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', ' fi',
'}', '}',
'complete_borgmatic() {', 'complete_borgmatic() {',
@ -55,3 +65,172 @@ def bash_completion():
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic', '\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
)
)

View file

@ -334,10 +334,13 @@ Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293).
### Shell completion ### 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 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, how you installed borgmatic, this may be enabled by default.
start by installing the `bash-completion` Linux package or the
#### 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) [`bash-completion@2`](https://formulae.brew.sh/formula/bash-completion@2)
macOS Homebrew formula. Then, install the shell completion script globally: 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 Finally, restart your shell (`exit` and open a new shell) so the completions
take effect. 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 ### Colored output

View file

@ -18,7 +18,7 @@ if [ -z "$TEST_CONTAINER" ] ; then
fi fi
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \ 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. # If certain dependencies of black are available in this version of Alpine, install them.
apk add --no-cache py3-typed-ast py3-regex || true 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 python3 -m pip install --no-cache --upgrade pip==22.2.2 setuptools==64.0.1

View file

@ -3,3 +3,7 @@ import subprocess
def test_bash_completion_runs_without_error(): def test_bash_completion_runs_without_error():
subprocess.check_call('borgmatic --bash-completion | bash', shell=True) 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)

View file

@ -3,3 +3,7 @@ from borgmatic.commands import completion as module
def test_bash_completion_does_not_raise(): def test_bash_completion_does_not_raise():
assert module.bash_completion() assert module.bash_completion()
def test_fish_completion_does_not_raise():
assert module.fish_completion()

View file

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