diff --git a/pyproject.toml b/pyproject.toml index e828a0e8c..03e689d87 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", + "jsonargparse>=4.27.0", "shtab>=1.8.0", ] diff --git a/scripts/make.py b/scripts/make.py index f7bae4d41..51d3ee9d5 100644 --- a/scripts/make.py +++ b/scripts/make.py @@ -46,7 +46,7 @@ class BuildUsage: 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 @@ -323,7 +323,7 @@ class BuildMan: 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 @@ -349,7 +349,7 @@ class BuildMan: 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}") diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 834bf7b5c..329157b44 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -46,6 +46,7 @@ try: 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: @@ -63,18 +64,6 @@ STATS_HEADER = " Original size Deduplicated size" 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 @@ -328,9 +317,7 @@ class Archiver( 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") ) @@ -340,32 +327,29 @@ class Archiver( 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="") + 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) @@ -384,12 +368,68 @@ class Archiver( 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, @@ -399,7 +439,7 @@ class Archiver( # 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). @@ -423,17 +463,24 @@ class Archiver( 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, @@ -486,7 +533,7 @@ class Archiver( 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 @@ -542,6 +589,7 @@ class Archiver( else: rc = func(args) assert rc is None + return get_ec(rc) diff --git a/src/borg/archiver/analyze_cmd.py b/src/borg/archiver/analyze_cmd.py index e55609588..821ae440c 100644 --- a/src/borg/archiver/analyze_cmd.py +++ b/src/borg/archiver/analyze_cmd.py @@ -1,4 +1,5 @@ import argparse + from collections import defaultdict import os @@ -7,6 +8,7 @@ from ..archive import Archive 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 @@ -126,14 +128,13 @@ class AnalyzeMixIn: 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) diff --git a/src/borg/archiver/benchmark_cmd.py b/src/borg/archiver/benchmark_cmd.py index b1b241c4a..d85e900a9 100644 --- a/src/borg/archiver/benchmark_cmd.py +++ b/src/borg/archiver/benchmark_cmd.py @@ -1,6 +1,6 @@ import argparse + from contextlib import contextmanager -import functools import os import tempfile import time @@ -10,6 +10,7 @@ from ..crypto.key import FlexiKey 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 @@ -250,23 +251,27 @@ class BenchmarkMixIn: 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 - benchmark_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, 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 = benchmark_parser.add_subcommands(required=False) bench_crud_epilog = process_epilog( """ @@ -309,16 +314,16 @@ class BenchmarkMixIn: 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") @@ -333,13 +338,11 @@ class BenchmarkMixIn: - 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.") diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index e78aef563..1935988b5 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -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 @@ -182,16 +184,15 @@ class CheckMixIn: ``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" ) diff --git a/src/borg/archiver/compact_cmd.py b/src/borg/archiver/compact_cmd.py index 1cd07a0ba..e875e1c88 100644 --- a/src/borg/archiver/compact_cmd.py +++ b/src/borg/archiver/compact_cmd.py @@ -1,4 +1,5 @@ import argparse + from pathlib import Path from ._common import with_repository @@ -10,6 +11,7 @@ from ..constants import * # NOQA from ..hashindex import ChunkIndex, ChunkIndexEntry from ..helpers import set_ec, EXIT_ERROR, format_file_size, bin_to_hex from ..helpers import ProgressIndicatorPercent +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..remote import RemoteRepository from ..repository import Repository, repo_lister @@ -257,16 +259,15 @@ class CompactMixIn: thus it cannot compute before/after compaction size statistics). """ ) - subparser = subparsers.add_parser( - "compact", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_compact.__doc__, epilog=compact_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="compact the repository", ) - subparser.set_defaults(func=self.do_compact) + + subparsers.add_subcommand("compact", subparser, help="compact the repository") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index abcc04f79..c304e4004 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -66,6 +66,7 @@ from ..helpers import ( relative_time_marker_validator, parse_file_size, ) +from ..helpers.jap_wrapper import ArgumentParser from ..helpers.time import timestamp from ..compress import CompressionSpec from ..helpers.parseformat import partial_format @@ -750,16 +751,14 @@ class CompletionMixIn: """ ) - subparser = subparsers.add_parser( - "completion", + subparser = ArgumentParser( 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) + subparsers.add_subcommand("completion", subparser, help="output shell completion script") subparser.add_argument( "shell", metavar="SHELL", choices=shells, help="shell to generate completion for (one of: %(choices)s)" ) diff --git a/src/borg/archiver/create_cmd.py b/src/borg/archiver/create_cmd.py index cbbdefc3c..543bde8e2 100644 --- a/src/borg/archiver/create_cmd.py +++ b/src/borg/archiver/create_cmd.py @@ -1,6 +1,7 @@ import errno import sys import argparse + import logging import os import posixpath @@ -31,6 +32,7 @@ from ..helpers import sig_int, ignore_sigint from ..helpers import iter_separated from ..helpers import MakePathSafeAction from ..helpers import Error, CommandError, BackupWarning, FileChangedWarning +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..patterns import PatternMatcher from ..platform import is_win32 @@ -672,7 +674,6 @@ class CreateMixIn: macOS examples are the apfs mounts of a typical macOS installation. Therefore, when using ``--one-file-system``, you should double-check that the backup works as intended. - .. _list_item_flags: Item flags @@ -765,16 +766,15 @@ class CreateMixIn: """ ) - subparser = subparsers.add_parser( - "create", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_create.__doc__, epilog=create_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="create a backup", ) - subparser.set_defaults(func=self.do_create) + + subparsers.add_subcommand("create", subparser, help="create a backup") # note: --dry-run and --stats are mutually exclusive, but we do not want to abort when # parsing, but rather proceed with the dry-run, but without stats (see run() method). diff --git a/src/borg/archiver/debug_cmd.py b/src/borg/archiver/debug_cmd.py index bb05c53d9..832fd4f09 100644 --- a/src/borg/archiver/debug_cmd.py +++ b/src/borg/archiver/debug_cmd.py @@ -1,5 +1,5 @@ import argparse -import functools + import json import textwrap @@ -13,6 +13,7 @@ from ..helpers import dash_open from ..helpers import StableDict from ..helpers import archivename_validator from ..helpers import CommandError, RTError +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..platform import get_process_id from ..repository import Repository, LIST_SCAN_LIMIT, repo_lister @@ -308,7 +309,8 @@ class DebugMixIn: with open(args.output, "wb") as wfd, open(args.input, "rb") as rfd: marshal.dump(msgpack.unpack(rfd, use_list=False, raw=False), wfd) - def build_parser_debug(self, subparsers, common_parser, mid_common_parser): + def build_parser_debug_l1(self, subparsers, mid_common_parser): + """Phase 1: Add the 'debug' container subcommand.""" debug_epilog = process_epilog( """ These commands are not intended for normal use and potentially very @@ -319,18 +321,20 @@ class DebugMixIn: what you are doing or if a trusted developer tells you what to do.""" ) - subparser = subparsers.add_parser( - "debug", + subparser = ArgumentParser( parents=[mid_common_parser], add_help=False, description="debugging command (not intended for normal use)", epilog=debug_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="debugging command (not intended for normal use)", ) + subparsers.add_subcommand("debug", subparser, help="debugging command (not intended for normal use)") + return subparser - debug_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + def build_parser_debug_l2(self, debug_parser, common_parser): + """Phase 2: Add leaf subcommands under the 'debug' container.""" + + debug_parsers = debug_parser.add_subcommands(required=False) debug_info_epilog = process_epilog( """ @@ -339,32 +343,28 @@ class DebugMixIn: already appended at the end of the traceback. """ ) - subparser = debug_parsers.add_parser( - "info", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_info.__doc__, epilog=debug_info_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="show system infos for debugging / bug reports (debug)", ) - subparser.set_defaults(func=self.do_debug_info) + debug_parsers.add_subcommand("info", subparser, help="show system infos for debugging / bug reports (debug)") debug_dump_archive_items_epilog = process_epilog( """ This command dumps raw (but decrypted and decompressed) archive items (only metadata) to files. """ ) - subparser = debug_parsers.add_parser( - "dump-archive-items", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_dump_archive_items.__doc__, epilog=debug_dump_archive_items_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="dump archive items (metadata) (debug)", ) - subparser.set_defaults(func=self.do_debug_dump_archive_items) + debug_parsers.add_subcommand("dump-archive-items", subparser, help="dump archive items (metadata) (debug)") subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") debug_dump_archive_epilog = process_epilog( @@ -372,16 +372,14 @@ class DebugMixIn: This command dumps all metadata of an archive in a decoded form to a file. """ ) - subparser = debug_parsers.add_parser( - "dump-archive", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_dump_archive.__doc__, epilog=debug_dump_archive_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="dump decoded archive metadata (debug)", ) - subparser.set_defaults(func=self.do_debug_dump_archive) + debug_parsers.add_subcommand("dump-archive", subparser, help="dump decoded archive metadata (debug)") subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument("path", metavar="PATH", type=str, help="file to dump data into") @@ -390,16 +388,14 @@ class DebugMixIn: This command dumps manifest metadata of a repository in a decoded form to a file. """ ) - subparser = debug_parsers.add_parser( - "dump-manifest", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_dump_manifest.__doc__, epilog=debug_dump_manifest_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="dump decoded repository metadata (debug)", ) - subparser.set_defaults(func=self.do_debug_dump_manifest) + debug_parsers.add_subcommand("dump-manifest", subparser, help="dump decoded repository metadata (debug)") subparser.add_argument("path", metavar="PATH", type=str, help="file to dump data into") debug_dump_repo_objs_epilog = process_epilog( @@ -407,32 +403,28 @@ class DebugMixIn: This command dumps raw (but decrypted and decompressed) repo objects to files. """ ) - subparser = debug_parsers.add_parser( - "dump-repo-objs", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_dump_repo_objs.__doc__, epilog=debug_dump_repo_objs_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="dump repo objects (debug)", ) - subparser.set_defaults(func=self.do_debug_dump_repo_objs) + debug_parsers.add_subcommand("dump-repo-objs", subparser, help="dump repo objects (debug)") debug_search_repo_objs_epilog = process_epilog( """ This command searches raw (but decrypted and decompressed) repo objects for a specific bytes sequence. """ ) - subparser = debug_parsers.add_parser( - "search-repo-objs", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_search_repo_objs.__doc__, epilog=debug_search_repo_objs_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="search repo objects (debug)", ) - subparser.set_defaults(func=self.do_debug_search_repo_objs) + debug_parsers.add_subcommand("search-repo-objs", subparser, help="search repo objects (debug)") subparser.add_argument( "wanted", metavar="WANTED", @@ -445,16 +437,14 @@ class DebugMixIn: This command computes the id-hash for some file content. """ ) - subparser = debug_parsers.add_parser( - "id-hash", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_id_hash.__doc__, epilog=debug_id_hash_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="compute id-hash for some file content (debug)", ) - subparser.set_defaults(func=self.do_debug_id_hash) + debug_parsers.add_subcommand("id-hash", subparser, help="compute id-hash for some file content (debug)") subparser.add_argument( "path", metavar="PATH", type=str, help="content for which the id-hash shall get computed" ) @@ -465,16 +455,14 @@ class DebugMixIn: This command parses the object file into metadata (as json) and uncompressed data. """ ) - subparser = debug_parsers.add_parser( - "parse-obj", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_parse_obj.__doc__, epilog=debug_parse_obj_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="parse borg object file into meta dict and data", ) - subparser.set_defaults(func=self.do_debug_parse_obj) + debug_parsers.add_subcommand("parse-obj", subparser, help="parse borg object file into meta dict and data") subparser.add_argument("id", metavar="ID", type=str, help="hex object ID to get from the repo") subparser.add_argument( "object_path", metavar="OBJECT_PATH", type=str, help="path of the object file to parse data from" @@ -492,16 +480,14 @@ class DebugMixIn: This command formats the file and metadata into a Borg object file. """ ) - subparser = debug_parsers.add_parser( - "format-obj", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_format_obj.__doc__, epilog=debug_format_obj_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="format file and metadata into a Borg object file", ) - subparser.set_defaults(func=self.do_debug_format_obj) + debug_parsers.add_subcommand("format-obj", subparser, help="format file and metadata into a Borg object file") subparser.add_argument("id", metavar="ID", type=str, help="hex object ID to get from the repo") subparser.add_argument( "binary_path", metavar="BINARY_PATH", type=str, help="path of the file to convert into an object file" @@ -531,16 +517,14 @@ class DebugMixIn: This command gets an object from the repository. """ ) - subparser = debug_parsers.add_parser( - "get-obj", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_get_obj.__doc__, epilog=debug_get_obj_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="get object from repository (debug)", ) - subparser.set_defaults(func=self.do_debug_get_obj) + debug_parsers.add_subcommand("get-obj", subparser, help="get object from repository (debug)") subparser.add_argument("id", metavar="ID", type=str, help="hex object ID to get from the repo") subparser.add_argument("path", metavar="PATH", type=str, help="file to write object data into") @@ -549,16 +533,14 @@ class DebugMixIn: This command puts an object into the repository. """ ) - subparser = debug_parsers.add_parser( - "put-obj", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_put_obj.__doc__, epilog=debug_put_obj_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="put object to repository (debug)", ) - subparser.set_defaults(func=self.do_debug_put_obj) + debug_parsers.add_subcommand("put-obj", subparser, help="put object to repository (debug)") subparser.add_argument("id", metavar="ID", type=str, help="hex object ID to put into the repo") subparser.add_argument("path", metavar="PATH", type=str, help="file to read and create object from") @@ -567,16 +549,14 @@ class DebugMixIn: This command deletes objects from the repository. """ ) - subparser = debug_parsers.add_parser( - "delete-obj", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_delete_obj.__doc__, epilog=debug_delete_obj_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="delete object from repository (debug)", ) - subparser.set_defaults(func=self.do_debug_delete_obj) + debug_parsers.add_subcommand("delete-obj", subparser, help="delete object from repository (debug)") subparser.add_argument( "ids", metavar="IDs", nargs="+", type=str, help="hex object ID(s) to delete from the repo" ) @@ -586,15 +566,15 @@ class DebugMixIn: Convert a Borg profile to a Python cProfile compatible profile. """ ) - subparser = debug_parsers.add_parser( - "convert-profile", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_debug_convert_profile.__doc__, epilog=debug_convert_profile_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="convert Borg profile to Python profile (debug)", ) - subparser.set_defaults(func=self.do_debug_convert_profile) + debug_parsers.add_subcommand( + "convert-profile", subparser, help="convert Borg profile to Python profile (debug)" + ) subparser.add_argument("input", metavar="INPUT", type=str, help="Borg profile") subparser.add_argument("output", metavar="OUTPUT", type=str, help="Output file") diff --git a/src/borg/archiver/delete_cmd.py b/src/borg/archiver/delete_cmd.py index 742b15144..375b09acd 100644 --- a/src/borg/archiver/delete_cmd.py +++ b/src/borg/archiver/delete_cmd.py @@ -1,9 +1,11 @@ import argparse + import logging from ._common import with_repository from ..constants import * # NOQA from ..helpers import format_archive, CommandError, bin_to_hex, archivename_validator +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -78,16 +80,15 @@ class DeleteMixIn: patterns, see :ref:`borg_patterns`). """ ) - subparser = subparsers.add_parser( - "delete", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_delete.__doc__, epilog=delete_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="delete archives", ) - subparser.set_defaults(func=self.do_delete) + + subparsers.add_subcommand("delete", subparser, help="delete archives") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/diff_cmd.py b/src/borg/archiver/diff_cmd.py index 0ea0954e8..f6a6d0119 100644 --- a/src/borg/archiver/diff_cmd.py +++ b/src/borg/archiver/diff_cmd.py @@ -1,4 +1,5 @@ import argparse + import textwrap import json import sys @@ -9,6 +10,7 @@ from ..archive import Archive from ..constants import * # NOQA from ..helpers import BaseFormatter, DiffFormatter, archivename_validator, PathSpec, BorgJsonEncoder from ..helpers import IncludePatternNeverMatchedWarning, remove_surrogates +from ..helpers.jap_wrapper import ArgumentParser from ..item import ItemDiff from ..manifest import Manifest from ..logger import create_logger @@ -203,7 +205,6 @@ class DiffMixIn: The following keys are always available: - """ ) + BaseFormatter.keys_help() @@ -293,16 +294,15 @@ class DiffMixIn: raise argparse.ArgumentTypeError(f"unsupported sort field: {field}") return ",".join(parts) - subparser = subparsers.add_parser( - "diff", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_diff.__doc__, epilog=diff_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="find differences in archive contents", ) - subparser.set_defaults(func=self.do_diff) + + subparsers.add_subcommand("diff", subparser, help="find differences in archive contents") subparser.add_argument( "--numeric-ids", dest="numeric_ids", diff --git a/src/borg/archiver/extract_cmd.py b/src/borg/archiver/extract_cmd.py index a3885a0c1..06d6f5b6f 100644 --- a/src/borg/archiver/extract_cmd.py +++ b/src/borg/archiver/extract_cmd.py @@ -1,5 +1,6 @@ import sys import argparse + import logging import stat @@ -12,6 +13,7 @@ from ..helpers import remove_surrogates from ..helpers import HardLinkManager from ..helpers import ProgressIndicatorPercent from ..helpers import BackupWarning, IncludePatternNeverMatchedWarning +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -154,16 +156,15 @@ class ExtractMixIn: group, permissions, etc. """ ) - subparser = subparsers.add_parser( - "extract", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_extract.__doc__, epilog=extract_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="extract archive contents", ) - subparser.set_defaults(func=self.do_extract) + + subparsers.add_subcommand("extract", subparser, help="extract archive contents") subparser.add_argument( "--list", dest="output_list", action="store_true", help="output a verbose list of items (files, dirs, ...)" ) diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index a73ef7e90..97658d9e6 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -1,8 +1,8 @@ import collections -import functools import textwrap from ..constants import * # NOQA +from ..helpers.jap_wrapper import ArgumentParser from ..helpers.nanorst import rst_to_terminal @@ -161,7 +161,6 @@ class HelpMixIn: # not '/home/user/importantjunk' or '/etc/junk': $ borg create -e 'home/*/junk' archive / - # The contents of directories in '/home' are not backed up when their name # ends in '.tmp' $ borg create --exclude 're:^home/[^/]+\\.tmp/' archive / @@ -551,10 +550,8 @@ class HelpMixIn: do_maincommand_help = do_subcommand_help def build_parser_help(self, subparsers, common_parser, mid_common_parser, parser): - subparser = subparsers.add_parser( - "help", parents=[common_parser], add_help=False, description="Extra help", help="Extra help" - ) + subparser = ArgumentParser(parents=[common_parser], add_help=False, description="Extra help") + subparsers.add_subcommand("help", subparser, help="Extra help") subparser.add_argument("--epilog-only", dest="epilog_only", action="store_true") subparser.add_argument("--usage-only", dest="usage_only", action="store_true") - subparser.set_defaults(func=functools.partial(self.do_help, parser, subparsers.choices)) subparser.add_argument("topic", metavar="TOPIC", type=str, nargs="?", help="additional help on TOPIC") diff --git a/src/borg/archiver/info_cmd.py b/src/borg/archiver/info_cmd.py index 5209bae15..90cd74b70 100644 --- a/src/borg/archiver/info_cmd.py +++ b/src/borg/archiver/info_cmd.py @@ -1,4 +1,5 @@ import argparse + import textwrap from datetime import timedelta @@ -6,6 +7,7 @@ from ._common import with_repository from ..archive import Archive from ..constants import * # NOQA from ..helpers import format_timedelta, json_print, basic_json_data, archivename_validator +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -77,16 +79,15 @@ class InfoMixIn: = all chunks in the repository. """ ) - subparser = subparsers.add_parser( - "info", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_info.__doc__, epilog=info_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="show repository or archive information", ) - subparser.set_defaults(func=self.do_info) + + subparsers.add_subcommand("info", subparser, help="show repository or archive information") subparser.add_argument("--json", action="store_true", help="format output as JSON") define_archive_filters_group(subparser) subparser.add_argument( diff --git a/src/borg/archiver/key_cmds.py b/src/borg/archiver/key_cmds.py index 5d7a9503e..14870bebd 100644 --- a/src/borg/archiver/key_cmds.py +++ b/src/borg/archiver/key_cmds.py @@ -1,5 +1,5 @@ import argparse -import functools + import os from ..constants import * # NOQA @@ -7,6 +7,7 @@ from ..crypto.key import AESOCBRepoKey, CHPORepoKey, Blake2AESOCBRepoKey, Blake2 from ..crypto.key import AESOCBKeyfileKey, CHPOKeyfileKey, Blake2AESOCBKeyfileKey, Blake2CHPOKeyfileKey from ..crypto.keymanager import KeyManager from ..helpers import PathSpec, CommandError +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ._common import with_repository @@ -18,7 +19,7 @@ logger = create_logger(__name__) class KeysMixIn: @with_repository(compatibility=(Manifest.Operation.CHECK,)) - def do_change_passphrase(self, args, repository, manifest): + def do_key_change_passphrase(self, args, repository, manifest): """Changes the repository key file passphrase.""" key = manifest.key if not hasattr(key, "change_passphrase"): @@ -30,7 +31,7 @@ class KeysMixIn: logger.info("Key location: %s", key.find_key()) @with_repository(exclusive=True, manifest=True, cache=True, compatibility=(Manifest.Operation.CHECK,)) - def do_change_location(self, args, repository, manifest, cache): + def do_key_change_location(self, args, repository, manifest, cache): """Changes the repository key location.""" key = manifest.key if not hasattr(key, "change_passphrase"): @@ -117,21 +118,23 @@ class KeysMixIn: raise CommandError(f"input file does not exist: {args.path}") manager.import_keyfile(args) - def build_parser_keys(self, subparsers, common_parser, mid_common_parser): - from ._common import process_epilog - - subparser = subparsers.add_parser( - "key", + def build_parser_keys_l1(self, subparsers, mid_common_parser): + """Phase 1: Add the 'key' container subcommand.""" + subparser = ArgumentParser( parents=[mid_common_parser], add_help=False, description="Manage the keyfile or repokey of a repository", epilog="", formatter_class=argparse.RawDescriptionHelpFormatter, - help="manage the repository key", ) + subparsers.add_subcommand("key", subparser, help="manage the repository key") + return subparser - key_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + def build_parser_keys_l2(self, key_parser, common_parser): + """Phase 2: Add leaf subcommands under the 'key' container.""" + from ._common import process_epilog + + key_parsers = key_parser.add_subcommands(required=False) key_export_epilog = process_epilog( """ @@ -164,16 +167,14 @@ class KeysMixIn: HTML template with a QR code and a copy of the ``--paper``-formatted key. """ ) - subparser = key_parsers.add_parser( - "export", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_key_export.__doc__, epilog=key_export_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="export the repository key for backup", ) - subparser.set_defaults(func=self.do_key_export) + key_parsers.add_subcommand("export", subparser, help="export the repository key for backup") subparser.add_argument("path", metavar="PATH", nargs="?", type=PathSpec, help="where to store the backup") subparser.add_argument( "--paper", @@ -206,16 +207,14 @@ class KeysMixIn: key import`` creates a new key file in ``$BORG_KEYS_DIR``. """ ) - subparser = key_parsers.add_parser( - "import", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_key_import.__doc__, epilog=key_import_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="import the repository key from backup", ) - subparser.set_defaults(func=self.do_key_import) + key_parsers.add_subcommand("import", subparser, help="import the repository key from backup") subparser.add_argument( "path", metavar="PATH", nargs="?", type=PathSpec, help="path to the backup ('-' to read from stdin)" ) @@ -237,16 +236,14 @@ class KeysMixIn: does not protect future (nor past) backups to the same repository. """ ) - subparser = key_parsers.add_parser( - "change-passphrase", + subparser = ArgumentParser( parents=[common_parser], add_help=False, - description=self.do_change_passphrase.__doc__, + description=self.do_key_change_passphrase.__doc__, epilog=change_passphrase_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="change the repository passphrase", ) - subparser.set_defaults(func=self.do_change_passphrase) + key_parsers.add_subcommand("change-passphrase", subparser, help="change the repository passphrase") change_location_epilog = process_epilog( """ @@ -261,16 +258,14 @@ class KeysMixIn: thus you must ONLY give the key location (keyfile or repokey). """ ) - subparser = key_parsers.add_parser( - "change-location", + subparser = ArgumentParser( parents=[common_parser], add_help=False, - description=self.do_change_location.__doc__, + description=self.do_key_change_location.__doc__, epilog=change_location_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="change the key location", ) - subparser.set_defaults(func=self.do_change_location) + key_parsers.add_subcommand("change-location", subparser, help="change the key location") subparser.add_argument( "key_mode", metavar="KEY_LOCATION", choices=("repokey", "keyfile"), help="select key location" ) diff --git a/src/borg/archiver/list_cmd.py b/src/borg/archiver/list_cmd.py index e3c13679f..48dd4b1c0 100644 --- a/src/borg/archiver/list_cmd.py +++ b/src/borg/archiver/list_cmd.py @@ -1,4 +1,5 @@ import argparse + import os import textwrap import sys @@ -8,6 +9,7 @@ from ..archive import Archive from ..cache import Cache from ..constants import * # NOQA from ..helpers import ItemFormatter, BaseFormatter, archivename_validator, PathSpec +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -89,7 +91,6 @@ class ListMixIn: The following keys are always available: - """ ) + BaseFormatter.keys_help() @@ -102,16 +103,15 @@ class ListMixIn: ) + ItemFormatter.keys_help() ) - subparser = subparsers.add_parser( - "list", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_list.__doc__, epilog=list_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="list archive contents", ) - subparser.set_defaults(func=self.do_list) + + subparsers.add_subcommand("list", subparser, help="list archive contents") subparser.add_argument( "--short", dest="short", action="store_true", help="only print file/directory names, nothing else" ) diff --git a/src/borg/archiver/lock_cmds.py b/src/borg/archiver/lock_cmds.py index 1739da6df..ed79cd1f6 100644 --- a/src/borg/archiver/lock_cmds.py +++ b/src/borg/archiver/lock_cmds.py @@ -1,10 +1,12 @@ import argparse + import subprocess from ._common import with_repository from ..cache import Cache from ..constants import * # NOQA from ..helpers import prepare_subprocess_env, set_ec, CommandError, ThreadRunner +from ..helpers.jap_wrapper import ArgumentParser from ..logger import create_logger @@ -45,16 +47,15 @@ class LocksMixIn: trying to access the cache or the repository. """ ) - subparser = subparsers.add_parser( - "break-lock", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_break_lock.__doc__, epilog=break_lock_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="break the repository and cache locks", ) - subparser.set_defaults(func=self.do_break_lock) + + subparsers.add_subcommand("break-lock", subparser, help="break the repository and cache locks") with_lock_epilog = process_epilog( """ @@ -77,15 +78,14 @@ class LocksMixIn: Borg is cautious and does not automatically remove stale locks made by a different host. """ ) - subparser = subparsers.add_parser( - "with-lock", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_with_lock.__doc__, epilog=with_lock_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="run a user command with the lock held", ) - subparser.set_defaults(func=self.do_with_lock) + + subparsers.add_subcommand("with-lock", subparser, help="run a user command with the lock held") subparser.add_argument("command", metavar="COMMAND", help="command to run") subparser.add_argument("args", metavar="ARGS", nargs=argparse.REMAINDER, help="command arguments") diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index d37d8cb47..6a8e712e4 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -1,4 +1,5 @@ import argparse + import os from ._common import with_repository, Highlander @@ -6,6 +7,7 @@ from ..constants import * # NOQA from ..helpers import RTError from ..helpers import PathSpec from ..helpers import umount +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..remote import cache_if_remote @@ -151,15 +153,15 @@ class MountMixIn: the logger to output to a file. """ ) - subparser = subparsers.add_parser( - "mount", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_mount.__doc__, epilog=mount_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="mount a repository", ) + + subparsers.add_subcommand("mount", subparser, help="mount a repository") self._define_borg_mount(subparser) umount_epilog = process_epilog( @@ -170,16 +172,15 @@ class MountMixIn: command - usually this is either umount or fusermount -u. """ ) - subparser = subparsers.add_parser( - "umount", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_umount.__doc__, epilog=umount_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="unmount a repository", ) - subparser.set_defaults(func=self.do_umount) + + subparsers.add_subcommand("umount", subparser, help="unmount a repository") subparser.add_argument( "mountpoint", metavar="MOUNTPOINT", type=str, help="mountpoint of the filesystem to unmount" ) @@ -196,7 +197,6 @@ class MountMixIn: def _define_borg_mount(self, parser): from ._common import define_exclusion_group, define_archive_filters_group - parser.set_defaults(func=self.do_mount) parser.add_argument("mountpoint", metavar="MOUNTPOINT", type=str, help="where to mount the filesystem") parser.add_argument( "-f", "--foreground", dest="foreground", action="store_true", help="stay in foreground, do not daemonize" diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 2f18b485b..885f0377b 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -1,4 +1,5 @@ import argparse + from collections import OrderedDict from datetime import datetime, timezone, timedelta import logging @@ -11,6 +12,7 @@ from ..cache import Cache from ..constants import * # NOQA from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error from ..helpers import archivename_validator +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -273,16 +275,15 @@ class PruneMixIn: the ``borg repo-list`` description for more details about the format string). """ ) - subparser = subparsers.add_parser( - "prune", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_prune.__doc__, epilog=prune_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="prune archives", ) - subparser.set_defaults(func=self.do_prune) + + subparsers.add_subcommand("prune", subparser, help="prune archives") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/recreate_cmd.py b/src/borg/archiver/recreate_cmd.py index 4ab928e25..493ca4916 100644 --- a/src/borg/archiver/recreate_cmd.py +++ b/src/borg/archiver/recreate_cmd.py @@ -7,6 +7,7 @@ from ..constants import * # NOQA from ..compress import CompressionSpec from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams, bin_to_hex from ..helpers import timestamp +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -101,16 +102,15 @@ class RecreateMixIn: if the chunks are still missing. """ ) - subparser = subparsers.add_parser( - "recreate", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_recreate.__doc__, epilog=recreate_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help=self.do_recreate.__doc__, ) - subparser.set_defaults(func=self.do_recreate) + + subparsers.add_subcommand("recreate", subparser, help=self.do_recreate.__doc__) subparser.add_argument( "--list", dest="output_list", action="store_true", help="output verbose list of items (files, dirs, ...)" ) diff --git a/src/borg/archiver/rename_cmd.py b/src/borg/archiver/rename_cmd.py index bdb338843..1fba741e5 100644 --- a/src/borg/archiver/rename_cmd.py +++ b/src/borg/archiver/rename_cmd.py @@ -3,6 +3,7 @@ import argparse from ._common import with_repository, with_archive from ..constants import * # NOQA from ..helpers import archivename_validator +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -28,16 +29,15 @@ class RenameMixIn: This results in a different archive ID. """ ) - subparser = subparsers.add_parser( - "rename", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_rename.__doc__, epilog=rename_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="rename an archive", ) - subparser.set_defaults(func=self.do_rename) + + subparsers.add_subcommand("rename", subparser, help="rename an archive") subparser.add_argument( "name", metavar="OLDNAME", type=archivename_validator, help="specify the current archive name" ) diff --git a/src/borg/archiver/repo_compress_cmd.py b/src/borg/archiver/repo_compress_cmd.py index a5aeb9a54..96589384c 100644 --- a/src/borg/archiver/repo_compress_cmd.py +++ b/src/borg/archiver/repo_compress_cmd.py @@ -1,4 +1,5 @@ import argparse + from collections import defaultdict from ._common import with_repository, Highlander @@ -6,6 +7,7 @@ from ..constants import * # NOQA from ..compress import CompressionSpec, ObfuscateSize, Auto, COMPRESSOR_TABLE from ..hashindex import ChunkIndex from ..helpers import sig_int, ProgressIndicatorPercent, Error +from ..helpers.jap_wrapper import ArgumentParser from ..repository import Repository from ..remote import RemoteRepository from ..manifest import Manifest @@ -180,16 +182,15 @@ class RepoCompressMixIn: You do **not** need to run ``borg compact`` after ``borg repo-compress``. """ ) - subparser = subparsers.add_parser( - "repo-compress", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_compress.__doc__, epilog=repo_compress_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help=self.do_repo_compress.__doc__, ) - subparser.set_defaults(func=self.do_repo_compress) + + subparsers.add_subcommand("repo-compress", subparser, help=self.do_repo_compress.__doc__) subparser.add_argument( "-C", diff --git a/src/borg/archiver/repo_create_cmd.py b/src/borg/archiver/repo_create_cmd.py index 07b60fa8d..0a5a7f5d5 100644 --- a/src/borg/archiver/repo_create_cmd.py +++ b/src/borg/archiver/repo_create_cmd.py @@ -6,6 +6,7 @@ from ..constants import * # NOQA from ..crypto.key import key_creator, key_argument_names from ..helpers import CancelledByUser from ..helpers import location_validator, Location +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -190,16 +191,15 @@ class RepoCreateMixIn: Then use ``borg transfer --other-repo ORIG_REPO --from-borg1 ...`` to transfer the archives. """ ) - subparser = subparsers.add_parser( - "repo-create", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_create.__doc__, epilog=repo_create_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="create a new, empty repository", ) - subparser.set_defaults(func=self.do_repo_create) + + subparsers.add_subcommand("repo-create", subparser, help="create a new, empty repository") subparser.add_argument( "--other-repo", metavar="SRC_REPOSITORY", diff --git a/src/borg/archiver/repo_delete_cmd.py b/src/borg/archiver/repo_delete_cmd.py index aa2b531ea..c345e763c 100644 --- a/src/borg/archiver/repo_delete_cmd.py +++ b/src/borg/archiver/repo_delete_cmd.py @@ -7,6 +7,7 @@ from ..helpers import CancelledByUser from ..helpers import format_archive from ..helpers import bin_to_hex from ..helpers import yes +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest, NoManifestError from ..logger import create_logger @@ -102,16 +103,15 @@ class RepoDeleteMixIn: Always first use ``--dry-run --list`` to see what would be deleted. """ ) - subparser = subparsers.add_parser( - "repo-delete", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_delete.__doc__, epilog=repo_delete_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="delete a repository", ) - subparser.set_defaults(func=self.do_repo_delete) + + subparsers.add_subcommand("repo-delete", subparser, help="delete a repository") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/repo_info_cmd.py b/src/borg/archiver/repo_info_cmd.py index 0b11ed6e8..c3e300803 100644 --- a/src/borg/archiver/repo_info_cmd.py +++ b/src/borg/archiver/repo_info_cmd.py @@ -1,9 +1,11 @@ import argparse + import textwrap from ._common import with_repository from ..constants import * # NOQA from ..helpers import bin_to_hex, json_print, basic_json_data +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -63,14 +65,13 @@ class RepoInfoMixIn: This command displays detailed information about the repository. """ ) - subparser = subparsers.add_parser( - "repo-info", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_info.__doc__, epilog=repo_info_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="show repository information", ) - subparser.set_defaults(func=self.do_repo_info) + + subparsers.add_subcommand("repo-info", subparser, help="show repository information") subparser.add_argument("--json", action="store_true", help="format output as JSON") diff --git a/src/borg/archiver/repo_list_cmd.py b/src/borg/archiver/repo_list_cmd.py index 28dced107..f76ea9208 100644 --- a/src/borg/archiver/repo_list_cmd.py +++ b/src/borg/archiver/repo_list_cmd.py @@ -1,4 +1,5 @@ import argparse + import os import textwrap import sys @@ -6,6 +7,7 @@ import sys from ._common import with_repository, Highlander from ..constants import * # NOQA from ..helpers import BaseFormatter, ArchiveFormatter, json_print, basic_json_data +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -72,7 +74,6 @@ class RepoListMixIn: The following keys are always available: - """ ) + BaseFormatter.keys_help() @@ -85,16 +86,15 @@ class RepoListMixIn: ) + ArchiveFormatter.keys_help() ) - subparser = subparsers.add_parser( - "repo-list", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_list.__doc__, epilog=repo_list_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="list repository contents", ) - subparser.set_defaults(func=self.do_repo_list) + + subparsers.add_subcommand("repo-list", subparser, help="list repository contents") subparser.add_argument( "--short", dest="short", action="store_true", help="only print the archive IDs, nothing else" ) diff --git a/src/borg/archiver/repo_space_cmd.py b/src/borg/archiver/repo_space_cmd.py index 45c1646a2..9698846d2 100644 --- a/src/borg/archiver/repo_space_cmd.py +++ b/src/borg/archiver/repo_space_cmd.py @@ -1,4 +1,5 @@ import argparse + import math import os @@ -7,6 +8,7 @@ from borgstore.store import ItemInfo from ._common import with_repository, Highlander from ..constants import * # NOQA from ..helpers import parse_file_size, format_file_size +from ..helpers.jap_wrapper import ArgumentParser from ..logger import create_logger @@ -82,20 +84,18 @@ class RepoSpaceMixIn: $ borg compact -v # only this actually frees space of deleted archives $ borg repo-space --reserve 1G # reserve space again for next time - Reserved space is always rounded up to full reservation blocks of 64 MiB. """ ) - subparser = subparsers.add_parser( - "repo-space", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_space.__doc__, epilog=repo_space_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="manage reserved space in a repository", ) - subparser.set_defaults(func=self.do_repo_space) + + subparsers.add_subcommand("repo-space", subparser, help="manage reserved space in a repository") subparser.add_argument( "--reserve", metavar="SPACE", diff --git a/src/borg/archiver/serve_cmd.py b/src/borg/archiver/serve_cmd.py index f8c170067..76c493dad 100644 --- a/src/borg/archiver/serve_cmd.py +++ b/src/borg/archiver/serve_cmd.py @@ -1,6 +1,7 @@ import argparse from ..constants import * # NOQA +from ..helpers.jap_wrapper import ArgumentParser from ..remote import RepositoryServer from ..logger import create_logger @@ -52,16 +53,15 @@ class ServeMixIn: Existing archives can be read, but no archives can be created or deleted. """ ) - subparser = subparsers.add_parser( - "serve", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_serve.__doc__, epilog=serve_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="start the repository server process", ) - subparser.set_defaults(func=self.do_serve) + + subparsers.add_subcommand("serve", subparser, help="start the repository server process") subparser.add_argument( "--restrict-to-path", metavar="PATH", diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 3dffbd803..92feed626 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -4,6 +4,7 @@ from ._common import with_repository, define_archive_filters_group from ..archive import Archive from ..constants import * # NOQA from ..helpers import bin_to_hex, archivename_validator, tag_validator, Error +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -80,16 +81,15 @@ class TagMixIn: removed). """ ) - subparser = subparsers.add_parser( - "tag", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_tag.__doc__, epilog=tag_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="tag archives", ) - subparser.set_defaults(func=self.do_tag) + + subparsers.add_subcommand("tag", subparser, help="tag archives") subparser.add_argument( "--set", dest="set_tags", diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index 4a0dd4c22..8df18ca54 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -1,4 +1,5 @@ import argparse + import base64 import logging import os @@ -20,6 +21,7 @@ from ..helpers import remove_surrogates from ..helpers import timestamp, archive_ts_now from ..helpers import basic_json_data, json_print from ..helpers import log_multi +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ._common import with_repository, with_archive, Highlander, define_exclusion_group @@ -386,16 +388,15 @@ class TarMixIn: pass over the archive metadata. """ ) - subparser = subparsers.add_parser( - "export-tar", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_export_tar.__doc__, epilog=export_tar_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="create tarball from archive", ) - subparser.set_defaults(func=self.do_export_tar) + + subparsers.add_subcommand("export-tar", subparser, help="create tarball from archive") subparser.add_argument( "--tar-filter", dest="tar_filter", @@ -462,16 +463,15 @@ class TarMixIn: ``--ignore-zeros`` option to skip through the stop markers between them. """ ) - subparser = subparsers.add_parser( - "import-tar", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_import_tar.__doc__, epilog=import_tar_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help=self.do_import_tar.__doc__, ) - subparser.set_defaults(func=self.do_import_tar) + + subparsers.add_subcommand("import-tar", subparser, help=self.do_import_tar.__doc__) subparser.add_argument( "--tar-filter", dest="tar_filter", diff --git a/src/borg/archiver/transfer_cmd.py b/src/borg/archiver/transfer_cmd.py index 99813039d..1050bc39b 100644 --- a/src/borg/archiver/transfer_cmd.py +++ b/src/borg/archiver/transfer_cmd.py @@ -10,6 +10,7 @@ from ..helpers import Error from ..helpers import location_validator, Location, archivename_validator, comment_validator from ..helpers import format_file_size, bin_to_hex from ..helpers import ChunkerParams, ChunkIteratorFileWrapper +from ..helpers.jap_wrapper import ArgumentParser from ..item import ChunkListEntry from ..manifest import Manifest from ..legacyrepository import LegacyRepository @@ -309,7 +310,6 @@ class TransferMixIn: borg --repo=DST_REPO transfer --other-repo=SRC_REPO # do it! borg --repo=DST_REPO transfer --other-repo=SRC_REPO --dry-run # check! anything left? - Data migration / upgrade from borg 1.x ++++++++++++++++++++++++++++++++++++++ @@ -330,19 +330,17 @@ class TransferMixIn: borg --repo=DST_REPO transfer --other-repo=SRC_REPO \\ --chunker-params=buzhash,19,23,21,4095 - """ ) - subparser = subparsers.add_parser( - "transfer", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_transfer.__doc__, epilog=transfer_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="transfer of archives from another repository", ) - subparser.set_defaults(func=self.do_transfer) + + subparsers.add_subcommand("transfer", subparser, help="transfer of archives from another repository") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change repository, just check" ) diff --git a/src/borg/archiver/undelete_cmd.py b/src/borg/archiver/undelete_cmd.py index a0455518f..ea13e2402 100644 --- a/src/borg/archiver/undelete_cmd.py +++ b/src/borg/archiver/undelete_cmd.py @@ -1,9 +1,11 @@ import argparse + import logging from ._common import with_repository from ..constants import * # NOQA from ..helpers import format_archive, CommandError, bin_to_hex, archivename_validator +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -72,16 +74,15 @@ class UnDeleteMixIn: patterns, see :ref:`borg_patterns`). """ ) - subparser = subparsers.add_parser( - "undelete", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_undelete.__doc__, epilog=undelete_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="undelete archives", ) - subparser.set_defaults(func=self.do_undelete) + + subparsers.add_subcommand("undelete", subparser, help="undelete archives") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/version_cmd.py b/src/borg/archiver/version_cmd.py index 36c52a997..e8f2e0069 100644 --- a/src/borg/archiver/version_cmd.py +++ b/src/borg/archiver/version_cmd.py @@ -2,6 +2,7 @@ import argparse from .. import __version__ from ..constants import * # NOQA +from ..helpers.jap_wrapper import ArgumentParser from ..remote import RemoteRepository from ..logger import create_logger @@ -51,13 +52,11 @@ class VersionMixIn: You can also use ``borg --version`` to display a potentially more precise client version. """ ) - subparser = subparsers.add_parser( - "version", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_version.__doc__, epilog=version_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="display the Borg client and server versions", ) - subparser.set_defaults(func=self.do_version) + subparsers.add_subcommand("version", subparser, help="display the Borg client and server versions") diff --git a/src/borg/helpers/jap_wrapper.py b/src/borg/helpers/jap_wrapper.py new file mode 100644 index 000000000..5abff53ef --- /dev/null +++ b/src/borg/helpers/jap_wrapper.py @@ -0,0 +1,178 @@ +"""Borg-specific ArgumentParser wrapping jsonargparse. + +This module provides a compatibility layer between Borg's argparse patterns +and jsonargparse's API. Key adaptations: + +1. type+action combination: jsonargparse forbids combining type= and action= + in add_argument(). Our override strips type= from kwargs and ensures + type conversion happens within the action itself: + - Highlander action class: handles type via _type_fn (pops type in __init__) + - Standard actions (append, store, etc.): wrapped in TypeConvertingAction + - Custom action classes: type is popped and stored on the action after creation + +2. Namespace flattening: jsonargparse creates nested namespaces for subcommands + (args.create.name instead of args.name). flatten_namespace() merges these + into a flat namespace compatible with Borg's command handlers. +""" + +import argparse + +from jsonargparse import ArgumentParser as _JAPArgumentParser +from jsonargparse._core import ArgumentGroup as _JAPArgumentGroup + + +def _is_highlander_action(action): + """Check if action is a Highlander subclass.""" + try: + from .parseformat import Highlander + except ImportError: + return False + return isinstance(action, type) and issubclass(action, Highlander) + + +def _make_type_converting_action(base_action_name, type_fn): + """Create a custom action class that wraps a standard action and applies type conversion. + + This is used for standard string actions (e.g. 'append', 'store') when combined with type=. + jsonargparse forbids type+action, so we strip type= and wrap the action to do conversion. + """ + # Map action name to argparse's built-in action class + _action_map = {"append": argparse._AppendAction, "store": argparse._StoreAction} + + base_cls = _action_map.get(base_action_name) + if base_cls is None: + # Unknown action string - can't wrap it + return None + + class TypeConvertingAction(base_cls): + def __call__(self, parser, namespace, values, option_string=None): + if type_fn is not None and isinstance(values, str): + try: + values = type_fn(values) + except argparse.ArgumentTypeError as e: + raise argparse.ArgumentError(self, str(e)) + super().__call__(parser, namespace, values, option_string) + + TypeConvertingAction.__name__ = f"TypeConverting{base_action_name.title()}Action" + return TypeConvertingAction + + +class BorgAddArgumentMixin: + """Mixin to provide Borg's add_argument logic to ArgumentParser and ArgumentGroup.""" + + def add_argument(self, *args, **kwargs): + """Handle type+action combination that jsonargparse forbids. + + jsonargparse raises ValueError when both type= and action= are given. + We strip type= from kwargs and ensure the action handles type conversion: + - Highlander/subclasses: type bound as class attribute _type_fn_override + - Standard string actions: wrapped in TypeConvertingAction + - Other custom actions: type stored as _type_fn on action instance + """ + action = kwargs.get("action") + if action is not None and "type" in kwargs: + type_fn = kwargs.pop("type") + + if _is_highlander_action(action): + # Create a dynamic subclass with _type_fn pre-bound as a class attribute. + # Highlander's __init__ will pick this up. + action_cls = action + + class BoundHighlander(action_cls): + _type_fn_override = type_fn + + BoundHighlander.__name__ = action_cls.__name__ + BoundHighlander.__qualname__ = action_cls.__qualname__ + kwargs["action"] = BoundHighlander + return super().add_argument(*args, **kwargs) + + if isinstance(action, str): + # Standard action string like 'append', 'store' + wrapper = _make_type_converting_action(action, type_fn) + if wrapper is not None: + kwargs["action"] = wrapper + return super().add_argument(*args, **kwargs) + else: + # Unknown standard action, put type back and try anyway + kwargs["type"] = type_fn + + elif isinstance(action, type) and issubclass(action, argparse.Action): + # Custom action class - register without type, then patch the action + result = super().add_argument(*args, **kwargs) + # Store type_fn on the action for potential manual use + if hasattr(result, "_type_fn"): + pass # already handled + else: + result._type_fn = type_fn + return result + + return super().add_argument(*args, **kwargs) + + +class ArgumentGroup(BorgAddArgumentMixin, _JAPArgumentGroup): + """ArgumentGroup that supports Borg's add_argument patterns.""" + + pass + + +class ArgumentParser(BorgAddArgumentMixin, _JAPArgumentParser): + """ArgumentParser bridging Borg's argparse patterns with jsonargparse.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Force jsonargparse to use our ArgumentGroup class instead of trying to + # auto-generate one from source code (which is fragile and fails on Windows CI). + self._group_class = ArgumentGroup + + +def flatten_namespace(args): + """Flatten jsonargparse's nested namespace into a flat one. + + jsonargparse creates nested namespaces for subcommands: + args.subcommand = "create" + args.create = Namespace(name="myarchive", ...) + + Borg expects a flat namespace: + args.name = "myarchive" + + For nested subcommands (key export, debug info, benchmark crud): + args.subcommand = "key" + args.key.subcommand = "export" + args.key.export = Namespace(path="/tmp/k", ...) + becomes: + args.subcommand = "key" + args.path = "/tmp/k" + """ + subcmd = getattr(args, "subcommand", None) + if subcmd is None: + return args + + subcmd_ns = getattr(args, subcmd, None) + if subcmd_ns is None: + return args + + # Handle nested subcommand (e.g., "key export") + nested_subcmd = getattr(subcmd_ns, "subcommand", None) + if nested_subcmd is not None: + nested_ns = getattr(subcmd_ns, nested_subcmd, None) + if nested_ns is not None: + for key, val in vars(nested_ns).items(): + if key != "subcommand": + setattr(args, key, val) + + # Flatten the direct subcommand namespace + for key, val in vars(subcmd_ns).items(): + if key == "subcommand": + continue + if isinstance(val, argparse.Namespace): + continue # Skip nested namespace (already handled above) + setattr(args, key, val) + + # Ensure paths and patterns exist as lists (used as accumulation targets during parsing). + # jsonargparse may set these to None rather than omitting them. + if not getattr(args, "paths", None): + args.paths = [] + if not getattr(args, "patterns", None): + args.patterns = [] + + return args diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 5132446b5..0a5b38962 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -1309,13 +1309,27 @@ class Highlander(argparse.Action): """make sure some option is only given once""" def __init__(self, *args, **kwargs): + # Pick up type function from class attribute (set by BoundHighlander in jap_wrapper.py) + # or from kwargs (for direct use with standard argparse). + self._type_fn = getattr(self.__class__, "_type_fn_override", None) or kwargs.pop("type", None) self.__called = False super().__init__(*args, **kwargs) + # Apply the type function to string defaults so that converted values + # are used even when the option is not given on the command line. + # We need to do this ourselves because type= was stripped before reaching + # argparse/jsonargparse, so they won't auto-convert the default. + if self._type_fn is not None and isinstance(self.default, str): + self.default = self._type_fn(self.default) def __call__(self, parser, namespace, values, option_string=None): if self.__called: raise argparse.ArgumentError(self, "There can be only one.") self.__called = True + if self._type_fn is not None and isinstance(values, str): + try: + values = self._type_fn(values) + except argparse.ArgumentTypeError as e: + raise argparse.ArgumentError(self, str(e)) setattr(namespace, self.dest, values) diff --git a/src/borg/patterns.py b/src/borg/patterns.py index c1f8f5727..43e40f57c 100644 --- a/src/borg/patterns.py +++ b/src/borg/patterns.py @@ -41,6 +41,11 @@ class ArgparsePatternAction(argparse.Action): super().__init__(nargs=nargs, **kw) def __call__(self, parser, args, values, option_string=None): + # jsonargparse may initialize list-like attributes to None instead of [] + if args.paths is None: + args.paths = [] + if args.patterns is None: + args.patterns = [] parse_patternfile_line(values[0], args.paths, args.patterns, ShellPattern) @@ -60,11 +65,19 @@ class ArgparsePatternFileAction(argparse.Action): raise Error(str(e)) def parse(self, fobj, args): + # jsonargparse may initialize list-like attributes to None instead of [] + if args.paths is None: + args.paths = [] + if args.patterns is None: + args.patterns = [] load_pattern_file(fobj, args.paths, args.patterns) class ArgparseExcludeFileAction(ArgparsePatternFileAction): def parse(self, fobj, args): + # jsonargparse may initialize list-like attributes to None instead of [] + if args.patterns is None: + args.patterns = [] load_exclude_file(fobj, args.patterns) diff --git a/src/borg/testsuite/archiver/argparsing_test.py b/src/borg/testsuite/archiver/argparsing_test.py index 974becf33..404bd4081 100644 --- a/src/borg/testsuite/archiver/argparsing_test.py +++ b/src/borg/testsuite/archiver/argparsing_test.py @@ -34,7 +34,7 @@ def test_get_args(): args = archiver.get_args( ["borg", "serve", "--umask=0027", "--restrict-to-path=/p1", "--restrict-to-path=/p2"], "borg serve --info" ) - assert args.func == archiver.do_serve + assert archiver.get_func(args) == archiver.do_serve assert args.restrict_to_paths == ["/p1", "/p2"] assert args.umask == 0o027 assert args.log_level == "info" @@ -66,13 +66,13 @@ def test_get_args(): ["borg", "serve", "--restrict-to-path=/p1", "--restrict-to-path=/p2"], f"borg --repo=/ repo-create {RK_ENCRYPTION}", ) - assert args.func == archiver.do_serve + assert archiver.get_func(args) == archiver.do_serve # Check that environment variables in the forced command don't cause issues. If the command # were not forced, environment variables would be interpreted by the shell, but this does not # happen for forced commands - we get the verbatim command line and need to deal with env vars. args = archiver.get_args(["borg", "serve"], "BORG_FOO=bar borg serve --info") - assert args.func == archiver.do_serve + assert archiver.get_func(args) == archiver.do_serve class TestCommonOptions: