ソースを参照

completion: borg can now generate completion scripts for supported shells, fixes #9172

Added `shtab` dependency for shell completion functionality:
- bash completion (works).
- zsh completion (known-broken due to iterative/shtab#183).
Thomas Waldmann 3 週間 前
コミット
15f59233b5

+ 76 - 0
docs/man/borg-completion.1

@@ -0,0 +1,76 @@
+.\" Man page generated from reStructuredText.
+.
+.
+.nr rst2man-indent-level 0
+.
+.de1 rstReportMargin
+\\$1 \\n[an-margin]
+level \\n[rst2man-indent-level]
+level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
+-
+\\n[rst2man-indent0]
+\\n[rst2man-indent1]
+\\n[rst2man-indent2]
+..
+.de1 INDENT
+.\" .rstReportMargin pre:
+. RS \\$1
+. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin]
+. nr rst2man-indent-level +1
+.\" .rstReportMargin post:
+..
+.de UNINDENT
+. RE
+.\" indent \\n[an-margin]
+.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]]
+.nr rst2man-indent-level -1
+.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
+.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
+..
+.TH "BORG-COMPLETION" "1" "2025-11-17" "" "borg backup tool"
+.SH NAME
+borg-completion \- Output shell completion script for the given shell.
+.SH SYNOPSIS
+.sp
+borg [common options] completion [options] SHELL
+.SH DESCRIPTION
+.sp
+This command prints a shell completion script for the given shell.
+.sp
+Please note that for some dynamic completions (like archive IDs), the shell
+completion script will call borg to query the repository. This will work best
+if that call can be made without prompting for user input, so you may want to
+set BORG_REPO and BORG_PASSPHRASE environment variables.
+.SH OPTIONS
+.sp
+See \fIborg\-common(1)\fP for common options of Borg commands.
+.SS arguments
+.INDENT 0.0
+.TP
+.B SHELL
+shell to generate completion for (one of: %(choices)s)
+.UNINDENT
+.SH EXAMPLES
+.sp
+To activate completion in your current shell session, evaluate the output
+of this command. To enable it persistently, add the corresponding line to
+your shell\(aqs startup file.
+.INDENT 0.0
+.INDENT 3.5
+.sp
+.EX
+# Bash (in ~/.bashrc)
+eval \(dq$(borg completion bash)\(dq
+
+# Zsh (in ~/.zshrc)
+eval \(dq$(borg completion zsh)\(dq
+.EE
+.UNINDENT
+.UNINDENT
+.SH SEE ALSO
+.sp
+\fIborg\-common(1)\fP
+.SH AUTHOR
+The Borg Collective
+.\" Generated by docutils manpage writer.
+.

+ 1 - 0
docs/usage.rst

@@ -68,5 +68,6 @@ Usage
    usage/benchmark
 
    usage/help
+   usage/completion
    usage/debug
    usage/notes

+ 17 - 0
docs/usage/completion.rst

@@ -0,0 +1,17 @@
+.. include:: completion.rst.inc
+
+Examples
+~~~~~~~~
+
+To activate completion in your current shell session, evaluate the output
+of this command. To enable it persistently, add the corresponding line to
+your shell's startup file.
+
+::
+
+    # Bash (in ~/.bashrc)
+    eval "$(borg completion bash)"
+
+    # Zsh (in ~/.zshrc)
+    eval "$(borg completion zsh)"
+

+ 50 - 0
docs/usage/completion.rst.inc

@@ -0,0 +1,50 @@
+.. IMPORTANT: this file is auto-generated from borg's built-in help, do not edit!
+
+.. _borg_completion:
+
+borg completion
+---------------
+.. code-block:: none
+
+    borg [common options] completion [options] SHELL
+
+.. only:: html
+
+    .. class:: borg-options-table
+
+    +-------------------------------------------------------+-----------+--------------------------------------------------------+
+    | **positional arguments**                                                                                                   |
+    +-------------------------------------------------------+-----------+--------------------------------------------------------+
+    |                                                       | ``SHELL`` | shell to generate completion for (one of: %(choices)s) |
+    +-------------------------------------------------------+-----------+--------------------------------------------------------+
+    | .. class:: borg-common-opt-ref                                                                                             |
+    |                                                                                                                            |
+    | :ref:`common_options`                                                                                                      |
+    +-------------------------------------------------------+-----------+--------------------------------------------------------+
+
+    .. raw:: html
+
+        <script type='text/javascript'>
+        $(document).ready(function () {
+            $('.borg-options-table colgroup').remove();
+        })
+        </script>
+
+.. only:: latex
+
+    SHELL
+        shell to generate completion for (one of: %(choices)s)
+
+
+    :ref:`common_options`
+        |
+
+Description
+~~~~~~~~~~~
+
+This command prints a shell completion script for the given shell.
+
+Please note that for some dynamic completions (like archive IDs), the shell
+completion script will call borg to query the repository. This will work best
+if that call can be made without prompting for user input, so you may want to
+set BORG_REPO and BORG_PASSPHRASE environment variables.

+ 1 - 0
pyproject.toml

@@ -37,6 +37,7 @@ dependencies = [
   "platformdirs >=3.0.0, <5.0.0; sys_platform == 'darwin'",  # for macOS: breaking changes in 3.0.0.
   "platformdirs >=2.6.0, <5.0.0; sys_platform != 'darwin'",  # for others: 2.6+ works consistently.
   "argon2-cffi",
+  "shtab>=1.7.0",
 ]
 
 [project.optional-dependencies]

+ 3 - 0
src/borg/archiver/__init__.py

@@ -79,6 +79,7 @@ from .analyze_cmd import AnalyzeMixIn
 from .benchmark_cmd import BenchmarkMixIn
 from .check_cmd import CheckMixIn
 from .compact_cmd import CompactMixIn
+from .completion_cmd import CompletionMixIn
 from .create_cmd import CreateMixIn
 from .debug_cmd import DebugMixIn
 from .delete_cmd import DeleteMixIn
@@ -112,6 +113,7 @@ class Archiver(
     BenchmarkMixIn,
     CheckMixIn,
     CompactMixIn,
+    CompletionMixIn,
     CreateMixIn,
     DebugMixIn,
     DeleteMixIn,
@@ -354,6 +356,7 @@ class Archiver(
         self.build_parser_benchmarks(subparsers, common_parser, mid_common_parser)
         self.build_parser_check(subparsers, common_parser, mid_common_parser)
         self.build_parser_compact(subparsers, common_parser, mid_common_parser)
+        self.build_parser_completion(subparsers, common_parser, mid_common_parser)
         self.build_parser_create(subparsers, common_parser, mid_common_parser)
         self.build_parser_debug(subparsers, common_parser, mid_common_parser)
         self.build_parser_delete(subparsers, common_parser, mid_common_parser)

+ 198 - 0
src/borg/archiver/completion_cmd.py

@@ -0,0 +1,198 @@
+import argparse
+
+import shtab
+
+from ._common import process_epilog
+from ..constants import *  # NOQA
+from ..helpers import archivename_validator  # used to detect ARCHIVE args for dynamic completion
+
+# Dynamic completion for archive IDs (aid:...)
+#
+# This integrates with shtab by:
+# - tagging argparse actions that accept an ARCHIVE (identified by type == archivename_validator)
+#   with a .complete mapping pointing to our helper function.
+# - using shtab.complete's 'preamble' parameter to inject the helper into the
+#   generated completion script for supported shells.
+#
+# Notes / constraints (per plan):
+# - Calls `borg repo-list --format ...` and filters results by the typed aid: hex prefix.
+# - Non-interactive only. We rely on Borg to fail fast without prompting in non-interactive contexts.
+#   If it cannot, we simply return no suggestions.
+
+# Name of the helper function inserted into the generated completion script(s)
+AID_BASH_FN_NAME = "_borg_complete_aid"
+AID_ZSH_FN_NAME = "_borg_complete_aid"
+
+# Global bash preamble that is prepended to the generated completion script.
+# It aggregates only what we need:
+# - wordbreak fixes for ':' and '=' so tokens like 'aid:' and '--repo=/path' stay intact
+# - a minimal dynamic completion helper for aid: archive IDs
+BASH_PREAMBLE = r"""
+# keep ':' and '=' intact so tokens like 'aid:' and '--repo=/path' stay whole
+if [[ ${COMP_WORDBREAKS-} == *:* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//:}; fi
+if [[ ${COMP_WORDBREAKS-} == *=* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//=}; fi
+
+# Complete aid:<hex-prefix> archive IDs by querying "borg repo-list --short"
+# Note: we only suggest the first 8 hex digits (short ID) for completion.
+_borg_complete_aid() {
+  local cur="${COMP_WORDS[COMP_CWORD]}"
+  [[ "$cur" == aid:* ]] || return 0
+
+  local prefix="${cur#aid:}"
+  [[ -n "$prefix" && ! "$prefix" =~ ^[0-9a-fA-F]*$ ]] && return 0
+
+  # derive repo context from words: --repo=V, --repo V, -r=V, -rV, or -r V
+  local repo_arg=()
+  local i w
+  for (( i=0; i<${#COMP_WORDS[@]}; i++ )); do
+    w="${COMP_WORDS[i]}"
+    if [[ "$w" == --repo=* ]]; then repo_arg=( --repo "${w#--repo=}" ); break
+    elif [[ "$w" == -r=* ]]; then repo_arg=( -r "${w#-r=}" ); break
+    elif [[ "$w" == -r* && "$w" != "-r" ]]; then repo_arg=( -r "${w#-r}" ); break
+    elif [[ "$w" == "--repo" || "$w" == "-r" ]]; then
+      if (( i+1 < ${#COMP_WORDS[@]} )); then repo_arg=( "$w" "${COMP_WORDS[i+1]}" ); fi
+      break
+    fi
+  done
+
+  # ask borg for raw IDs; avoid prompts and suppress stderr
+  local out
+  if [[ -n "${repo_arg[*]}" ]]; then
+    out=$( borg repo-list "${repo_arg[@]}" --short 2>/dev/null </dev/null )
+  else
+    out=$( borg repo-list --short 2>/dev/null </dev/null )
+  fi
+  [[ -z "$out" ]] && return 0
+
+  # filter by (case-insensitive) hex prefix and emit candidates
+  local IFS=$'\n' id prelower idlower
+  prelower="$(printf '%s' "$prefix" | tr '[:upper:]' '[:lower:]')"
+  while IFS= read -r id; do
+    [[ -z "$id" ]] && continue
+    idlower="$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]')"
+    # Print only the first 8 hex digits of the ID for completion suggestions.
+    [[ "$idlower" == "$prelower"* ]] && printf 'aid:%s\n' "${id:0:8}"
+  done <<< "$out"
+  return 0
+}
+"""
+
+
+# Global zsh preamble providing dynamic completion for aid:<hex> archive IDs.
+#
+# Notes:
+# - We use zsh's $words/$CURRENT arrays to inspect the command line.
+# - Candidates are returned via `compadd`.
+# - We try to detect repo context from --repo=V, --repo V, -r=V, -rV, -r V.
+ZSH_PREAMBLE = r"""
+# Complete aid:<hex-prefix> archive IDs by querying "borg repo-list --short"
+# Note: we only suggest the first 8 hex digits (short ID) for completion.
+_borg_complete_aid() {
+  local cur
+  cur="${words[$CURRENT]}"
+  [[ "$cur" == aid:* ]] || return 0
+
+  local prefix="${cur#aid:}"
+  # allow only hex digits as prefix; empty prefix also allowed (list all)
+  [[ -n "$prefix" && ! "$prefix" == [0-9a-fA-F]# ]] && return 0
+
+  # derive repo context from words: --repo=V, --repo V, -r=V, -rV, or -r V
+  local -a repo_arg=()
+  local i w
+  for i in {1..$#words}; do
+    w="$words[$i]"
+    if [[ "$w" == --repo=* ]]; then repo_arg=( --repo "${w#--repo=}" ); break
+    elif [[ "$w" == -r=* ]]; then repo_arg=( -r "${w#-r=}" ); break
+    elif [[ "$w" == -r* && "$w" != "-r" ]]; then repo_arg=( -r "${w#-r}" ); break
+    elif [[ "$w" == "--repo" || "$w" == "-r" ]]; then
+      if (( i+1 <= $#words )); then repo_arg=( "$w" "${words[$((i+1))]}" ); fi
+      break
+    fi
+  done
+
+  # ask borg for raw IDs; avoid prompts and suppress stderr
+  local out
+  if (( ${#repo_arg[@]} > 0 )); then
+    out=$( borg repo-list "${repo_arg[@]}" --short 2>/dev/null </dev/null )
+  else
+    out=$( borg repo-list --short 2>/dev/null </dev/null )
+  fi
+  [[ -z "$out" ]] && return 0
+
+  # filter by (case-insensitive) hex prefix and emit candidates
+  local prelower id idlower
+  prelower="${prefix:l}"
+  local -a candidates=()
+  for id in ${(f)out}; do
+    [[ -z "$id" ]] && continue
+    idlower="${id:l}"
+    if [[ "$idlower" == "$prelower"* ]]; then
+      candidates+=( "aid:${id[1,8]}" )
+    fi
+  done
+  # -Q: do not escape special chars, so ':' remains as-is
+  compadd -Q -- $candidates
+  return 0
+}
+"""
+
+
+def _attach_aid_completion(parser: argparse.ArgumentParser):
+    """Tag all arguments that accept an ARCHIVE with aid:-completion.
+
+    We detect ARCHIVE arguments by their type being archivename_validator.
+    This function mutates the parser actions to add a .complete mapping used by shtab.
+    """
+
+    for action in parser._actions:
+        # Recurse into subparsers
+        if isinstance(action, argparse._SubParsersAction):
+            for sub in action.choices.values():
+                _attach_aid_completion(sub)
+            continue
+
+        # Assign dynamic completion only for arguments that take an archive name.
+        if action.type is archivename_validator:
+            action.complete = {"bash": AID_BASH_FN_NAME, "zsh": AID_ZSH_FN_NAME}  # type: ignore[attr-defined]
+
+
+class CompletionMixIn:
+    def do_completion(self, args):
+        """Output shell completion script for the given shell."""
+        # Automagically generates completions for subcommands and options. Also
+        # adds dynamic completion for archive IDs with the aid: prefix for all ARCHIVE
+        # arguments (identified by archivename_validator). It reuses `borg repo-list`
+        # to enumerate archives and does not introduce any new commands or caching.
+        parser = self.build_parser()
+        _attach_aid_completion(parser)
+        preamble = {"bash": BASH_PREAMBLE, "zsh": ZSH_PREAMBLE}
+        script = shtab.complete(parser, shell=args.shell, preamble=preamble)  # nosec B604
+        print(script)
+
+    def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
+        shells = tuple(shtab.SUPPORTED_SHELLS)
+
+        completion_epilog = process_epilog(
+            """
+        This command prints a shell completion script for the given shell.
+
+        Please note that for some dynamic completions (like archive IDs), the shell
+        completion script will call borg to query the repository. This will work best
+        if that call can be made without prompting for user input, so you may want to
+        set BORG_REPO and BORG_PASSPHRASE environment variables.
+        """
+        )
+
+        subparser = subparsers.add_parser(
+            "completion",
+            parents=[common_parser],
+            add_help=False,
+            description=self.do_completion.__doc__,
+            epilog=completion_epilog,
+            formatter_class=argparse.RawDescriptionHelpFormatter,
+            help="output shell completion script",
+        )
+        subparser.set_defaults(func=self.do_completion)
+        subparser.add_argument(
+            "shell", metavar="SHELL", choices=shells, help="shell to generate completion for (one of: %(choices)s)"
+        )

+ 17 - 0
src/borg/testsuite/archiver/completion_cmd_test.py

@@ -0,0 +1,17 @@
+from . import cmd, generate_archiver_tests
+
+pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local")  # NOQA
+
+
+def test_bash_completion(archivers, request):
+    """Ensure the generated Bash completion includes our helper."""
+    archiver = request.getfixturevalue(archivers)
+    output = cmd(archiver, "completion", "bash")
+    assert "_borg_complete_aid() {" in output
+
+
+def test_zsh_completion(archivers, request):
+    """Ensure the generated Zsh completion includes our helper."""
+    archiver = request.getfixturevalue(archivers)
+    output = cmd(archiver, "completion", "zsh")
+    assert "_borg_complete_aid() {" in output