diff --git a/docs/usage/create.rst b/docs/usage/create.rst
index ee02f0be9..0f42aa887 100644
--- a/docs/usage/create.rst
+++ b/docs/usage/create.rst
@@ -89,6 +89,9 @@ Examples
$ find ~ -size -1000k | borg create --paths-from-stdin small-files-only
# Use --paths-from-command with find to back up files from only a given user
$ borg create --paths-from-command joes-files -- find /srv/samba/shared -user joe
+ # Use --paths-from-shell-command with find to back up a few files from only a given user -
+ # BE VERY CAREFUL AND ONLY USE TRUSTED INPUT FOR THE SHELL COMMAND!
+ $ borg create --paths-from-shell-command some-of-joes-files -- "find /srv/samba/shared -user joe | head"
# Use --paths-from-stdin with --paths-delimiter (for example, for filenames with newlines in them)
$ find ~ -size -1000k -print0 | borg create \
--paths-from-stdin \
diff --git a/docs/usage/general/config.rst.inc b/docs/usage/general/config.rst.inc
new file mode 100644
index 000000000..7c6028467
--- /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 10455252e..d684012ec 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 3cd396f05..ec61ef6b5 100644
--- a/docs/usage/usage_general.rst.inc
+++ b/docs/usage/usage_general.rst.inc
@@ -10,6 +10,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 bd175a5cb..b8355601a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,8 @@ dependencies = [
"shtab>=1.8.0",
"backports-zstd; python_version < '3.14'", # for python < 3.14.
"xxhash>=2.0.0",
+ "jsonargparse @ https://github.com/omni-us/jsonargparse/zipball/main", # pull it from there until it is on pypi
+ "PyYAML>=6.0.2", # we need to register our types with yaml, jsonargparse uses yaml for config files
]
[project.optional-dependencies]
@@ -259,7 +261,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 e5cd36cd1..cc2ada4ff 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 5da779e80..51317ec4c 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 f7bae4d41..3b71b01c6 100644
--- a/scripts/make.py
+++ b/scripts/make.py
@@ -9,6 +9,7 @@ import textwrap
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 @@ class BuildUsage:
is_subcommand = False
choices = {}
for action in parser._actions:
- if action.choices is not None and "SubParsersAction" in str(action.__class__):
+ if action.choices is not None and "SubCommands" in str(action.__class__):
is_subcommand = True
for cmd, parser in action.choices.items():
choices[prefix + cmd] = parser
@@ -100,17 +101,18 @@ class BuildUsage:
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 @@ class BuildUsage:
# (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 @@ class BuildUsage:
)
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 @@ class BuildUsage:
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 @@ class BuildMan:
is_subcommand = False
choices = {}
for action in parser._actions:
- if action.choices is not None and "SubParsersAction" in str(action.__class__):
+ if action.choices is not None and "SubCommands" in str(action.__class__):
is_subcommand = True
for cmd, parser in action.choices.items():
choices[prefix + cmd] = parser
@@ -349,7 +353,7 @@ class BuildMan:
self.write_heading(write, "SYNOPSIS")
if is_intermediary:
- subparsers = [action for action in parser._actions if "SubParsersAction" in str(action.__class__)][0]
+ subparsers = [action for action in parser._actions if "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 @@ class BuildMan:
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 509c46ddb..61f1f0bfa 100644
--- a/src/borg/archive.py
+++ b/src/borg/archive.py
@@ -25,7 +25,6 @@ from . import xattr
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 HardLinkManager
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 834bf7b5c..591f02913 100644
--- a/src/borg/archiver/__init__.py
+++ b/src/borg/archiver/__init__.py
@@ -15,7 +15,6 @@ else:
sys.exit(2) # == EXIT_ERROR
try:
- import argparse
import faulthandler
import functools
import inspect
@@ -40,12 +39,13 @@ try:
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 @@ STATS_HEADER = " Original size Deduplicated size"
PURE_PYTHON_MSGPACK_WARNING = "Using a pure-python msgpack! This will result in lower performance."
-def get_func(args):
- # This works around https://bugs.python.org/issue9351
- # func is used at the leaf parsers of the argparse parser tree,
- # fallback_func at next level towards the root,
- # fallback2_func at the 2nd next level (which is root in our case).
- for name in "func", "fallback_func", "fallback2_func":
- func = getattr(args, name, None)
- if func is not None:
- return func
- raise Exception("expected func attributes not found")
-
-
from .analyze_cmd import AnalyzeMixIn
from .benchmark_cmd import BenchmarkMixIn
from .check_cmd import CheckMixIn
@@ -191,62 +179,45 @@ class Archiver(
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 @@ class Archiver(
"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,14 @@ class Archiver(
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)
+ args = flatten_namespace(args)
+
+ # Ensure list defaults previously handled by set_defaults are present
+ for list_attr in ("paths", "patterns", "pattern_roots"):
+ if getattr(args, list_attr, None) is None:
+ setattr(args, list_attr, [])
+
+ 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 +360,7 @@ class Archiver(
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 +378,24 @@ class Archiver(
if value:
setattr(args, name, [replace_placeholders(elem) for elem in value])
+ args.func = func
+
return args
+ def get_func(self, args, parser):
+ if not getattr(args, "subcommand", None):
+ return functools.partial(self.do_maincommand_help, parser)
+
+ method_name = "do_" + args.subcommand.replace(" ", "_").replace("-", "_")
+ func = getattr(self, method_name, None)
+ if func is not None:
+ if method_name == "do_help":
+ return functools.partial(func, parser)
+ return func
+
+ # fallback to general help for e.g., "borg key"
+ return functools.partial(self.do_maincommand_help, parser)
+
def prerun_checks(self, logger, is_serve):
selftest(logger)
@@ -486,7 +428,7 @@ class Archiver(
def run(self, args):
os.umask(args.umask) # early, before opening files
self.lock_wait = args.lock_wait
- func = get_func(args)
+ func = args.func
# do not use loggers before this!
is_serve = func == self.do_serve
self.log_json = args.log_json and not is_serve
@@ -633,7 +575,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 673e53d41..19f08c319 100644
--- a/src/borg/archiver/_common.py
+++ b/src/borg/archiver/_common.py
@@ -7,8 +7,9 @@ from ..archive import Archive
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 e55609588..3db076aaa 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 ..archive import Archive
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 @@ class AnalyzeMixIn:
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 cf346e65a..1903909b8 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 @@ import time
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
@@ -303,8 +302,6 @@ class BenchmarkMixIn:
else:
print(f"{spec:<24} {number_kdf:<10} {dt:.3f}s")
- from ..compress import CompressionSpec
-
if not args.json:
print("Compression ====================================================")
else:
@@ -355,18 +352,12 @@ class BenchmarkMixIn:
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(
"""
@@ -409,16 +400,12 @@ class BenchmarkMixIn:
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.")
@@ -434,14 +421,8 @@ class BenchmarkMixIn:
- 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 e78aef563..83d1f6e29 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 @@ class CheckMixIn:
repair=args.repair,
find_lost_archives=args.find_lost_archives,
match=args.match_archives,
- sort_by=args.sort_by or "ts",
+ sort_by=args.sort_by or "timestamp",
first=args.first,
last=args.last,
older=args.older,
@@ -182,16 +182,8 @@ class CheckMixIn:
``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 1cd07a0ba..c25bb03e1 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 ..archive import Archive
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 @@ class CompactMixIn:
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 abcc04f79..4b973fafc 100644
--- a/src/borg/archiver/completion_cmd.py
+++ b/src/borg/archiver/completion_cmd.py
@@ -50,8 +50,6 @@ The following argument types have intelligent, context-aware completion:
- Suggests common file size values (500M, 1G, 10G, 100G, 1T, etc.)
"""
-import argparse
-
import shtab
from ._common import process_epilog
@@ -62,12 +60,14 @@ from ..helpers import (
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.time import timestamp
-from ..compress import CompressionSpec
from ..helpers.parseformat import partial_format
from ..manifest import AI_HUMAN_SORT_KEYS
@@ -341,7 +341,6 @@ _borg_help_topics() {
}
"""
-
# Global zsh preamble providing dynamic completion for aid: archive IDs.
#
# Notes:
@@ -628,12 +627,11 @@ _borg_help_topics() {
"""
-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 +640,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 +690,7 @@ class CompletionMixIn:
# 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 +730,14 @@ class CompletionMixIn:
}
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
+
+ if args.shell == "bash":
+ preambles = [bash_preamble]
+ elif args.shell == "zsh":
+ preambles = [zsh_preamble]
+ else:
+ preambles = []
+ script = parser.get_completion_script(f"shtab-{args.shell}", preambles=preambles)
print(script)
def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
@@ -750,16 +754,10 @@ class CompletionMixIn:
"""
)
- 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 426adc0ed..92ea929aa 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 BackupError, BackupOSError, BackupItemExcluded, backup_io,
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 sig_int, ignore_sigint
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
@@ -92,13 +91,24 @@ class CreateMixIn:
else:
status = "+" # included
self.print_file_status(status, path)
- elif args.paths_from_command or args.paths_from_stdin:
+ elif args.paths_from_command or args.paths_from_shell_command or args.paths_from_stdin:
paths_sep = eval_escapes(args.paths_delimiter) if args.paths_delimiter is not None else "\n"
- if args.paths_from_command:
+ if args.paths_from_command or args.paths_from_shell_command:
try:
env = prepare_subprocess_env(system=True)
- proc = subprocess.Popen( # nosec B603
- args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=None if is_win32 else ignore_sigint
+ if args.paths_from_shell_command:
+ # Use shell=True to support pipes, redirection, etc.
+ shell = True
+ cmd = " ".join(args.paths)
+ else:
+ shell = False
+ cmd = args.paths
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ env=env,
+ shell=shell, # nosec B602
+ preexec_fn=None if is_win32 else ignore_sigint,
)
except (FileNotFoundError, PermissionError) as e:
raise CommandError(f"Failed to execute command: {e}")
@@ -132,12 +142,13 @@ class CreateMixIn:
self.print_file_status(status, path)
if not dry_run and status is not None:
fso.stats.files_stats[status] += 1
- if args.paths_from_command:
+ if args.paths_from_command or args.paths_from_shell_command:
rc = proc.wait()
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
@@ -678,7 +689,6 @@ class CreateMixIn:
macOS examples are the apfs mounts of a typical macOS installation.
Therefore, when using ``--one-file-system``, you should double-check that the backup works as intended.
-
.. _list_item_flags:
Item flags
@@ -763,24 +773,16 @@ class CreateMixIn:
If you need more control and you want to give every single fs object path
to borg (maybe implementing your own recursion or your own rules), you can use
- ``--paths-from-stdin`` or ``--paths-from-command`` (with the latter, borg will
- fail to create an archive should the command fail).
+ ``--paths-from-stdin``, ``--paths-from-command`` or ``--paths-from-shell-command``
+ (with the latter two, borg will fail to create an archive should the command fail).
Borg supports paths with the slashdot hack to strip path prefixes here also.
So, be careful not to unintentionally trigger that.
"""
)
- 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).
@@ -830,7 +832,7 @@ class CreateMixIn:
"--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)",
@@ -851,6 +853,11 @@ class CreateMixIn:
action="store_true",
help="interpret PATH as command and treat its output as ``--paths-from-stdin``",
)
+ subparser.add_argument(
+ "--paths-from-shell-command",
+ action="store_true",
+ help="interpret PATH as shell command and treat its output as ``--paths-from-stdin``",
+ )
subparser.add_argument(
"--paths-delimiter",
action=Highlander,
diff --git a/src/borg/archiver/debug_cmd.py b/src/borg/archiver/debug_cmd.py
index ed3d4ee51..723d413af 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 @@ class DebugMixIn:
what you are doing or if a trusted developer tells you what to do."""
)
- subparser = subparsers.add_parser(
- "debug",
+ subparser = ArgumentParser(
parents=[mid_common_parser],
- add_help=False,
description="debugging command (not intended for normal use)",
epilog=debug_epilog,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- help="debugging command (not intended for normal use)",
)
+ subparsers.add_subcommand("debug", subparser, help="debugging command (not intended for normal use)")
- debug_parsers = subparser.add_subparsers(title="required arguments", metavar="")
- subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser))
+ debug_parsers = subparser.add_subcommands(required=False, title="required arguments", metavar="")
debug_info_epilog = process_epilog(
"""
@@ -339,32 +333,22 @@ class DebugMixIn:
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 @@ class DebugMixIn:
This command dumps all metadata of an archive in a decoded form to a file.
"""
)
- subparser = debug_parsers.add_parser(
- "dump-archive",
- parents=[common_parser],
- 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 @@ class DebugMixIn:
This command dumps manifest metadata of a repository in a decoded form to a file.
"""
)
- subparser = debug_parsers.add_parser(
- "dump-manifest",
- parents=[common_parser],
- 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 @@ class DebugMixIn:
This command dumps raw (but decrypted and decompressed) repo objects to files.
"""
)
- subparser = debug_parsers.add_parser(
- "dump-repo-objs",
- parents=[common_parser],
- 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 @@ class DebugMixIn:
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 @@ class DebugMixIn:
This command parses the object file into metadata (as json) and uncompressed data.
"""
)
- subparser = debug_parsers.add_parser(
- "parse-obj",
- parents=[common_parser],
- 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 @@ class DebugMixIn:
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 @@ class DebugMixIn:
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 @@ class DebugMixIn:
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 @@ class DebugMixIn:
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 @@ class DebugMixIn:
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 49c913f93..985abb475 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
@@ -80,16 +80,8 @@ class DeleteMixIn:
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 0ea0954e8..22d424375 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 ..archive import Archive
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 @@ class DiffMixIn:
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 @@ class DiffMixIn:
The following keys are always available:
-
"""
)
+ BaseFormatter.keys_help()
@@ -268,7 +268,7 @@ class DiffMixIn:
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 @@ class DiffMixIn:
}
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 a3885a0c1..eaa6c4049 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 remove_surrogates
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 @@ class ExtractMixIn:
"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 @@ class ExtractMixIn:
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 a73ef7e90..d0a240b4b 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 @@ class HelpMixIn:
do_maincommand_help = do_subcommand_help
def build_parser_help(self, subparsers, common_parser, mid_common_parser, parser):
- subparser = subparsers.add_parser(
- "help", parents=[common_parser], add_help=False, description="Extra help", help="Extra help"
- )
+ subparser = ArgumentParser(parents=[common_parser], description="Extra help")
+ subparsers.add_subcommand("help", subparser, help="Extra help")
subparser.add_argument("--epilog-only", dest="epilog_only", action="store_true")
subparser.add_argument("--usage-only", dest="usage_only", action="store_true")
- subparser.set_defaults(func=functools.partial(self.do_help, parser, subparsers.choices))
subparser.add_argument("topic", metavar="TOPIC", type=str, nargs="?", help="additional help on TOPIC")
diff --git a/src/borg/archiver/info_cmd.py b/src/borg/archiver/info_cmd.py
index dbb8b3ab6..d88493a42 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 ._common import with_repository
from ..archive import Archive
from ..constants import * # NOQA
from ..helpers import format_timedelta, json_print, basic_json_data, archivename_validator
+from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest
from ..logger import create_logger
@@ -78,16 +78,8 @@ class InfoMixIn:
= 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 5d7a9503e..5bdfc0b5d 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 AESOCBRepoKey, CHPORepoKey, Blake2AESOCBRepoKey, Blake2
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 @@ logger = create_logger(__name__)
class KeysMixIn:
@with_repository(compatibility=(Manifest.Operation.CHECK,))
- def do_change_passphrase(self, args, repository, manifest):
+ def do_key_change_passphrase(self, args, repository, manifest):
"""Changes the repository key file passphrase."""
key = manifest.key
if not hasattr(key, "change_passphrase"):
@@ -30,7 +29,7 @@ class KeysMixIn:
logger.info("Key location: %s", key.find_key())
@with_repository(exclusive=True, manifest=True, cache=True, compatibility=(Manifest.Operation.CHECK,))
- def do_change_location(self, args, repository, manifest, cache):
+ def do_key_change_location(self, args, repository, manifest, cache):
"""Changes the repository key location."""
key = manifest.key
if not hasattr(key, "change_passphrase"):
@@ -120,18 +119,12 @@ class KeysMixIn:
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 @@ class KeysMixIn:
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 @@ class KeysMixIn:
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 @@ class KeysMixIn:
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 @@ class KeysMixIn:
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 e3c13679f..98abd18c3 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 ..archive import Archive
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 @@ class ListMixIn:
The following keys are always available:
-
"""
)
+ BaseFormatter.keys_help()
@@ -102,16 +102,8 @@ class ListMixIn:
)
+ 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 1739da6df..971fb653e 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 @@ class LocksMixIn:
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 @@ class LocksMixIn:
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 d37d8cb47..6111df006 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 ..constants import * # NOQA
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 @@ class MountMixIn:
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 @@ class MountMixIn:
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 @@ class MountMixIn:
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 @@ class MountMixIn:
def _define_borg_mount(self, parser):
from ._common import define_exclusion_group, define_archive_filters_group
- parser.set_defaults(func=self.do_mount)
parser.add_argument("mountpoint", metavar="MOUNTPOINT", type=str, help="where to mount the filesystem")
parser.add_argument(
"-f", "--foreground", dest="foreground", action="store_true", help="stay in foreground, do not daemonize"
diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py
index 481b2014e..19b8426c9 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
@@ -9,6 +8,7 @@ from ._common import with_repository, Highlander
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 @@ class PruneMixIn:
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 4ab928e25..49b7b9cfa 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 @@ class RecreateMixIn:
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 bdb338843..b1d482961 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 @@ class RenameMixIn:
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 a5aeb9a54..8fb26e2f7 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 @@ class RepoCompressMixIn:
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 07b60fa8d..05235ce9d 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 @@ class RepoCreateMixIn:
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 aa2b531ea..d9774ee35 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 CancelledByUser
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 @@ class RepoDeleteMixIn:
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 0b11ed6e8..d8feeaf8c 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 @@ class RepoInfoMixIn:
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 6f5c5ae47..5ae41ea95 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 @@ import sys
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 @@ class RepoListMixIn:
The following keys are always available:
-
"""
)
+ BaseFormatter.keys_help()
@@ -85,16 +84,10 @@ class RepoListMixIn:
)
+ 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 45c1646a2..37ed12d88 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 borgstore.store import ItemInfo
from ._common import with_repository, Highlander
from ..constants import * # NOQA
from ..helpers import parse_file_size, format_file_size
+from ..helpers.argparsing import ArgumentParser
from ..logger import create_logger
@@ -82,20 +82,13 @@ class RepoSpaceMixIn:
$ borg compact -v # only this actually frees space of deleted archives
$ borg repo-space --reserve 1G # reserve space again for next time
-
Reserved space is always rounded up to full reservation blocks of 64 MiB.
"""
)
- subparser = subparsers.add_parser(
- "repo-space",
- 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 f8c170067..36661758e 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 @@ class ServeMixIn:
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 3dffbd803..f57b89f34 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 @@ class TagMixIn:
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 = 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(
- "--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.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 af67d6d10..bb3ac41e3 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
@@ -6,7 +5,6 @@ import stat
import tarfile
from ..archive import Archive, TarfileObjectProcessors, ChunksProcessor
-from ..compress import CompressionSpec
from ..constants import * # NOQA
from ..helpers import HardLinkManager, IncludePatternNeverMatchedWarning
from ..helpers import ProgressIndicatorPercent
@@ -14,11 +12,12 @@ from ..helpers import dash_open
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
@@ -86,6 +85,7 @@ class TarMixIn:
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
@@ -383,16 +383,10 @@ class TarMixIn:
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",
@@ -459,16 +453,10 @@ class TarMixIn:
``--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 99813039d..71088f8de 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 @@ class TransferMixIn:
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 @@ class TransferMixIn:
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 @@ class TransferMixIn:
borg --repo=DST_REPO transfer --other-repo=SRC_REPO # do it!
borg --repo=DST_REPO transfer --other-repo=SRC_REPO --dry-run # check! anything left?
-
Data migration / upgrade from borg 1.x
++++++++++++++++++++++++++++++++++++++
@@ -330,19 +327,12 @@ class TransferMixIn:
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 a0455518f..8a37e6fb5 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 @@ class UnDeleteMixIn:
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 36c52a997..409baaeb7 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 @@ class VersionMixIn:
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 c8a271a1c..d627e6e20 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 ba7d124b3..faf303127 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 15c1e13d4..10cf6c4bd 100644
--- a/src/borg/fuse.py
+++ b/src/borg/fuse.py
@@ -370,6 +370,7 @@ class FuseBackend:
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 7902d5bb6..12db71b2a 100644
--- a/src/borg/helpers/__init__.py
+++ b/src/borg/helpers/__init__.py
@@ -25,13 +25,14 @@ from .fs import O_, flags_dir, flags_special_follow, flags_special, flags_base,
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 000000000..9a073705b
--- /dev/null
+++ b/src/borg/helpers/argparsing.py
@@ -0,0 +1,152 @@
+"""
+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, ActionSubCommands, SUPPRESS, REMAINDER # noqa: F401
+from jsonargparse.typing import register_type, PositiveInt # 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 flatten_namespace(ns: Any) -> 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.
+ """
+ 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:
+ 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 00dc14e5b..b6c0c7a21 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 @@ from ..logger import create_logger
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 @@ if TYPE_CHECKING:
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 @@ replace_placeholders = PlaceholderReplacer()
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 @@ class FileSize(int):
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 location_validator(proto=None, other=False):
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
@@ -1307,7 +1394,7 @@ def prepare_dump_dict(d):
return decode(d)
-class Highlander(argparse.Action):
+class Highlander(Action):
"""make sure some option is only given once"""
def __init__(self, *args, **kwargs):
@@ -1316,7 +1403,7 @@ class Highlander(argparse.Action):
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)
@@ -1326,7 +1413,7 @@ class MakePathSafeAction(Highlander):
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 b98d7fe1f..49a036c8f 100644
--- a/src/borg/helpers/time.py
+++ b/src/borg/helpers/time.py
@@ -37,6 +37,8 @@ def utcfromtimestampns(ts_ns: int) -> datetime:
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_ns = safe_ns(os.stat(s).st_mtime_ns)
diff --git a/src/borg/hlfuse.py b/src/borg/hlfuse.py
index c08475961..d05cd7127 100644
--- a/src/borg/hlfuse.py
+++ b/src/borg/hlfuse.py
@@ -193,6 +193,7 @@ class FuseBackend:
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 c1f8f5727..bfbf69712 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 collections import namedtuple
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 @@ class ArgparsePatternFileAction(argparse.Action):
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 974becf33..1819d9ca7 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 ArgumentParser, flatten_namespace
def test_bad_filters(archiver):
@@ -93,45 +93,35 @@ class TestCommonOptions:
@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)
+ args = flatten_namespace(args)
return vars(args)
return parse_vars_from_line
@@ -144,25 +134,25 @@ class TestCommonOptions:
"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 +161,7 @@ class TestCommonOptions:
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 +171,7 @@ class TestCommonOptions:
"log_level": "warning",
"progress": False,
"foo_bar": False,
- "func": 1234,
+ "subcommand": "subcmd",
args_key: args_value,
}
diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py
index f99b187a6..5b06b7dfd 100644
--- a/src/borg/testsuite/archiver/create_cmd_test.py
+++ b/src/borg/testsuite/archiver/create_cmd_test.py
@@ -418,6 +418,21 @@ def test_create_paths_from_command_missing_command(archivers, request):
assert output.endswith("No command given." + os.linesep)
+@pytest.mark.skipif(is_win32, reason="shell patterns not supported on Windows")
+def test_create_paths_from_shell_command(archivers, request):
+ archiver = request.getfixturevalue(archivers)
+ cmd(archiver, "repo-create", RK_ENCRYPTION)
+ create_regular_file(archiver.input_path, "file1", size=1024 * 80)
+ create_regular_file(archiver.input_path, "file2", size=1024 * 80)
+ create_regular_file(archiver.input_path, "file3", size=1024 * 80)
+ input_data = "input/file1\ninput/file2\ninput/file3"
+ # Use a shell pipe to test that shell=True works correctly.
+ cmd(archiver, "create", "--paths-from-shell-command", "test", "--", f"echo '{input_data}' | head -n 2")
+ archive_list = cmd(archiver, "list", "test", "--json-lines")
+ paths = [json.loads(line)["path"] for line in archive_list.split("\n") if line]
+ assert paths == ["input/file1", "input/file2"]
+
+
def test_create_without_root(archivers, request):
"""test create without a root"""
archiver = request.getfixturevalue(archivers)
diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py
index 06be79730..2ada635c2 100644
--- a/src/borg/testsuite/archiver/tag_cmd_test.py
+++ b/src/borg/testsuite/archiver/tag_cmd_test.py
@@ -15,7 +15,7 @@ def test_tag_set(archivers, request):
assert "tags: aa." in output
output = cmd(archiver, "tag", "-a", "archive", "--set", "bb")
assert "tags: bb." in output
- output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", "--set", "aa")
+ output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", "aa")
assert "tags: aa,bb." in output # sorted!
output = cmd(archiver, "tag", "-a", "archive", "--set", "")
assert "tags: ." in output # no tags!
@@ -46,7 +46,7 @@ def test_tag_set_noclobber_special(archivers, request):
output = cmd(archiver, "tag", "-a", "archive", "--set", "clobber")
assert "tags: @PROT." in output
# it is possible though to use --set if the existing special tags are also given:
- output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "--set", "@PROT")
+ output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "@PROT")
assert "tags: @PROT,noclobber." in output
diff --git a/src/borg/testsuite/compress_test.py b/src/borg/testsuite/compress_test.py
index 9ec9f1046..62ef59f51 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 c90c55920..82026f0b0 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 f6fd602c5..be2bc4c07 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: