diff --git a/pyproject.toml b/pyproject.toml index bd175a5cb..ad6539a60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "shtab>=1.8.0", "backports-zstd; python_version < '3.14'", # for python < 3.14. "xxhash>=2.0.0", + "jsonargparse @ git+https://github.com/omni-us/jsonargparse.git@main", ] [project.optional-dependencies] diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 6d6cb6807..7ee8792b0 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -27,6 +27,8 @@ try: import signal from datetime import datetime, timezone + from jsonargparse import ArgumentParser + from ..logger import create_logger, setup_logging logger = create_logger() @@ -40,6 +42,7 @@ try: from ..helpers import format_file_size from ..helpers import remove_surrogates, text_to_json from ..helpers import DatetimeWrapper, replace_placeholders + from ..helpers.jap_helpers import flatten_namespace from ..helpers import is_slow_msgpack, is_supported_msgpack, sysinfo from ..helpers import signal_handler, raising_signal_handler, SigHup, SigTerm @@ -63,16 +66,7 @@ STATS_HEADER = " Original size Deduplicated size" PURE_PYTHON_MSGPACK_WARNING = "Using a pure-python msgpack! This will result in lower performance." -def get_func(args): - # This works around https://bugs.python.org/issue9351 - # func is used at the leaf parsers of the argparse parser tree, - # fallback_func at next level towards the root, - # fallback2_func at the 2nd next level (which is root in our case). - for name in "func", "fallback_func", "fallback2_func": - func = getattr(args, name, None) - if func is not None: - return func - raise Exception("expected func attributes not found") + from .analyze_cmd import AnalyzeMixIn @@ -277,7 +271,7 @@ class Archiver( # Note: We control all inputs. kwargs["help"] = kwargs["help"] % kwargs if not is_append: - kwargs["default"] = self.default_sentinel + kwargs["default"] = argparse.SUPPRESS common_group.add_argument(*args, **kwargs) @@ -328,9 +322,8 @@ class Archiver( def build_parser(self): from ._common import define_common_options - parser = argparse.ArgumentParser(prog=self.prog, description="Borg - Deduplicated Backups", add_help=False) + parser = 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=[], pattern_roots=[]) parser.common_options = self.CommonOptions( define_common_options, suffix_precedence=("_maincommand", "_midcommand", "_subcommand") ) @@ -340,18 +333,16 @@ class Archiver( parser.add_argument("--cockpit", dest="cockpit", action="store_true", help="Start the Borg TUI") parser.common_options.add_common_group(parser, "_maincommand", provide_defaults=True) - common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog) - common_parser.set_defaults(paths=[], patterns=[], pattern_roots=[]) + 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=[], pattern_roots=[]) + 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, title="required arguments", metavar="") self.build_parser_analyze(subparsers, common_parser, mid_common_parser) self.build_parser_benchmarks(subparsers, common_parser, mid_common_parser) @@ -424,8 +415,15 @@ class Archiver( args = self.preprocess_args(args) parser = self.build_parser() args = parser.parse_args(args or ["-h"]) + args = flatten_namespace(args) + + # Ensure list defaults previously handled by set_defaults are present + for list_attr in ("paths", "patterns", "pattern_roots"): + if getattr(args, list_attr, None) is None: + setattr(args, list_attr, []) + parser.common_options.resolve(args) - func = get_func(args) + func = self.get_func(args, parser) if func == self.do_create and args.paths and args.paths_from_stdin: parser.error("Must not pass PATH with --paths-from-stdin.") if args.progress and getattr(args, "output_list", False) and not args.log_json: @@ -451,8 +449,24 @@ class Archiver( if value: setattr(args, name, [replace_placeholders(elem) for elem in value]) + args.func = func + return args + def get_func(self, args, parser): + if not getattr(args, "subcommand", None): + return functools.partial(self.do_maincommand_help, parser) + + method_name = "do_" + args.subcommand.replace(" ", "_").replace("-", "_") + func = getattr(self, method_name, None) + if func is not None: + if method_name == "do_help": + return functools.partial(func, parser) + return func + + # fallback to general help for e.g., "borg key" + return functools.partial(self.do_maincommand_help, parser) + def prerun_checks(self, logger, is_serve): selftest(logger) @@ -485,7 +499,7 @@ class Archiver( def run(self, args): os.umask(args.umask) # early, before opening files self.lock_wait = args.lock_wait - func = get_func(args) + func = args.func # do not use loggers before this! is_serve = func == self.do_serve self.log_json = args.log_json and not is_serve diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py index 2369f49f3..9ae39a769 100644 --- a/src/borg/archiver/_common.py +++ b/src/borg/archiver/_common.py @@ -1,3 +1,4 @@ +import argparse import functools import os import textwrap @@ -268,6 +269,20 @@ def process_epilog(epilog): def define_exclude_and_patterns(add_option, *, tag_files=False, strip_components=False): + add_option( + "--pattern-roots-internal", + dest="pattern_roots", + action="append", + default=[], + help=argparse.SUPPRESS, + ) + add_option( + "--patterns-internal", + dest="patterns", + action="append", + default=[], + help=argparse.SUPPRESS, + ) add_option( "-e", "--exclude", @@ -275,6 +290,7 @@ def define_exclude_and_patterns(add_option, *, tag_files=False, strip_components dest="patterns", type=parse_exclude_pattern, action="append", + default=[], help="exclude paths matching PATTERN", ) add_option( @@ -372,7 +388,6 @@ def define_archive_filters_group( metavar="N", dest="first", type=positive_int_validator, - default=0, action=Highlander, help="consider the first N archives after other filters are applied", ) @@ -381,7 +396,6 @@ def define_archive_filters_group( metavar="N", dest="last", type=positive_int_validator, - default=0, action=Highlander, help="consider the last N archives after other filters are applied", ) @@ -508,7 +522,7 @@ def define_common_options(add_common_option): "--umask", metavar="M", dest="umask", - type=lambda s: int(s, 8), + type=lambda s: s if isinstance(s, int) else int(s, 8), default=UMASK_DEFAULT, action=Highlander, help="set umask to M (local only, default: %(default)04o)", diff --git a/src/borg/archiver/analyze_cmd.py b/src/borg/archiver/analyze_cmd.py index e55609588..1d99075ad 100644 --- a/src/borg/archiver/analyze_cmd.py +++ b/src/borg/archiver/analyze_cmd.py @@ -2,6 +2,8 @@ import argparse from collections import defaultdict import os +from jsonargparse import ArgumentParser + from ._common import with_repository, define_archive_filters_group from ..archive import Archive from ..constants import * # NOQA @@ -126,14 +128,12 @@ class AnalyzeMixIn: to recreate existing archives without them. """ ) - subparser = subparsers.add_parser( - "analyze", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_analyze.__doc__, epilog=analyze_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="analyze archives", ) - subparser.set_defaults(func=self.do_analyze) + subparsers.add_subcommand("analyze", subparser, help="analyze archives") define_archive_filters_group(subparser) diff --git a/src/borg/archiver/benchmark_cmd.py b/src/borg/archiver/benchmark_cmd.py index cf346e65a..ce317e428 100644 --- a/src/borg/archiver/benchmark_cmd.py +++ b/src/borg/archiver/benchmark_cmd.py @@ -1,12 +1,13 @@ import argparse from contextlib import contextmanager -import functools import json import logging import os import tempfile import time +from jsonargparse import ArgumentParser + from ..constants import * # NOQA from ..crypto.key import FlexiKey from ..helpers import format_file_size @@ -355,18 +356,16 @@ class BenchmarkMixIn: 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") - benchmark_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + benchmark_parsers = subparser.add_subcommands(required=False, title="required arguments", metavar="") bench_crud_epilog = process_epilog( """ @@ -409,16 +408,14 @@ class BenchmarkMixIn: Try multiple measurements and having a otherwise idle machine (and network, if you use it). """ ) - subparser = benchmark_parsers.add_parser( - "crud", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_benchmark_crud.__doc__, epilog=bench_crud_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="benchmarks Borg CRUD (create, extract, update, delete).", ) - subparser.set_defaults(func=self.do_benchmark_crud) + benchmark_parsers.add_subcommand("crud", subparser, help="benchmarks Borg CRUD (create, extract, update, delete).") subparser.add_argument("path", metavar="PATH", help="path where to create benchmark input data") subparser.add_argument("--json-lines", action="store_true", help="Format output as JSON Lines.") @@ -434,14 +431,12 @@ class BenchmarkMixIn: - enough free memory so there will be no slow down due to paging activity """ ) - subparser = benchmark_parsers.add_parser( - "cpu", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_benchmark_cpu.__doc__, epilog=bench_cpu_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="benchmarks Borg CPU-bound operations.", ) - subparser.set_defaults(func=self.do_benchmark_cpu) + benchmark_parsers.add_subcommand("cpu", subparser, help="benchmarks Borg CPU-bound operations.") 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 e78aef563..a39232468 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -1,4 +1,7 @@ import argparse + +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from ..archive import ArchiveChecker from ..constants import * # NOQA @@ -60,7 +63,7 @@ class CheckMixIn: repair=args.repair, find_lost_archives=args.find_lost_archives, match=args.match_archives, - sort_by=args.sort_by or "ts", + sort_by=args.sort_by or "timestamp", first=args.first, last=args.last, older=args.older, @@ -182,16 +185,14 @@ class CheckMixIn: ``borg compact`` would remove the archives' data completely. """ ) - subparser = subparsers.add_parser( - "check", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_check.__doc__, epilog=check_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="verify the repository", ) - subparser.set_defaults(func=self.do_check) + subparsers.add_subcommand("check", subparser, help="verify the repository") subparser.add_argument( "--repository-only", dest="repo_only", action="store_true", help="only perform repository checks" ) diff --git a/src/borg/archiver/compact_cmd.py b/src/borg/archiver/compact_cmd.py index 1cd07a0ba..4e1cbe1c9 100644 --- a/src/borg/archiver/compact_cmd.py +++ b/src/borg/archiver/compact_cmd.py @@ -1,6 +1,8 @@ import argparse from pathlib import Path +from jsonargparse import ArgumentParser + from ._common import with_repository from ..archive import Archive from ..cache import write_chunkindex_to_repo_cache, build_chunkindex_from_repo @@ -257,16 +259,14 @@ class CompactMixIn: thus it cannot compute before/after compaction size statistics). """ ) - subparser = subparsers.add_parser( - "compact", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_compact.__doc__, epilog=compact_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="compact the repository", ) - subparser.set_defaults(func=self.do_compact) + subparsers.add_subcommand("compact", subparser, help="compact the repository") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index abcc04f79..9120dd965 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -54,6 +54,8 @@ import argparse import shtab +from jsonargparse import ArgumentParser + from ._common import process_epilog from ..constants import * # NOQA from ..helpers import ( @@ -750,16 +752,14 @@ class CompletionMixIn: """ ) - subparser = subparsers.add_parser( - "completion", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_completion.__doc__, epilog=completion_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="output shell completion script", ) - subparser.set_defaults(func=self.do_completion) + subparsers.add_subcommand("completion", subparser, help="output shell completion script") subparser.add_argument( "shell", metavar="SHELL", choices=shells, help="shell to generate completion for (one of: %(choices)s)" ) diff --git a/src/borg/archiver/create_cmd.py b/src/borg/archiver/create_cmd.py index bd6cb8caa..6313837e2 100644 --- a/src/borg/archiver/create_cmd.py +++ b/src/borg/archiver/create_cmd.py @@ -9,6 +9,8 @@ import subprocess import time from io import TextIOWrapper +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from .. import helpers from ..archive import Archive, is_special @@ -772,16 +774,14 @@ class CreateMixIn: """ ) - subparser = subparsers.add_parser( - "create", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_create.__doc__, epilog=create_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="create a backup", ) - subparser.set_defaults(func=self.do_create) + subparsers.add_subcommand("create", subparser, help="create a backup") # note: --dry-run and --stats are mutually exclusive, but we do not want to abort when # parsing, but rather proceed with the dry-run, but without stats (see run() method). @@ -831,7 +831,7 @@ class CreateMixIn: "--stdin-mode", metavar="M", dest="stdin_mode", - type=lambda s: int(s, 8), + type=lambda s: s if isinstance(s, int) else int(s, 8), default=STDIN_MODE_DEFAULT, action=Highlander, help="set mode to M in archive for stdin data (default: %(default)04o)", diff --git a/src/borg/archiver/debug_cmd.py b/src/borg/archiver/debug_cmd.py index ed3d4ee51..2f311320f 100644 --- a/src/borg/archiver/debug_cmd.py +++ b/src/borg/archiver/debug_cmd.py @@ -1,8 +1,9 @@ import argparse -import functools import json import textwrap +from jsonargparse import ArgumentParser + from ..archive import Archive from ..compress import CompressionSpec from ..constants import * # NOQA @@ -319,18 +320,16 @@ class DebugMixIn: what you are doing or if a trusted developer tells you what to do.""" ) - subparser = subparsers.add_parser( - "debug", + subparser = ArgumentParser( parents=[mid_common_parser], add_help=False, description="debugging command (not intended for normal use)", epilog=debug_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="debugging command (not intended for normal use)", ) + subparsers.add_subcommand("debug", subparser, help="debugging command (not intended for normal use)") - debug_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + debug_parsers = subparser.add_subcommands(required=False, title="required arguments", metavar="") debug_info_epilog = process_epilog( """ @@ -339,32 +338,28 @@ class DebugMixIn: already appended at the end of the traceback. """ ) - subparser = debug_parsers.add_parser( - "info", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +367,14 @@ class DebugMixIn: This command dumps all metadata of an archive in a decoded form to a file. """ ) - subparser = debug_parsers.add_parser( - "dump-archive", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +383,14 @@ class DebugMixIn: This command dumps manifest metadata of a repository in a decoded form to a file. """ ) - subparser = debug_parsers.add_parser( - "dump-manifest", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_common_parser], add_help=False, description=self.do_debug_dump_manifest.__doc__, epilog=debug_dump_manifest_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="dump decoded repository metadata (debug)", ) - subparser.set_defaults(func=self.do_debug_dump_manifest) + debug_parsers.add_subcommand("dump-manifest", subparser, help="dump decoded repository metadata (debug)") subparser.add_argument("path", metavar="PATH", type=str, help="file to dump data into") debug_dump_repo_objs_epilog = process_epilog( @@ -407,32 +398,28 @@ class DebugMixIn: This command dumps raw (but decrypted and decompressed) repo objects to files. """ ) - subparser = debug_parsers.add_parser( - "dump-repo-objs", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_common_parser], add_help=False, description=self.do_debug_search_repo_objs.__doc__, epilog=debug_search_repo_objs_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="search repo objects (debug)", ) - subparser.set_defaults(func=self.do_debug_search_repo_objs) + debug_parsers.add_subcommand("search-repo-objs", subparser, help="search repo objects (debug)") subparser.add_argument( "wanted", metavar="WANTED", @@ -445,16 +432,14 @@ class DebugMixIn: This command computes the id-hash for some file content. """ ) - subparser = debug_parsers.add_parser( - "id-hash", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +450,14 @@ class DebugMixIn: This command parses the object file into metadata (as json) and uncompressed data. """ ) - subparser = debug_parsers.add_parser( - "parse-obj", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +475,14 @@ class DebugMixIn: This command formats the file and metadata into a Borg object file. """ ) - subparser = debug_parsers.add_parser( - "format-obj", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_common_parser], add_help=False, description=self.do_debug_format_obj.__doc__, epilog=debug_format_obj_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="format file and metadata into a Borg object file", ) - subparser.set_defaults(func=self.do_debug_format_obj) + debug_parsers.add_subcommand("format-obj", subparser, help="format file and metadata into a Borg object file") subparser.add_argument("id", metavar="ID", type=str, help="hex object ID to get from the repo") subparser.add_argument( "binary_path", metavar="BINARY_PATH", type=str, help="path of the file to convert into an object file" @@ -531,16 +512,14 @@ class DebugMixIn: This command gets an object from the repository. """ ) - subparser = debug_parsers.add_parser( - "get-obj", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +528,14 @@ class DebugMixIn: This command puts an object into the repository. """ ) - subparser = debug_parsers.add_parser( - "put-obj", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +544,14 @@ class DebugMixIn: This command deletes objects from the repository. """ ) - subparser = debug_parsers.add_parser( - "delete-obj", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 +561,13 @@ class DebugMixIn: Convert a Borg profile to a Python cProfile compatible profile. """ ) - subparser = debug_parsers.add_parser( - "convert-profile", - parents=[common_parser], + subparser = ArgumentParser( + parents=[mid_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 49c913f93..995c6e2e4 100644 --- a/src/borg/archiver/delete_cmd.py +++ b/src/borg/archiver/delete_cmd.py @@ -1,6 +1,8 @@ import argparse import logging +from jsonargparse import ArgumentParser + from ._common import with_repository from ..constants import * # NOQA from ..helpers import format_archive, CommandError, bin_to_hex, archivename_validator @@ -80,16 +82,14 @@ class DeleteMixIn: patterns, see :ref:`borg_patterns`). """ ) - subparser = subparsers.add_parser( - "delete", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_delete.__doc__, epilog=delete_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="delete archives", ) - subparser.set_defaults(func=self.do_delete) + subparsers.add_subcommand("delete", subparser, help="delete archives") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/diff_cmd.py b/src/borg/archiver/diff_cmd.py index 87b47a7bd..ceaa0b0d2 100644 --- a/src/borg/archiver/diff_cmd.py +++ b/src/borg/archiver/diff_cmd.py @@ -4,6 +4,8 @@ import json import sys import os +from jsonargparse import ArgumentParser + from ._common import with_repository, build_matcher, Highlander from ..archive import Archive from ..constants import * # NOQA @@ -294,16 +296,14 @@ class DiffMixIn: raise argparse.ArgumentTypeError(f"unsupported sort field: {field}") return ",".join(parts) - subparser = subparsers.add_parser( - "diff", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_diff.__doc__, epilog=diff_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="find differences in archive contents", ) - subparser.set_defaults(func=self.do_diff) + subparsers.add_subcommand("diff", subparser, help="find differences in archive contents") subparser.add_argument( "--numeric-ids", dest="numeric_ids", diff --git a/src/borg/archiver/extract_cmd.py b/src/borg/archiver/extract_cmd.py index 1a020f371..fb5a67e76 100644 --- a/src/borg/archiver/extract_cmd.py +++ b/src/borg/archiver/extract_cmd.py @@ -3,6 +3,8 @@ import argparse import logging import stat +from jsonargparse import ArgumentParser + from ._common import with_repository, with_archive from ._common import build_filter, build_matcher from ..archive import BackupError @@ -155,16 +157,14 @@ class ExtractMixIn: group, permissions, etc. """ ) - subparser = subparsers.add_parser( - "extract", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_extract.__doc__, epilog=extract_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="extract archive contents", ) - subparser.set_defaults(func=self.do_extract) + subparsers.add_subcommand("extract", subparser, help="extract archive contents") subparser.add_argument( "--list", dest="output_list", action="store_true", help="output a verbose list of items (files, dirs, ...)" ) diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index a73ef7e90..3b1486cd0 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -1,7 +1,8 @@ import collections -import functools import textwrap +from jsonargparse import ArgumentParser + from ..constants import * # NOQA from ..helpers.nanorst import rst_to_terminal @@ -523,7 +524,10 @@ class HelpMixIn: borg create --compression obfuscate,250,zstd,3 ...\n\n""" ) - def do_help(self, parser, commands, args): + def do_help(self, parser, args): + commands = getattr(parser, "_subcommands_action", None) + commands = commands._name_parser_map if commands else {} + if not args.topic: parser.print_help() elif args.topic in self.helptext: @@ -551,10 +555,12 @@ class HelpMixIn: do_maincommand_help = do_subcommand_help def build_parser_help(self, subparsers, common_parser, mid_common_parser, parser): - subparser = subparsers.add_parser( - "help", parents=[common_parser], add_help=False, description="Extra help", help="Extra help" + subparser = ArgumentParser( + parents=[common_parser], + add_help=False, + description="Extra help", ) + subparsers.add_subcommand("help", subparser, help="Extra help") subparser.add_argument("--epilog-only", dest="epilog_only", action="store_true") subparser.add_argument("--usage-only", dest="usage_only", action="store_true") - subparser.set_defaults(func=functools.partial(self.do_help, parser, subparsers.choices)) subparser.add_argument("topic", metavar="TOPIC", type=str, nargs="?", help="additional help on TOPIC") diff --git a/src/borg/archiver/info_cmd.py b/src/borg/archiver/info_cmd.py index dbb8b3ab6..c4a808265 100644 --- a/src/borg/archiver/info_cmd.py +++ b/src/borg/archiver/info_cmd.py @@ -2,6 +2,8 @@ import argparse import textwrap from datetime import timedelta +from jsonargparse import ArgumentParser + from ._common import with_repository from ..archive import Archive from ..constants import * # NOQA @@ -78,16 +80,14 @@ class InfoMixIn: = all chunks in the repository. """ ) - subparser = subparsers.add_parser( - "info", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_info.__doc__, epilog=info_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="show repository or archive information", ) - subparser.set_defaults(func=self.do_info) + subparsers.add_subcommand("info", subparser, help="show repository or archive information") subparser.add_argument("--json", action="store_true", help="format output as JSON") define_archive_filters_group(subparser) subparser.add_argument( diff --git a/src/borg/archiver/key_cmds.py b/src/borg/archiver/key_cmds.py index c00d6f80b..0984a38fb 100644 --- a/src/borg/archiver/key_cmds.py +++ b/src/borg/archiver/key_cmds.py @@ -1,7 +1,8 @@ import argparse -import functools import os +from jsonargparse import ArgumentParser + from ..constants import * # NOQA from ..crypto.key import AESOCBRepoKey, CHPORepoKey, Blake2AESOCBRepoKey, Blake2CHPORepoKey from ..crypto.key import AESOCBKeyfileKey, CHPOKeyfileKey, Blake2AESOCBKeyfileKey, Blake2CHPOKeyfileKey @@ -120,18 +121,16 @@ class KeysMixIn: def build_parser_keys(self, subparsers, common_parser, mid_common_parser): from ._common import process_epilog - subparser = subparsers.add_parser( - "key", + 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") - key_parsers = subparser.add_subparsers(title="required arguments", metavar="") - subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser)) + key_parsers = subparser.add_subcommands(required=False, title="required arguments", metavar="") key_export_epilog = process_epilog( """ @@ -164,16 +163,14 @@ class KeysMixIn: HTML template with a QR code and a copy of the ``--paper``-formatted key. """ ) - subparser = key_parsers.add_parser( - "export", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_key_export.__doc__, epilog=key_export_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="export the repository key for backup", ) - subparser.set_defaults(func=self.do_key_export) + key_parsers.add_subcommand("export", subparser, help="export the repository key for backup") subparser.add_argument("path", metavar="PATH", nargs="?", type=PathSpec, help="where to store the backup") subparser.add_argument( "--paper", @@ -206,16 +203,14 @@ class KeysMixIn: key import`` creates a new key file in ``$BORG_KEYS_DIR``. """ ) - subparser = key_parsers.add_parser( - "import", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_key_import.__doc__, epilog=key_import_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="import the repository key from backup", ) - subparser.set_defaults(func=self.do_key_import) + key_parsers.add_subcommand("import", subparser, help="import the repository key from backup") subparser.add_argument( "path", metavar="PATH", nargs="?", type=PathSpec, help="path to the backup ('-' to read from stdin)" ) @@ -237,16 +232,14 @@ class KeysMixIn: does not protect future (nor past) backups to the same repository. """ ) - subparser = key_parsers.add_parser( - "change-passphrase", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_key_change_passphrase.__doc__, epilog=change_passphrase_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="change the repository passphrase", ) - subparser.set_defaults(func=self.do_key_change_passphrase) + key_parsers.add_subcommand("change-passphrase", subparser, help="change the repository passphrase") change_location_epilog = process_epilog( """ @@ -261,16 +254,14 @@ class KeysMixIn: thus you must ONLY give the key location (keyfile or repokey). """ ) - subparser = key_parsers.add_parser( - "change-location", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_key_change_location.__doc__, epilog=change_location_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="change the key location", ) - subparser.set_defaults(func=self.do_key_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 0df719c42..4a355275e 100644 --- a/src/borg/archiver/list_cmd.py +++ b/src/borg/archiver/list_cmd.py @@ -3,6 +3,8 @@ import os import textwrap import sys +from jsonargparse import ArgumentParser + from ._common import with_repository, build_matcher, Highlander from ..archive import Archive from ..cache import Cache @@ -103,16 +105,14 @@ class ListMixIn: ) + ItemFormatter.keys_help() ) - subparser = subparsers.add_parser( - "list", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_list.__doc__, epilog=list_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="list archive contents", ) - subparser.set_defaults(func=self.do_list) + subparsers.add_subcommand("list", subparser, help="list archive contents") subparser.add_argument( "--short", dest="short", action="store_true", help="only print file/directory names, nothing else" ) diff --git a/src/borg/archiver/lock_cmds.py b/src/borg/archiver/lock_cmds.py index 1739da6df..0e5e251f2 100644 --- a/src/borg/archiver/lock_cmds.py +++ b/src/borg/archiver/lock_cmds.py @@ -1,6 +1,8 @@ import argparse import subprocess +from jsonargparse import ArgumentParser + from ._common import with_repository from ..cache import Cache from ..constants import * # NOQA @@ -45,16 +47,14 @@ class LocksMixIn: trying to access the cache or the repository. """ ) - subparser = subparsers.add_parser( - "break-lock", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_break_lock.__doc__, epilog=break_lock_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="break the repository and cache locks", ) - subparser.set_defaults(func=self.do_break_lock) + subparsers.add_subcommand("break-lock", subparser, help="break the repository and cache locks") with_lock_epilog = process_epilog( """ @@ -77,15 +77,13 @@ class LocksMixIn: Borg is cautious and does not automatically remove stale locks made by a different host. """ ) - subparser = subparsers.add_parser( - "with-lock", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_with_lock.__doc__, epilog=with_lock_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="run a user command with the lock held", ) - subparser.set_defaults(func=self.do_with_lock) + subparsers.add_subcommand("with-lock", subparser, help="run a user command with the lock held") subparser.add_argument("command", metavar="COMMAND", help="command to run") subparser.add_argument("args", metavar="ARGS", nargs=argparse.REMAINDER, help="command arguments") diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index d37d8cb47..7a4164de1 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -1,6 +1,8 @@ import argparse import os +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from ..constants import * # NOQA from ..helpers import RTError @@ -151,15 +153,14 @@ class MountMixIn: the logger to output to a file. """ ) - subparser = subparsers.add_parser( - "mount", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_mount.__doc__, epilog=mount_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="mount a repository", ) + subparsers.add_subcommand("mount", subparser, help="mount a repository") self._define_borg_mount(subparser) umount_epilog = process_epilog( @@ -170,16 +171,14 @@ class MountMixIn: command - usually this is either umount or fusermount -u. """ ) - subparser = subparsers.add_parser( - "umount", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_umount.__doc__, epilog=umount_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="unmount a repository", ) - subparser.set_defaults(func=self.do_umount) + subparsers.add_subcommand("umount", subparser, help="unmount a repository") subparser.add_argument( "mountpoint", metavar="MOUNTPOINT", type=str, help="mountpoint of the filesystem to unmount" ) @@ -196,7 +195,6 @@ class MountMixIn: def _define_borg_mount(self, parser): from ._common import define_exclusion_group, define_archive_filters_group - parser.set_defaults(func=self.do_mount) parser.add_argument("mountpoint", metavar="MOUNTPOINT", type=str, help="where to mount the filesystem") parser.add_argument( "-f", "--foreground", dest="foreground", action="store_true", help="stay in foreground, do not daemonize" diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 481b2014e..4f8481bdd 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -5,6 +5,8 @@ import logging from operator import attrgetter import os +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from ..constants import * # NOQA from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error @@ -273,16 +275,14 @@ class PruneMixIn: the ``borg repo-list`` description for more details about the format string). """ ) - subparser = subparsers.add_parser( - "prune", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_prune.__doc__, epilog=prune_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="prune archives", ) - subparser.set_defaults(func=self.do_prune) + subparsers.add_subcommand("prune", subparser, help="prune archives") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/recreate_cmd.py b/src/borg/archiver/recreate_cmd.py index 149f665a9..ff074261e 100644 --- a/src/borg/archiver/recreate_cmd.py +++ b/src/borg/archiver/recreate_cmd.py @@ -1,5 +1,7 @@ import argparse +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from ._common import build_matcher from ..archive import ArchiveRecreater @@ -102,16 +104,14 @@ class RecreateMixIn: if the chunks are still missing. """ ) - subparser = subparsers.add_parser( - "recreate", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_recreate.__doc__, epilog=recreate_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help=self.do_recreate.__doc__, ) - subparser.set_defaults(func=self.do_recreate) + subparsers.add_subcommand("recreate", subparser, help=self.do_recreate.__doc__) subparser.add_argument( "--list", dest="output_list", action="store_true", help="output verbose list of items (files, dirs, ...)" ) diff --git a/src/borg/archiver/rename_cmd.py b/src/borg/archiver/rename_cmd.py index bdb338843..316f9734c 100644 --- a/src/borg/archiver/rename_cmd.py +++ b/src/borg/archiver/rename_cmd.py @@ -1,5 +1,7 @@ import argparse +from jsonargparse import ArgumentParser + from ._common import with_repository, with_archive from ..constants import * # NOQA from ..helpers import archivename_validator @@ -28,16 +30,14 @@ class RenameMixIn: This results in a different archive ID. """ ) - subparser = subparsers.add_parser( - "rename", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_rename.__doc__, epilog=rename_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="rename an archive", ) - subparser.set_defaults(func=self.do_rename) + subparsers.add_subcommand("rename", subparser, help="rename an archive") subparser.add_argument( "name", metavar="OLDNAME", type=archivename_validator, help="specify the current archive name" ) diff --git a/src/borg/archiver/repo_compress_cmd.py b/src/borg/archiver/repo_compress_cmd.py index a5aeb9a54..7bf38e31c 100644 --- a/src/borg/archiver/repo_compress_cmd.py +++ b/src/borg/archiver/repo_compress_cmd.py @@ -1,6 +1,8 @@ import argparse from collections import defaultdict +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from ..constants import * # NOQA from ..compress import CompressionSpec, ObfuscateSize, Auto, COMPRESSOR_TABLE @@ -180,16 +182,14 @@ class RepoCompressMixIn: You do **not** need to run ``borg compact`` after ``borg repo-compress``. """ ) - subparser = subparsers.add_parser( - "repo-compress", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_compress.__doc__, epilog=repo_compress_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help=self.do_repo_compress.__doc__, ) - subparser.set_defaults(func=self.do_repo_compress) + subparsers.add_subcommand("repo-compress", subparser, help=self.do_repo_compress.__doc__) subparser.add_argument( "-C", diff --git a/src/borg/archiver/repo_create_cmd.py b/src/borg/archiver/repo_create_cmd.py index 07b60fa8d..7856e46fc 100644 --- a/src/borg/archiver/repo_create_cmd.py +++ b/src/borg/archiver/repo_create_cmd.py @@ -1,5 +1,7 @@ import argparse +from jsonargparse import ArgumentParser + from ._common import with_repository, with_other_repository, Highlander from ..cache import Cache from ..constants import * # NOQA @@ -190,16 +192,14 @@ class RepoCreateMixIn: Then use ``borg transfer --other-repo ORIG_REPO --from-borg1 ...`` to transfer the archives. """ ) - subparser = subparsers.add_parser( - "repo-create", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_create.__doc__, epilog=repo_create_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="create a new, empty repository", ) - subparser.set_defaults(func=self.do_repo_create) + subparsers.add_subcommand("repo-create", subparser, help="create a new, empty repository") subparser.add_argument( "--other-repo", metavar="SRC_REPOSITORY", diff --git a/src/borg/archiver/repo_delete_cmd.py b/src/borg/archiver/repo_delete_cmd.py index aa2b531ea..51319c6a3 100644 --- a/src/borg/archiver/repo_delete_cmd.py +++ b/src/borg/archiver/repo_delete_cmd.py @@ -1,5 +1,7 @@ import argparse +from jsonargparse import ArgumentParser + from ._common import with_repository from ..cache import Cache, SecurityManager from ..constants import * # NOQA @@ -102,16 +104,14 @@ class RepoDeleteMixIn: Always first use ``--dry-run --list`` to see what would be deleted. """ ) - subparser = subparsers.add_parser( - "repo-delete", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_delete.__doc__, epilog=repo_delete_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="delete a repository", ) - subparser.set_defaults(func=self.do_repo_delete) + subparsers.add_subcommand("repo-delete", subparser, help="delete a repository") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/repo_info_cmd.py b/src/borg/archiver/repo_info_cmd.py index 0b11ed6e8..43c37d503 100644 --- a/src/borg/archiver/repo_info_cmd.py +++ b/src/borg/archiver/repo_info_cmd.py @@ -1,6 +1,8 @@ import argparse import textwrap +from jsonargparse import ArgumentParser + from ._common import with_repository from ..constants import * # NOQA from ..helpers import bin_to_hex, json_print, basic_json_data @@ -63,14 +65,12 @@ class RepoInfoMixIn: This command displays detailed information about the repository. """ ) - subparser = subparsers.add_parser( - "repo-info", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_info.__doc__, epilog=repo_info_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="show repository information", ) - subparser.set_defaults(func=self.do_repo_info) + subparsers.add_subcommand("repo-info", subparser, help="show repository information") subparser.add_argument("--json", action="store_true", help="format output as JSON") diff --git a/src/borg/archiver/repo_list_cmd.py b/src/borg/archiver/repo_list_cmd.py index 6f5c5ae47..a91c4a53b 100644 --- a/src/borg/archiver/repo_list_cmd.py +++ b/src/borg/archiver/repo_list_cmd.py @@ -3,6 +3,8 @@ import os import textwrap import sys +from jsonargparse import ArgumentParser + from ._common import with_repository, Highlander from ..constants import * # NOQA from ..helpers import BaseFormatter, ArchiveFormatter, json_print, basic_json_data @@ -85,16 +87,14 @@ class RepoListMixIn: ) + ArchiveFormatter.keys_help() ) - subparser = subparsers.add_parser( - "repo-list", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_list.__doc__, epilog=repo_list_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="list repository contents", ) - subparser.set_defaults(func=self.do_repo_list) + subparsers.add_subcommand("repo-list", subparser, help="list repository contents") subparser.add_argument( "--short", dest="short", action="store_true", help="only print the archive IDs, nothing else" ) diff --git a/src/borg/archiver/repo_space_cmd.py b/src/borg/archiver/repo_space_cmd.py index 45c1646a2..72a03007d 100644 --- a/src/borg/archiver/repo_space_cmd.py +++ b/src/borg/archiver/repo_space_cmd.py @@ -2,6 +2,8 @@ import argparse import math import os +from jsonargparse import ArgumentParser + from borgstore.store import ItemInfo from ._common import with_repository, Highlander @@ -86,16 +88,14 @@ class RepoSpaceMixIn: Reserved space is always rounded up to full reservation blocks of 64 MiB. """ ) - subparser = subparsers.add_parser( - "repo-space", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_repo_space.__doc__, epilog=repo_space_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="manage reserved space in a repository", ) - subparser.set_defaults(func=self.do_repo_space) + subparsers.add_subcommand("repo-space", subparser, help="manage reserved space in a repository") subparser.add_argument( "--reserve", metavar="SPACE", diff --git a/src/borg/archiver/serve_cmd.py b/src/borg/archiver/serve_cmd.py index f8c170067..6774d65a2 100644 --- a/src/borg/archiver/serve_cmd.py +++ b/src/borg/archiver/serve_cmd.py @@ -19,6 +19,7 @@ class ServeMixIn: ).serve() def build_parser_serve(self, subparsers, common_parser, mid_common_parser): + from jsonargparse import ArgumentParser from ._common import process_epilog serve_epilog = process_epilog( @@ -52,16 +53,14 @@ class ServeMixIn: Existing archives can be read, but no archives can be created or deleted. """ ) - subparser = subparsers.add_parser( - "serve", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_serve.__doc__, epilog=serve_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="start the repository server process", ) - subparser.set_defaults(func=self.do_serve) + subparsers.add_subcommand("serve", subparser, help="start the repository server process") subparser.add_argument( "--restrict-to-path", metavar="PATH", diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 3dffbd803..1b24ca08a 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -1,5 +1,7 @@ import argparse +from jsonargparse import ArgumentParser + from ._common import with_repository, define_archive_filters_group from ..archive import Archive from ..constants import * # NOQA @@ -80,39 +82,37 @@ class TagMixIn: removed). """ ) - subparser = subparsers.add_parser( - "tag", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_tag.__doc__, epilog=tag_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="tag archives", ) - subparser.set_defaults(func=self.do_tag) + subparsers.add_subcommand("tag", subparser, help="tag archives") subparser.add_argument( "--set", dest="set_tags", metavar="TAG", type=tag_validator, - action="append", - help="set tags (can be given multiple times)", + nargs="+", + help="set tags", ) subparser.add_argument( "--add", dest="add_tags", metavar="TAG", type=tag_validator, - action="append", - help="add tags (can be given multiple times)", + nargs="+", + help="add tags", ) subparser.add_argument( "--remove", dest="remove_tags", metavar="TAG", type=tag_validator, - action="append", - help="remove tags (can be given multiple times)", + nargs="+", + help="remove tags", ) define_archive_filters_group(subparser) subparser.add_argument( diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index fe0d7bd1d..8830ebcdc 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -5,6 +5,8 @@ import os import stat import tarfile +from jsonargparse import ArgumentParser + from ..archive import Archive, TarfileObjectProcessors, ChunksProcessor from ..compress import CompressionSpec from ..constants import * # NOQA @@ -384,16 +386,14 @@ class TarMixIn: pass over the archive metadata. """ ) - subparser = subparsers.add_parser( - "export-tar", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_export_tar.__doc__, epilog=export_tar_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="create tarball from archive", ) - subparser.set_defaults(func=self.do_export_tar) + subparsers.add_subcommand("export-tar", subparser, help="create tarball from archive") subparser.add_argument( "--tar-filter", dest="tar_filter", @@ -460,16 +460,14 @@ class TarMixIn: ``--ignore-zeros`` option to skip through the stop markers between them. """ ) - subparser = subparsers.add_parser( - "import-tar", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_import_tar.__doc__, epilog=import_tar_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help=self.do_import_tar.__doc__, ) - subparser.set_defaults(func=self.do_import_tar) + subparsers.add_subcommand("import-tar", subparser, help=self.do_import_tar.__doc__) subparser.add_argument( "--tar-filter", dest="tar_filter", diff --git a/src/borg/archiver/transfer_cmd.py b/src/borg/archiver/transfer_cmd.py index 99813039d..a034b0635 100644 --- a/src/borg/archiver/transfer_cmd.py +++ b/src/borg/archiver/transfer_cmd.py @@ -1,5 +1,7 @@ import argparse +from jsonargparse import ArgumentParser + from ._common import with_repository, with_other_repository, Highlander from ..archive import Archive, cached_hash, DownloadPipeline from ..chunkers import get_chunker @@ -333,16 +335,14 @@ class TransferMixIn: """ ) - subparser = subparsers.add_parser( - "transfer", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_transfer.__doc__, epilog=transfer_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="transfer of archives from another repository", ) - subparser.set_defaults(func=self.do_transfer) + subparsers.add_subcommand("transfer", subparser, help="transfer of archives from another repository") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change repository, just check" ) diff --git a/src/borg/archiver/undelete_cmd.py b/src/borg/archiver/undelete_cmd.py index a0455518f..1c2bdf682 100644 --- a/src/borg/archiver/undelete_cmd.py +++ b/src/borg/archiver/undelete_cmd.py @@ -1,6 +1,8 @@ import argparse import logging +from jsonargparse import ArgumentParser + from ._common import with_repository from ..constants import * # NOQA from ..helpers import format_archive, CommandError, bin_to_hex, archivename_validator @@ -72,16 +74,14 @@ class UnDeleteMixIn: patterns, see :ref:`borg_patterns`). """ ) - subparser = subparsers.add_parser( - "undelete", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_undelete.__doc__, epilog=undelete_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="undelete archives", ) - subparser.set_defaults(func=self.do_undelete) + subparsers.add_subcommand("undelete", subparser, help="undelete archives") subparser.add_argument( "-n", "--dry-run", dest="dry_run", action="store_true", help="do not change the repository" ) diff --git a/src/borg/archiver/version_cmd.py b/src/borg/archiver/version_cmd.py index 36c52a997..bfa825ba0 100644 --- a/src/borg/archiver/version_cmd.py +++ b/src/borg/archiver/version_cmd.py @@ -23,6 +23,7 @@ class VersionMixIn: print(f"{format_version(client_version)} / {format_version(server_version)}") def build_parser_version(self, subparsers, common_parser, mid_common_parser): + from jsonargparse import ArgumentParser from ._common import process_epilog version_epilog = process_epilog( @@ -51,13 +52,11 @@ class VersionMixIn: You can also use ``borg --version`` to display a potentially more precise client version. """ ) - subparser = subparsers.add_parser( - "version", + subparser = ArgumentParser( parents=[common_parser], add_help=False, description=self.do_version.__doc__, epilog=version_epilog, formatter_class=argparse.RawDescriptionHelpFormatter, - help="display the Borg client and server versions", ) - subparser.set_defaults(func=self.do_version) + subparsers.add_subcommand("version", subparser, help="display the Borg client and server versions") diff --git a/src/borg/helpers/jap_helpers.py b/src/borg/helpers/jap_helpers.py new file mode 100644 index 000000000..be6962ea2 --- /dev/null +++ b/src/borg/helpers/jap_helpers.py @@ -0,0 +1,33 @@ +import argparse +from typing import Any + + +def flatten_namespace(ns: Any) -> argparse.Namespace: + """ + Recursively flattens a nested namespace into a single-level namespace. + JSONArgparse uses nested namespaces for subcommands, whereas borg's + internal dispatch and logic expect a flat namespace. + """ + flat = argparse.Namespace() + + # Extract the nested path of subcommands + subcmds = [] + current = ns + while current and hasattr(current, "subcommand") and current.subcommand: + subcmds.append(current.subcommand) + current = getattr(current, current.subcommand, None) + + if subcmds: + flat.subcommand = " ".join(subcmds) + + def _flatten(source, target): + items = vars(source).items() if hasattr(source, '__dict__') else source.items() if hasattr(source, 'items') else [] + for k, v in items: + if isinstance(v, argparse.Namespace) or type(v).__name__ == 'Namespace': + _flatten(v, target) + else: + if k != "subcommand" and not hasattr(target, k): + setattr(target, k, v) + + _flatten(ns, flat) + return flat diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 2505954df..c4e7133d4 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, N > 0.""" int_value = int(value) if int_value <= 0: raise argparse.ArgumentTypeError("A positive integer is required: %s" % value) @@ -352,7 +352,7 @@ def SortBySpec(text): from ..manifest import AI_HUMAN_SORT_KEYS for token in text.split(","): - if token not in AI_HUMAN_SORT_KEYS: + if token not in AI_HUMAN_SORT_KEYS and token != "ts": # idempotency: do not reject ts raise argparse.ArgumentTypeError("Invalid sort key: %s" % token) return text.replace("timestamp", "ts").replace("archive", "name") diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index 06be79730..2ada635c2 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