Skip to content
Draft
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"jsonargparse>=4.27.0",
"shtab>=1.8.0",
]

Expand Down
6 changes: 3 additions & 3 deletions scripts/make.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def generate_level(self, prefix, parser, Archiver, extra_choices=None):
is_subcommand = False
choices = {}
for action in parser._actions:
if action.choices is not None and "SubParsersAction" in str(action.__class__):
if action.choices is not None and "_ActionSubCommands" in str(action.__class__):
is_subcommand = True
for cmd, parser in action.choices.items():
choices[prefix + cmd] = parser
Expand Down Expand Up @@ -323,7 +323,7 @@ def generate_level(self, prefix, parser, Archiver, extra_choices=None):
is_subcommand = False
choices = {}
for action in parser._actions:
if action.choices is not None and "SubParsersAction" in str(action.__class__):
if action.choices is not None and "_ActionSubCommands" in str(action.__class__):
is_subcommand = True
for cmd, parser in action.choices.items():
choices[prefix + cmd] = parser
Expand All @@ -349,7 +349,7 @@ def generate_level(self, prefix, parser, Archiver, extra_choices=None):

self.write_heading(write, "SYNOPSIS")
if is_intermediary:
subparsers = [action for action in parser._actions if "SubParsersAction" in str(action.__class__)][0]
subparsers = [action for action in parser._actions if "_ActionSubCommands" in str(action.__class__)][0]
for subcommand in subparsers.choices:
write("| borg", "[common options]", command, subcommand, "...")
self.see_also.setdefault(command, []).append(f"{command}-{subcommand}")
Expand Down
112 changes: 80 additions & 32 deletions src/borg/archiver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from ..helpers import ErrorIgnoringTextIOWrapper
from ..helpers import msgpack
from ..helpers import sig_int
from ..helpers.jap_wrapper import ArgumentParser, flatten_namespace
from ..remote import RemoteRepository
from ..selftest import selftest
except BaseException:
Expand All @@ -63,18 +64,6 @@
PURE_PYTHON_MSGPACK_WARNING = "Using a pure-python msgpack! This will result in lower performance."


def get_func(args):
# This works around https://bugs.python.org/issue9351
# func is used at the leaf parsers of the argparse parser tree,
# fallback_func at next level towards the root,
# fallback2_func at the 2nd next level (which is root in our case).
for name in "func", "fallback_func", "fallback2_func":
func = getattr(args, name, None)
if func is not None:
return func
raise Exception("expected func attributes not found")


from .analyze_cmd import AnalyzeMixIn
from .benchmark_cmd import BenchmarkMixIn
from .check_cmd import CheckMixIn
Expand Down Expand Up @@ -328,9 +317,7 @@ def resolve(self, args: argparse.Namespace): # Namespace has "in" but otherwise
def build_parser(self):
from ._common import define_common_options

parser = argparse.ArgumentParser(prog=self.prog, description="Borg - Deduplicated Backups", add_help=False)
# paths and patterns must have an empty list as default everywhere
parser.set_defaults(fallback2_func=functools.partial(self.do_maincommand_help, parser), paths=[], patterns=[])
parser = ArgumentParser(prog=self.prog, description="Borg - Deduplicated Backups", add_help=False)
parser.common_options = self.CommonOptions(
define_common_options, suffix_precedence=("_maincommand", "_midcommand", "_subcommand")
)
Expand All @@ -340,32 +327,29 @@ def build_parser(self):
parser.add_argument("--cockpit", dest="cockpit", action="store_true", help="Start the Borg TUI")
parser.common_options.add_common_group(parser, "_maincommand", provide_defaults=True)

common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog)
common_parser.set_defaults(paths=[], patterns=[])
common_parser = ArgumentParser(add_help=False, prog=self.prog)
parser.common_options.add_common_group(common_parser, "_subcommand")

