Skip to content

Commit b73b5fe

Browse files
archiver: replace CommonOptions suffix hack with jsonargparse-native approach
The old code worked around argparse's flat namespace by appending _maincommand / _midcommand / _subcommand suffixes to every common option's dest (e.g. log_level_subcommand), then resolving them with CommonOptions.resolve() after parsing. This polluted config key names and env var names (BORG_LOG_LEVEL_SUBCOMMAND instead of BORG_LOG_LEVEL). jsonargparse nests subcommand arguments automatically, so the workaround is no longer needed. Each parser level now registers common options with their clean dest name. flatten_namespace() is updated to a two-pass depth-first walk so the most-specific (innermost) value wins naturally: borg --info create --debug → log_level = "debug" (subcommand wins) borg --info create → log_level = "info" (top-level fills gap) For append-action options (--debug-topic) values from all levels are merged (outer + inner) to preserve the accumulation behaviour.
1 parent 5b9b450 commit b73b5fe

File tree

3 files changed

+47
-106
lines changed

3 files changed

+47
-106
lines changed

src/borg/archiver/__init__.py

Lines changed: 24 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from ..helpers import format_file_size
4040
from ..helpers import remove_surrogates, text_to_json
4141
from ..helpers import DatetimeWrapper, replace_placeholders
42-
from ..helpers.argparsing import flatten_namespace, ArgumentTypeError, ArgumentParser, Namespace, SUPPRESS
42+
from ..helpers.argparsing import flatten_namespace, ArgumentTypeError, ArgumentParser, SUPPRESS
4343
from ..helpers import is_slow_msgpack, is_supported_msgpack, sysinfo
4444
from ..helpers import signal_handler, raising_signal_handler, SigHup, SigTerm
4545
from ..helpers import ErrorIgnoringTextIOWrapper
@@ -178,62 +178,45 @@ def preprocess_args(self, args):
178178

179179
class CommonOptions:
180180
"""
181-
Support class to allow specifying common options directly after the top-level command.
181+
Support class to allow specifying common options at multiple levels of the command hierarchy.
182182
183-
Normally options can only be specified on the parser defining them, which means
184-
that generally speaking *all* options go after all sub-commands. This is annoying
185-
for common options in scripts, e.g. --remote-path or logging options.
183+
Common options (e.g. --log-level, --repo) can be placed anywhere in the command line:
186184
187-
This class allows adding the same set of options to both the top-level parser
188-
and the final sub-command parsers (but not intermediary sub-commands, at least for now).
185+
borg --info create ... # before the subcommand
186+
borg create --info ... # after the subcommand
187+
borg --info debug info --debug # at both levels of a two-level command
189188
190-
It does so by giving every option's target name ("dest") a suffix indicating its level
191-
-- no two options in the parser hierarchy can have the same target --
192-
then, after parsing the command line, multiple definitions are resolved.
189+
Each parser level registers the same options with the same dest names.
190+
Defaults are only provided on the top-level parser; all sub-parsers use SUPPRESS so
191+
that unset options don't appear in the namespace at all.
193192
194-
Defaults are handled by only setting them on the top-level parser and setting
195-
a sentinel object in all sub-parsers, which then allows one to discern which parser
196-
supplied the option.
193+
flatten_namespace() handles precedence: it walks sub-namespaces depth-first, so the
194+
most-specific (innermost) value wins. For append-action options (e.g. --debug-topic)
195+
it merges lists from all levels.
197196
"""
198197

199-
def __init__(self, define_common_options, suffix_precedence):
198+
def __init__(self, define_common_options):
200199
"""
201200
*define_common_options* should be a callable taking one argument, which
202-
will be a argparse.Parser.add_argument-like function.
201+
will be an argparse.Parser.add_argument-like function.
203202
204203
*define_common_options* will be called multiple times, and should call
205204
the passed function to define common options exactly the same way each time.
206-
207-
*suffix_precedence* should be a tuple of the suffixes that will be used.
208-
It is ordered from lowest precedence to highest precedence:
209-
An option specified on the parser belonging to index 0 is overridden if the
210-
same option is specified on any parser with a higher index.
211205
"""
212206
self.define_common_options = define_common_options
213-
self.suffix_precedence = suffix_precedence
214-
215-
# Maps suffixes to sets of target names.
216-
# E.g. common_options["_subcommand"] = {..., "log_level", ...}
217-
self.common_options = dict()
218-
# Set of options with the 'append' action.
219-
self.append_options = set()
220207
# This is the sentinel object that replaces all default values in parsers
221208
# below the top-level parser.
222209
self.default_sentinel = object()
223210

224-
def add_common_group(self, parser, suffix, provide_defaults=False):
211+
def add_common_group(self, parser, provide_defaults=False):
225212
"""
226213
Add common options to *parser*.
227214
228-
*provide_defaults* must only be True exactly once in a parser hierarchy,
229-
at the top level, and False on all lower levels. The default is chosen
230-
accordingly.
231-
232-
*suffix* indicates the suffix to use internally. It also indicates
233-
which precedence the *parser* has for common options. See *suffix_precedence*
234-
of __init__.
215+
*provide_defaults* must be True exactly once in a parser hierarchy (the top-level
216+
parser) and False on all sub-parsers. Sub-parsers get SUPPRESS as the default so
217+
that an unspecified option produces no attribute, leaving the top-level default intact
218+
after flatten_namespace() merges the namespaces.
235219
"""
236-
assert suffix in self.suffix_precedence
237220

238221
def add_argument(*args, **kwargs):
239222
if "dest" in kwargs:
@@ -248,20 +231,9 @@ def add_argument(*args, **kwargs):
248231
"append",
249232
)
250233
is_append = kwargs["action"] == "append"
251-
if is_append:
252-
self.append_options.add(kwargs["dest"])
253-
assert (
254-
kwargs["default"] == []
255-
), "The default is explicitly constructed as an empty list in resolve()"
256-
else:
257-
self.common_options.setdefault(suffix, set()).add(kwargs["dest"])
258-
kwargs["dest"] += suffix
259234
if not provide_defaults:
260-
# Interpolate help now, in case the %(default)d (or so) is mentioned,
235+
# Interpolate help now, in case %(default)d (or similar) is mentioned,
261236
# to avoid producing incorrect help output.
262-
# Assumption: Interpolated output can safely be interpolated again,
263-
# which should always be the case.
264-
# Note: We control all inputs.
265237
kwargs["help"] = kwargs["help"] % kwargs
266238
if not is_append:
267239
kwargs["default"] = SUPPRESS
@@ -271,66 +243,23 @@ def add_argument(*args, **kwargs):
271243
common_group = parser.add_argument_group("Common options")
272244
self.define_common_options(add_argument)
273245

274-
def resolve(self, args: Namespace): # Namespace has "in" but otherwise is not like a dict.
275-
"""
276-
Resolve the multiple definitions of each common option to the final value.
277-
"""
278-
for suffix in self.suffix_precedence:
279-
# From highest level to lowest level, so the "most-specific" option wins, e.g.
280-
# "borg --debug create --info" shall result in --info being effective.
281-
for dest in self.common_options.get(suffix, []):
282-
# map_from is this suffix' option name, e.g. log_level_subcommand
283-
# map_to is the target name, e.g. log_level
284-
map_from = dest + suffix
285-
map_to = dest
286-
# Retrieve value; depending on the action it may not exist, but usually does
287-
# (store_const/store_true/store_false), either because the action implied a default
288-
# or a default is explicitly supplied.
289-
# Note that defaults on lower levels are replaced with default_sentinel.
290-
# Only the top level has defaults.
291-
value = getattr(args, map_from, self.default_sentinel)
292-
if value is not self.default_sentinel:
293-
# value was indeed specified on this level. Transfer value to target,
294-
# and un-clobber the args (for tidiness - you *cannot* use the suffixed
295-
# names for other purposes, obviously).
296-
setattr(args, map_to, value)
297-
try:
298-
delattr(args, map_from)
299-
except AttributeError:
300-
pass
301-
302-
# Options with an "append" action need some special treatment. Instead of
303-
# overriding values, all specified values are merged together.
304-
for dest in self.append_options:
305-
option_value = []
306-
for suffix in self.suffix_precedence:
307-
# Find values of this suffix, if any, and add them to the final list
308-
extend_from = dest + suffix
309-
if extend_from in args:
310-
values = getattr(args, extend_from)
311-
delattr(args, extend_from)
312-
option_value.extend(values)
313-
setattr(args, dest, option_value)
314-
315246
def build_parser(self):
316247
from ._common import define_common_options
317248

318249
parser = ArgumentParser(prog=self.prog, description="Borg - Deduplicated Backups", add_help=False)
319250
# paths and patterns must have an empty list as default everywhere
320-
parser.common_options = self.CommonOptions(
321-
define_common_options, suffix_precedence=("_maincommand", "_midcommand", "_subcommand")
322-
)
251+
parser.common_options = self.CommonOptions(define_common_options)
323252
parser.add_argument(
324253
"-V", "--version", action="version", version="%(prog)s " + __version__, help="show version number and exit"
325254
)
326255
parser.add_argument("--cockpit", dest="cockpit", action="store_true", help="Start the Borg TUI")
327-
parser.common_options.add_common_group(parser, "_maincommand", provide_defaults=True)
256+
parser.common_options.add_common_group(parser, provide_defaults=True)
328257

329258
common_parser = ArgumentParser(add_help=False, prog=self.prog)
330-
parser.common_options.add_common_group(common_parser, "_subcommand")
259+
parser.common_options.add_common_group(common_parser)
331260

332261
mid_common_parser = ArgumentParser(add_help=False, prog=self.prog)
333-
parser.common_options.add_common_group(mid_common_parser, "_midcommand")
262+
parser.common_options.add_common_group(mid_common_parser)
334263

335264
if parser.prog == "borgfs":
336265
return self.build_parser_borgfs(parser)
@@ -415,7 +344,6 @@ def parse_args(self, args=None):
415344
if getattr(args, list_attr, None) is None:
416345
setattr(args, list_attr, [])
417346

418-
parser.common_options.resolve(args)
419347
func = self.get_func(args, parser)
420348
if func == self.do_create and args.paths and args.paths_from_stdin:
421349
parser.error("Must not pass PATH with --paths-from-stdin.")

src/borg/helpers/argparsing.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ def flatten_namespace(ns: Any) -> Namespace:
1616
Recursively flattens a nested namespace into a single-level namespace.
1717
JSONArgparse uses nested namespaces for subcommands, whereas borg's
1818
internal dispatch and logic expect a flat namespace.
19+
20+
Inner (subcommand) values take precedence over outer (top-level) values.
21+
For list-typed values (append-action options like --debug-topic) that appear
22+
at multiple levels, the lists are merged: outer values first, inner values last.
1923
"""
2024
flat = Namespace()
2125

@@ -30,15 +34,27 @@ def flatten_namespace(ns: Any) -> Namespace:
3034
flat.subcommand = " ".join(subcmds)
3135

3236
def _flatten(source, target):
33-
items = (
37+
items = list(
3438
vars(source).items() if hasattr(source, "__dict__") else source.items() if hasattr(source, "items") else []
3539
)
40+
# First pass: recurse into sub-namespaces so inner (subcommand) values are set first.
3641
for k, v in items:
3742
if isinstance(v, Namespace) or type(v).__name__ == "Namespace":
3843
_flatten(v, target)
39-
else:
40-
if k != "subcommand" and not hasattr(target, k):
41-
setattr(target, k, v)
44+
# Second pass: apply this level's plain values.
45+
# - If not yet set: set it (inner already won via the first pass).
46+
# - If already set and both are lists: merge outer + inner (for append-action options).
47+
for k, v in items:
48+
if isinstance(v, Namespace) or type(v).__name__ == "Namespace":
49+
continue
50+
if k == "subcommand":
51+
continue
52+
existing = getattr(target, k, None)
53+
if existing is None:
54+
setattr(target, k, v)
55+
elif isinstance(existing, list) and isinstance(v, list):
56+
# Append-action options (e.g. --debug-topic): outer values come first.
57+
setattr(target, k, list(v) + list(existing))
4258

4359
_flatten(ns, flat)
4460
return flat

src/borg/testsuite/archiver/argparsing_test.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,7 @@ def define_common_options(add_common_option):
9494
@pytest.fixture
9595
def basic_parser(self):
9696
parser = ArgumentParser(prog="test", description="test parser", add_help=False)
97-
parser.common_options = Archiver.CommonOptions(
98-
self.define_common_options, suffix_precedence=("_level0", "_level1")
99-
)
97+
parser.common_options = Archiver.CommonOptions(self.define_common_options)
10098
return parser
10199

102100
@pytest.fixture
@@ -105,13 +103,13 @@ def subcommands(self, basic_parser):
105103

106104
@pytest.fixture
107105
def parser(self, basic_parser):
108-
basic_parser.common_options.add_common_group(basic_parser, "_level0", provide_defaults=True)
106+
basic_parser.common_options.add_common_group(basic_parser, provide_defaults=True)
109107
return basic_parser
110108

111109
@pytest.fixture
112110
def common_parser(self, parser):
113111
common_parser = ArgumentParser(add_help=False, prog="test")
114-
parser.common_options.add_common_group(common_parser, "_level1")
112+
parser.common_options.add_common_group(common_parser)
115113
return common_parser
116114

117115
@pytest.fixture
@@ -130,7 +128,6 @@ def parse_vars_from_line(*line):
130128
print(line)
131129
args = parser.parse_args(line)
132130
args = flatten_namespace(args)
133-
parser.common_options.resolve(args)
134131
return vars(args)
135132

136133
return parse_vars_from_line

0 commit comments

Comments
 (0)