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
40 changes: 40 additions & 0 deletions scripts/test-ux.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
set -x
# Stable argparse usage() wrapping when comparing to test-ux.sh.blessed_stderr.
export COLUMNS="${COLUMNS:-80}"
# Fixed paths; remove leftovers so each run matches test-ux.sh.blessed_stderr.
rm -rf /tmp/demo-repo
#errors that should be have helpful help

borg --repo /tmp/demo-repo init -e repokey-aes-ocb
borg --repo /tmp/demo-repo rcreate -e repokey-aes-ocb

#Typo suggestions (Did you mean ...?)

borg repo-creat
borg repoo-list
Borg1 -> Borg2 option hints

borg --repo /tmp/demo-repo list --glob-archives 'my*'
borg --repo /tmp/demo-repo create --numeric-owner test ~/data
borg --repo /tmp/demo-repo create --nobsdflags test ~/data
borg --repo /tmp/demo-repo create --remote-ratelimit 1000 test ~/data

#Missing encryption guidance for repo-create

borg --repo /tmp/demo-repo repo-create

#repo::archive migration help (BORG_REPO / --repo guidance)

borg --repo /tmp/demo-repo::test1 list

#Missing repo recovery hint (includes repo-create example + -e modes)

borg --repo /tmp/does-not-exist repo-info
borg --repo /tmp/does-not-exist list

#Common fixes block (missing repo / unknown command)

borg list
borg frobnicate

#Options are preserved by command-line correction.
263 changes: 263 additions & 0 deletions scripts/test-ux.sh.blessed_stderr

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/borg/archiver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,28 @@ def print_file_status(self, status, path):
logging.getLogger("borg.output.list").info("%1s %s", status, remove_surrogates(path))