mid_common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog)
mid_common_parser.set_defaults(paths=[], patterns=[])
mid_common_parser = ArgumentParser(add_help=False, prog=self.prog)
parser.common_options.add_common_group(mid_common_parser, "_midcommand")

if parser.prog == "borgfs":
return self.build_parser_borgfs(parser)

subparsers = parser.add_subparsers(title="required arguments", metavar="<command>")
subparsers = parser.add_subcommands(required=False)

# Phase 1: All level-1 subcommands (ALL must be added before any level-2).
# Non-nested commands:
self.build_parser_analyze(subparsers, common_parser, mid_common_parser)
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)
self.build_parser_diff(subparsers, common_parser, mid_common_parser)
self.build_parser_extract(subparsers, common_parser, mid_common_parser)
self.build_parser_help(subparsers, common_parser, mid_common_parser, parser)
self.build_parser_info(subparsers, common_parser, mid_common_parser)
self.build_parser_keys(subparsers, common_parser, mid_common_parser)
self.build_parser_list(subparsers, common_parser, mid_common_parser)
self.build_parser_locks(subparsers, common_parser, mid_common_parser)
self.build_parser_mount_umount(subparsers, common_parser, mid_common_parser)
Expand All @@ -384,12 +368,68 @@ def build_parser(self):
self.build_parser_transfer(subparsers, common_parser, mid_common_parser)
self.build_parser_undelete(subparsers, common_parser, mid_common_parser)
self.build_parser_version(subparsers, common_parser, mid_common_parser)
# Nested commands: add level-1 container parsers only
benchmark_parser = self.build_parser_benchmarks_l1(subparsers, mid_common_parser)
debug_parser = self.build_parser_debug_l1(subparsers, mid_common_parser)
key_parser = self.build_parser_keys_l1(subparsers, mid_common_parser)

# Phase 2: All level-2 subcommands (must be after ALL level-1 are added).
self.build_parser_benchmarks_l2(benchmark_parser, common_parser)
self.build_parser_debug_l2(debug_parser, common_parser)
self.build_parser_keys_l2(key_parser, common_parser)

# Build the commands dict for help and completion
self._commands = self._build_commands_dict(subparsers)

return parser

def _build_commands_dict(self, subparsers):
"""Build a dict mapping command names to their parsers for help/completion."""
commands = {}
# subparsers is an _ActionSubCommands instance with a .choices dict
for name, parser in subparsers.choices.items():
commands[name] = parser
# For nested subcommands (key, debug, benchmark), check _subcommands_action
nested_action = getattr(parser, "_subcommands_action", None)
if nested_action is not None and hasattr(nested_action, "choices"):
for sub_name, sub_parser in nested_action.choices.items():
commands[f"{name} {sub_name}"] = sub_parser
return commands

def get_func(self, args):
"""Get the handler function from the dispatch table based on subcommand name."""
subcmd = getattr(args, "subcommand", None)
if subcmd is None:
return functools.partial(self.do_maincommand_help, self.parser)

subcmd_ns = getattr(args, subcmd, None)
nested_subcmd = getattr(subcmd_ns, "subcommand", None) if subcmd_ns else None

if nested_subcmd is None:
method_name = f"do_{subcmd}".replace("-", "_")
else:
method_name = f"do_{subcmd}_{nested_subcmd}".replace("-", "_")

func = getattr(self, method_name, None)

if func is None:
# Fallback for container commands or unknown commands
if nested_subcmd is None and subcmd_ns is not None:
# Might be a container command without a subcommand selected (e.g. just "borg key")
subparser = getattr(self, "_commands", {}).get(subcmd)
return functools.partial(self.do_subcommand_help, subparser or self.parser)
return functools.partial(self.do_maincommand_help, self.parser)

# Special handling for "help" command which needs extra args
if subcmd == "help":
func = functools.partial(self.do_help, self.parser, getattr(self, "_commands", {}))

