diff --git a/pyproject.toml b/pyproject.toml index d5e0187345..62043c3d6b 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", "backports-zstd; python_version < '3.14'", # for python < 3.14. ] diff --git a/scripts/make.py b/scripts/make.py index f7bae4d41d..51d3ee9d53 100644 --- a/scripts/make.py +++ b/scripts/make.py @@ -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 @@ -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 @@ -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}") diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 834bf7b5cf..da514ac64f 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -31,7 +31,6 @@ logger = create_logger() - from ._common import Highlander from .. import __version__ from ..constants import * # NOQA from ..helpers import EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE @@ -46,6 +45,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: @@ -63,18 +63,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 @@ -233,6 +221,9 @@ def __init__(self, define_common_options, suffix_precedence): # This is the sentinel object that replaces all default values in parsers # below the top-level parser. self.default_sentinel = object() + # Maps dest names to their type functions, for options where type= was + # stripped from sub-parsers to avoid jsonargparse validation of sentinel defaults. + self.type_functions = dict() def add_common_group(self, parser, suffix, provide_defaults=False): """ @@ -250,17 +241,9 @@ def add_common_group(self, parser, suffix, provide_defaults=False): def add_argument(*args, **kwargs): if "dest" in kwargs: - kwargs.setdefault("action", "store") - assert kwargs["action"] in ( - Highlander, - "help", - "store_const", - "store_true", - "store_false", - "store", - "append", - ) - is_append = kwargs["action"] == "append" + action = kwargs.get("action", "store") + assert action in ("help", "store_const", "store_true", "store_false", "store", "append") + is_append = action == "append" if is_append: self.append_options.add(kwargs["dest"]) assert ( @@ -278,6 +261,11 @@ def add_argument(*args, **kwargs): kwargs["help"] = kwargs["help"] % kwargs if not is_append: kwargs["default"] = self.default_sentinel + # Remove type= so jsonargparse won't validate the sentinel default. + # Store the type function for manual conversion in resolve(). + type_fn = kwargs.pop("type", None) + if type_fn is not None: + self.type_functions[kwargs["dest"]] = type_fn common_group.add_argument(*args, **kwargs) @@ -306,6 +294,10 @@ def resolve(self, args: argparse.Namespace): # Namespace has "in" but otherwise # value was indeed specified on this level. Transfer value to target, # and un-clobber the args (for tidiness - you *cannot* use the suffixed # names for other purposes, obviously). + # Apply type conversion if type= was stripped from this sub-parser. + type_fn = self.type_functions.get(map_from) + if type_fn is not None and isinstance(value, str): + value = type_fn(value) setattr(args, map_to, value) try: delattr(args, map_from) @@ -328,9 +320,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") ) @@ -340,32 +330,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="") + 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 +371,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, @@ -399,7 +442,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). @@ -423,17 +466,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, @@ -486,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 @@ -542,6 +592,7 @@ def run(self, args): else: rc = func(args) assert rc is None + return get_ec(rc) diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py index 673e53d41a..85f0ba36ca 100644 --- a/src/borg/archiver/_common.py +++ b/src/borg/archiver/_common.py @@ -8,7 +8,6 @@ from ..cache import Cache, assert_secure from ..helpers import Error from ..helpers import SortBySpec, positive_int_validator, location_validator, Location, relative_time_marker_validator -from ..helpers import Highlander from ..helpers.nanorst import rst_to_terminal from ..manifest import Manifest, AI_HUMAN_SORT_KEYS from ..patterns import PatternMatcher @@ -304,8 +303,8 @@ def define_exclude_and_patterns(add_option, *, tag_files=False, strip_components "--exclude-if-present", metavar="NAME", dest="exclude_if_present", - action="append", type=str, + action="append", help="exclude directories that are tagged by containing a filesystem object with the given NAME", ) add_option( @@ -323,7 +322,6 @@ def define_exclude_and_patterns(add_option, *, tag_files=False, strip_components dest="strip_components", type=int, default=0, - action=Highlander, help="Remove the specified number of leading path elements. " "Paths with fewer elements will be silently skipped.", ) @@ -359,7 +357,6 @@ def define_archive_filters_group( dest="sort_by", type=SortBySpec, default=sort_by_default, - action=Highlander, help="Comma-separated list of sorting keys; valid keys are: {}; default is: {}".format( ", ".join(AI_HUMAN_SORT_KEYS), sort_by_default ), @@ -372,8 +369,7 @@ def define_archive_filters_group( metavar="N", dest="first", type=positive_int_validator, - default=0, - action=Highlander, + default=None, help="consider the first N archives after other filters are applied", ) group.add_argument( @@ -381,8 +377,7 @@ def define_archive_filters_group( metavar="N", dest="last", type=positive_int_validator, - default=0, - action=Highlander, + default=None, help="consider the last N archives after other filters are applied", ) @@ -393,7 +388,6 @@ def define_archive_filters_group( metavar="TIMESPAN", dest="oldest", type=relative_time_marker_validator, - action=Highlander, help="consider archives between the oldest archive's timestamp and (oldest + TIMESPAN), e.g., 7d or 12m.", ) group.add_argument( @@ -401,7 +395,6 @@ def define_archive_filters_group( metavar="TIMESPAN", dest="newest", type=relative_time_marker_validator, - action=Highlander, help="consider archives between the newest archive's timestamp and (newest - TIMESPAN), e.g., 7d or 12m.", ) @@ -412,7 +405,6 @@ def define_archive_filters_group( metavar="TIMESPAN", dest="older", type=relative_time_marker_validator, - action=Highlander, help="consider archives older than (now - TIMESPAN), e.g., 7d or 12m.", ) group.add_argument( @@ -420,7 +412,6 @@ def define_archive_filters_group( metavar="TIMESPAN", dest="newer", type=relative_time_marker_validator, - action=Highlander, help="consider archives newer than (now - TIMESPAN), e.g., 7d or 12m.", ) @@ -499,7 +490,6 @@ def define_common_options(add_common_option): dest="lock_wait", type=int, default=int(os.environ.get("BORG_LOCK_WAIT", 10)), - action=Highlander, help="wait at most SECONDS for acquiring a repository/cache lock (default: %(default)d).", ) add_common_option("--show-version", dest="show_version", action="store_true", help="show/log the borg version") @@ -508,16 +498,14 @@ def define_common_options(add_common_option): "--umask", metavar="M", dest="umask", - type=lambda s: int(s, 8), + type=lambda s: int(s, 8) if isinstance(s, str) else s, default=UMASK_DEFAULT, - action=Highlander, help="set umask to M (local only, default: %(default)04o)", ) add_common_option( "--remote-path", metavar="PATH", dest="remote_path", - action=Highlander, help='use PATH as borg executable on the remote (default: "borg")', ) add_common_option( @@ -525,7 +513,6 @@ def define_common_options(add_common_option): metavar="RATE", dest="upload_ratelimit", type=int, - action=Highlander, help="set network upload rate limit in kiByte/s (default: 0=unlimited)", ) add_common_option( @@ -533,7 +520,6 @@ def define_common_options(add_common_option): metavar="UPLOAD_BUFFER", dest="upload_buffer", type=int, - action=Highlander, help="set network upload buffer size in MiB. (default: 0=no buffer)", ) add_common_option( @@ -541,7 +527,6 @@ def define_common_options(add_common_option): metavar="FILE", dest="debug_profile", default=None, - action=Highlander, help="Write execution profile in Borg format into FILE. For local use a Python-" 'compatible file can be generated by suffixing FILE with ".pyprof".', ) @@ -549,7 +534,6 @@ def define_common_options(add_common_option): "--rsh", metavar="RSH", dest="rsh", - action=Highlander, help="Use this command to connect to the 'borg serve' process (default: 'ssh')", ) add_common_option( @@ -559,7 +543,6 @@ def define_common_options(add_common_option): default=False, const=True, nargs="?", - action=Highlander, help="Use UNIX DOMAIN (IPC) socket at PATH for client/server communication with socket: protocol.", ) add_common_option( @@ -569,7 +552,6 @@ def define_common_options(add_common_option): dest="location", type=location_validator(other=False), default=Location(other=False), - action=Highlander, help="repository to use", ) diff --git a/src/borg/archiver/analyze_cmd.py b/src/borg/archiver/analyze_cmd.py index e556095886..821ae440c0 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 ..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 @@ 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) diff --git a/src/borg/archiver/benchmark_cmd.py b/src/borg/archiver/benchmark_cmd.py index d3ecdc8904..1207ddc8a1 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 json import logging import os @@ -13,6 +13,7 @@ from ..helpers import json_print from ..helpers import msgpack from ..helpers import get_reset_ec +from ..helpers.jap_wrapper import ArgumentParser from ..item import Item from ..platform import SyncFile @@ -343,23 +344,27 @@ def chunkit(ch): if args.json: json_print(result) - 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="") - 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( """ @@ -402,16 +407,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") subparser.add_argument("--json-lines", action="store_true", help="Format output as JSON Lines.") @@ -427,14 +432,12 @@ 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.") subparser.add_argument("--json", action="store_true", help="format output as JSON") diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index e78aef563f..56b50c131a 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 ._common import with_repository 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 @@ 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" ) @@ -216,7 +217,6 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser): dest="max_duration", type=int, default=0, - action=Highlander, help="perform only a partial repository check for at most SECONDS seconds (default: unlimited)", ) define_archive_filters_group(subparser) diff --git a/src/borg/archiver/compact_cmd.py b/src/borg/archiver/compact_cmd.py index 1cd07a0ba9..e875e1c88f 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 ..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 @@ def build_parser_compact(self, subparsers, common_parser, mid_common_parser): 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 abcc04f792..c304e40049 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -66,6 +66,7 @@ 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 @@ def build_parser_completion(self, subparsers, common_parser, mid_common_parser): """ ) - 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 cc89960733..fc25a4754a 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 @@ -9,7 +10,7 @@ import time from io import TextIOWrapper -from ._common import with_repository, Highlander +from ._common import with_repository from .. import helpers from ..archive import Archive, is_special from ..archive import BackupError, BackupOSError, BackupItemExcluded, backup_io, OsOpen, stat_update_check @@ -17,7 +18,7 @@ from ..cache import Cache from ..constants import * # NOQA from ..compress import CompressionSpec -from ..helpers import comment_validator, ChunkerParams, FilesystemPathSpec +from ..helpers import comment_validator, ChunkerParams, FilesystemPathSpec, compression_spec_validator from ..helpers import archivename_validator, FilesCacheMode from ..helpers import eval_escapes from ..helpers import timestamp, archive_ts_now @@ -29,8 +30,9 @@ from ..helpers import prepare_subprocess_env from ..helpers import sig_int, ignore_sigint from ..helpers import iter_separated -from ..helpers import MakePathSafeAction +from ..helpers import SafePathSpec 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 @@ -680,7 +682,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): 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 @@ -773,16 +774,15 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): """ ) - 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). @@ -800,7 +800,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): "--filter", metavar="STATUSCHARS", dest="output_filter", - action=Highlander, help="only display items with the given status characters (see description)", ) subparser.add_argument("--json", action="store_true", help="output stats as JSON. Implies ``--stats``.") @@ -809,7 +808,7 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): metavar="NAME", dest="stdin_name", default="stdin", - action=MakePathSafeAction, + type=SafePathSpec, help="use NAME in archive for stdin data (default: %(default)r)", ) subparser.add_argument( @@ -817,7 +816,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): metavar="USER", dest="stdin_user", default=None, - action=Highlander, help="set user USER in archive for stdin data (default: do not store user/uid)", ) subparser.add_argument( @@ -825,16 +823,14 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): metavar="GROUP", dest="stdin_group", default=None, - action=Highlander, help="set group GROUP in archive for stdin data (default: do not store group/gid)", ) subparser.add_argument( "--stdin-mode", metavar="M", dest="stdin_mode", - type=lambda s: int(s, 8), + type=lambda s: int(s, 8) if isinstance(s, str) else s, default=STDIN_MODE_DEFAULT, - action=Highlander, help="set mode to M in archive for stdin data (default: %(default)04o)", ) subparser.add_argument( @@ -855,7 +851,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): ) subparser.add_argument( "--paths-delimiter", - action=Highlander, metavar="DELIM", help="set path delimiter for ``--paths-from-stdin`` and ``--paths-from-command`` (default: ``\\n``) ", ) @@ -907,7 +902,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): "--files-cache", metavar="MODE", dest="files_cache_mode", - action=Highlander, type=FilesCacheMode, default=FILES_CACHE_MODE_UI_DEFAULT, help="operate files cache in MODE. default: %s" % FILES_CACHE_MODE_UI_DEFAULT, @@ -916,7 +910,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): "--files-changed", metavar="MODE", dest="files_changed", - action=Highlander, choices=["ctime", "mtime", "disabled"], default="mtime" if is_win32 else "ctime", help="specify how to detect if a file has changed during backup (ctime, mtime, disabled). " @@ -937,7 +930,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): dest="comment", type=comment_validator, default="", - action=Highlander, help="add a comment text to the archive", ) archive_group.add_argument( @@ -946,7 +938,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): dest="timestamp", type=timestamp, default=None, - action=Highlander, help="manually specify the archive creation date/time (yyyy-mm-ddThh:mm:ss[(+|-)HH:MM] format, " "(+|-)HH:MM is the UTC offset, default: local time zone). Alternatively, give a reference file/directory.", ) @@ -956,7 +947,6 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): dest="chunker_params", type=ChunkerParams, default=CHUNKER_PARAMS, - action=Highlander, help="specify the chunker parameters (ALGO, CHUNK_MIN_EXP, CHUNK_MAX_EXP, " "HASH_MASK_BITS, HASH_WINDOW_SIZE). default: %s,%d,%d,%d,%d" % CHUNKER_PARAMS, ) @@ -965,13 +955,10 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser): "--compression", metavar="COMPRESSION", dest="compression", - type=CompressionSpec, + type=compression_spec_validator, default=CompressionSpec("lz4"), - action=Highlander, help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") - subparser.add_argument( - "paths", metavar="PATH", nargs="*", type=FilesystemPathSpec, action="extend", help="paths to archive" - ) + subparser.add_argument("paths", metavar="PATH", nargs="*", type=FilesystemPathSpec, help="paths to archive") diff --git a/src/borg/archiver/debug_cmd.py b/src/borg/archiver/debug_cmd.py index bb05c53d95..605991a3e5 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 @@ -11,14 +11,15 @@ from ..helpers import bin_to_hex, hex_to_bin, prepare_dump_dict from ..helpers import dash_open from ..helpers import StableDict -from ..helpers import archivename_validator +from ..helpers import archivename_validator, compression_spec_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 from ..repoobj import RepoObj -from ._common import with_repository, Highlander +from ._common import with_repository from ._common import process_epilog @@ -308,7 +309,8 @@ def do_debug_convert_profile(self, args): 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 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 + + def build_parser_debug_l2(self, debug_parser, common_parser): + """Phase 2: Add leaf subcommands under the 'debug' container.""" - debug_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + debug_parsers = debug_parser.add_subcommands(required=False) debug_info_epilog = process_epilog( """ @@ -339,32 +343,28 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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,37 +403,32 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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", type=str, - action=Highlander, help="term to search the repo for, either 0x1234abcd hex term or a string", ) debug_id_hash_epilog = process_epilog( @@ -445,16 +436,14 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 +454,14 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 +479,14 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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" @@ -514,9 +499,8 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): "--compression", metavar="COMPRESSION", dest="compression", - type=CompressionSpec, + type=compression_spec_validator, default=CompressionSpec("lz4"), - action=Highlander, help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) subparser.add_argument( @@ -531,16 +515,14 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 +531,14 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 +547,14 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 +564,15 @@ def build_parser_debug(self, subparsers, common_parser, mid_common_parser): 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 742b151443..375b09acd9 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 @@ def build_parser_delete(self, subparsers, common_parser, mid_common_parser): 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 0ea0954e84..3174c39493 100644 --- a/src/borg/archiver/diff_cmd.py +++ b/src/borg/archiver/diff_cmd.py @@ -1,14 +1,16 @@ import argparse + import textwrap import json import sys import os -from ._common import with_repository, build_matcher, Highlander +from ._common import with_repository, build_matcher 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 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser): The following keys are always available: - """ ) + BaseFormatter.keys_help() @@ -293,16 +294,15 @@ def diff_sort_spec_validator(s): 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", @@ -319,7 +319,6 @@ def diff_sort_spec_validator(s): "--format", metavar="FORMAT", dest="format", - action=Highlander, help='specify format for differences between archives (default: "{change} {path}{NL}")', ) subparser.add_argument("--json-lines", action="store_true", help="Format output as JSON Lines.") diff --git a/src/borg/archiver/extract_cmd.py b/src/borg/archiver/extract_cmd.py index a3885a0c11..06d6f5b6f9 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 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 @@ def build_parser_extract(self, subparsers, common_parser, mid_common_parser): 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 a73ef7e909..97658d9e62 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 @@ def do_subcommand_help(self, parser, args): 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 5209bae155..90cd74b70c 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 ..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 @@ def build_parser_info(self, subparsers, common_parser, mid_common_parser): = 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 5d7a9503ee..14870bebd1 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 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 @@ 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 @@ def do_change_passphrase(self, args, repository, manifest): 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 @@ def do_key_import(self, args, repository): 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 + + 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 = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + key_parsers = key_parser.add_subcommands(required=False) key_export_epilog = process_epilog( """ @@ -164,16 +167,14 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser): 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 e3c13679fe..96249b45c7 100644 --- a/src/borg/archiver/list_cmd.py +++ b/src/borg/archiver/list_cmd.py @@ -1,13 +1,15 @@ import argparse + import os import textwrap import sys -from ._common import with_repository, build_matcher, Highlander +from ._common import with_repository, build_matcher 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 @@ def build_parser_list(self, subparsers, common_parser, mid_common_parser): The following keys are always available: - """ ) + BaseFormatter.keys_help() @@ -102,16 +103,15 @@ def build_parser_list(self, subparsers, common_parser, mid_common_parser): ) + 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" ) @@ -119,7 +119,6 @@ def build_parser_list(self, subparsers, common_parser, mid_common_parser): "--format", metavar="FORMAT", dest="format", - action=Highlander, help="specify format for file listing " '(default: "{mode} {user:6} {group:6} {size:8} {mtime} {path}{extra}{NL}")', ) diff --git a/src/borg/archiver/lock_cmds.py b/src/borg/archiver/lock_cmds.py index 1739da6df3..ed79cd1f68 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 @@ def build_parser_locks(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_locks(self, subparsers, common_parser, mid_common_parser): 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 d37d8cb47b..b1c9533316 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -1,11 +1,13 @@ import argparse + import os -from ._common import with_repository, Highlander +from ._common import with_repository 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 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser 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 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser 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,12 +197,11 @@ def build_parser_borgfs(self, parser): 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" ) - parser.add_argument("-o", dest="options", type=str, action=Highlander, help="extra mount options") + parser.add_argument("-o", dest="options", type=str, help="extra mount options") parser.add_argument( "--numeric-ids", dest="numeric_ids", diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 2f18b485b8..7b427480b4 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -1,16 +1,18 @@ import argparse + from collections import OrderedDict from datetime import datetime, timezone, timedelta import logging from operator import attrgetter import os -from ._common import with_repository, Highlander +from ._common import with_repository from ..archive import Archive 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 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): 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" ) @@ -300,7 +301,6 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): "--format", metavar="FORMAT", dest="format", - action=Highlander, help="specify format for the archive part " '(default: "{archive:<36} {time} [{id}]")', ) subparser.add_argument( @@ -308,7 +308,6 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): metavar="INTERVAL", dest="within", type=interval, - action=Highlander, help="keep all archives within this time interval", ) subparser.add_argument( @@ -317,52 +316,22 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): dest="secondly", type=int, default=0, - action=Highlander, help="number of secondly archives to keep", ) subparser.add_argument( - "--keep-minutely", - dest="minutely", - type=int, - default=0, - action=Highlander, - help="number of minutely archives to keep", + "--keep-minutely", dest="minutely", type=int, default=0, help="number of minutely archives to keep" ) subparser.add_argument( - "-H", - "--keep-hourly", - dest="hourly", - type=int, - default=0, - action=Highlander, - help="number of hourly archives to keep", + "-H", "--keep-hourly", dest="hourly", type=int, default=0, help="number of hourly archives to keep" ) subparser.add_argument( - "-d", - "--keep-daily", - dest="daily", - type=int, - default=0, - action=Highlander, - help="number of daily archives to keep", + "-d", "--keep-daily", dest="daily", type=int, default=0, help="number of daily archives to keep" ) subparser.add_argument( - "-w", - "--keep-weekly", - dest="weekly", - type=int, - default=0, - action=Highlander, - help="number of weekly archives to keep", + "-w", "--keep-weekly", dest="weekly", type=int, default=0, help="number of weekly archives to keep" ) subparser.add_argument( - "-m", - "--keep-monthly", - dest="monthly", - type=int, - default=0, - action=Highlander, - help="number of monthly archives to keep", + "-m", "--keep-monthly", dest="monthly", type=int, default=0, help="number of monthly archives to keep" ) quarterly_group = subparser.add_mutually_exclusive_group() quarterly_group.add_argument( @@ -380,13 +349,7 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): help="number of quarterly archives to keep (3 month strategy)", ) subparser.add_argument( - "-y", - "--keep-yearly", - dest="yearly", - type=int, - default=0, - action=Highlander, - help="number of yearly archives to keep", + "-y", "--keep-yearly", dest="yearly", type=int, default=0, help="number of yearly archives to keep" ) define_archive_filters_group(subparser, sort_by=False, first_last=False) subparser.add_argument( diff --git a/src/borg/archiver/recreate_cmd.py b/src/borg/archiver/recreate_cmd.py index 4ab928e251..1c9adfe775 100644 --- a/src/borg/archiver/recreate_cmd.py +++ b/src/borg/archiver/recreate_cmd.py @@ -1,12 +1,20 @@ import argparse -from ._common import with_repository, Highlander +from ._common import with_repository from ._common import build_matcher from ..archive import ArchiveRecreater from ..constants import * # NOQA from ..compress import CompressionSpec -from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams, bin_to_hex +from ..helpers import ( + archivename_validator, + comment_validator, + PathSpec, + ChunkerParams, + bin_to_hex, + compression_spec_validator, +) from ..helpers import timestamp +from ..helpers.jap_wrapper import ArgumentParser from ..manifest import Manifest from ..logger import create_logger @@ -101,16 +109,15 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): 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, ...)" ) @@ -118,7 +125,6 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): "--filter", metavar="STATUSCHARS", dest="output_filter", - action=Highlander, help="only display items with the given status characters (listed in borg create --help)", ) subparser.add_argument("-n", "--dry-run", dest="dry_run", action="store_true", help="do not change anything") @@ -133,7 +139,6 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): metavar="TARGET", default=None, type=archivename_validator, - action=Highlander, help="create a new archive with the name ARCHIVE, do not replace existing archive", ) archive_group.add_argument( @@ -142,7 +147,6 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): dest="comment", type=comment_validator, default=None, - action=Highlander, help="add a comment text to the archive", ) archive_group.add_argument( @@ -151,7 +155,6 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): dest="timestamp", type=timestamp, default=None, - action=Highlander, help="manually specify the archive creation date/time (yyyy-mm-ddThh:mm:ss[(+|-)HH:MM] format, " "(+|-)HH:MM is the UTC offset, default: local time zone). Alternatively, give a reference file/directory.", ) @@ -160,9 +163,8 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): "--compression", metavar="COMPRESSION", dest="compression", - type=CompressionSpec, + type=compression_spec_validator, default=CompressionSpec("lz4"), - action=Highlander, help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) archive_group.add_argument( @@ -171,7 +173,6 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser): dest="chunker_params", type=ChunkerParams, default=None, - action=Highlander, help="rechunk using given chunker parameters (ALGO, CHUNK_MIN_EXP, CHUNK_MAX_EXP, " "HASH_MASK_BITS, HASH_WINDOW_SIZE) or `default` to use the chunker defaults. " "default: do not rechunk", diff --git a/src/borg/archiver/rename_cmd.py b/src/borg/archiver/rename_cmd.py index bdb338843f..1fba741e5a 100644 --- a/src/borg/archiver/rename_cmd.py +++ b/src/borg/archiver/rename_cmd.py @@ -3,6 +3,7 @@ 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 @@ def build_parser_rename(self, subparsers, common_parser, mid_common_parser): 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 a5aeb9a545..f2e6da65f9 100644 --- a/src/borg/archiver/repo_compress_cmd.py +++ b/src/borg/archiver/repo_compress_cmd.py @@ -1,11 +1,13 @@ import argparse + from collections import defaultdict -from ._common import with_repository, Highlander +from ._common import with_repository 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 import sig_int, ProgressIndicatorPercent, Error, compression_spec_validator +from ..helpers.jap_wrapper import ArgumentParser from ..repository import Repository from ..remote import RemoteRepository from ..manifest import Manifest @@ -180,25 +182,23 @@ def build_parser_repo_compress(self, subparsers, common_parser, mid_common_parse 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", "--compression", metavar="COMPRESSION", dest="compression", - type=CompressionSpec, + type=compression_spec_validator, default=CompressionSpec("lz4"), - action=Highlander, help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) diff --git a/src/borg/archiver/repo_create_cmd.py b/src/borg/archiver/repo_create_cmd.py index 07b60fa8d5..5c2d72fc7b 100644 --- a/src/borg/archiver/repo_create_cmd.py +++ b/src/borg/archiver/repo_create_cmd.py @@ -1,11 +1,12 @@ import argparse -from ._common import with_repository, with_other_repository, Highlander +from ._common import with_repository, with_other_repository from ..cache import Cache 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,23 +191,21 @@ def build_parser_repo_create(self, subparsers, common_parser, mid_common_parser) 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", dest="other_location", type=location_validator(other=True), default=Location(other=True), - action=Highlander, help="reuse the key material from the other repository", ) subparser.add_argument( @@ -219,7 +218,6 @@ def build_parser_repo_create(self, subparsers, common_parser, mid_common_parser) dest="encryption", required=True, choices=key_argument_names(), - action=Highlander, help="select encryption key mode **(required)**", ) subparser.add_argument( diff --git a/src/borg/archiver/repo_delete_cmd.py b/src/borg/archiver/repo_delete_cmd.py index aa2b531eaa..c345e763c4 100644 --- a/src/borg/archiver/repo_delete_cmd.py +++ b/src/borg/archiver/repo_delete_cmd.py @@ -7,6 +7,7 @@ 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 @@ def build_parser_repo_delete(self, subparsers, common_parser, mid_common_parser) 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 0b11ed6e8a..c3e300803a 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 @@ def build_parser_repo_info(self, subparsers, common_parser, mid_common_parser): 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 28dced1074..dc00743123 100644 --- a/src/borg/archiver/repo_list_cmd.py +++ b/src/borg/archiver/repo_list_cmd.py @@ -1,11 +1,13 @@ import argparse + import os import textwrap import sys -from ._common import with_repository, Highlander +from ._common import with_repository 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 @@ def build_parser_repo_list(self, subparsers, common_parser, mid_common_parser): The following keys are always available: - """ ) + BaseFormatter.keys_help() @@ -85,16 +86,15 @@ def build_parser_repo_list(self, subparsers, common_parser, mid_common_parser): ) + 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" ) @@ -102,7 +102,6 @@ def build_parser_repo_list(self, subparsers, common_parser, mid_common_parser): "--format", metavar="FORMAT", dest="format", - action=Highlander, help="specify format for archive listing " '(default: "{archive:<36} {time} [{id}]{NL}")', ) subparser.add_argument( diff --git a/src/borg/archiver/repo_space_cmd.py b/src/borg/archiver/repo_space_cmd.py index 45c1646a28..781882571d 100644 --- a/src/borg/archiver/repo_space_cmd.py +++ b/src/borg/archiver/repo_space_cmd.py @@ -1,12 +1,14 @@ import argparse + import math import os from borgstore.store import ItemInfo -from ._common import with_repository, Highlander +from ._common import with_repository 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,27 +84,24 @@ def build_parser_repo_space(self, subparsers, common_parser, mid_common_parser): $ 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", dest="reserve_space", default=0, type=parse_file_size, - action=Highlander, help="Amount of space to reserve (e.g. 100M, 1G). Default: 0.", ) subparser.add_argument( diff --git a/src/borg/archiver/serve_cmd.py b/src/borg/archiver/serve_cmd.py index f8c1700676..76c493dad9 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 @@ def build_parser_serve(self, subparsers, common_parser, mid_common_parser): 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 3dffbd8030..0994758ad4 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -4,6 +4,7 @@ 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,22 +81,21 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): 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", metavar="TAG", type=tag_validator, - action="append", + nargs="*", help="set tags (can be given multiple times)", ) subparser.add_argument( @@ -103,7 +103,7 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): dest="add_tags", metavar="TAG", type=tag_validator, - action="append", + nargs="*", help="add tags (can be given multiple times)", ) subparser.add_argument( @@ -111,7 +111,7 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): dest="remove_tags", metavar="TAG", type=tag_validator, - action="append", + nargs="*", help="remove tags (can be given multiple times)", ) define_archive_filters_group(subparser) diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index 4a0dd4c225..ed2bca4d57 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 @@ -15,14 +16,15 @@ from ..helpers import msgpack from ..helpers import create_filter_process from ..helpers import ChunkIteratorFileWrapper -from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams +from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams, compression_spec_validator 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 +from ._common import with_repository, with_archive, define_exclusion_group from ._common import build_matcher, build_filter from ..logger import create_logger @@ -386,22 +388,17 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): 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", - default="auto", - action=Highlander, - help="filter program to pipe data through", + "--tar-filter", dest="tar_filter", default="auto", help="filter program to pipe data through" ) subparser.add_argument( "--list", dest="output_list", action="store_true", help="output verbose list of items (files, dirs, ...)" @@ -412,7 +409,6 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): dest="tar_format", default="PAX", choices=("BORG", "PAX", "GNU"), - action=Highlander, help="select tar format: BORG, PAX or GNU", ) subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") @@ -462,22 +458,17 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): ``--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", - default="auto", - action=Highlander, - help="filter program to pipe data through", + "--tar-filter", dest="tar_filter", default="auto", help="filter program to pipe data through" ) subparser.add_argument( "-s", @@ -498,7 +489,6 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): "--filter", dest="output_filter", metavar="STATUSCHARS", - action=Highlander, help="only display items with the given status characters", ) subparser.add_argument("--json", action="store_true", help="output stats as JSON (implies --stats)") @@ -516,7 +506,6 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): dest="comment", type=comment_validator, default="", - action=Highlander, help="add a comment text to the archive", ) archive_group.add_argument( @@ -524,7 +513,6 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): dest="timestamp", type=timestamp, default=None, - action=Highlander, metavar="TIMESTAMP", help="manually specify the archive creation date/time (yyyy-mm-ddThh:mm:ss[(+|-)HH:MM] format, " "(+|-)HH:MM is the UTC offset, default: local time zone). Alternatively, give a reference file/directory.", @@ -534,7 +522,6 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): dest="chunker_params", type=ChunkerParams, default=CHUNKER_PARAMS, - action=Highlander, metavar="PARAMS", help="specify the chunker parameters (ALGO, CHUNK_MIN_EXP, CHUNK_MAX_EXP, " "HASH_MASK_BITS, HASH_WINDOW_SIZE). default: %s,%d,%d,%d,%d" % CHUNKER_PARAMS, @@ -544,9 +531,8 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): "--compression", metavar="COMPRESSION", dest="compression", - type=CompressionSpec, + type=compression_spec_validator, default=CompressionSpec("lz4"), - action=Highlander, help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) diff --git a/src/borg/archiver/transfer_cmd.py b/src/borg/archiver/transfer_cmd.py index 99813039dd..7b5b3f5830 100644 --- a/src/borg/archiver/transfer_cmd.py +++ b/src/borg/archiver/transfer_cmd.py @@ -1,6 +1,6 @@ import argparse -from ._common import with_repository, with_other_repository, Highlander +from ._common import with_repository, with_other_repository from ..archive import Archive, cached_hash, DownloadPipeline from ..chunkers import get_chunker from ..compress import CompressionSpec @@ -9,7 +9,8 @@ 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 import ChunkerParams, ChunkIteratorFileWrapper, compression_spec_validator +from ..helpers.jap_wrapper import ArgumentParser from ..item import ChunkListEntry from ..manifest import Manifest from ..legacyrepository import LegacyRepository @@ -309,7 +310,6 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): 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 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): 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" ) @@ -352,7 +350,6 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): dest="other_location", type=location_validator(other=True), default=Location(other=True), - action=Highlander, help="transfer archives from the other repository", ) subparser.add_argument( @@ -365,7 +362,6 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): type=str, choices=("NoOp", "From12To20"), default="NoOp", - action=Highlander, help="use the upgrader to convert transferred data (default: no conversion)", ) subparser.add_argument( @@ -373,9 +369,8 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): "--compression", metavar="COMPRESSION", dest="compression", - type=CompressionSpec, + type=compression_spec_validator, default=CompressionSpec("lz4"), - action=Highlander, help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) subparser.add_argument( @@ -386,7 +381,6 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): default="never", const="always", choices=("never", "always"), - action=Highlander, help="recompress data chunks according to `MODE` and ``--compression``. " "Possible modes are " "`always`: recompress unconditionally; and " @@ -400,7 +394,6 @@ def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): dest="chunker_params", type=ChunkerParams, default=None, - action=Highlander, help="rechunk using given chunker parameters (ALGO, CHUNK_MIN_EXP, CHUNK_MAX_EXP, " "HASH_MASK_BITS, HASH_WINDOW_SIZE) or `default` to use the chunker defaults. " "default: do not rechunk", diff --git a/src/borg/archiver/undelete_cmd.py b/src/borg/archiver/undelete_cmd.py index a0455518f2..ea13e24022 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 @@ def build_parser_undelete(self, subparsers, common_parser, mid_common_parser): 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 36c52a9975..e8f2e0069f 100644 --- a/src/borg/archiver/version_cmd.py +++ b/src/borg/archiver/version_cmd.py @@ -2,6 +2,7 @@ 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 @@ def build_parser_version(self, subparsers, common_parser, mid_common_parser): 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/__init__.py b/src/borg/helpers/__init__.py index 7902d5bb67..eb02fcfe44 100644 --- a/src/borg/helpers/__init__.py +++ b/src/borg/helpers/__init__.py @@ -36,6 +36,8 @@ FilesCacheMode, partial_format, DatetimeWrapper, + SafePathSpec, + compression_spec_validator, ) from .parseformat import format_file_size, parse_file_size, FileSize from .parseformat import sizeof_fmt, sizeof_fmt_iec, sizeof_fmt_decimal, Location, text_validator @@ -45,7 +47,6 @@ from .parseformat import BaseFormatter, ArchiveFormatter, ItemFormatter, DiffFormatter, file_status from .parseformat import swidth_slice, ellipsis_truncate from .parseformat import BorgJsonEncoder, basic_json_data, json_print, json_dump, prepare_dump_dict -from .parseformat import Highlander, MakePathSafeAction from .process import daemonize, daemonizing, ThreadRunner from .process import signal_handler, raising_signal_handler, sig_int, ignore_sigint, SigHup, SigTerm from .process import popen_with_error_handling, is_terminal, prepare_subprocess_env, create_filter_process diff --git a/src/borg/helpers/jap_wrapper.py b/src/borg/helpers/jap_wrapper.py new file mode 100644 index 0000000000..9529968ae2 --- /dev/null +++ b/src/borg/helpers/jap_wrapper.py @@ -0,0 +1,142 @@ +"""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 keeps type= at call sites for readability + and wraps both into a composite action class that jsonargparse accepts. + +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 _make_type_converting_action(base_action_name, type_fn): + """Create a custom action class that wraps a standard action and applies type conversion. + + jsonargparse forbids type+action, so we strip both from kwargs and replace + them with a single composite action class that does type conversion + action. + """ + _action_map = {"append": argparse._AppendAction, "store": argparse._StoreAction} + base_cls = _action_map.get(base_action_name) + if base_cls is None: + 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 that handles the type+action combination jsonargparse forbids. + + Call sites can use both type= and action= naturally (e.g. type=parse_exclude_pattern, + action="append"). This mixin intercepts the call, strips both, and creates a + composite action class that jsonargparse accepts. + """ + + def add_argument(self, *args, **kwargs): + action = kwargs.get("action") + if action is not None and "type" in kwargs and isinstance(action, str): + type_fn = kwargs.pop("type") + wrapper = _make_type_converting_action(action, type_fn) + if wrapper is not None: + kwargs["action"] = wrapper + else: + # Unknown action string, put type back and hope for the best. + kwargs["type"] = type_fn + 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 = [] + + # Merge roots from pattern files (R lines in --patterns-from) with CLI paths. + # Pattern file roots are stored separately during parsing to avoid being + # overwritten by the positional "paths" argument. + from ..patterns import ArgparsePatternFileAction + + roots_from_patterns = ArgparsePatternFileAction.roots_from_patterns + if roots_from_patterns: + args.paths = list(roots_from_patterns) + args.paths + ArgparsePatternFileAction.roots_from_patterns.clear() + + return args diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 5132446b5a..6b07cb5a53 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -121,7 +121,7 @@ def decode_dict(d, keys, encoding="utf-8", errors="surrogateescape"): def positive_int_validator(value): - """argparse type for positive integers.""" + """argparse type for positive integers > 0.""" int_value = int(value) if int_value <= 0: raise argparse.ArgumentTypeError("A positive integer is required: %s" % value) @@ -146,6 +146,9 @@ def interval(s): S=1, ) + if isinstance(s, int): + return s + if s.endswith(tuple(multiplier.keys())): number = s[:-1] suffix = s[-1] @@ -163,7 +166,17 @@ def interval(s): return seconds +def compression_spec_validator(s): + from ..compress import CompressionSpec + + if isinstance(s, CompressionSpec): + return s + return CompressionSpec(s) + + def ChunkerParams(s): + if isinstance(s, tuple): + return s params = s.strip().split(",") count = len(params) if count == 0: @@ -228,6 +241,8 @@ def ChunkerParams(s): def FilesCacheMode(s): ENTRIES_MAP = dict(ctime="c", mtime="m", size="s", inode="i", rechunk="r", disabled="d") VALID_MODES = ("cis", "ims", "cs", "ms", "cr", "mr", "d", "s") # letters in alpha order + if s in VALID_MODES: + return s entries = set(s.strip().split(",")) if not entries <= set(ENTRIES_MAP): raise argparse.ArgumentTypeError( @@ -369,6 +384,8 @@ def __format__(self, format_spec): def parse_file_size(s): """Return int from file size (1234, 55G, 1.7T).""" + if isinstance(s, int): + return s if not s: return int(s) # will raise s = s.upper() @@ -629,6 +646,8 @@ def with_timestamp(self, timestamp): def location_validator(proto=None, other=False): def validator(text): + if isinstance(text, Location): + return text try: loc = Location(text, other=other) except ValueError as err: @@ -1305,26 +1324,11 @@ def decode(d): return decode(d) -class Highlander(argparse.Action): - """make sure some option is only given once""" - - def __init__(self, *args, **kwargs): - self.__called = False - super().__init__(*args, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - if self.__called: - raise argparse.ArgumentError(self, "There can be only one.") - self.__called = True - setattr(namespace, self.dest, values) - - -class MakePathSafeAction(Highlander): - def __call__(self, parser, namespace, path, option_string=None): - try: - sanitized_path = make_path_safe(path) - except ValueError as e: - raise argparse.ArgumentError(self, e) - if sanitized_path == ".": - raise argparse.ArgumentError(self, f"{path!r} is not a valid file name") - setattr(namespace, self.dest, sanitized_path) +def SafePathSpec(path): + try: + sanitized_path = make_path_safe(path) + except ValueError as e: + raise argparse.ArgumentTypeError(str(e)) + if sanitized_path == ".": + raise argparse.ArgumentTypeError(f"{path!r} is not a valid file name") + return sanitized_path diff --git a/src/borg/helpers/time.py b/src/borg/helpers/time.py index 7dfe0d38c4..f99a7f30ef 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -28,6 +28,8 @@ def parse_local_timestamp(timestamp, tzinfo=None): def timestamp(s): """Convert a --timestamp=s argument to a datetime object.""" + if isinstance(s, datetime): + return s try: # is it pointing to a file / directory? ts = safe_s(os.stat(s).st_mtime) diff --git a/src/borg/patterns.py b/src/borg/patterns.py index c1f8f57277..dc221e4774 100644 --- a/src/borg/patterns.py +++ b/src/borg/patterns.py @@ -41,6 +41,11 @@ def __init__(self, nargs=1, **kw): 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) @@ -59,12 +64,22 @@ def __call__(self, parser, args, values, option_string=None): except FileNotFoundError as e: raise Error(str(e)) + # Class-level storage for roots from pattern files, to avoid being overwritten + # by the positional "paths" argument. Merged into args.paths in flatten_namespace(). + roots_from_patterns: list[str] = [] + def parse(self, fobj, args): - load_pattern_file(fobj, args.paths, args.patterns) + # jsonargparse may initialize list-like attributes to None instead of [] + if args.patterns is None: + args.patterns = [] + load_pattern_file(fobj, ArgparsePatternFileAction.roots_from_patterns, 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 974becf332..62b8d565c9 100644 --- a/src/borg/testsuite/archiver/argparsing_test.py +++ b/src/borg/testsuite/archiver/argparsing_test.py @@ -10,22 +10,6 @@ def test_bad_filters(archiver): cmd(archiver, "delete", "--first", "1", "--last", "1", fork=True, exit_code=2) -def test_highlander(archiver): - cmd(archiver, "repo-create", RK_ENCRYPTION) - cmd(archiver, "create", "--comment", "comment 1", "test-1", __file__) - error_msg = "There can be only one" - # Default umask value is 0077 - # Test that it works with a one-time specified default or custom value - output_default = cmd(archiver, "--umask", "0077", "repo-list") - assert error_msg not in output_default - output_custom = cmd(archiver, "--umask", "0007", "repo-list") - assert error_msg not in output_custom - # Test that all combinations of custom and default values fail - for first, second in [("0007", "0007"), ("0007", "0077"), ("0077", "0007"), ("0077", "0077")]: - output_custom = cmd(archiver, "--umask", first, "--umask", second, "repo-list", exit_code=2) - assert error_msg in output_custom - - def test_get_args(): archiver = Archiver() # everything normal: @@ -34,7 +18,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 +50,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: diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py index 00edc0935d..73875773fa 100644 --- a/src/borg/testsuite/archiver/create_cmd_test.py +++ b/src/borg/testsuite/archiver/create_cmd_test.py @@ -475,6 +475,26 @@ def test_create_pattern_file(archivers, request): assert "- input/otherfile" in output +def test_create_pattern_file_mixed_roots(archivers, request): + """test that roots from a pattern file and roots from the command line can be mixed.""" + archiver = request.getfixturevalue(archivers) + patterns_file_path2 = os.path.join(archiver.tmpdir, "patterns_roots") + with open(patterns_file_path2, "wb") as fd: + fd.write(b"R input/dir1\n- input/dir1/file2\n") + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_regular_file(archiver.input_path, "dir1/file1", size=1024 * 80) + create_regular_file(archiver.input_path, "dir1/file2", size=1024 * 80) + create_regular_file(archiver.input_path, "dir2/file3", size=1024 * 80) + # root "input/dir1" comes from the pattern file, root "input/dir2" from the command line + output = cmd(archiver, "create", "-v", "--list", "--patterns-from=" + patterns_file_path2, "test", "input/dir2") + # file1 is included (root from pattern file, not excluded) + assert "A input/dir1/file1" in output + # file2 is excluded by the pattern file + assert "- input/dir1/file2" in output + # file3 is included (root from command line) + assert "A input/dir2/file3" in output + + def test_create_pattern_exclude_folder_but_recurse(archivers, request): """test when patterns exclude a parent folder, but include a child""" archiver = request.getfixturevalue(archivers) diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index 06be79730e..2ada635c28 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -15,7 +15,7 @@ def test_tag_set(archivers, request): assert "tags: aa." in output output = cmd(archiver, "tag", "-a", "archive", "--set", "bb") assert "tags: bb." in output - output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", "--set", "aa") + output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", "aa") assert "tags: aa,bb." in output # sorted! output = cmd(archiver, "tag", "-a", "archive", "--set", "") assert "tags: ." in output # no tags! @@ -46,7 +46,7 @@ def test_tag_set_noclobber_special(archivers, request): output = cmd(archiver, "tag", "-a", "archive", "--set", "clobber") assert "tags: @PROT." in output # it is possible though to use --set if the existing special tags are also given: - output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "--set", "@PROT") + output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "@PROT") assert "tags: @PROT,noclobber." in output