def preprocess_args(self, args):
borg1_option_equivalents = {
"--glob-archives": "--match-archives 'sh:PATTERN'",
"--numeric-owner": "--numeric-ids",
"--nobsdflags": "--noflags",
"--remote-ratelimit": "--upload-ratelimit",
}
deprecations = [
# ('--old', '--new' or None, 'Warning: "--old" has been deprecated. Use "--new" instead.'),
]
seen_borg1_options = []
for arg in args:
if arg in borg1_option_equivalents and arg not in seen_borg1_options:
seen_borg1_options.append(arg)
if seen_borg1_options:
print("Common fixes:", file=sys.stderr)
for arg in seen_borg1_options:
print(
f'- borg1 option "{arg}" is not used in borg2. ' f'Use "{borg1_option_equivalents[arg]}" instead.',
file=sys.stderr,
)
if "--glob-archives" in seen_borg1_options:
print("- Example: borg list ARCHIVE --match-archives 'sh:old-*'", file=sys.stderr)
for i, arg in enumerate(args[:]):
for old_name, new_name, warning in deprecations:
# either --old_name or --old_name=...
Expand Down
281 changes: 281 additions & 0 deletions src/borg/helpers/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@
unchanged.
"""

import difflib
import os
import re
import shlex
import sys
from typing import Any

# here are the only imports from argparse and jsonargparse,
Expand All @@ -103,12 +108,288 @@
from jsonargparse import Namespace, ActionSubCommands, SUPPRESS, REMAINDER # noqa: F401
from jsonargparse.typing import register_type, PositiveInt # noqa: F401

# Borg 1.x / informal names -> borg2 top-level subcommand (same list as parser choices targets).
_TOP_COMMAND_SYNONYMS = {
"init": "repo-create",
"rcreate": "repo-create",
"repocreate": "repo-create",
"rm": "delete",
"clean": "compact",
"unrm": "undelete",
"undel": "undelete",
"restore": "undelete",
}

# Example line after 'Maybe you meant `<canonical>` not `<typed>`:\n\t' (placeholders intentionally generic).
_TOP_COMMAND_EXAMPLES = {
"repo-create": "borg -r REPO repo-create -e repokey-aes-ocb",
"delete": "borg -r REPO delete ARCHIVE_OR_AID",
"compact": "borg -r REPO compact",
"undelete": "borg -r REPO undelete …",
"list": "borg -r REPO list ARCHIVE",
}

# Top-level subcommand names (must match build_parser / <command> choices).
_TOP_LEVEL_COMMANDS = frozenset(
{
"analyze",
"benchmark",
"check",
"compact",
"completion",
"create",
"debug",
"delete",
"diff",
"extract",
"help",
"info",
"key",
"list",
"break-lock",
"with-lock",
"mount",
"umount",
"prune",
"repo-compress",
"repo-create",
"repo-delete",
"repo-info",
"repo-list",
"recreate",
"rename",
"repo-space",
"serve",
"tag",
"export-tar",
"import-tar",
"transfer",
"undelete",
"version",
}
)


def _parse_unrecognized_arguments_raw(message: str) -> str | None:
if "unrecognized arguments" not in message.lower():
return None
m = re.search(r"Unrecognized arguments:\s*(.+?)(?:\n|$)", message, re.IGNORECASE | re.DOTALL)
if not m:
return None
return m.group(1).strip()


def _find_contiguous_subsequence(haystack: list[str], needle: list[str]) -> int | None:
if not needle or len(needle) > len(haystack):
return None
for i in range(len(haystack) - len(needle) + 1):
if haystack[i : i + len(needle)] == needle:
return i
return None


def _remove_contiguous_subsequence(haystack: list[str], needle: list[str]) -> list[str] | None:
i = _find_contiguous_subsequence(haystack, needle)
if i is None:
return None
return haystack[:i] + haystack[i + len(needle) :]


def _suggest_move_options_after_subcommand(message: str) -> str | None:
"""
If the user put subcommand-specific flags before <command> (e.g. borg --stats create ...),
suggest the same argv with those flags after the subcommand.
"""
raw = _parse_unrecognized_arguments_raw(message)
if not raw:
return None
try:
tokens = shlex.split(raw)
except ValueError:
return None
if not tokens:
return None
argv = sys.argv
sub_idx = None
for i, a in enumerate(argv):
if a in _TOP_LEVEL_COMMANDS:
sub_idx = i
break
if sub_idx is None or sub_idx < 2:
return None
prefix = argv[1:sub_idx]
if _find_contiguous_subsequence(prefix, tokens) is None:
return None
keep = _remove_contiguous_subsequence(prefix, tokens)
if keep is None:
return None
corrected = [argv[0]] + keep + [argv[sub_idx]] + tokens + argv[sub_idx + 1 :]
return " ".join(shlex.quote(c) for c in corrected)


def _argv_tail_after_invalid_choice(invalid: str) -> list[str]:
"""Tokens after the invalid top-level subcommand in sys.argv, if any."""
try:
idx = sys.argv.index(invalid)
except ValueError:
return []
return sys.argv[idx + 1 :]


def _repo_path_from_argv() -> str | None:
"""Return the path/URL after ``-r``/``--repo`` in ``sys.argv``, if present."""
argv = sys.argv
for i, a in enumerate(argv):
if a in ("--repo", "-r") and i + 1 < len(argv):
return argv[i + 1]
return None


def _local_repo_path_missing(repo_path: str) -> bool:
"""True if *repo_path* looks like a local filesystem path and does not exist."""
if not repo_path or "://" in repo_path:
return False
if repo_path.startswith("/") or (len(repo_path) > 2 and repo_path[1] == ":"):
return not os.path.exists(repo_path)
return False


def _argv_display_for_hint(argv: list[str]) -> list[str]:
"""Normalize argv to a readable `borg ...` line when launched via python -m or a borg binary."""
if (
len(argv) >= 3
and os.path.basename(argv[0]).lower().startswith("python")
and argv[1] == "-m"
and argv[2] == "borg"
):
return ["borg"] + argv[3:]
if len(argv) >= 1 and os.path.basename(argv[0]).lower() in ("borg", "borg.exe"):
return ["borg"] + argv[1:]
return list(argv)


def _corrected_command_line_for_invalid_subcommand(invalid: str, canonical: str) -> str | None:
"""Replace invalid with canonical in sys.argv; keep all other tokens (same order)."""
try:
idx = sys.argv.index(invalid)
except ValueError:
return None
if idx < 1:
return None
argv = list(sys.argv)
argv[idx] = canonical
display = _argv_display_for_hint(argv)
if not display:
return None
return " ".join(shlex.quote(a) for a in display)


def _apply_argv_tail_to_example(canonical: str, example: str, argv_tail: list[str]) -> str:
"""Replace generic placeholders with argv tokens the user actually typed after the bad command."""
if not argv_tail:
return example
tail = " ".join(shlex.quote(a) for a in argv_tail)
if canonical == "delete" and "ARCHIVE_OR_AID" in example:
return example.replace("ARCHIVE_OR_AID", tail)
if canonical == "list" and "ARCHIVE" in example:
return example.replace("ARCHIVE", tail)
if canonical == "undelete" and "…" in example:
return example.replace("…", tail)
return example


class ArgumentParser(_ArgumentParser):
# the borg code always uses RawDescriptionHelpFormatter and add_help=False:
def __init__(self, *args, formatter_class=RawDescriptionHelpFormatter, add_help=False, **kwargs):
super().__init__(*args, formatter_class=formatter_class, add_help=add_help, **kwargs)

def _top_command_choice_hint(self, message: str) -> str | None:
match = re.search(r"invalid choice: '([^']+)' \(choose from ([^)]+)\)", message)
if not match:
return None
invalid = match.group(1)
choices = [choice.strip().strip("'\"") for choice in match.group(2).split(",")]
canonical = _TOP_COMMAND_SYNONYMS.get(invalid)
if canonical is None:
candidates = difflib.get_close_matches(invalid, choices, n=1, cutoff=0.6)
if not candidates:
return None
canonical = candidates[0]
if canonical == invalid:
return None
example = _corrected_command_line_for_invalid_subcommand(invalid, canonical)
if example is None:
example = _TOP_COMMAND_EXAMPLES.get(canonical, f"borg -r REPO {canonical}")
example = _apply_argv_tail_to_example(canonical, example, _argv_tail_after_invalid_choice(invalid))
return f"Maybe you meant `{canonical}` not `{invalid}`:\n\t{example}"

def _common_fix_hints(self, message: str) -> list[str]:
hints = []
reorder = _suggest_move_options_after_subcommand(message)
if reorder:
hints.append(f"Put subcommand-specific options after `<command>`: {reorder}")
if "missing repository" in message.lower():
hints.append("Set the repository via --repo REPO or BORG_REPO.")
list_name_missing = "list.name is none" in message.lower() or ("list.name" in message and "is None" in message)
if list_name_missing:
hints.append("For 'borg list', set repository via -r/--repo or BORG_REPO and pass an archive name.")
if "invalid choice" in message and "<command>" in message:
cmd_hint = self._top_command_choice_hint(message)
if cmd_hint:
hints.append(cmd_hint)
hints.append("Run 'borg help' to list valid borg2 commands.")
return hints

def error(self, message, *args, **kwargs):
message = str(message)
if "Option 'repo-create.encryption' is required but not provided" in message:
from ..crypto.key import key_argument_names

modes = key_argument_names()
mode_list = ", ".join(modes)
message = (
f"{message}\n"
"Use -e/--encryption to choose a mode, for example: -e repokey-aes-ocb\n"
f"Available encryption modes: {mode_list}"
)
if "Option 'list.paths' is required but not provided" in message:
repo_path = _repo_path_from_argv()
if repo_path and _local_repo_path_missing(repo_path):
from ..crypto.key import key_argument_names

mode_list = ", ".join(key_argument_names())
message = (
f"{message}\n\n"
"Common fixes:\n"
f'- "{repo_path}" does not exist, pick a repository that exists or create one:\n'
f"\tborg repo-create --repo {repo_path} -e repokey-aes-ocb\n"
f"Available -e modes: {mode_list}"
)
else:
message = (
f"{message}\n"
"borg list requires an archive NAME to list contents.\n"
"Common fixes:\n"
"- Provide archive name: borg list NAME\n"
"- To list archives in a repository, use: borg -r REPO repo-list"
)
loc_match = re.search(r'Invalid location format: "([^"]+)"', message)
if loc_match and "::" in loc_match.group(1):
repo, archive = loc_match.group(1).split("::", 1)
message = (
f"{message}\n\n"
"Common fixes:\n"
" * Borg 2 does not accept repo::archive syntax. Corrected command lines:\n"
f"\tborg list --repo {repo} list ::{archive}\n"
f"\t\tOR\n"
f"\texport BORG_REPO={repo}\n"
f"\tborg list ::{archive}"
)
common_hints = self._common_fix_hints(message)
if common_hints:
message = f"{message}\nCommon fixes:\n- " + "\n- ".join(common_hints)
super().error(message, *args, **kwargs)


def flatten_namespace(ns: Any) -> Namespace:
"""
Expand Down
3 changes: 3 additions & 0 deletions src/borg/helpers/parseformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,9 @@ def parse(self, text, overrides={}):

self.raw = text # as given by user, might contain placeholders
self.processed = replace_placeholders(self.raw, overrides) # after placeholder replacement
if "::" in self.processed:
# Keep this message short; ArgumentParser.error() adds Common fixes with examples.
raise ValueError(f'Invalid location format: "{self.processed}".')
valid = self._parse(self.processed)
if valid:
self.valid = True
Expand Down
Loading
Loading