return func

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:])
if cmd is not None and result.func == self.do_serve:
if cmd is not None and self.get_func(result) == self.do_serve:
# borg serve case:
# - "result" is how borg got invoked (e.g. via forced command from authorized_keys),
# - "client_result" (from "cmd") refers to the command the client wanted to execute,
Expand All @@ -399,7 +439,7 @@ def get_args(self, argv, cmd):
# the borg command line.
client_argv = list(itertools.dropwhile(lambda arg: "=" in arg, client_argv))
client_result = self.parse_args(client_argv[1:])
if client_result.func == result.func:
if self.get_func(client_result) == self.get_func(result):
# make sure we only process like normal if the client is executing
# the same command as specified in the forced command, otherwise
# just skip this block and return the forced command (== result).
Expand All @@ -423,17 +463,24 @@ def parse_args(self, args=None):
if args:
args = self.preprocess_args(args)
parser = self.build_parser()
self.parser = parser # save for get_func and help
args = parser.parse_args(args or ["-h"])
# Flatten jsonargparse's nested namespace into a flat one
args = flatten_namespace(args)
parser.common_options.resolve(args)
func = get_func(args)
if func == self.do_create and args.paths and args.paths_from_stdin:
func = self.get_func(args)
if func == self.do_create and getattr(args, "paths", []) and getattr(args, "paths_from_stdin", False):
parser.error("Must not pass PATH with --paths-from-stdin.")
if args.progress and getattr(args, "output_list", False) and not args.log_json:
if (
getattr(args, "progress", False)
and getattr(args, "output_list", False)
and not getattr(args, "log_json", False)
):
parser.error("Options --progress and --list do not play nicely together.")
if func == self.do_create and not args.paths:
if args.content_from_command or args.paths_from_command:
if func == self.do_create and not getattr(args, "paths", []):
if getattr(args, "content_from_command", False) or getattr(args, "paths_from_command", False):
parser.error("No command given.")
elif not args.paths_from_stdin:
elif not getattr(args, "paths_from_stdin", False):
# need at least 1 path but args.paths may also be populated from patterns
parser.error("Need at least one PATH argument.")
# we can only have a complete knowledge of placeholder replacements we should do **after** arg parsing,
Expand Down Expand Up @@ -489,7 +536,7 @@ def _setup_topic_debugging(self, args):
def run(self, args):
os.umask(args.umask) # early, before opening files
self.lock_wait = args.lock_wait
func = get_func(args)
func = self.get_func(args)
# do not use loggers before this!
is_serve = func == self.do_serve
self.log_json = args.log_json and not is_serve
Expand Down Expand Up @@ -545,6 +592,7 @@ def run(self, args):
else:
rc = func(args)
assert rc is None

return get_ec(rc)


Expand Down
9 changes: 5 additions & 4 deletions src/borg/archiver/analyze_cmd.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse

from collections import defaultdict
import os

Expand All @@ -7,6 +8,7 @@
from ..constants import * # NOQA
from ..helpers import bin_to_hex, Error
from ..helpers import ProgressIndicatorPercent
from ..helpers.jap_wrapper import ArgumentParser
from ..manifest import Manifest
from ..remote import RemoteRepository
from ..repository import Repository
Expand Down Expand Up @@ -126,14 +128,13 @@ def build_parser_analyze(self, subparsers, common_parser, mid_common_parser):
to recreate existing archives without them.
"""
)
subparser = subparsers.add_parser(
"analyze",
subparser = ArgumentParser(
parents=[common_parser],
add_help=False,
description=self.do_analyze.__doc__,
epilog=analyze_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
help="analyze archives",
)
subparser.set_defaults(func=self.do_analyze)

subparsers.add_subcommand("analyze", subparser, help="analyze archives")
define_archive_filters_group(subparser)
33 changes: 18 additions & 15 deletions src/borg/archiver/benchmark_cmd.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import argparse

from contextlib import contextmanager
import functools
import os
import tempfile
import time
Expand All @@ -10,6 +10,7 @@
from ..helpers import format_file_size
from ..helpers import msgpack
from ..helpers import get_reset_ec
from ..helpers.jap_wrapper import ArgumentParser
from ..item import Item
from ..platform import SyncFile

Expand Down Expand Up @@ -250,23 +251,27 @@ def chunkit(ch):
spec = "msgpack"
print(f"{spec:<12} {size:<10} {timeit(lambda: msgpack.packb(items), number=100):.3f}s")

def build_parser_benchmarks(self, subparsers, common_parser, mid_common_parser):
def build_parser_benchmarks_l1(self, subparsers, mid_common_parser):
"""Phase 1: Add the 'benchmark' container subcommand."""
from ._common import process_epilog

benchmark_epilog = process_epilog("These commands do various benchmarks.")

subparser = subparsers.add_parser(
"benchmark",
subparser = ArgumentParser(
parents=[mid_common_parser],
add_help=False,
description="benchmark command",
epilog=benchmark_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
help="benchmark command",
)
subparsers.add_subcommand("benchmark", subparser, help="benchmark command")
return subparser

def build_parser_benchmarks_l2(self, benchmark_parser, common_parser):
"""Phase 2: Add leaf subcommands under the 'benchmark' container."""
from ._common import process_epilog

benchmark_parsers = subparser.add_subparsers(title="required arguments", metavar="<command>")
subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser))
benchmark_parsers = benchmark_parser.add_subcommands(required=False)

bench_crud_epilog = process_epilog(
"""
Expand Down Expand Up @@ -309,16 +314,16 @@ def build_parser_benchmarks(self, subparsers, common_parser, mid_common_parser):
Try multiple measurements and having a otherwise idle machine (and network, if you use it).
"""
)
subparser = benchmark_parsers.add_parser(
"crud",
subparser = ArgumentParser(
parents=[common_parser],
add_help=False,
description=self.do_benchmark_crud.__doc__,
epilog=bench_crud_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
help="benchmarks Borg CRUD (create, extract, update, delete).",
)
subparser.set_defaults(func=self.do_benchmark_crud)
benchmark_parsers.add_subcommand(
"crud", subparser, help="benchmarks Borg CRUD (create, extract, update, delete)."
)

subparser.add_argument("path", metavar="PATH", help="path where to create benchmark input data")

Expand All @@ -333,13 +338,11 @@ def build_parser_benchmarks(self, subparsers, common_parser, mid_common_parser):
- enough free memory so there will be no slow down due to paging activity
"""
)
subparser = benchmark_parsers.add_parser(
"cpu",
subparser = ArgumentParser(
parents=[common_parser],
add_help=False,
description=self.do_benchmark_cpu.__doc__,
epilog=bench_cpu_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
help="benchmarks Borg CPU-bound operations.",
)
subparser.set_defaults(func=self.do_benchmark_cpu)
benchmark_parsers.add_subcommand("cpu", subparser, help="benchmarks Borg CPU-bound operations.")
9 changes: 5 additions & 4 deletions src/borg/archiver/check_cmd.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import argparse

from ._common import with_repository, Highlander
from ..archive import ArchiveChecker
from ..constants import * # NOQA
from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, IntegrityError
from ..helpers import yes
from ..helpers.jap_wrapper import ArgumentParser

from ..logger import create_logger

Expand Down Expand Up @@ -182,16 +184,15 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser):
``borg compact`` would remove the archives' data completely.
"""
)
subparser = subparsers.add_parser(
"check",
subparser = ArgumentParser(
parents=[common_parser],
add_help=False,
description=self.do_check.__doc__,
epilog=check_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
help="verify the repository",
)
subparser.set_defaults(func=self.do_check)

subparsers.add_subcommand("check", subparser, help="verify the repository")
subparser.add_argument(
"--repository-only", dest="repo_only", action="store_true", help="only perform repository checks"
)
Expand Down
Loading