From 15f59233b5e980c80a2d63d93f3668493ad442f8 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 14 Nov 2025 23:49:03 +0100 Subject: [PATCH] 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). --- docs/man/borg-completion.1 | 76 +++++++ docs/usage.rst | 1 + docs/usage/completion.rst | 17 ++ docs/usage/completion.rst.inc | 50 +++++ pyproject.toml | 1 + src/borg/archiver/__init__.py | 3 + src/borg/archiver/completion_cmd.py | 198 ++++++++++++++++++ .../testsuite/archiver/completion_cmd_test.py | 17 ++ 8 files changed, 363 insertions(+) create mode 100644 docs/man/borg-completion.1 create mode 100644 docs/usage/completion.rst create mode 100644 docs/usage/completion.rst.inc create mode 100644 src/borg/archiver/completion_cmd.py create mode 100644 src/borg/testsuite/archiver/completion_cmd_test.py diff --git a/docs/man/borg-completion.1 b/docs/man/borg-completion.1 new file mode 100644 index 000000000..0b2f0783d --- /dev/null +++ b/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. +. diff --git a/docs/usage.rst b/docs/usage.rst index a07b3165b..ce9a82ea4 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -68,5 +68,6 @@ Usage usage/benchmark usage/help + usage/completion usage/debug usage/notes diff --git a/docs/usage/completion.rst b/docs/usage/completion.rst new file mode 100644 index 000000000..04d554c8f --- /dev/null +++ b/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)" + diff --git a/docs/usage/completion.rst.inc b/docs/usage/completion.rst.inc new file mode 100644 index 000000000..9ce0720d3 --- /dev/null +++ b/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 + + + +.. 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. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b8ec2784f..2a585cc7a 100644 --- a/pyproject.toml +++ b/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] diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index ce0b1e530..999ba8e1c 100644 --- a/src/borg/archiver/__init__.py +++ b/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) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py new file mode 100644 index 000000000..949c79105 --- /dev/null +++ b/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: 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 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: 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