Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions src/borg/archiver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,155 @@ def build_parser(self):
self.build_parser_version(subparsers, common_parser, mid_common_parser)
return parser

@staticmethod
def _first_positional_index(args, parser):
option_actions = {}
for action in parser._actions:
for option_string in getattr(action, "option_strings", ()):
option_actions[option_string] = action

i = 0
while i < len(args):
token = args[i]
if token == "--":
return i + 1 if i + 1 < len(args) else len(args)
if not token.startswith("-") or token == "-":
return i

option_name, has_value, _ = token.partition("=")
action = option_actions.get(option_name)
if action is None:
return None
if has_value:
i += 1
continue

nargs = getattr(action, "nargs", None)
if nargs in (0, "0"):
i += 1
elif nargs == "?":
if i + 1 < len(args) and not args[i + 1].startswith("-"):
i += 2
else:
i += 1
elif isinstance(nargs, int):
i += 1 + nargs
else:
i += 2

return len(args)

@staticmethod
def _first_toplevel_command_index(args, parser):
index = Archiver._first_positional_index(args, parser)
if index is None or index == len(args):
return None
return index

def _legacy_command_hint(self, args, parser):
command_index = self._first_toplevel_command_index(args, parser)
if command_index is None or args[command_index] != "init":
return None

corrected_args = list(args)
corrected_args[command_index] = "repo-create"
corrected_command = shlex.join([self.prog or "borg", *corrected_args])
return "\n".join(
[
"init is not a borg2 command; use repo-create.",
"Corrected command:",
corrected_command,
"Use `borg help` to see the list of valid commands.",
]
)

def _legacy_option_hint(self, args, parser):
command_index = self._first_toplevel_command_index(args, parser)
if command_index is None or args[command_index] != "list":
return None

if not any(arg == "--glob-archives" or arg.startswith("--glob-archives=") for arg in args[command_index + 1 :]):
return None

prog = self.prog or "borg"
example = shlex.join([prog, "list", "ARCHIVE", "--match-archives", "sh:my*"])
return "\n".join(
[
"--glob-archives is a borg1 option and is not used in borg2.",
"Use --match-archives in borg2. It defaults to exact `id:` matching, "
"so use `sh:` for borg1-style globbing.",
"Example:",
example,
f"tip: For details of accepted options run: {prog} list --help",
]
)

@staticmethod
def _option_value(args, option_strings):
for i, arg in enumerate(args):
for option_string in option_strings:
if arg == option_string:
return args[i + 1] if i + 1 < len(args) else None
if arg.startswith(option_string + "="):
return arg.split("=", 1)[1]
return None

def _legacy_repo_archive_hint(self, args, parser):
command_index = self._first_toplevel_command_index(args, parser)
if command_index is None or args[command_index] != "list":
return None

repo_value = self._option_value(args, ("-r", "--repo"))
if not repo_value or "::" not in repo_value:
return None

repo, archive = repo_value.split("::", 1)
if not repo or not archive:
return None

prog = self.prog or "borg"
corrected = shlex.join([prog, "--repo", repo, "list", f"::{archive}"])
export_cmd = f"export BORG_REPO={shlex.quote(repo)}"
positional = shlex.join([prog, "list", f"::{archive}"])
return "\n".join(
[
"Borg2 does not accept repo::archive in --repo.",
"Use one of these borg2 forms instead:",
corrected,
export_cmd,
positional,
f"tip: For details of accepted options run: {prog} list --help",
]
)

def _missing_list_name_hint(self, args, parser):
command_index = self._first_toplevel_command_index(args, parser)
if command_index is None or args[command_index] != "list":
return None

commands = getattr(parser, "_subcommands_action", None)
commands = commands._name_parser_map if commands else {}
list_parser = commands.get("list")
if list_parser is None:
return None

subcommand_args = args[command_index + 1 :]
positional_index = self._first_positional_index(subcommand_args, list_parser)
if positional_index is None or positional_index != len(subcommand_args):
return None

prog = self.prog or "borg"
repo_value = self._option_value(args, ("-r", "--repo")) or "REPO"
repo_list_command = shlex.join([prog, "-r", repo_value, "repo-list"])
return "\n".join(
[
"borg list NAME lists contents of an archive and needs an archive NAME.",
"If you meant to list archives in a repository, use repo-list:",
repo_list_command,
f"tip: For details of accepted options run: {prog} list --help",
]
)

def get_args(self, argv, cmd):
"""Usually just returns argv, except when dealing with an SSH forced command for borg serve."""
result = self.parse_args(argv[1:])
Expand Down Expand Up @@ -345,6 +494,19 @@ def parse_args(self, args=None):
if args:
args = self.preprocess_args(args)
parser = self.build_parser()
if args:
legacy_hint = self._legacy_command_hint(args, parser)
if legacy_hint:
parser.exit(EXIT_ERROR, legacy_hint + "\n")
legacy_hint = self._legacy_option_hint(args, parser)
if legacy_hint:
parser.exit(EXIT_ERROR, legacy_hint + "\n")
legacy_hint = self._legacy_repo_archive_hint(args, parser)
if legacy_hint:
parser.exit(EXIT_ERROR, legacy_hint + "\n")
legacy_hint = self._missing_list_name_hint(args, parser)
if legacy_hint:
parser.exit(EXIT_ERROR, legacy_hint + "\n")
args = parser.parse_args(args or ["-h"])
args = flatten_namespace(args)

Expand Down
107 changes: 106 additions & 1 deletion src/borg/testsuite/archiver/help_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from ...constants import * # NOQA
from ...helpers.nanorst import RstToTextLazy, rst_to_terminal
from . import Archiver, cmd
from . import Archiver, cmd, exec_cmd


def get_all_parsers():
Expand Down Expand Up @@ -43,6 +43,111 @@ def test_help(archiver):
assert "creates a new, empty repository" not in cmd(archiver, "help", "repo-create", "--usage-only")


def test_borg1_init_shows_repo_create_hint(archiver):
ret, output = exec_cmd(
"--repo",
archiver.repository_location,
"init",
"-e",
"repokey-aes-ocb",
archiver=archiver.archiver,
fork=archiver.FORK_DEFAULT,
exe=archiver.EXE,
)

assert ret == 2
assert "init is not a borg2 command; use repo-create." in output
assert "Corrected command:" in output
assert f"borg --repo {archiver.repository_location} repo-create -e repokey-aes-ocb" in output
assert "Use `borg help` to see the list of valid commands." in output


def test_borg1_glob_archives_shows_match_archives_hint(archiver):
ret, output = exec_cmd(
"--repo",
archiver.repository_location,
"list",
"--glob-archives",
"my*",
archiver=archiver.archiver,
fork=archiver.FORK_DEFAULT,
exe=archiver.EXE,
)

assert ret == 2
assert "--glob-archives is a borg1 option and is not used in borg2." in output
assert (
"Use --match-archives in borg2. It defaults to exact `id:` matching, "
"so use `sh:` for borg1-style globbing." in output
)
assert "Example:" in output
assert "borg list ARCHIVE --match-archives 'sh:my*'" in output
assert "tip: For details of accepted options run: borg list --help" in output


def test_borg1_repo_archive_in_repo_shows_borg2_forms(archiver):
ret, output = exec_cmd(
"--repo",
f"{archiver.repository_location}::test1",
"list",
archiver=archiver.archiver,
fork=archiver.FORK_DEFAULT,
exe=archiver.EXE,
)

assert ret == 2
assert "Borg2 does not accept repo::archive in --repo." in output
assert "Use one of these borg2 forms instead:" in output
assert f"borg --repo {archiver.repository_location} list ::test1" in output
assert f"export BORG_REPO={archiver.repository_location}" in output
assert "borg list ::test1" in output
assert "tip: For details of accepted options run: borg list --help" in output


def test_borg1_repo_archive_in_repo_shows_borg2_forms_when_repo_is_after_command(archiver):
ret, output = exec_cmd(
"list",
"--repo",
f"{archiver.repository_location}::test1",
archiver=archiver.archiver,
fork=archiver.FORK_DEFAULT,
exe=archiver.EXE,
)

assert ret == 2
assert "Borg2 does not accept repo::archive in --repo." in output
assert f"borg --repo {archiver.repository_location} list ::test1" in output
assert f"export BORG_REPO={archiver.repository_location}" in output
assert "borg list ::test1" in output


def test_list_without_name_suggests_repo_list(archiver):
ret, output = exec_cmd("list", archiver=archiver.archiver, fork=archiver.FORK_DEFAULT, exe=archiver.EXE)

assert ret == 2
assert "borg list NAME lists contents of an archive and needs an archive NAME." in output
assert "If you meant to list archives in a repository, use repo-list:" in output
assert "borg -r REPO repo-list" in output
assert "tip: For details of accepted options run: borg list --help" in output


def test_list_without_name_with_repo_suggests_repo_list(archiver):
ret, output = exec_cmd(
"--repo",
archiver.repository_location,
"list",
archiver=archiver.archiver,
fork=archiver.FORK_DEFAULT,
exe=archiver.EXE,
)

assert ret == 2
assert "borg list NAME lists contents of an archive and needs an archive NAME." in output
assert "If you meant to list archives in a repository, use repo-list:" in output
assert f"borg -r {archiver.repository_location} repo-list" in output
assert "tip: For details of accepted options run: borg list --help" in output


@pytest.mark.parametrize("command, parser", list(get_all_parsers().items()))
def test_help_formatting(command, parser):
if isinstance(parser.epilog, RstToTextLazy):
Expand Down