From 77b84f8a4888fc2d674ddb8680da79b9ae08aa63 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 26 May 2022 10:27:53 -0700 Subject: [PATCH] Add Bash completion script so you can tab-complete the borgmatic command-line. --- NEWS | 3 + borgmatic/commands/arguments.py | 21 ++++++- borgmatic/commands/borgmatic.py | 4 ++ borgmatic/commands/completion.py | 60 +++++++++++++++++++ docs/how-to/set-up-backups.md | 30 +++++++++- tests/end-to-end/test_completion.py | 5 ++ tests/integration/commands/test_completion.py | 5 ++ 7 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 borgmatic/commands/completion.py create mode 100644 tests/end-to-end/test_completion.py create mode 100644 tests/integration/commands/test_completion.py diff --git a/NEWS b/NEWS index bec7e4a..95d99f1 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,9 @@ 1.6.2.dev0 * #536: Fix generate-borgmatic-config with "--source" flag to support more complex schema changes like the new Healthchecks configuration options. + * Add Bash completion script so you can tab-complete the borgmatic command-line. See the + documentation for more information: + https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion 1.6.1 * #294: Add Healthchecks monitoring hook "ping_body_limit" option to configure how many bytes of diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 143a963..bb215ae 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -109,10 +109,9 @@ class Extend_action(Action): setattr(namespace, self.dest, list(values)) -def parse_arguments(*unparsed_arguments): +def make_parsers(): ''' - Given command-line arguments with which this script was invoked, parse the arguments and return - them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance. + Build a top-level parser and its subparsers and return them as a tuple. ''' config_paths = collect.get_default_config_paths(expand_home=True) unexpanded_config_paths = collect.get_default_config_paths(expand_home=False) @@ -189,6 +188,12 @@ def parse_arguments(*unparsed_arguments): action='extend', help='One or more configuration file options to override with specified values', ) + global_group.add_argument( + '--bash-completion', + default=False, + action='store_true', + help='Show bash completion script and exit', + ) global_group.add_argument( '--version', dest='version', @@ -647,6 +652,16 @@ def parse_arguments(*unparsed_arguments): ) borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + return top_level_parser, subparsers + + +def parse_arguments(*unparsed_arguments): + ''' + Given command-line arguments with which this script was invoked, parse the arguments and return + them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance. + ''' + top_level_parser, subparsers = make_parsers() + arguments, remaining_arguments = parse_subparser_arguments( unparsed_arguments, subparsers.choices ) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index d8d2559..9e45988 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -11,6 +11,7 @@ from subprocess import CalledProcessError import colorama import pkg_resources +import borgmatic.commands.completion from borgmatic.borg import borg as borg_borg from borgmatic.borg import check as borg_check from borgmatic.borg import compact as borg_compact @@ -884,6 +885,9 @@ def main(): # pragma: no cover if global_arguments.version: print(pkg_resources.require('borgmatic')[0].version) sys.exit(0) + if global_arguments.bash_completion: + print(borgmatic.commands.completion.bash_completion()) + sys.exit(0) config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides) diff --git a/borgmatic/commands/completion.py b/borgmatic/commands/completion.py new file mode 100644 index 0000000..6239232 --- /dev/null +++ b/borgmatic/commands/completion.py @@ -0,0 +1,60 @@ +import pkg_resources + +from borgmatic.commands import arguments + +UPGRADE_MESSAGE = ''' +Your bash 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-completions > $BASH_SOURCE" + source $BASH_SOURCE +''' + + +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()) + borgmatic_version = pkg_resources.require('borgmatic')[0].version + + # Avert your eyes. + return '\n'.join( + ( + 'check_version() {', + ' local installed_version="$(borgmatic --version 2> /dev/null)"', + ' if [ "$installed_version" != "%s" ] && [ "$installed_version" != "" ];' + % borgmatic_version, + ' then cat << EOF\n%s\nEOF' % UPGRADE_MESSAGE, + ' 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]}"))' + % (actions, global_flags), + ' (check_version &)', + '}', + '\ncomplete -F complete_borgmatic borgmatic', + ) + ) diff --git a/docs/how-to/set-up-backups.md b/docs/how-to/set-up-backups.md index 4b5fa74..76e448d 100644 --- a/docs/how-to/set-up-backups.md +++ b/docs/how-to/set-up-backups.md @@ -111,6 +111,7 @@ Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and [Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage offerings, but do not currently fund borgmatic development or hosting. + ## Configuration After you install borgmatic, generate a sample configuration file: @@ -302,9 +303,34 @@ interested in an [unofficial work-around for Full Disk Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293). -## Colored output +## Niceties -Borgmatic produces colored terminal output by default. It is disabled when a + +### Shell completion + +borgmatic includes a shell completion script (currently only for Bash) 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, +you can install the shell completion script globally: + +```bash +sudo su -c "borgmatic --bash-completion > /usr/share/bash-completion/completions/borgmatic" +``` + +Alternatively, if you'd like to install the script for just the current user: + +```bash +mkdir --parents ~/.local/share/bash-completion/completions +borgmatic --bash-completion > ~/.local/share/bash-completion/completions/borgmatic +``` + +In either case, you may also need to install the `bash-completion` Linux +package and restart your shell (`exit` and open a new shell). + + +### Colored output + +borgmatic produces colored terminal output by default. It is disabled when a non-interactive terminal is detected (like a cron job), or when you use the `--json` flag. Otherwise, you can disable it by passing the `--no-color` flag, setting the environment variable `PY_COLORS=False`, or setting the `color` diff --git a/tests/end-to-end/test_completion.py b/tests/end-to-end/test_completion.py new file mode 100644 index 0000000..7975106 --- /dev/null +++ b/tests/end-to-end/test_completion.py @@ -0,0 +1,5 @@ +import subprocess + + +def test_bash_completion_runs_without_error(): + subprocess.check_call('eval "$(borgmatic --bash-completion)"', shell=True) diff --git a/tests/integration/commands/test_completion.py b/tests/integration/commands/test_completion.py new file mode 100644 index 0000000..a3b0b9c --- /dev/null +++ b/tests/integration/commands/test_completion.py @@ -0,0 +1,5 @@ +from borgmatic.commands import completion as module + + +def test_bash_completion_does_not_raise(): + assert module.bash_completion()