Add fish shell completions support (#686).
Merge pull request #70 from isaec/feat/fish-completions
This commit is contained in:
commit
4aae7968b8
8 changed files with 342 additions and 11 deletions
|
@ -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',
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
set -e
|
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 "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 "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."
|
echo "scripts/run-end-to-end-dev-tests instead."
|
||||||
|
@ -18,14 +18,14 @@ 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
|
||||||
pip3 install --ignore-installed tox==3.25.1
|
pip3 install --ignore-installed tox==3.25.1
|
||||||
export COVERAGE_FILE=/tmp/.coverage
|
export COVERAGE_FILE=/tmp/.coverage
|
||||||
|
|
||||||
if [ "$1" != "--end-to-end-only" ] ; then
|
if [ "$1" != "--end-to-end-only" ]; then
|
||||||
tox --workdir /tmp/.tox --sitepackages
|
tox --workdir /tmp/.tox --sitepackages
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
124
tests/unit/commands/test_completions.py
Normal file
124
tests/unit/commands/test_completions.py
Normal 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
|
||||||
|
'''
|
||||||
|
)
|
Loading…
Reference in a new issue