diff --git a/docs/usage/general/config.rst.inc b/docs/usage/general/config.rst.inc
new file mode 100644
index 0000000000..d5f2831dad
--- /dev/null
+++ b/docs/usage/general/config.rst.inc
@@ -0,0 +1,64 @@
+Configuration Precedence
+ ~~~~~~~~~~~~~~~~~~~~~~~
+
+ From lowest to highest:
+
+ 1. Defaults defined in the source code.
+ 2. Default config file (``$BORG_CONFIG_DIR/default.yaml``).
+ 3. ``--config`` file(s) (in the order given).
+ 4. Full config environment variable: (``BORG_CONFIG``).
+ 5. Environment variables (e.g. ``BORG_LOG_LEVEL``).
+ 6. Command-line arguments in order left to right (might include config files).
+
+Configuration files
+~~~~~~~~~~~~~~~~~~~
+
+Borg supports reading options from YAML configuration files. This is
+implemented via `jsonargparse `_
+and works for all options that can also be set on the command line.
+
+Default configuration file
+ ``$BORG_CONFIG_DIR/default.yaml`` is loaded automatically on every Borg
+ invocation if it exists. You do not need to pass ``--config`` explicitly
+ for this file.
+
+``--config PATH``
+ Load additional options from the YAML file at *PATH*.
+ Options in this file take precedence over the default config file but are
+ overridden by explicit command-line arguments. This option can be used
+ multiple times, with later files overriding earlier ones.
+
+``--print_config``
+ Print the current effective configuration (all options in YAML format) to
+ stdout and exit. This reflects the merged result of the default config
+ file, any ``--config`` file, environment variables, and command-line
+ arguments given before ``--print_config``. The output can be used as a
+ starting point for a config file.
+
+File format
+ Config files are YAML documents. Top-level keys are option names
+ (without leading ``--`` and with ``-`` replaced by ``_``).
+ Nested keys correspond to subcommands.
+
+ Example ``default.yaml``::
+
+ # apply to all borg commands:
+ log_level: info
+ show_rc: true
+
+ # options specific to "borg create":
+ create:
+ compression: zstd,3
+ stats: true
+
+ The top-level keys set options that are common to all commands (equivalent
+ to placing them before the subcommand on the command line). Keys nested
+ under a subcommand name (e.g. ``create:``) are only applied when that
+ subcommand is invoked.
+
+.. note::
+ ``--print_config`` shows the merged effective configuration and is a
+ convenient way to check what values Borg will actually use, and to
+ generate contents for your borg config file(s)::
+
+ borg --repo /backup/main create --compression zstd,3 --print_config
diff --git a/docs/usage/general/environment.rst.inc b/docs/usage/general/environment.rst.inc
index 10455252e6..d684012ec3 100644
--- a/docs/usage/general/environment.rst.inc
+++ b/docs/usage/general/environment.rst.inc
@@ -284,3 +284,29 @@ Please note:
.. _INI: https://docs.python.org/3/library/logging.config.html#configuration-file-format
.. _tempfile: https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir
+
+
+Automatically generated Environment Variables (jsonargparse)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Borg uses jsonargparse_ with ``default_env=True``, which means that every
+command-line option can also be set via an environment variable.
+
+The environment variable name is derived from the program name (``borg``),
+the subcommand (if any), and the option name, all converted to uppercase
+with dashes replaced by underscores.
+
+For **top-level options** (not specific to a subcommand), the pattern is::
+
+ BORG_
+
+For example, ``--lock-wait`` can be set via ``BORG_LOCK_WAIT``.
+
+For **subcommand options**, the subcommand and option are separated by a
+double underscore::
+
+ BORG___
+
+For example, ``borg create --comment`` can be set via ``BORG_CREATE__COMMENT``.
+
+.. _jsonargparse: https://jsonargparse.readthedocs.io/
diff --git a/docs/usage/usage_general.rst.inc b/docs/usage/usage_general.rst.inc
index 9334f3d216..85a41ce24d 100644
--- a/docs/usage/usage_general.rst.inc
+++ b/docs/usage/usage_general.rst.inc
@@ -8,6 +8,10 @@
.. include:: general/return-codes.rst.inc
+.. _config:
+
+.. include:: general/config.rst.inc
+
.. _env_vars:
.. include:: general/environment.rst.inc
diff --git a/pyproject.toml b/pyproject.toml
index d5e0187345..8d17f5d9a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,8 @@ dependencies = [
"argon2-cffi",
"shtab>=1.8.0",
"backports-zstd; python_version < '3.14'", # for python < 3.14.
+ "jsonargparse @ git+https://github.com/omni-us/jsonargparse.git@main",
+ "PyYAML>=6.0.2", # we need to register our types with yaml, jsonargparse uses yaml for config files
]
[project.optional-dependencies]
@@ -258,7 +260,7 @@ deps = ["ruff"]
commands = [["ruff", "check", "."]]
[tool.tox.env.mypy]
-deps = ["pytest", "mypy", "pkgconfig"]
+deps = ["pytest", "mypy", "pkgconfig", "types-PyYAML"]
commands = [["mypy", "--ignore-missing-imports"]]
[tool.tox.env.docs]
diff --git a/requirements.d/development.lock.txt b/requirements.d/development.lock.txt
index dc4c6bc87b..feefe2a383 100644
--- a/requirements.d/development.lock.txt
+++ b/requirements.d/development.lock.txt
@@ -14,3 +14,4 @@ pytest-cov==7.0.0
pytest-benchmark==5.2.3
Cython==3.2.4
pre-commit==4.5.1
+types-PyYAML==6.0.12.20250915
diff --git a/requirements.d/development.txt b/requirements.d/development.txt
index 5da779e80b..51317ec4c3 100644
--- a/requirements.d/development.txt
+++ b/requirements.d/development.txt
@@ -15,3 +15,4 @@ pytest-benchmark
Cython
pre-commit
bandit[toml]
+types-PyYAML
diff --git a/scripts/make.py b/scripts/make.py
index f7bae4d41d..3b71b01c6e 100644
--- a/scripts/make.py
+++ b/scripts/make.py
@@ -9,6 +9,7 @@
from collections import OrderedDict
from datetime import datetime, timezone
import time
+import argparse # do not change to jsonargparse, shall not require 3rd party pkgs
def format_metavar(option):
@@ -46,7 +47,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 "SubCommands" in str(action.__class__):
is_subcommand = True
for cmd, parser in action.choices.items():
choices[prefix + cmd] = parser
@@ -100,17 +101,18 @@ def generate_level(self, prefix, parser, Archiver, extra_choices=None):
return is_subcommand
def write_usage(self, parser, fp):
- if any(len(o.option_strings) for o in parser._actions):
+ actions = [o for o in parser._actions if getattr(o, "help", None) != argparse.SUPPRESS]
+ if any(len(o.option_strings) for o in actions):
fp.write(" [options]")
- for option in parser._actions:
+ for option in actions:
if option.option_strings:
continue
fp.write(" " + format_metavar(option))
fp.write("\n\n")
def write_options(self, parser, fp):
- def is_positional_group(group):
- return any(not o.option_strings for o in group._group_actions)
+ def is_positional_group(actions):
+ return any(not o.option_strings for o in actions)
# HTML output:
# A table using some column-spans
@@ -121,17 +123,18 @@ def is_positional_group(group):
# (no of columns used, columns, ...)
rows.append((1, ".. class:: borg-common-opt-ref\n\n:ref:`common_options`"))
else:
- if not group._group_actions:
+ actions = [o for o in group._group_actions if getattr(o, "help", None) != argparse.SUPPRESS]
+ if not actions:
continue
group_header = "**%s**" % group.title
if group.description:
group_header += " — " + group.description
rows.append((1, group_header))
- if is_positional_group(group):
- for option in group._group_actions:
+ if is_positional_group(actions):
+ for option in actions:
rows.append((3, "", "``%s``" % option.metavar, option.help or ""))
else:
- for option in group._group_actions:
+ for option in actions:
if option.metavar:
option_fmt = "``%s " + option.metavar + "``"
else:
@@ -218,18 +221,19 @@ def write_row_separator():
)
def write_options_group(self, group, fp, with_title=True, base_indent=4):
- def is_positional_group(group):
- return any(not o.option_strings for o in group._group_actions)
+ def is_positional_group(actions):
+ return any(not o.option_strings for o in actions)
indent = " " * base_indent
+ actions = [o for o in group._group_actions if getattr(o, "help", None) != argparse.SUPPRESS]
- if is_positional_group(group):
- for option in group._group_actions:
+ if is_positional_group(actions):
+ for option in actions:
fp.write(option.metavar + "\n")
fp.write(textwrap.indent(option.help or "", " " * base_indent) + "\n")
return
- if not group._group_actions:
+ if not actions:
return
if with_title:
@@ -238,7 +242,7 @@ def is_positional_group(group):
opts = OrderedDict()
- for option in group._group_actions:
+ for option in actions:
if option.metavar:
option_fmt = "%s " + option.metavar
else:
@@ -323,7 +327,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 "SubCommands" in str(action.__class__):
is_subcommand = True
for cmd, parser in action.choices.items():
choices[prefix + cmd] = parser
@@ -349,7 +353,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 "SubCommands" 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}")
@@ -503,34 +507,38 @@ def ref_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
fd.write(man_page)
def write_usage(self, write, parser):
- if any(len(o.option_strings) for o in parser._actions):
+ actions = [o for o in parser._actions if getattr(o, "help", None) != argparse.SUPPRESS]
+ if any(len(o.option_strings) for o in actions):
write(" [options] ", end="")
- for option in parser._actions:
+ for option in actions:
if option.option_strings:
continue
write(format_metavar(option), end=" ")
def write_options(self, write, parser):
for group in parser._action_groups:
- if group.title == "Common options" or not group._group_actions:
+ actions = [o for o in group._group_actions if getattr(o, "help", None) != argparse.SUPPRESS]
+ if group.title == "Common options" or not actions:
continue
title = "arguments" if group.title == "positional arguments" else group.title
self.write_heading(write, title, "+")
self.write_options_group(write, group)
def write_options_group(self, write, group):
- def is_positional_group(group):
- return any(not o.option_strings for o in group._group_actions)
+ def is_positional_group(actions):
+ return any(not o.option_strings for o in actions)
- if is_positional_group(group):
- for option in group._group_actions:
+ actions = [o for o in group._group_actions if getattr(o, "help", None) != argparse.SUPPRESS]
+
+ if is_positional_group(actions):
+ for option in actions:
write(option.metavar)
write(textwrap.indent(option.help or "", " " * 4))
return
opts = OrderedDict()
- for option in group._group_actions:
+ for option in actions:
if option.metavar:
option_fmt = "%s " + option.metavar
else:
diff --git a/src/borg/archive.py b/src/borg/archive.py
index a4cdea93aa..8db82ed3a2 100644
--- a/src/borg/archive.py
+++ b/src/borg/archive.py
@@ -25,7 +25,6 @@
from .chunkers import get_chunker, Chunk
from .cache import ChunkListEntry, build_chunkindex_from_repo, delete_chunkindex_cache
from .crypto.key import key_factory, UnsupportedPayloadError
-from .compress import CompressionSpec
from .constants import * # NOQA
from .crypto.low_level import IntegrityError as IntegrityErrorBase
from .helpers import BackupError, BackupRaceConditionError, BackupItemExcluded
@@ -35,7 +34,7 @@
from .helpers import ChunkIteratorFileWrapper, open_item
from .helpers import Error, IntegrityError, set_ec
from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns
-from .helpers import parse_timestamp, archive_ts_now
+from .helpers import parse_timestamp, archive_ts_now, CompressionSpec
from .helpers import OutputTimestamp, format_timedelta, format_file_size, file_status, FileSize
from .helpers import safe_encode, make_path_safe, remove_surrogates, text_to_json, join_cmd, remove_dotdot_prefixes
from .helpers import StableDict
diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py
index 834bf7b5cf..7cac790be4 100644
--- a/src/borg/archiver/__init__.py
+++ b/src/borg/archiver/__init__.py
@@ -15,7 +15,6 @@
sys.exit(2) # == EXIT_ERROR
try:
- import argparse
import faulthandler
import functools
import inspect
@@ -40,12 +39,13 @@
from ..helpers import format_file_size
from ..helpers import remove_surrogates, text_to_json
from ..helpers import DatetimeWrapper, replace_placeholders
-
+ from ..helpers.argparsing import flatten_namespace, ArgumentTypeError, ArgumentParser, SUPPRESS
from ..helpers import is_slow_msgpack, is_supported_msgpack, sysinfo
from ..helpers import signal_handler, raising_signal_handler, SigHup, SigTerm
from ..helpers import ErrorIgnoringTextIOWrapper
from ..helpers import msgpack
from ..helpers import sig_int
+ from ..helpers import get_config_dir
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
@@ -191,62 +179,45 @@ def preprocess_args(self, args):
class CommonOptions:
"""
- Support class to allow specifying common options directly after the top-level command.
+ Support class to allow specifying common options at multiple levels of the command hierarchy.
- Normally options can only be specified on the parser defining them, which means
- that generally speaking *all* options go after all sub-commands. This is annoying
- for common options in scripts, e.g. --remote-path or logging options.
+ Common options (e.g. --log-level, --repo) can be placed anywhere in the command line:
- This class allows adding the same set of options to both the top-level parser
- and the final sub-command parsers (but not intermediary sub-commands, at least for now).
+ borg --info create ... # before the subcommand
+ borg create --info ... # after the subcommand
+ borg --info debug info --debug # at both levels of a two-level command
- It does so by giving every option's target name ("dest") a suffix indicating its level
- -- no two options in the parser hierarchy can have the same target --
- then, after parsing the command line, multiple definitions are resolved.
+ Each parser level registers the same options with the same dest names.
+ Defaults are only provided on the top-level parser; all sub-parsers use SUPPRESS so
+ that unset options don't appear in the namespace at all.
- Defaults are handled by only setting them on the top-level parser and setting
- a sentinel object in all sub-parsers, which then allows one to discern which parser
- supplied the option.
+ flatten_namespace() handles precedence: it walks sub-namespaces depth-first, so the
+ most-specific (innermost) value wins. For append-action options (e.g. --debug-topic)
+ it merges lists from all levels.
"""
- def __init__(self, define_common_options, suffix_precedence):
+ def __init__(self, define_common_options):
"""
*define_common_options* should be a callable taking one argument, which
- will be a argparse.Parser.add_argument-like function.
+ will be an argparse.Parser.add_argument-like function.
*define_common_options* will be called multiple times, and should call
the passed function to define common options exactly the same way each time.
-
- *suffix_precedence* should be a tuple of the suffixes that will be used.
- It is ordered from lowest precedence to highest precedence:
- An option specified on the parser belonging to index 0 is overridden if the
- same option is specified on any parser with a higher index.
"""
self.define_common_options = define_common_options
- self.suffix_precedence = suffix_precedence
-
- # Maps suffixes to sets of target names.
- # E.g. common_options["_subcommand"] = {..., "log_level", ...}
- self.common_options = dict()
- # Set of options with the 'append' action.
- self.append_options = set()
# This is the sentinel object that replaces all default values in parsers
# below the top-level parser.
self.default_sentinel = object()
- def add_common_group(self, parser, suffix, provide_defaults=False):
+ def add_common_group(self, parser, provide_defaults=False):
"""
Add common options to *parser*.
- *provide_defaults* must only be True exactly once in a parser hierarchy,
- at the top level, and False on all lower levels. The default is chosen
- accordingly.
-
- *suffix* indicates the suffix to use internally. It also indicates
- which precedence the *parser* has for common options. See *suffix_precedence*
- of __init__.
+ *provide_defaults* must be True exactly once in a parser hierarchy (the top-level
+ parser) and False on all sub-parsers. Sub-parsers get SUPPRESS as the default so
+ that an unspecified option produces no attribute, leaving the top-level default intact
+ after flatten_namespace() merges the namespaces.
"""
- assert suffix in self.suffix_precedence
def add_argument(*args, **kwargs):
if "dest" in kwargs:
@@ -261,97 +232,47 @@ def add_argument(*args, **kwargs):
"append",
)
is_append = kwargs["action"] == "append"
- if is_append:
- self.append_options.add(kwargs["dest"])
- assert (
- kwargs["default"] == []
- ), "The default is explicitly constructed as an empty list in resolve()"
- else:
- self.common_options.setdefault(suffix, set()).add(kwargs["dest"])
- kwargs["dest"] += suffix
if not provide_defaults:
- # Interpolate help now, in case the %(default)d (or so) is mentioned,
+ # Interpolate help now, in case %(default)d (or similar) is mentioned,
# to avoid producing incorrect help output.
- # Assumption: Interpolated output can safely be interpolated again,
- # which should always be the case.
- # Note: We control all inputs.
kwargs["help"] = kwargs["help"] % kwargs
if not is_append:
- kwargs["default"] = self.default_sentinel
+ kwargs["default"] = SUPPRESS
common_group.add_argument(*args, **kwargs)
common_group = parser.add_argument_group("Common options")
self.define_common_options(add_argument)
- def resolve(self, args: argparse.Namespace): # Namespace has "in" but otherwise is not like a dict.
- """
- Resolve the multiple definitions of each common option to the final value.
- """
- for suffix in self.suffix_precedence:
- # From highest level to lowest level, so the "most-specific" option wins, e.g.
- # "borg --debug create --info" shall result in --info being effective.
- for dest in self.common_options.get(suffix, []):
- # map_from is this suffix' option name, e.g. log_level_subcommand
- # map_to is the target name, e.g. log_level
- map_from = dest + suffix
- map_to = dest
- # Retrieve value; depending on the action it may not exist, but usually does
- # (store_const/store_true/store_false), either because the action implied a default
- # or a default is explicitly supplied.
- # Note that defaults on lower levels are replaced with default_sentinel.
- # Only the top level has defaults.
- value = getattr(args, map_from, self.default_sentinel)
- if value is not self.default_sentinel:
- # 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).
- setattr(args, map_to, value)
- try:
- delattr(args, map_from)
- except AttributeError:
- pass
-
- # Options with an "append" action need some special treatment. Instead of
- # overriding values, all specified values are merged together.
- for dest in self.append_options:
- option_value = []
- for suffix in self.suffix_precedence:
- # Find values of this suffix, if any, and add them to the final list
- extend_from = dest + suffix
- if extend_from in args:
- values = getattr(args, extend_from)
- delattr(args, extend_from)
- option_value.extend(values)
- setattr(args, dest, option_value)
-
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.common_options = self.CommonOptions(
- define_common_options, suffix_precedence=("_maincommand", "_midcommand", "_subcommand")
+ parser = ArgumentParser(
+ prog=self.prog,
+ description="Borg - Deduplicated Backups",
+ default_config_files=[os.path.join(get_config_dir(), "default.yaml")],
+ default_env=True,
+ env_prefix="BORG",
)
+ parser.add_argument("--config", action="config")
+ # paths and patterns must have an empty list as default everywhere
+ parser.common_options = self.CommonOptions(define_common_options)
parser.add_argument(
"-V", "--version", action="version", version="%(prog)s " + __version__, help="show version number and exit"
)
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)
+ parser.common_options.add_common_group(parser, provide_defaults=True)
- common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog)
- common_parser.set_defaults(paths=[], patterns=[])
- parser.common_options.add_common_group(common_parser, "_subcommand")
+ common_parser = ArgumentParser(prog=self.prog)
+ parser.common_options.add_common_group(common_parser)
- mid_common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog)
- mid_common_parser.set_defaults(paths=[], patterns=[])
- parser.common_options.add_common_group(mid_common_parser, "_midcommand")
+ mid_common_parser = ArgumentParser(prog=self.prog)
+ parser.common_options.add_common_group(mid_common_parser)
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 +345,24 @@ def parse_args(self, args=None):
args = self.preprocess_args(args)
parser = self.build_parser()
args = parser.parse_args(args or ["-h"])
- parser.common_options.resolve(args)
- func = get_func(args)
+ # Collect all ActionYes dests from the parser and all subparsers so flatten_namespace
+ # can correctly convert None (flag absent) to False only for boolean flag fields.
+ # We scan _actions directly because parent parser actions are copied into subparser._actions
+ # by argparse, so they won't appear in the subparser's own _action_yes_dests set.
+ from ..helpers.argparsing import ActionYes as _ActionYes
+
+ action_yes_dests = {ac.dest for ac in parser._actions if isinstance(ac, _ActionYes)}
+ if parser._subcommands_action is not None:
+ for sp in parser._subcommands_action._name_parser_map.values():
+ action_yes_dests.update(ac.dest for ac in sp._actions if isinstance(ac, _ActionYes))
+ args = flatten_namespace(args, action_yes_dests=action_yes_dests)
+
+ # 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, [])
+
+ 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:
@@ -433,8 +370,7 @@ def parse_args(self, args=None):
if func == self.do_create and not args.paths:
if args.content_from_command or args.paths_from_command:
parser.error("No command given.")
- elif not args.paths_from_stdin:
- # need at least 1 path but args.paths may also be populated from patterns
+ elif not args.paths_from_stdin and not args.pattern_roots:
parser.error("Need at least one PATH argument.")
# we can only have a complete knowledge of placeholder replacements we should do **after** arg parsing,
# e.g. due to options like --timestamp that override the current time.
@@ -452,8 +388,24 @@ def parse_args(self, args=None):
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)
@@ -486,7 +438,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 = args.func
# do not use loggers before this!
is_serve = func == self.do_serve
self.log_json = args.log_json and not is_serve
@@ -633,7 +585,7 @@ def main(): # pragma: no cover
tb = format_tb(e)
print(tb, file=sys.stderr)
sys.exit(e.exit_code)
- except argparse.ArgumentTypeError as e:
+ except ArgumentTypeError as e:
# we might not have logging setup yet, so get out quickly
print(str(e), file=sys.stderr)
sys.exit(CommandError.exit_mcode if modern_ec else EXIT_ERROR)
diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py
index 673e53d41a..19f08c319b 100644
--- a/src/borg/archiver/_common.py
+++ b/src/borg/archiver/_common.py
@@ -7,8 +7,9 @@
from ..constants import * # NOQA
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 import SortBySpec, location_validator, Location, relative_time_marker_validator
+from ..helpers import Highlander, octal_int
+from ..helpers.argparsing import SUPPRESS, PositiveInt
from ..helpers.nanorst import rst_to_terminal
from ..manifest import Manifest, AI_HUMAN_SORT_KEYS
from ..patterns import PatternMatcher
@@ -268,6 +269,8 @@ 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=SUPPRESS)
+ add_option("--patterns-internal", dest="patterns", action="append", default=[], help=SUPPRESS)
add_option(
"-e",
"--exclude",
@@ -275,6 +278,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(
@@ -371,8 +375,7 @@ def define_archive_filters_group(
"--first",
metavar="N",
dest="first",
- type=positive_int_validator,
- default=0,
+ type=PositiveInt,
action=Highlander,
help="consider the first N archives after other filters are applied",
)
@@ -380,8 +383,7 @@ def define_archive_filters_group(
"--last",
metavar="N",
dest="last",
- type=positive_int_validator,
- default=0,
+ type=PositiveInt,
action=Highlander,
help="consider the last N archives after other filters are applied",
)
@@ -508,7 +510,7 @@ def define_common_options(add_common_option):
"--umask",
metavar="M",
dest="umask",
- type=lambda s: int(s, 8),
+ type=octal_int,
default=UMASK_DEFAULT,
action=Highlander,
help="set umask to M (local only, default: %(default)04o)",
@@ -574,10 +576,11 @@ def define_common_options(add_common_option):
)
-def build_matcher(inclexcl_patterns, include_paths):
+def build_matcher(inclexcl_patterns, include_paths, pattern_roots=()):
matcher = PatternMatcher()
matcher.add_inclexcl(inclexcl_patterns)
- matcher.add_includepaths(include_paths)
+ paths = list(pattern_roots) + list(include_paths)
+ matcher.add_includepaths(paths)
return matcher
diff --git a/src/borg/archiver/analyze_cmd.py b/src/borg/archiver/analyze_cmd.py
index e556095886..3db076aaa1 100644
--- a/src/borg/archiver/analyze_cmd.py
+++ b/src/borg/archiver/analyze_cmd.py
@@ -1,4 +1,3 @@
-import argparse
from collections import defaultdict
import os
@@ -7,6 +6,7 @@
from ..constants import * # NOQA
from ..helpers import bin_to_hex, Error
from ..helpers import ProgressIndicatorPercent
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..remote import RemoteRepository
from ..repository import Repository
@@ -126,14 +126,6 @@ def build_parser_analyze(self, subparsers, common_parser, mid_common_parser):
to recreate existing archives without them.
"""
)
- subparser = subparsers.add_parser(
- "analyze",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_analyze.__doc__, epilog=analyze_epilog)
+ 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..d7f2c84a3f 100644
--- a/src/borg/archiver/benchmark_cmd.py
+++ b/src/borg/archiver/benchmark_cmd.py
@@ -1,6 +1,4 @@
-import argparse
from contextlib import contextmanager
-import functools
import json
import logging
import os
@@ -9,10 +7,11 @@
from ..constants import * # NOQA
from ..crypto.key import FlexiKey
-from ..helpers import format_file_size
+from ..helpers import format_file_size, CompressionSpec
from ..helpers import json_print
from ..helpers import msgpack
from ..helpers import get_reset_ec
+from ..helpers.argparsing import ArgumentParser
from ..item import Item
from ..platform import SyncFile
@@ -296,8 +295,6 @@ def chunkit(ch):
else:
print(f"{spec:<24} {count:<10} {dt:.3f}s")
- from ..compress import CompressionSpec
-
if not args.json:
print("Compression ====================================================")
else:
@@ -348,18 +345,12 @@ def build_parser_benchmarks(self, subparsers, common_parser, mid_common_parser):
benchmark_epilog = process_epilog("These commands do various benchmarks.")
- subparser = subparsers.add_parser(
- "benchmark",
- parents=[mid_common_parser],
- add_help=False,
- description="benchmark command",
- epilog=benchmark_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="benchmark command",
+ subparser = ArgumentParser(
+ parents=[mid_common_parser], description="benchmark command", epilog=benchmark_epilog
)
+ 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(
"""
@@ -402,16 +393,12 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_benchmark_crud.__doc__, epilog=bench_crud_epilog
+ )
+ benchmark_parsers.add_subcommand(
+ "crud", subparser, help="benchmarks Borg CRUD (create, extract, update, delete)."
)
- subparser.set_defaults(func=self.do_benchmark_crud)
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 +414,8 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_benchmark_cpu.__doc__, epilog=bench_cpu_epilog
)
- 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..83d1f6e294 100644
--- a/src/borg/archiver/check_cmd.py
+++ b/src/borg/archiver/check_cmd.py
@@ -1,9 +1,9 @@
-import argparse
from ._common import with_repository, Highlander
from ..archive import ArchiveChecker
from ..constants import * # NOQA
from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, IntegrityError
from ..helpers import yes
+from ..helpers.argparsing import ArgumentParser
from ..logger import create_logger
@@ -60,7 +60,7 @@ def do_check(self, args, repository):
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 +182,8 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser):
``borg compact`` would remove the archives' data completely.
"""
)
- subparser = subparsers.add_parser(
- "check",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_check.__doc__, epilog=check_epilog)
+ 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 1cd07a0ba9..c25bb03e17 100644
--- a/src/borg/archiver/compact_cmd.py
+++ b/src/borg/archiver/compact_cmd.py
@@ -1,4 +1,3 @@
-import argparse
from pathlib import Path
from ._common import with_repository
@@ -6,6 +5,7 @@
from ..cache import write_chunkindex_to_repo_cache, build_chunkindex_from_repo
from ..cache import files_cache_name, discover_files_cache_names
from ..helpers import get_cache_dir
+from ..helpers.argparsing import ArgumentParser
from ..constants import * # NOQA
from ..hashindex import ChunkIndex, ChunkIndexEntry
from ..helpers import set_ec, EXIT_ERROR, format_file_size, bin_to_hex
@@ -257,16 +257,8 @@ 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",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_compact.__doc__, epilog=compact_epilog)
+ 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..2f6a46eb77 100644
--- a/src/borg/archiver/completion_cmd.py
+++ b/src/borg/archiver/completion_cmd.py
@@ -50,8 +50,6 @@
- Suggests common file size values (500M, 1G, 10G, 100G, 1T, etc.)
"""
-import argparse
-
import shtab
from ._common import process_epilog
@@ -62,12 +60,15 @@
FilesCacheMode,
PathSpec,
ChunkerParams,
+ CompressionSpec,
tag_validator,
relative_time_marker_validator,
parse_file_size,
)
+from ..helpers.argparsing import ArgumentParser
+from ..helpers.argparsing import _ActionSubCommands
+from ..helpers.argparsing import prepare_actions_context, shtab_prepare_actions, bash_compgen_typehint
from ..helpers.time import timestamp
-from ..compress import CompressionSpec
from ..helpers.parseformat import partial_format
from ..manifest import AI_HUMAN_SORT_KEYS
@@ -341,7 +342,6 @@
}
"""
-
# Global zsh preamble providing dynamic completion for aid: archive IDs.
#
# Notes:
@@ -628,12 +628,11 @@
"""
-def _attach_completion(parser: argparse.ArgumentParser, type_class, completion_dict: dict):
+def _attach_completion(parser: ArgumentParser, type_class, completion_dict: dict):
"""Tag all arguments with type `type_class` with completion choices from `completion_dict`."""
for action in parser._actions:
- # Recurse into subparsers
- if isinstance(action, argparse._SubParsersAction):
+ if isinstance(action, _ActionSubCommands):
for sub in action.choices.values():
_attach_completion(sub, type_class, completion_dict)
continue
@@ -642,10 +641,10 @@ def _attach_completion(parser: argparse.ArgumentParser, type_class, completion_d
action.complete = completion_dict # type: ignore[attr-defined]
-def _attach_help_completion(parser: argparse.ArgumentParser, completion_dict: dict):
+def _attach_help_completion(parser: ArgumentParser, completion_dict: dict):
"""Tag the 'topic' argument of the 'help' command with static completion choices."""
for action in parser._actions:
- if isinstance(action, argparse._SubParsersAction):
+ if isinstance(action, _ActionSubCommands):
for sub in action.choices.values():
_attach_help_completion(sub, completion_dict)
continue
@@ -692,7 +691,7 @@ def do_completion(self, args):
# Collect all commands and help topics for "borg help" completion
help_choices = list(self.helptext.keys())
for action in parser._actions:
- if isinstance(action, argparse._SubParsersAction):
+ if isinstance(action, _ActionSubCommands):
help_choices.extend(action.choices.keys())
help_completion_fn = "_borg_help_topics"
@@ -732,8 +731,20 @@ def do_completion(self, args):
}
bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping)
zsh_preamble = partial_format(ZSH_PREAMBLE_TMPL, mapping)
- preamble = {"bash": bash_preamble, "zsh": zsh_preamble}
- script = shtab.complete(parser, shell=args.shell, preamble=preamble) # nosec B604
+
+ parser.prog = "borg"
+ prog = "borg"
+ preambles = []
+ if args.shell == "bash":
+ preambles.append(bash_compgen_typehint.strip().replace("%s", prog))
+ preambles.append(bash_preamble)
+ elif args.shell == "zsh":
+ preambles.append(zsh_preamble)
+
+ with prepare_actions_context(args.shell, prog, preambles):
+ shtab_prepare_actions(parser)
+
+ script = shtab.complete(parser, shell=args.shell, preamble="\n".join(preambles)) # nosec B604
print(script)
def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
@@ -750,16 +761,10 @@ def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
"""
)
- subparser = subparsers.add_parser(
- "completion",
- parents=[common_parser],
- add_help=False,
- description=self.do_completion.__doc__,
- epilog=completion_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="output shell completion script",
+ subparser = ArgumentParser(
+ parents=[common_parser], description=self.do_completion.__doc__, epilog=completion_epilog
)
- 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..11d7e4c652 100644
--- a/src/borg/archiver/create_cmd.py
+++ b/src/borg/archiver/create_cmd.py
@@ -1,6 +1,5 @@
import errno
import sys
-import argparse
import logging
import os
import posixpath
@@ -16,9 +15,8 @@
from ..archive import FilesystemObjectProcessors, MetadataCollector, ChunksProcessor
from ..cache import Cache
from ..constants import * # NOQA
-from ..compress import CompressionSpec
-from ..helpers import comment_validator, ChunkerParams, FilesystemPathSpec
-from ..helpers import archivename_validator, FilesCacheMode
+from ..helpers import comment_validator, ChunkerParams, FilesystemPathSpec, CompressionSpec
+from ..helpers import archivename_validator, FilesCacheMode, octal_int
from ..helpers import eval_escapes
from ..helpers import timestamp, archive_ts_now
from ..helpers import get_cache_dir, os_stat, get_strip_prefix, slashify
@@ -31,6 +29,7 @@
from ..helpers import iter_separated
from ..helpers import MakePathSafeAction
from ..helpers import Error, CommandError, BackupWarning, FileChangedWarning
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..patterns import PatternMatcher
from ..platform import is_win32
@@ -137,7 +136,8 @@ def create_inner(archive, cache, fso):
if rc != 0:
raise CommandError(f"Command {args.paths[0]!r} exited with status {rc}")
else:
- for path in args.paths:
+ paths = list(args.pattern_roots) + list(args.paths)
+ for path in paths:
if path == "": # issue #5637
self.print_warning("An empty string was given as PATH, ignoring.")
continue
@@ -680,7 +680,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 +772,8 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser):
"""
)
- subparser = subparsers.add_parser(
- "create",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_create.__doc__, epilog=create_epilog)
+ 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).
@@ -832,7 +823,7 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser):
"--stdin-mode",
metavar="M",
dest="stdin_mode",
- type=lambda s: int(s, 8),
+ type=octal_int,
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 bb05c53d95..55dd57075a 100644
--- a/src/borg/archiver/debug_cmd.py
+++ b/src/borg/archiver/debug_cmd.py
@@ -1,18 +1,16 @@
-import argparse
-import functools
import json
import textwrap
from ..archive import Archive
-from ..compress import CompressionSpec
from ..constants import * # NOQA
from ..helpers import msgpack
from ..helpers import sysinfo
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, CompressionSpec
from ..helpers import CommandError, RTError
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..platform import get_process_id
from ..repository import Repository, LIST_SCAN_LIMIT, repo_lister
@@ -319,18 +317,14 @@ 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)")
- 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 +333,22 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_info.__doc__, epilog=debug_info_epilog
)
- 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],
- add_help=False,
+ subparser = ArgumentParser(
+ parents=[mid_common_parser],
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 +356,12 @@ 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",
- parents=[common_parser],
- add_help=False,
+ subparser = ArgumentParser(
+ parents=[mid_common_parser],
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 +370,12 @@ 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",
- parents=[common_parser],
- add_help=False,
+ subparser = ArgumentParser(
+ parents=[mid_common_parser],
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 +383,24 @@ 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",
- parents=[common_parser],
- add_help=False,
+ subparser = ArgumentParser(
+ parents=[mid_common_parser],
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],
- add_help=False,
+ subparser = ArgumentParser(
+ parents=[mid_common_parser],
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 +413,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_id_hash.__doc__, epilog=debug_id_hash_epilog
)
- 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 +427,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_parse_obj.__doc__, epilog=debug_parse_obj_epilog
)
- 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 +448,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_format_obj.__doc__, epilog=debug_format_obj_epilog
)
- 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 +481,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_get_obj.__doc__, epilog=debug_get_obj_epilog
)
- 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 +493,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_put_obj.__doc__, epilog=debug_put_obj_epilog
)
- 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 +505,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[mid_common_parser], description=self.do_debug_delete_obj.__doc__, epilog=debug_delete_obj_epilog
)
- 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 +518,13 @@ 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",
- parents=[common_parser],
- add_help=False,
+ subparser = ArgumentParser(
+ parents=[mid_common_parser],
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..fc6d80490d 100644
--- a/src/borg/archiver/delete_cmd.py
+++ b/src/borg/archiver/delete_cmd.py
@@ -1,9 +1,9 @@
-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.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -78,16 +78,8 @@ def build_parser_delete(self, subparsers, common_parser, mid_common_parser):
patterns, see :ref:`borg_patterns`).
"""
)
- subparser = subparsers.add_parser(
- "delete",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_delete.__doc__, epilog=delete_epilog)
+ 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..22d4243758 100644
--- a/src/borg/archiver/diff_cmd.py
+++ b/src/borg/archiver/diff_cmd.py
@@ -1,4 +1,3 @@
-import argparse
import textwrap
import json
import sys
@@ -9,6 +8,7 @@
from ..constants import * # NOQA
from ..helpers import BaseFormatter, DiffFormatter, archivename_validator, PathSpec, BorgJsonEncoder
from ..helpers import IncludePatternNeverMatchedWarning, remove_surrogates
+from ..helpers.argparsing import ArgumentParser, ArgumentTypeError
from ..item import ItemDiff
from ..manifest import Manifest
from ..logger import create_logger
@@ -84,6 +84,7 @@ def print_text_output(diff, formatter):
wc=None,
)
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(args.patterns, args.paths)
diffs_iter = Archive.compare_archives_iter(
@@ -203,7 +204,6 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
The following keys are always available:
-
"""
)
+ BaseFormatter.keys_help()
@@ -268,7 +268,7 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
def diff_sort_spec_validator(s):
if not isinstance(s, str):
- raise argparse.ArgumentTypeError("unsupported sort field (not a string)")
+ raise ArgumentTypeError("unsupported sort field (not a string)")
allowed = {
"path",
"size_added",
@@ -286,23 +286,15 @@ def diff_sort_spec_validator(s):
}
parts = [p.strip() for p in s.split(",") if p.strip()]
if not parts:
- raise argparse.ArgumentTypeError("unsupported sort field: empty spec")
+ raise ArgumentTypeError("unsupported sort field: empty spec")
for spec in parts:
field = spec[1:] if spec and spec[0] in (">", "<") else spec
if field not in allowed:
- raise argparse.ArgumentTypeError(f"unsupported sort field: {field}")
+ raise ArgumentTypeError(f"unsupported sort field: {field}")
return ",".join(parts)
- subparser = subparsers.add_parser(
- "diff",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_diff.__doc__, epilog=diff_epilog)
+ 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 a3885a0c11..eaa6c40491 100644
--- a/src/borg/archiver/extract_cmd.py
+++ b/src/borg/archiver/extract_cmd.py
@@ -1,5 +1,4 @@
import sys
-import argparse
import logging
import stat
@@ -12,6 +11,7 @@
from ..helpers import HardLinkManager
from ..helpers import ProgressIndicatorPercent
from ..helpers import BackupWarning, IncludePatternNeverMatchedWarning
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -33,6 +33,7 @@ def do_extract(self, args, repository, manifest, archive):
"For example, install locales and use: LANG=en_US.UTF-8"
)
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(args.patterns, args.paths)
progress = args.progress
@@ -154,16 +155,8 @@ def build_parser_extract(self, subparsers, common_parser, mid_common_parser):
group, permissions, etc.
"""
)
- subparser = subparsers.add_parser(
- "extract",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_extract.__doc__, epilog=extract_epilog)
+ 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..d0a240b4b3 100644
--- a/src/borg/archiver/help_cmd.py
+++ b/src/borg/archiver/help_cmd.py
@@ -1,7 +1,7 @@
import collections
-import functools
import textwrap
+from ..helpers.argparsing import ArgumentParser
from ..constants import * # NOQA
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 /
@@ -523,7 +522,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 +553,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], 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..61a04edb61 100644
--- a/src/borg/archiver/info_cmd.py
+++ b/src/borg/archiver/info_cmd.py
@@ -1,4 +1,3 @@
-import argparse
import textwrap
from datetime import timedelta
@@ -6,6 +5,7 @@
from ..archive import Archive
from ..constants import * # NOQA
from ..helpers import format_timedelta, json_print, basic_json_data, archivename_validator
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -77,16 +77,8 @@ def build_parser_info(self, subparsers, common_parser, mid_common_parser):
= all chunks in the repository.
"""
)
- subparser = subparsers.add_parser(
- "info",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_info.__doc__, epilog=info_epilog)
+ 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..5bdfc0b5dc 100644
--- a/src/borg/archiver/key_cmds.py
+++ b/src/borg/archiver/key_cmds.py
@@ -1,5 +1,3 @@
-import argparse
-import functools
import os
from ..constants import * # NOQA
@@ -7,6 +5,7 @@
from ..crypto.key import AESOCBKeyfileKey, CHPOKeyfileKey, Blake2AESOCBKeyfileKey, Blake2CHPOKeyfileKey
from ..crypto.keymanager import KeyManager
from ..helpers import PathSpec, CommandError
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ._common import with_repository
@@ -18,7 +17,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 +29,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"):
@@ -120,18 +119,12 @@ def do_key_import(self, args, repository):
def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
from ._common import process_epilog
- subparser = subparsers.add_parser(
- "key",
- 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",
+ subparser = ArgumentParser(
+ parents=[mid_common_parser], description="Manage the keyfile or repokey of a repository", epilog=""
)
+ 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 +157,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_key_export.__doc__, epilog=key_export_epilog
)
- 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 +193,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_key_import.__doc__, epilog=key_import_epilog
)
- 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 +218,10 @@ 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",
- parents=[common_parser],
- add_help=False,
- description=self.do_change_passphrase.__doc__,
- epilog=change_passphrase_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="change the repository passphrase",
+ subparser = ArgumentParser(
+ parents=[common_parser], description=self.do_key_change_passphrase.__doc__, epilog=change_passphrase_epilog
)
- 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 +236,10 @@ 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",
- parents=[common_parser],
- add_help=False,
- description=self.do_change_location.__doc__,
- epilog=change_location_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="change the key location",
+ subparser = ArgumentParser(
+ parents=[common_parser], description=self.do_key_change_location.__doc__, epilog=change_location_epilog
)
- 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..98abd18c3e 100644
--- a/src/borg/archiver/list_cmd.py
+++ b/src/borg/archiver/list_cmd.py
@@ -1,4 +1,3 @@
-import argparse
import os
import textwrap
import sys
@@ -8,6 +7,7 @@
from ..cache import Cache
from ..constants import * # NOQA
from ..helpers import ItemFormatter, BaseFormatter, archivename_validator, PathSpec
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -19,6 +19,7 @@ class ListMixIn:
@with_repository(compatibility=(Manifest.Operation.READ,))
def do_list(self, args, repository, manifest):
"""List archive contents."""
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(args.patterns, args.paths)
if args.format is not None:
format = args.format
@@ -89,7 +90,6 @@ def build_parser_list(self, subparsers, common_parser, mid_common_parser):
The following keys are always available:
-
"""
)
+ BaseFormatter.keys_help()
@@ -102,16 +102,8 @@ def build_parser_list(self, subparsers, common_parser, mid_common_parser):
)
+ ItemFormatter.keys_help()
)
- subparser = subparsers.add_parser(
- "list",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_list.__doc__, epilog=list_epilog)
+ 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 1739da6df3..971fb653e8 100644
--- a/src/borg/archiver/lock_cmds.py
+++ b/src/borg/archiver/lock_cmds.py
@@ -1,10 +1,10 @@
-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.argparsing import ArgumentParser, REMAINDER
from ..logger import create_logger
@@ -45,16 +45,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_break_lock.__doc__, epilog=break_lock_epilog
)
- 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 +71,9 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_with_lock.__doc__, epilog=with_lock_epilog
)
- 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")
+ subparser.add_argument("args", metavar="ARGS", nargs=REMAINDER, help="command arguments")
diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py
index d37d8cb47b..6111df006c 100644
--- a/src/borg/archiver/mount_cmds.py
+++ b/src/borg/archiver/mount_cmds.py
@@ -1,4 +1,3 @@
-import argparse
import os
from ._common import with_repository, Highlander
@@ -6,6 +5,7 @@
from ..helpers import RTError
from ..helpers import PathSpec
from ..helpers import umount
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..remote import cache_if_remote
@@ -151,15 +151,8 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser
the logger to output to a file.
"""
)
- subparser = subparsers.add_parser(
- "mount",
- parents=[common_parser],
- add_help=False,
- description=self.do_mount.__doc__,
- epilog=mount_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="mount a repository",
- )
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_mount.__doc__, epilog=mount_epilog)
+ subparsers.add_subcommand("mount", subparser, help="mount a repository")
self._define_borg_mount(subparser)
umount_epilog = process_epilog(
@@ -170,16 +163,8 @@ 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",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_umount.__doc__, epilog=umount_epilog)
+ subparsers.add_subcommand("umount", subparser, help="unmount a repository")
subparser.add_argument(
"mountpoint", metavar="MOUNTPOINT", type=str, help="mountpoint of the filesystem to unmount"
)
@@ -188,7 +173,6 @@ def build_parser_borgfs(self, parser):
assert parser.prog == "borgfs"
parser.description = self.do_mount.__doc__
parser.epilog = "For more information, see borg mount --help."
- parser.formatter_class = argparse.RawDescriptionHelpFormatter
parser.help = "mount a repository"
self._define_borg_mount(parser)
return parser
@@ -196,7 +180,6 @@ 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"
diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py
index 2f18b485b8..7179a0c4be 100644
--- a/src/borg/archiver/prune_cmd.py
+++ b/src/borg/archiver/prune_cmd.py
@@ -1,4 +1,3 @@
-import argparse
from collections import OrderedDict
from datetime import datetime, timezone, timedelta
import logging
@@ -11,6 +10,7 @@
from ..constants import * # NOQA
from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error
from ..helpers import archivename_validator
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -273,16 +273,8 @@ 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",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_prune.__doc__, epilog=prune_epilog)
+ 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 4ab928e251..49b7b9cfac 100644
--- a/src/borg/archiver/recreate_cmd.py
+++ b/src/borg/archiver/recreate_cmd.py
@@ -1,12 +1,10 @@
-import argparse
-
from ._common import with_repository, Highlander
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, CompressionSpec
from ..helpers import timestamp
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -18,6 +16,7 @@ class RecreateMixIn:
@with_repository(cache=True, compatibility=(Manifest.Operation.CHECK,))
def do_recreate(self, args, repository, manifest, cache):
"""Recreate archives."""
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(args.patterns, args.paths)
self.output_list = args.output_list
self.output_filter = args.output_filter
@@ -101,16 +100,10 @@ def build_parser_recreate(self, subparsers, common_parser, mid_common_parser):
if the chunks are still missing.
"""
)
- subparser = subparsers.add_parser(
- "recreate",
- parents=[common_parser],
- add_help=False,
- description=self.do_recreate.__doc__,
- epilog=recreate_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help=self.do_recreate.__doc__,
+ subparser = ArgumentParser(
+ parents=[common_parser], description=self.do_recreate.__doc__, epilog=recreate_epilog
)
- 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 bdb338843f..b1d4829616 100644
--- a/src/borg/archiver/rename_cmd.py
+++ b/src/borg/archiver/rename_cmd.py
@@ -1,8 +1,7 @@
-import argparse
-
from ._common import with_repository, with_archive
from ..constants import * # NOQA
from ..helpers import archivename_validator
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -28,16 +27,8 @@ def build_parser_rename(self, subparsers, common_parser, mid_common_parser):
This results in a different archive ID.
"""
)
- subparser = subparsers.add_parser(
- "rename",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_rename.__doc__, epilog=rename_epilog)
+ 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..8fb26e2f7e 100644
--- a/src/borg/archiver/repo_compress_cmd.py
+++ b/src/borg/archiver/repo_compress_cmd.py
@@ -1,11 +1,11 @@
-import argparse
from collections import defaultdict
from ._common import with_repository, Highlander
from ..constants import * # NOQA
-from ..compress import CompressionSpec, ObfuscateSize, Auto, COMPRESSOR_TABLE
+from ..compress import ObfuscateSize, Auto, COMPRESSOR_TABLE
from ..hashindex import ChunkIndex
-from ..helpers import sig_int, ProgressIndicatorPercent, Error
+from ..helpers import sig_int, ProgressIndicatorPercent, Error, CompressionSpec
+from ..helpers.argparsing import ArgumentParser
from ..repository import Repository
from ..remote import RemoteRepository
from ..manifest import Manifest
@@ -180,16 +180,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_repo_compress.__doc__, epilog=repo_compress_epilog
)
- 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 07b60fa8d5..05235ce9d6 100644
--- a/src/borg/archiver/repo_create_cmd.py
+++ b/src/borg/archiver/repo_create_cmd.py
@@ -1,11 +1,10 @@
-import argparse
-
from ._common import with_repository, with_other_repository, Highlander
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.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -190,16 +189,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_repo_create.__doc__, epilog=repo_create_epilog
)
- 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 aa2b531eaa..d9774ee350 100644
--- a/src/borg/archiver/repo_delete_cmd.py
+++ b/src/borg/archiver/repo_delete_cmd.py
@@ -1,5 +1,3 @@
-import argparse
-
from ._common import with_repository
from ..cache import Cache, SecurityManager
from ..constants import * # NOQA
@@ -7,6 +5,7 @@
from ..helpers import format_archive
from ..helpers import bin_to_hex
from ..helpers import yes
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest, NoManifestError
from ..logger import create_logger
@@ -102,16 +101,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_repo_delete.__doc__, epilog=repo_delete_epilog
)
- 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..d8feeaf8c3 100644
--- a/src/borg/archiver/repo_info_cmd.py
+++ b/src/borg/archiver/repo_info_cmd.py
@@ -1,9 +1,9 @@
-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.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -63,14 +63,8 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_repo_info.__doc__, epilog=repo_info_epilog
)
- 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 6f5c5ae47f..5ae41ea958 100644
--- a/src/borg/archiver/repo_list_cmd.py
+++ b/src/borg/archiver/repo_list_cmd.py
@@ -1,4 +1,3 @@
-import argparse
import os
import textwrap
import sys
@@ -6,6 +5,7 @@
from ._common import with_repository, Highlander
from ..constants import * # NOQA
from ..helpers import BaseFormatter, ArchiveFormatter, json_print, basic_json_data
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -72,7 +72,6 @@ def build_parser_repo_list(self, subparsers, common_parser, mid_common_parser):
The following keys are always available:
-
"""
)
+ BaseFormatter.keys_help()
@@ -85,16 +84,10 @@ def build_parser_repo_list(self, subparsers, common_parser, mid_common_parser):
)
+ ArchiveFormatter.keys_help()
)
- subparser = subparsers.add_parser(
- "repo-list",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_repo_list.__doc__, epilog=repo_list_epilog
)
- 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 45c1646a28..37ed12d884 100644
--- a/src/borg/archiver/repo_space_cmd.py
+++ b/src/borg/archiver/repo_space_cmd.py
@@ -1,4 +1,3 @@
-import argparse
import math
import os
@@ -7,6 +6,7 @@
from ._common import with_repository, Highlander
from ..constants import * # NOQA
from ..helpers import parse_file_size, format_file_size
+from ..helpers.argparsing import ArgumentParser
from ..logger import create_logger
@@ -82,20 +82,13 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_repo_space.__doc__, epilog=repo_space_epilog
)
- 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 f8c1700676..36661758e3 100644
--- a/src/borg/archiver/serve_cmd.py
+++ b/src/borg/archiver/serve_cmd.py
@@ -1,9 +1,8 @@
-import argparse
-
from ..constants import * # NOQA
from ..remote import RepositoryServer
from ..logger import create_logger
+from ..helpers.argparsing import ArgumentParser
logger = create_logger()
@@ -52,16 +51,8 @@ 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",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_serve.__doc__, epilog=serve_epilog)
+ 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..f57b89f34e 100644
--- a/src/borg/archiver/tag_cmd.py
+++ b/src/borg/archiver/tag_cmd.py
@@ -1,9 +1,8 @@
-import argparse
-
from ._common import with_repository, define_archive_filters_group
from ..archive import Archive
from ..constants import * # NOQA
from ..helpers import bin_to_hex, archivename_validator, tag_validator, Error
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -80,39 +79,12 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser):
removed).
"""
)
- subparser = subparsers.add_parser(
- "tag",
- 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)
- subparser.add_argument(
- "--set",
- dest="set_tags",
- metavar="TAG",
- type=tag_validator,
- action="append",
- help="set tags (can be given multiple times)",
- )
- subparser.add_argument(
- "--add",
- dest="add_tags",
- metavar="TAG",
- type=tag_validator,
- action="append",
- help="add tags (can be given multiple times)",
- )
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_tag.__doc__, epilog=tag_epilog)
+ subparsers.add_subcommand("tag", subparser, help="tag archives")
+ subparser.add_argument("--set", dest="set_tags", metavar="TAG", type=tag_validator, nargs="+", help="set tags")
+ subparser.add_argument("--add", dest="add_tags", metavar="TAG", type=tag_validator, 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)",
+ "--remove", dest="remove_tags", metavar="TAG", type=tag_validator, 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 4a0dd4c225..a799414ecc 100644
--- a/src/borg/archiver/tar_cmds.py
+++ b/src/borg/archiver/tar_cmds.py
@@ -1,4 +1,3 @@
-import argparse
import base64
import logging
import os
@@ -7,7 +6,6 @@
import time
from ..archive import Archive, TarfileObjectProcessors, ChunksProcessor
-from ..compress import CompressionSpec
from ..constants import * # NOQA
from ..helpers import HardLinkManager, IncludePatternNeverMatchedWarning
from ..helpers import ProgressIndicatorPercent
@@ -15,11 +13,12 @@
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, CompressionSpec
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.argparsing import ArgumentParser
from ..manifest import Manifest
from ._common import with_repository, with_archive, Highlander, define_exclusion_group
@@ -87,6 +86,7 @@ def do_export_tar(self, args, repository, manifest, archive):
self._export_tar(args, archive, _stream)
def _export_tar(self, args, archive, tarstream):
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(args.patterns, args.paths)
progress = args.progress
@@ -386,16 +386,10 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser):
pass over the archive metadata.
"""
)
- subparser = subparsers.add_parser(
- "export-tar",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_export_tar.__doc__, epilog=export_tar_epilog
)
- subparser.set_defaults(func=self.do_export_tar)
+ subparsers.add_subcommand("export-tar", subparser, help="create tarball from archive")
subparser.add_argument(
"--tar-filter",
dest="tar_filter",
@@ -462,16 +456,10 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_import_tar.__doc__, epilog=import_tar_epilog
)
- 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 99813039dd..71088f8ded 100644
--- a/src/borg/archiver/transfer_cmd.py
+++ b/src/borg/archiver/transfer_cmd.py
@@ -1,15 +1,13 @@
-import argparse
-
from ._common import with_repository, with_other_repository, Highlander
from ..archive import Archive, cached_hash, DownloadPipeline
from ..chunkers import get_chunker
-from ..compress import CompressionSpec
from ..constants import * # NOQA
from ..crypto.key import uses_same_id_hash, uses_same_chunker_secret
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, CompressionSpec
+from ..helpers.argparsing import ArgumentParser, ArgumentTypeError
from ..item import ChunkListEntry
from ..manifest import Manifest
from ..legacyrepository import LegacyRepository
@@ -156,7 +154,7 @@ def do_transfer(self, args, *, repository, manifest, cache, other_repository=Non
for archive_info in archive_infos:
try:
archivename_validator(archive_info.name)
- except argparse.ArgumentTypeError as err:
+ except ArgumentTypeError as err:
an_errors.append(str(err))
if an_errors:
an_errors.insert(0, "Invalid archive names detected, please rename them before transfer:")
@@ -167,7 +165,7 @@ def do_transfer(self, args, *, repository, manifest, cache, other_repository=Non
archive = Archive(other_manifest, archive_info.id)
try:
comment_validator(archive.metadata.get("comment", ""))
- except argparse.ArgumentTypeError as err:
+ except ArgumentTypeError as err:
ac_errors.append(f"{archive_info.name}: {err}")
if ac_errors:
ac_errors.insert(0, "Invalid archive comments detected, please fix them before transfer:")
@@ -309,7 +307,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 +327,12 @@ 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",
- 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 = ArgumentParser(
+ parents=[common_parser], description=self.do_transfer.__doc__, epilog=transfer_epilog
)
- 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 a0455518f2..8a37e6fb5f 100644
--- a/src/borg/archiver/undelete_cmd.py
+++ b/src/borg/archiver/undelete_cmd.py
@@ -1,9 +1,9 @@
-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.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -72,16 +72,10 @@ def build_parser_undelete(self, subparsers, common_parser, mid_common_parser):
patterns, see :ref:`borg_patterns`).
"""
)
- subparser = subparsers.add_parser(
- "undelete",
- parents=[common_parser],
- add_help=False,
- description=self.do_undelete.__doc__,
- epilog=undelete_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="undelete archives",
+ subparser = ArgumentParser(
+ parents=[common_parser], description=self.do_undelete.__doc__, epilog=undelete_epilog
)
- 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..409baaeb78 100644
--- a/src/borg/archiver/version_cmd.py
+++ b/src/borg/archiver/version_cmd.py
@@ -1,7 +1,6 @@
-import argparse
-
from .. import __version__
from ..constants import * # NOQA
+from ..helpers.argparsing import ArgumentParser
from ..remote import RemoteRepository
from ..logger import create_logger
@@ -51,13 +50,5 @@ 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",
- 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)
+ subparser = ArgumentParser(parents=[common_parser], description=self.do_version.__doc__, epilog=version_epilog)
+ subparsers.add_subcommand("version", subparser, help="display the Borg client and server versions")
diff --git a/src/borg/compress.pyi b/src/borg/compress.pyi
index c8a271a1c6..d627e6e202 100644
--- a/src/borg/compress.pyi
+++ b/src/borg/compress.pyi
@@ -2,12 +2,6 @@ from typing import Any, Type, Dict, Tuple
def get_compressor(name: str, **kwargs) -> Any: ...
-class CompressionSpec:
- def __init__(self, spec: str) -> None: ...
- @property
- def compressor(self) -> Any: ...
- inner: CompressionSpec
-
class Compressor:
def __init__(self, name: Any = ..., **kwargs) -> None: ...
def compress(self, meta: Dict, data: bytes) -> Tuple[Dict, bytes]: ...
diff --git a/src/borg/compress.pyx b/src/borg/compress.pyx
index ba7d124b32..faf3031273 100644
--- a/src/borg/compress.pyx
+++ b/src/borg/compress.pyx
@@ -15,10 +15,10 @@ which compressor has been used to compress the data and dispatch to the correct
decompressor.
"""
-from argparse import ArgumentTypeError
import math
import random
from struct import Struct
+import sys
import zlib
try:
@@ -28,15 +28,13 @@ except ImportError:
from .constants import MAX_DATA_SIZE, ROBJ_FILE_STREAM
from .helpers import Buffer, DecompressionError
-
-import sys
+from .helpers.argparsing import ArgumentTypeError
if sys.version_info >= (3, 14):
from compression import zstd
else:
from backports import zstd
-
cdef extern from "lz4.h":
int LZ4_compress_default(const char* source, char* dest, int inputSize, int maxOutputSize) nogil
int LZ4_decompress_safe(const char* source, char* dest, int inputSize, int maxOutputSize) nogil
@@ -120,7 +118,6 @@ cdef class CompressorBase:
else:
pass # raise ValueError("size not present and not in legacy mode")
-
cdef class DecidingCompressor(CompressorBase):
"""
base class for (de)compression classes that (based on an internal _decide
@@ -188,7 +185,6 @@ class CNONE(CompressorBase):
self.check_fix_size(meta, data)
return meta, data
-
class LZ4(DecidingCompressor):
"""
raw LZ4 compression / decompression (liblz4).
@@ -260,7 +256,6 @@ class LZ4(DecidingCompressor):
self.check_fix_size(meta, data)
return meta, data
-
class LZMA(DecidingCompressor):
"""
lzma compression / decompression
@@ -355,7 +350,6 @@ class ZLIB(DecidingCompressor):
except zlib.error as e:
raise DecompressionError(str(e)) from None
-
class ZLIB_legacy(CompressorBase):
"""
zlib compression / decompression (python stdlib)
@@ -402,7 +396,6 @@ class ZLIB_legacy(CompressorBase):
except zlib.error as e:
raise DecompressionError(str(e)) from None
-
class Auto(CompressorBase):
"""
Meta-Compressor that decides which compression to use based on LZ4's ratio.
@@ -484,7 +477,6 @@ class Auto(CompressorBase):
def detect(cls, data):
raise NotImplementedError
-
class ObfuscateSize(CompressorBase):
"""
Meta-Compressor that obfuscates the compressed data size.
@@ -569,7 +561,6 @@ class ObfuscateSize(CompressorBase):
self.compressor = compressor_cls()
return self.compressor.decompress(meta, compressed_data) # decompress data
-
# Maps valid compressor names to their class
COMPRESSOR_TABLE = {
CNONE.name: CNONE,
@@ -623,64 +614,3 @@ class Compressor:
return cls, (255 if cls.name == 'zlib_legacy' else level)
else:
raise ValueError('No decompressor for this data found: %r.', data[:2])
-
-
-class CompressionSpec:
- def __init__(self, s):
- values = s.split(',')
- count = len(values)
- if count < 1:
- raise ArgumentTypeError("not enough arguments")
- # --compression algo[,level]
- self.name = values[0]
- if self.name in ('none', 'lz4', ):
- return
- elif self.name in ('zlib', 'lzma', 'zlib_legacy'): # zlib_legacy just for testing
- if count < 2:
- level = 6 # default compression level in py stdlib
- elif count == 2:
- level = int(values[1])
- if not 0 <= level <= 9:
- raise ArgumentTypeError("level must be >= 0 and <= 9")
- else:
- raise ArgumentTypeError("too many arguments")
- self.level = level
- elif self.name in ('zstd', ):
- if count < 2:
- level = 3 # default compression level in zstd
- elif count == 2:
- level = int(values[1])
- if not 1 <= level <= 22:
- raise ArgumentTypeError("level must be >= 1 and <= 22")
- else:
- raise ArgumentTypeError("too many arguments")
- self.level = level
- elif self.name == 'auto':
- if 2 <= count <= 3:
- compression = ','.join(values[1:])
- else:
- raise ArgumentTypeError("bad arguments")
- self.inner = CompressionSpec(compression)
- elif self.name == 'obfuscate':
- if 3 <= count <= 5:
- level = int(values[1])
- if not ((1 <= level <= 6) or (110 <= level <= 123) or (level == 250)):
- raise ArgumentTypeError("level must be (inclusively) within 1...6, 110...123 or equal to 250")
- self.level = level
- compression = ','.join(values[2:])
- else:
- raise ArgumentTypeError("bad arguments")
- self.inner = CompressionSpec(compression)
- else:
- raise ArgumentTypeError("unsupported compression type")
-
- @property
- def compressor(self):
- if self.name in ('none', 'lz4', ):
- return get_compressor(self.name)
- elif self.name in ('zlib', 'lzma', 'zstd', 'zlib_legacy'):
- return get_compressor(self.name, level=self.level)
- elif self.name == 'auto':
- return get_compressor(self.name, compressor=self.inner.compressor)
- elif self.name == 'obfuscate':
- return get_compressor(self.name, level=self.level, compressor=self.inner.compressor)
diff --git a/src/borg/fuse.py b/src/borg/fuse.py
index 0d6bc13fa3..7d12c40eda 100644
--- a/src/borg/fuse.py
+++ b/src/borg/fuse.py
@@ -354,6 +354,7 @@ def _process_archive(self, archive_id, prefix=[]):
t0 = time.perf_counter()
archive = Archive(self._manifest, archive_id)
strip_components = self._args.strip_components
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(self._args.patterns, self._args.paths)
hlm = HardLinkManager(id_type=bytes, info_type=str) # hlid -> path
diff --git a/src/borg/helpers/__init__.py b/src/borg/helpers/__init__.py
index 7902d5bb67..12db71b2ac 100644
--- a/src/borg/helpers/__init__.py
+++ b/src/borg/helpers/__init__.py
@@ -25,13 +25,14 @@
from .fs import HardLinkManager
from .misc import sysinfo, log_multi, consume
from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper
-from .parseformat import bin_to_hex, hex_to_bin, safe_encode, safe_decode
+from .parseformat import octal_int, bin_to_hex, hex_to_bin, safe_encode, safe_decode
from .parseformat import text_to_json, binary_to_json, remove_surrogates, join_cmd
-from .parseformat import eval_escapes, decode_dict, positive_int_validator, interval
+from .parseformat import eval_escapes, decode_dict, interval
from .parseformat import (
PathSpec,
FilesystemPathSpec,
SortBySpec,
+ CompressionSpec,
ChunkerParams,
FilesCacheMode,
partial_format,
diff --git a/src/borg/helpers/argparsing.py b/src/borg/helpers/argparsing.py
new file mode 100644
index 0000000000..3d42a638fe
--- /dev/null
+++ b/src/borg/helpers/argparsing.py
@@ -0,0 +1,235 @@
+"""
+Borg argument-parsing layer
+===========================
+
+All imports of ``ArgumentParser``, ``Namespace``, ``SUPPRESS``, etc. come
+from this module. It is the single seam between borg and the underlying
+parser library (jsonargparse).
+
+Library choice
+--------------
+Borg uses **jsonargparse** instead of plain argparse. jsonargparse is a
+superset of argparse that additionally supports:
+
+* reading arguments from YAML/JSON config files (``--config``)
+* reading arguments from environment variables
+* nested namespaces for subcommands (each subcommand's arguments live in
+ their own ``Namespace`` object rather than the flat top-level namespace)
+
+Parser hierarchy
+----------------
+Borg's command line has up to three levels::
+
+ borg [common-opts] [common-opts] [ [common-opts] [args]]
+
+ e.g. borg --info create ...
+ borg create --info ...
+ borg debug info --debug ...
+
+Three ``ArgumentParser`` instances are constructed in ``build_parser()``:
+
+``parser`` (top-level)
+ The root parser. Common options are registered here **with real
+ defaults** (``provide_defaults=True``).
+
+``common_parser``
+ A helper parser (``add_help=False``) passed as ``parents=[common_parser]``
+ to every *leaf* subcommand parser (e.g. ``create``, ``repo-create``, …).
+ Common options are registered here **with** ``default=SUPPRESS`` so that
+ an option not given on the command line leaves no attribute at all in the
+ subcommand namespace.
+
+``mid_common_parser``
+ Same as ``common_parser`` but used as the parent for *group* subcommand
+ parsers that introduce a second level (e.g. ``debug``, ``key``,
+ ``benchmark``). Their *leaf* subcommand parsers also use
+ ``mid_common_parser`` as a parent.
+
+Common options (``--info``, ``--debug``, ``--repo``, ``--lock-wait``, …)
+are managed by ``Archiver.CommonOptions``, which calls
+``define_common_options()`` once per parser so the same options appear at
+every level with identical ``dest`` names.
+
+Namespace flattening and precedence
+-------------------------------------
+jsonargparse stores each subcommand's parsed values in a nested
+``Namespace`` object::
+
+ # borg --info create --debug ...
+ Namespace(
+ log_level = "info", # top-level
+ subcommand = "create",
+ create = Namespace(
+ log_level = "debug", # subcommand level
+ ...
+ )
+ )
+
+After ``parser.parse_args()`` returns, ``flatten_namespace()`` collapses
+this tree into a single ``Namespace`` that borg's dispatch and command
+implementations expect.
+
+Precedence rule: the **most-specific** (innermost) value wins.
+``flatten_namespace`` uses ``Namespace.as_flat()`` (provided by jsonargparse)
+to linearise the nested tree into a flat dict with dotted keys encoding
+depth, for example::
+
+ log_level = "info" # top-level (0 dots)
+ create.log_level = "debug" # one level deep (1 dot)
+ debug.info.log_level = "critical" # two levels deep (2 dots)
+
+The entries are then sorted deepest-first so the most-specific value is
+encountered first and wins. Shallower values only fill in if the key
+has not been set yet.
+
+Special case — append-action options (e.g. ``--debug-topic``):
+If a key already holds a list and the outer level also supplies a list,
+the two lists are **merged** (outer values first, inner values last) so
+that ``borg --debug-topic foo create --debug-topic bar`` accumulates
+``["foo", "bar"]`` rather than losing one of the values.
+
+The ``SUPPRESS`` default on sub-parsers is essential: if a common option
+is not given at the subcommand level, it simply produces no attribute in
+the subcommand namespace and the outer (top-level) default flows through
+unchanged.
+"""
+
+from typing import Any
+
+# here are the only imports from argparse and jsonargparse,
+# all other imports of these names import them from here:
+from argparse import Action, ArgumentError, ArgumentTypeError, RawDescriptionHelpFormatter # noqa: F401
+from jsonargparse import ArgumentParser as _ArgumentParser # we subclass that to add custom behavior
+from jsonargparse import Namespace, SUPPRESS, REMAINDER # noqa: F401
+from jsonargparse.typing import register_type # noqa: F401
+from jsonargparse.typing import PositiveInt # noqa: F401
+
+# borg completion uses these private symbols, so we need to import them:
+from jsonargparse._actions import _ActionSubCommands # noqa: F401
+from jsonargparse._completions import prepare_actions_context, shtab_prepare_actions # noqa: F401
+from jsonargparse._completions import bash_compgen_typehint # noqa: F401
+
+
+class ArgumentParser(_ArgumentParser):
+ # the borg code always uses RawDescriptionHelpFormatter and add_help=False:
+ def __init__(self, *args, formatter_class=RawDescriptionHelpFormatter, add_help=False, **kwargs):
+ super().__init__(*args, formatter_class=formatter_class, add_help=add_help, **kwargs)
+
+ def add_argument(self, *args, **kwargs):
+ # the current implementation of store_true in jsonargparse has troubles with
+ # env vars, see https://github.com/omni-us/jsonargparse/issues/857
+ if "action" in kwargs and kwargs["action"] == "store_true":
+ kwargs["action"] = ActionYes
+ return super().add_argument(*args, **kwargs)
+
+ def merge_config(self, cfg_from: Namespace, cfg_to: Namespace) -> Namespace:
+ """Merges cfg_from into cfg_to, skipping None and protecting True from False.
+
+ - None values in cfg_from are skipped so they don't overwrite real values in cfg_to.
+ - False in cfg_from never overwrites True in cfg_to, so that env-var-sourced True
+ values survive merging with defaults or config-file values.
+ """
+ cfg_from = cfg_from.clone()
+ cfg_to = cfg_to.clone()
+ for key, value in list(vars(cfg_from).items()):
+ if value is None or (value is False and cfg_to.get(key) is True):
+ delattr(cfg_from, key)
+ cfg_to.update(cfg_from)
+ return cfg_to
+
+
+class ActionYes(Action): # subclass ActionYesNo?
+ """Option ``--opt`` to set ``True``, replacement for store_true."""
+
+ # ActionYesNo can be too much and cannot support short options (e.g. -v instead of --verbose).
+ # ActionYes supports short options.
+ # ActionYes does not add the additional "no" options as ActionYesNo does.
+ # ActionYes can only store True if the option is used.
+ # The default is None (not False) so that env-var values are not overwritten
+ # by a False default when the flag is absent from the CLI.
+
+ def __init__(self, **kwargs):
+ """Initializer for ActionYes instance.
+
+ Raises:
+ ValueError: If a parameter is invalid.
+ """
+ if kwargs:
+ if len(kwargs["option_strings"]) == 0:
+ raise ValueError(f'{type(self).__name__} not intended for positional arguments ({kwargs["dest"]}).')
+ kwargs["nargs"] = 0
+ kwargs["metavar"] = None
+ if "default" not in kwargs:
+ kwargs["default"] = None # None so env-var values are not overwritten by a False default.
+ kwargs["type"] = ActionYes._boolean_type
+ super().__init__(**kwargs)
+
+ def __call__(self, *args, **kwargs):
+ """Sets the corresponding key to True if the option is used."""
+ if len(args) == 0:
+ return ActionYes(**kwargs)
+ setattr(args[1], self.dest, True)
+
+ def _add_dest_prefix(self, prefix):
+ self.dest = prefix + "." + self.dest
+ self.option_strings[0] = "--" + prefix + "." + self.option_strings[0][2:]
+
+ @staticmethod
+ def _boolean_type(x):
+ if x is None:
+ return None # Preserve None so env-var values are not overwritten by a False default.
+ if isinstance(x, str) and x.lower() in {"true", "yes", "false", "no"}:
+ x = True if x.lower() in {"true", "yes"} else False
+ elif not isinstance(x, bool):
+ raise TypeError(f"Value not boolean: {x}.")
+ return x
+
+ def completer(self, **kwargs):
+ """Used by argcomplete to support tab completion of arguments."""
+ return []
+
+
+def flatten_namespace(ns: Any, action_yes_dests: set = frozenset()) -> Namespace:
+ """
+ Flattens the nested namespace jsonargparse produces for subcommands into a
+ single-level namespace that borg's dispatch and command implementations expect.
+
+ Inner (subcommand) values take precedence over outer (top-level) values.
+ For list-typed values (append-action options like --debug-topic) that appear
+ at multiple levels, the lists are merged: outer values first, inner values last.
+ ActionYes fields with a None value (flag absent from CLI and no env var) are set to False.
+ """
+ flat = Namespace()
+
+ # Extract the joined subcommand path from the nested namespace tree.
+ 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)
+
+ # as_flat() linearises the nested tree into dotted-key entries, e.g.:
+ # log_level='info' (outer, 0 dots)
+ # create.log_level='debug' (subcommand, 1 dot)
+ # debug.info.log_level='crit' (two-level subcommand, 2 dots)
+ # Sorting deepest-first ensures the most-specific value is processed first and therefore wins ("inner wins" rule).
+ all_items = sorted(vars(ns.as_flat()).items(), key=lambda kv: kv[0].count("."), reverse=True)
+
+ for dotted_key, value in all_items:
+ dest = dotted_key.rsplit(".", 1)[-1] # e.g. "create.log_level" -> "log_level"
+ if dest == "subcommand":
+ continue
+ existing = getattr(flat, dest, None)
+ if existing is None:
+ # For ActionYes flags absent from CLI and env: treat None as False.
+ if value is None and dest in action_yes_dests:
+ value = False
+ setattr(flat, dest, value)
+ elif isinstance(existing, list) and isinstance(value, list):
+ # Append-action options (e.g. --debug-topic): outer values come first.
+ setattr(flat, dest, list(value) + list(existing))
+
+ return flat
diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py
index 5132446b5a..4108c2404e 100644
--- a/src/borg/helpers/parseformat.py
+++ b/src/borg/helpers/parseformat.py
@@ -1,5 +1,4 @@
import abc
-import argparse
import base64
import binascii
import hashlib
@@ -22,8 +21,11 @@
logger = create_logger()
+import yaml
+
from .errors import Error
from .fs import get_keys_dir, make_path_safe, slashify
+from .argparsing import Action, ArgumentError, ArgumentTypeError, register_type
from .msgpack import Timestamp
from .time import OutputTimestamp, format_time, safe_timestamp
from .. import __version__ as borg_version
@@ -35,6 +37,12 @@
from ..item import ItemDiff
+def octal_int(s):
+ if isinstance(s, int):
+ return s
+ return int(s, 8)
+
+
def bin_to_hex(binary):
return binascii.hexlify(binary).decode("ascii")
@@ -120,16 +128,10 @@ def decode_dict(d, keys, encoding="utf-8", errors="surrogateescape"):
return d
-def positive_int_validator(value):
- """argparse type for positive integers."""
- int_value = int(value)
- if int_value <= 0:
- raise argparse.ArgumentTypeError("A positive integer is required: %s" % value)
- return int_value
-
-
def interval(s):
"""Convert a string representing a valid interval to a number of seconds."""
+ if isinstance(s, int):
+ return s
seconds_in_a_minute = 60
seconds_in_an_hour = 60 * seconds_in_a_minute
seconds_in_a_day = 24 * seconds_in_an_hour
@@ -150,7 +152,7 @@ def interval(s):
number = s[:-1]
suffix = s[-1]
else:
- raise argparse.ArgumentTypeError(f'Unexpected time unit "{s[-1]}": choose from {", ".join(multiplier)}')
+ raise ArgumentTypeError(f'Unexpected time unit "{s[-1]}": choose from {", ".join(multiplier)}')
try:
seconds = int(number) * multiplier[suffix]
@@ -158,16 +160,96 @@ def interval(s):
seconds = -1
if seconds <= 0:
- raise argparse.ArgumentTypeError(f'Invalid number "{number}": expected positive integer')
+ raise ArgumentTypeError(f'Invalid number "{number}": expected positive integer')
return seconds
+class CompressionSpec:
+ def __init__(self, s):
+ if isinstance(s, CompressionSpec):
+ self.__dict__.update(s.__dict__)
+ return
+ values = s.split(",")
+ count = len(values)
+ if count < 1:
+ raise ArgumentTypeError("not enough arguments")
+ # --compression algo[,level]
+ self.name = values[0]
+ if self.name in ("none", "lz4"):
+ return
+ elif self.name in ("zlib", "lzma", "zlib_legacy"): # zlib_legacy just for testing
+ if count < 2:
+ level = 6 # default compression level in py stdlib
+ elif count == 2:
+ level = int(values[1])
+ if not 0 <= level <= 9:
+ raise ArgumentTypeError("level must be >= 0 and <= 9")
+ else:
+ raise ArgumentTypeError("too many arguments")
+ self.level = level
+ elif self.name in ("zstd",):
+ if count < 2:
+ level = 3 # default compression level in zstd
+ elif count == 2:
+ level = int(values[1])
+ if not 1 <= level <= 22:
+ raise ArgumentTypeError("level must be >= 1 and <= 22")
+ else:
+ raise ArgumentTypeError("too many arguments")
+ self.level = level
+ elif self.name == "auto":
+ if 2 <= count <= 3:
+ compression = ",".join(values[1:])
+ else:
+ raise ArgumentTypeError("bad arguments")
+ self.inner = CompressionSpec(compression)
+ elif self.name == "obfuscate":
+ if 3 <= count <= 5:
+ level = int(values[1])
+ if not ((1 <= level <= 6) or (110 <= level <= 123) or (level == 250)):
+ raise ArgumentTypeError("level must be (inclusively) within 1...6, 110...123 or equal to 250")
+ self.level = level
+ compression = ",".join(values[2:])
+ else:
+ raise ArgumentTypeError("bad arguments")
+ self.inner = CompressionSpec(compression)
+ else:
+ raise ArgumentTypeError("unsupported compression type")
+
+ @property
+ def compressor(self):
+ from ..compress import get_compressor
+
+ if self.name in ("none", "lz4"):
+ return get_compressor(self.name)
+ elif self.name in ("zlib", "lzma", "zstd", "zlib_legacy"):
+ return get_compressor(self.name, level=self.level)
+ elif self.name == "auto":
+ return get_compressor(self.name, compressor=self.inner.compressor)
+ elif self.name == "obfuscate":
+ return get_compressor(self.name, level=self.level, compressor=self.inner.compressor)
+
+ def __str__(self):
+ if self.name in ("none", "lz4"):
+ return f"{self.name}"
+ elif self.name in ("zlib", "lzma", "zstd", "zlib_legacy"):
+ return f"{self.name},{self.level}"
+ elif self.name == "auto":
+ return f"auto,{self.inner}"
+ elif self.name == "obfuscate":
+ return f"obfuscate,{self.level},{self.inner}"
+ else:
+ raise ValueError(f"unsupported compression type: {self.name}")
+
+
def ChunkerParams(s):
+ if isinstance(s, (list, tuple)):
+ return tuple(s)
params = s.strip().split(",")
count = len(params)
if count == 0:
- raise argparse.ArgumentTypeError("no chunker params given")
+ raise ArgumentTypeError("no chunker params given")
algo = params[0].lower()
if algo == CH_FAIL and count == 3:
block_size = int(params[1])
@@ -182,61 +264,51 @@ def ChunkerParams(s):
# or in-memory chunk management.
# choose the block (chunk) size wisely: if you have a lot of data and you cut
# it into very small chunks, you are asking for trouble!
- raise argparse.ArgumentTypeError("block_size must not be less than 64 Bytes")
+ raise ArgumentTypeError("block_size must not be less than 64 Bytes")
if block_size > MAX_DATA_SIZE or header_size > MAX_DATA_SIZE:
- raise argparse.ArgumentTypeError(
- "block_size and header_size must not exceed MAX_DATA_SIZE [%d]" % MAX_DATA_SIZE
- )
+ raise ArgumentTypeError("block_size and header_size must not exceed MAX_DATA_SIZE [%d]" % MAX_DATA_SIZE)
return algo, block_size, header_size
if algo == "default" and count == 1: # default
return CHUNKER_PARAMS
if algo == CH_BUZHASH64 and count == 5: # buzhash64, chunk_min, chunk_max, chunk_mask, window_size
chunk_min, chunk_max, chunk_mask, window_size = (int(p) for p in params[1:])
if not (chunk_min <= chunk_mask <= chunk_max):
- raise argparse.ArgumentTypeError("required: chunk_min <= chunk_mask <= chunk_max")
+ raise ArgumentTypeError("required: chunk_min <= chunk_mask <= chunk_max")
if chunk_min < 6:
# see comment in 'fixed' algo check
- raise argparse.ArgumentTypeError(
- "min. chunk size exponent must not be less than 6 (2^6 = 64B min. chunk size)"
- )
+ raise ArgumentTypeError("min. chunk size exponent must not be less than 6 (2^6 = 64B min. chunk size)")
if chunk_max > 23:
- raise argparse.ArgumentTypeError(
- "max. chunk size exponent must not be more than 23 (2^23 = 8MiB max. chunk size)"
- )
+ raise ArgumentTypeError("max. chunk size exponent must not be more than 23 (2^23 = 8MiB max. chunk size)")
# note that for buzhash64, there is no problem with even window_size.
return CH_BUZHASH64, chunk_min, chunk_max, chunk_mask, window_size
# this must stay last as it deals with old-style compat mode (no algorithm, 4 params, buzhash):
if algo == CH_BUZHASH and count == 5 or count == 4: # [buzhash, ]chunk_min, chunk_max, chunk_mask, window_size
chunk_min, chunk_max, chunk_mask, window_size = (int(p) for p in params[count - 4 :])
if not (chunk_min <= chunk_mask <= chunk_max):
- raise argparse.ArgumentTypeError("required: chunk_min <= chunk_mask <= chunk_max")
+ raise ArgumentTypeError("required: chunk_min <= chunk_mask <= chunk_max")
if chunk_min < 6:
# see comment in 'fixed' algo check
- raise argparse.ArgumentTypeError(
- "min. chunk size exponent must not be less than 6 (2^6 = 64B min. chunk size)"
- )
+ raise ArgumentTypeError("min. chunk size exponent must not be less than 6 (2^6 = 64B min. chunk size)")
if chunk_max > 23:
- raise argparse.ArgumentTypeError(
- "max. chunk size exponent must not be more than 23 (2^23 = 8MiB max. chunk size)"
- )
+ raise ArgumentTypeError("max. chunk size exponent must not be more than 23 (2^23 = 8MiB max. chunk size)")
if window_size % 2 == 0:
- raise argparse.ArgumentTypeError("window_size must be an uneven (odd) number")
+ raise ArgumentTypeError("window_size must be an uneven (odd) number")
return CH_BUZHASH, chunk_min, chunk_max, chunk_mask, window_size
- raise argparse.ArgumentTypeError("invalid chunker params")
+ raise ArgumentTypeError("invalid chunker params")
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(
- "cache mode must be a comma-separated list of: %s" % ",".join(sorted(ENTRIES_MAP))
- )
+ raise ArgumentTypeError("cache mode must be a comma-separated list of: %s" % ",".join(sorted(ENTRIES_MAP)))
short_entries = {ENTRIES_MAP[entry] for entry in entries}
mode = "".join(sorted(short_entries))
if mode not in VALID_MODES:
- raise argparse.ArgumentTypeError("cache mode short must be one of: %s" % ",".join(VALID_MODES))
+ raise ArgumentTypeError("cache mode short must be one of: %s" % ",".join(VALID_MODES))
return mode
@@ -332,22 +404,22 @@ def __call__(self, text, overrides=None):
def PathSpec(text):
if not text:
- raise argparse.ArgumentTypeError("Empty strings are not accepted as paths.")
+ raise ArgumentTypeError("Empty strings are not accepted as paths.")
return text
def FilesystemPathSpec(text):
if not text:
- raise argparse.ArgumentTypeError("Empty strings are not accepted as paths.")
+ raise ArgumentTypeError("Empty strings are not accepted as paths.")
return slashify(text)
def SortBySpec(text):
from ..manifest import AI_HUMAN_SORT_KEYS
- for token in text.split(","):
- if token not in AI_HUMAN_SORT_KEYS:
- raise argparse.ArgumentTypeError("Invalid sort key: %s" % token)
+ for sort_key in text.split(","):
+ if sort_key not in AI_HUMAN_SORT_KEYS and sort_key != "ts": # idempotency: do not reject ts
+ raise ArgumentTypeError("Invalid sort key: %s" % sort_key)
return text.replace("timestamp", "ts").replace("archive", "name")
@@ -369,6 +441,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()
@@ -507,6 +581,9 @@ class Location:
local_re = re.compile(local_path_re, re.VERBOSE)
def __init__(self, text="", overrides={}, other=False):
+ if isinstance(text, Location):
+ self.__dict__.update(text.__dict__)
+ return
self.repo_env_var = "BORG_OTHER_REPO" if other else "BORG_REPO"
self.valid = False
self.proto = None
@@ -632,22 +709,34 @@ def validator(text):
try:
loc = Location(text, other=other)
except ValueError as err:
- raise argparse.ArgumentTypeError(str(err)) from None
+ raise ArgumentTypeError(str(err)) from None
if proto is not None and loc.proto != proto:
if proto == "file":
- raise argparse.ArgumentTypeError('"%s": Repository must be local' % text)
+ raise ArgumentTypeError('"%s": Repository must be local' % text)
else:
- raise argparse.ArgumentTypeError('"%s": Repository must be remote' % text)
+ raise ArgumentTypeError('"%s": Repository must be remote' % text)
return loc
return validator
+# Register types with jsonargparse so they can be represented in config files
+# (e.g. for --print_config). Two things are needed:
+# 1. A YAML representer so yaml.safe_dump can serialize Location objects to strings.
+# 2. A jsonargparse register_type so it knows how to deserialize strings back to Location.
+
+yaml.SafeDumper.add_representer(Location, lambda dumper, loc: dumper.represent_str(loc.raw or ""))
+register_type(Location, serializer=lambda loc: loc.raw or "")
+
+yaml.SafeDumper.add_representer(CompressionSpec, lambda dumper, cs: dumper.represent_str(str(cs)))
+register_type(CompressionSpec)
+
+
def relative_time_marker_validator(text: str):
time_marker_regex = r"^\d+[ymwdHMS]$"
match = re.compile(time_marker_regex).search(text)
if not match:
- raise argparse.ArgumentTypeError(f"Invalid relative time marker used: {text}, choose from y, m, w, d, H, M, S")
+ raise ArgumentTypeError(f"Invalid relative time marker used: {text}, choose from y, m, w, d, H, M, S")
else:
return text
@@ -656,22 +745,20 @@ def text_validator(*, name, max_length, min_length=0, invalid_ctrl_chars="\0", i
def validator(text):
assert isinstance(text, str)
if len(text) < min_length:
- raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [length < {min_length}]')
+ raise ArgumentTypeError(f'Invalid {name}: "{text}" [length < {min_length}]')
if len(text) > max_length:
- raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [length > {max_length}]')
+ raise ArgumentTypeError(f'Invalid {name}: "{text}" [length > {max_length}]')
if invalid_ctrl_chars and re.search(f"[{re.escape(invalid_ctrl_chars)}]", text):
- raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [invalid control chars detected]')
+ raise ArgumentTypeError(f'Invalid {name}: "{text}" [invalid control chars detected]')
if invalid_chars and re.search(f"[{re.escape(invalid_chars)}]", text):
- raise argparse.ArgumentTypeError(
- f'Invalid {name}: "{text}" [invalid chars detected matching "{invalid_chars}"]'
- )
+ raise ArgumentTypeError(f'Invalid {name}: "{text}" [invalid chars detected matching "{invalid_chars}"]')
if no_blanks and (text.startswith(" ") or text.endswith(" ")):
- raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [leading or trailing blanks detected]')
+ raise ArgumentTypeError(f'Invalid {name}: "{text}" [leading or trailing blanks detected]')
try:
text.encode("utf-8", errors="strict")
except UnicodeEncodeError:
# looks like text contains surrogate-escapes
- raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [contains non-unicode characters]')
+ raise ArgumentTypeError(f'Invalid {name}: "{text}" [contains non-unicode characters]')
return text
return validator
@@ -1305,7 +1392,7 @@ def decode(d):
return decode(d)
-class Highlander(argparse.Action):
+class Highlander(Action):
"""make sure some option is only given once"""
def __init__(self, *args, **kwargs):
@@ -1314,7 +1401,7 @@ def __init__(self, *args, **kwargs):
def __call__(self, parser, namespace, values, option_string=None):
if self.__called:
- raise argparse.ArgumentError(self, "There can be only one.")
+ raise ArgumentError(self, "There can be only one.")
self.__called = True
setattr(namespace, self.dest, values)
@@ -1324,7 +1411,7 @@ 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)
+ raise ArgumentError(self, e)
if sanitized_path == ".":
- raise argparse.ArgumentError(self, f"{path!r} is not a valid file name")
+ raise ArgumentError(self, f"{path!r} is not a valid file name")
setattr(namespace, self.dest, 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/hlfuse.py b/src/borg/hlfuse.py
index c08475961e..d05cd71276 100644
--- a/src/borg/hlfuse.py
+++ b/src/borg/hlfuse.py
@@ -193,6 +193,7 @@ def _process_archive(self, archive_id, root_node=None):
archive = Archive(self._manifest, archive_id)
strip_components = self._args.strip_components
+ # omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(self._args.patterns, self._args.paths)
hlm = HardLinkManager(id_type=bytes, info_type=str)
diff --git a/src/borg/patterns.py b/src/borg/patterns.py
index c1f8f57277..bfbf69712b 100644
--- a/src/borg/patterns.py
+++ b/src/borg/patterns.py
@@ -1,4 +1,3 @@
-import argparse
import fnmatch
import posixpath
import re
@@ -8,6 +7,7 @@
from enum import Enum
from .helpers import clean_lines, shellpattern
+from .helpers.argparsing import Action, ArgumentTypeError
from .helpers.errors import Error
@@ -36,15 +36,15 @@ def load_exclude_file(fileobj, patterns):
patterns.append(parse_exclude_pattern(patternstr))
-class ArgparsePatternAction(argparse.Action):
+class ArgparsePatternAction(Action):
def __init__(self, nargs=1, **kw):
super().__init__(nargs=nargs, **kw)
def __call__(self, parser, args, values, option_string=None):
- parse_patternfile_line(values[0], args.paths, args.patterns, ShellPattern)
+ parse_patternfile_line(values[0], args.pattern_roots, args.patterns, ShellPattern)
-class ArgparsePatternFileAction(argparse.Action):
+class ArgparsePatternFileAction(Action):
def __init__(self, nargs=1, **kw):
super().__init__(nargs=nargs, **kw)
@@ -60,7 +60,7 @@ def __call__(self, parser, args, values, option_string=None):
raise Error(str(e))
def parse(self, fobj, args):
- load_pattern_file(fobj, args.paths, args.patterns)
+ load_pattern_file(fobj, args.pattern_roots, args.patterns)
class ArgparseExcludeFileAction(ArgparsePatternFileAction):
@@ -357,16 +357,16 @@ def parse_inclexcl_command(cmd_line_str, fallback=ShellPattern):
"p": IECommand.PatternStyle,
}
if not cmd_line_str:
- raise argparse.ArgumentTypeError("A pattern/command must not be empty.")
+ raise ArgumentTypeError("A pattern/command must not be empty.")
cmd = cmd_prefix_map.get(cmd_line_str[0])
if cmd is None:
- raise argparse.ArgumentTypeError("A pattern/command must start with any of: %s" % ", ".join(cmd_prefix_map))
+ raise ArgumentTypeError("A pattern/command must start with any of: %s" % ", ".join(cmd_prefix_map))
# remaining text on command-line following the command character
remainder_str = cmd_line_str[1:].lstrip()
if not remainder_str:
- raise argparse.ArgumentTypeError("A pattern/command must have a value part.")
+ raise ArgumentTypeError("A pattern/command must have a value part.")
if cmd is IECommand.RootPath:
# TODO: validate string?
@@ -376,7 +376,7 @@ def parse_inclexcl_command(cmd_line_str, fallback=ShellPattern):
try:
val = get_pattern_class(remainder_str)
except ValueError:
- raise argparse.ArgumentTypeError(f"Invalid pattern style: {remainder_str}")
+ raise ArgumentTypeError(f"Invalid pattern style: {remainder_str}")
else:
# determine recurse_dir based on command type
recurse_dir = command_recurses_dir(cmd)
diff --git a/src/borg/testsuite/archiver/argparsing_test.py b/src/borg/testsuite/archiver/argparsing_test.py
index 974becf332..2d8a1b067f 100644
--- a/src/borg/testsuite/archiver/argparsing_test.py
+++ b/src/borg/testsuite/archiver/argparsing_test.py
@@ -1,7 +1,7 @@
-import argparse
import pytest
from . import Archiver, RK_ENCRYPTION, cmd
+from ...helpers.argparsing import ActionYes, ArgumentParser, flatten_namespace
def test_bad_filters(archiver):
@@ -26,6 +26,14 @@ def test_highlander(archiver):
assert error_msg in output_custom
+def test_env_var_bool_flag(monkeypatch):
+ """BORG_CREATE__STATS=true must activate --stats even without the CLI flag."""
+ monkeypatch.setenv("BORG_CREATE__STATS", "true")
+ archiver = Archiver()
+ args = archiver.parse_args(["create", "test", "/some/path"])
+ assert args.stats is True, "env var BORG_CREATE__STATS=true must set stats=True"
+
+
def test_get_args():
archiver = Archiver()
# everything normal:
@@ -93,45 +101,39 @@ def define_common_options(add_common_option):
@pytest.fixture
def basic_parser(self):
- parser = argparse.ArgumentParser(prog="test", description="test parser", add_help=False)
- parser.common_options = Archiver.CommonOptions(
- self.define_common_options, suffix_precedence=("_level0", "_level1")
- )
+ parser = ArgumentParser(prog="test", description="test parser")
+ parser.common_options = Archiver.CommonOptions(self.define_common_options)
return parser
@pytest.fixture
- def subparsers(self, basic_parser):
- return basic_parser.add_subparsers(title="required arguments", metavar="")
+ def subcommands(self, basic_parser):
+ return basic_parser.add_subcommands(required=False, title="required arguments", metavar="")
@pytest.fixture
def parser(self, basic_parser):
- basic_parser.common_options.add_common_group(basic_parser, "_level0", provide_defaults=True)
+ basic_parser.common_options.add_common_group(basic_parser, provide_defaults=True)
return basic_parser
@pytest.fixture
def common_parser(self, parser):
- common_parser = argparse.ArgumentParser(add_help=False, prog="test")
- parser.common_options.add_common_group(common_parser, "_level1")
+ common_parser = ArgumentParser(prog="test")
+ parser.common_options.add_common_group(common_parser)
return common_parser
@pytest.fixture
- def parse_vars_from_line(self, parser, subparsers, common_parser):
- subparser = subparsers.add_parser(
- "subcommand",
- parents=[common_parser],
- add_help=False,
- description="foo",
- epilog="bar",
- help="baz",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- subparser.set_defaults(func=1234)
+ def parse_vars_from_line(self, parser, subcommands, common_parser):
+ subparser = ArgumentParser(parents=[common_parser], description="foo", epilog="bar")
subparser.add_argument("--foo-bar", dest="foo_bar", action="store_true")
+ subcommands.add_subcommand("subcmd", subparser, help="baz")
def parse_vars_from_line(*line):
print(line)
args = parser.parse_args(line)
- parser.common_options.resolve(args)
+ action_yes_dests = {ac.dest for ac in parser._actions if isinstance(ac, ActionYes)}
+ if parser._subcommands_action is not None:
+ for sp in parser._subcommands_action._name_parser_map.values():
+ action_yes_dests.update(ac.dest for ac in sp._actions if isinstance(ac, ActionYes))
+ args = flatten_namespace(args, action_yes_dests=action_yes_dests)
return vars(args)
return parse_vars_from_line
@@ -144,25 +146,25 @@ def test_simple(self, parse_vars_from_line):
"progress": False,
}
- assert parse_vars_from_line("--error", "subcommand", "--critical") == {
+ assert parse_vars_from_line("--error", "subcmd", "--critical") == {
"append": [],
"lock_wait": 1,
"log_level": "critical",
"progress": False,
"foo_bar": False,
- "func": 1234,
+ "subcommand": "subcmd",
}
with pytest.raises(SystemExit):
- parse_vars_from_line("--foo-bar", "subcommand")
+ parse_vars_from_line("--foo-bar", "subcmd")
- assert parse_vars_from_line("--append=foo", "--append", "bar", "subcommand", "--append", "baz") == {
+ assert parse_vars_from_line("--append=foo", "--append", "bar", "subcmd", "--append", "baz") == {
"append": ["foo", "bar", "baz"],
"lock_wait": 1,
"log_level": "warning",
"progress": False,
"foo_bar": False,
- "func": 1234,
+ "subcommand": "subcmd",
}
@pytest.mark.parametrize("position", ("before", "after", "both"))
@@ -171,7 +173,7 @@ def test_flag_position_independence(self, parse_vars_from_line, position, flag,
line = []
if position in ("before", "both"):
line.append(flag)
- line.append("subcommand")
+ line.append("subcmd")
if position in ("after", "both"):
line.append(flag)
@@ -181,7 +183,7 @@ def test_flag_position_independence(self, parse_vars_from_line, position, flag,
"log_level": "warning",
"progress": False,
"foo_bar": False,
- "func": 1234,
+ "subcommand": "subcmd",
args_key: args_value,
}
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
diff --git a/src/borg/testsuite/compress_test.py b/src/borg/testsuite/compress_test.py
index 9ec9f1046e..62ef59f51b 100644
--- a/src/borg/testsuite/compress_test.py
+++ b/src/borg/testsuite/compress_test.py
@@ -1,11 +1,12 @@
-import argparse
import os
import zlib
import pytest
-from ..compress import get_compressor, Compressor, CompressionSpec, CNONE, ZLIB, LZ4, LZMA, ZSTD, Auto
+from ..compress import get_compressor, Compressor, CNONE, ZLIB, LZ4, LZMA, ZSTD, Auto
+from ..helpers import CompressionSpec
from ..constants import ROBJ_FILE_STREAM, ROBJ_ARCHIVE_META
+from ..helpers.argparsing import ArgumentTypeError
DATA = b"fooooooooobaaaaaaaar" * 10
params = dict(name="zlib", level=6)
@@ -209,7 +210,7 @@ def test_specified_compression_level(c_type, c_name, c_levels):
@pytest.mark.parametrize("invalid_spec", ["", "lzma,9,invalid", "invalid"])
def test_invalid_compression_level(invalid_spec):
- with pytest.raises(argparse.ArgumentTypeError):
+ with pytest.raises(ArgumentTypeError):
CompressionSpec(invalid_spec)
diff --git a/src/borg/testsuite/helpers/parseformat_test.py b/src/borg/testsuite/helpers/parseformat_test.py
index c90c55920b..82026f0b0f 100644
--- a/src/borg/testsuite/helpers/parseformat_test.py
+++ b/src/borg/testsuite/helpers/parseformat_test.py
@@ -1,11 +1,12 @@
import base64
import os
-from argparse import ArgumentTypeError
+
from datetime import datetime, timezone
import pytest
from ...constants import * # NOQA
+from ...helpers.argparsing import ArgumentTypeError
from ...helpers.parseformat import (
bin_to_hex,
binary_to_json,
diff --git a/src/borg/testsuite/patterns_test.py b/src/borg/testsuite/patterns_test.py
index f6fd602c57..be2bc4c07e 100644
--- a/src/borg/testsuite/patterns_test.py
+++ b/src/borg/testsuite/patterns_test.py
@@ -1,10 +1,10 @@
-import argparse
import io
import os.path
import sys
import pytest
+from ..helpers.argparsing import ArgumentTypeError
from ..patterns import PathFullPattern, PathPrefixPattern, FnmatchPattern, ShellPattern, RegexPattern
from ..patterns import load_exclude_file, load_pattern_file
from ..patterns import parse_pattern, PatternMatcher
@@ -491,7 +491,7 @@ def test_load_invalid_patterns_from_file(tmpdir, lines):
with patternfile.open("wt") as fh:
fh.write("\n".join(lines))
filename = str(patternfile)
- with pytest.raises(argparse.ArgumentTypeError):
+ with pytest.raises(ArgumentTypeError):
roots = []
inclexclpatterns = []
with open(filename) as f: