diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 780f68b3a..e07aeaeec 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -1870,6 +1870,126 @@ class Archiver: print(warning, file=sys.stderr) return args + class CommonOptions: + """ + Support class to allow specifying common options directly after the top-level command. + + 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. + + 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). + + 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. + + Defaults are handled by only setting them on the top-level parser and setting + a sentinel object in all sub-parsers, which then allows to discern which parser + supplied the option. + """ + + def __init__(self, define_common_options, suffix_precedence): + """ + *define_common_options* should be a callable taking one argument, which + will be a 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): + """ + 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__. + """ + assert suffix in self.suffix_precedence + + def add_argument(*args, **kwargs): + if 'dest' in kwargs: + kwargs.setdefault('action', 'store') + assert kwargs['action'] in ('help', 'store_const', 'store_true', 'store_false', 'store', '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 and 'default' in kwargs: + # Interpolate help now, in case the %(default)d (or so) 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 + + 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) + delattr(args, map_from) + + # 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 + values = getattr(args, dest + suffix, []) + option_value.extend(values) + setattr(args, dest, option_value) + def build_parser(self): def process_epilog(epilog): epilog = textwrap.dedent(epilog).splitlines() @@ -1881,156 +2001,53 @@ class Archiver: epilog = [line for line in epilog if not line.startswith('.. man')] return '\n'.join(epilog) - class CommonOptions: - """ - Support class to allow specifying common options directly after the top-level command. - - 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. - - 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). - - It does so by giving every option's target name ("dest") a suffix indicating it's level - -- no two options in the parser hierarchy can have the same target -- - then, after parsing the command line, multiple definitions are resolved. - - Defaults are handled by only setting them on the top-level parser and setting - a sentinel object in all sub-parsers, which then allows to discern which parser - supplied the option. - """ - - # 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. - SUFFIX_PRECEDENCE = ('_maincommand', '_midcommand', '_subcommand') - - def __init__(self): - from collections import defaultdict - - # Maps suffixes to sets of target names. - # E.g. common_options["_subcommand"] = {..., "log_level", ...} - self.common_options = defaultdict(defaultdict) - self.append_options = set() - self.default_sentinel = object() - - def add_common_group(self, parser, suffix='_subcommand', 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. - """ - assert suffix in self.SUFFIX_PRECEDENCE - - def add_argument(*args, **kwargs): - if 'dest' in kwargs: - is_append = kwargs.get('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 and 'default' in kwargs: - # Interpolate help now, in case the %(default)d (or so) 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 - - common_group.add_argument(*args, **kwargs) - - common_group = parser.add_argument_group('Common options') - - add_argument('-h', '--help', action='help', help='show this help message and exit') - add_argument('--critical', dest='log_level', - action='store_const', const='critical', default='warning', - help='work on log level CRITICAL') - add_argument('--error', dest='log_level', - action='store_const', const='error', default='warning', - help='work on log level ERROR') - add_argument('--warning', dest='log_level', - action='store_const', const='warning', default='warning', - help='work on log level WARNING (default)') - add_argument('--info', '-v', '--verbose', dest='log_level', - action='store_const', const='info', default='warning', - help='work on log level INFO') - add_argument('--debug', dest='log_level', - action='store_const', const='debug', default='warning', - help='enable debug output, work on log level DEBUG') - add_argument('--debug-topic', dest='debug_topics', - action='append', metavar='TOPIC', default=[], - help='enable TOPIC debugging (can be specified multiple times). ' - 'The logger path is borg.debug. if TOPIC is not fully qualified.') - add_argument('-p', '--progress', dest='progress', action='store_true', - help='show progress information') - add_argument('--log-json', dest='log_json', action='store_true', - help='Output one JSON object per log line instead of formatted text.') - add_argument('--lock-wait', dest='lock_wait', type=int, metavar='N', default=1, - help='wait for the lock, but max. N seconds (default: %(default)d).') - add_argument('--show-version', dest='show_version', action='store_true', default=False, - help='show/log the borg version') - add_argument('--show-rc', dest='show_rc', action='store_true', default=False, - help='show/log the return code (rc)') - add_argument('--no-files-cache', dest='cache_files', action='store_false', - help='do not load/update the file metadata cache used to detect unchanged files') - add_argument('--umask', dest='umask', type=lambda s: int(s, 8), default=UMASK_DEFAULT, metavar='M', - help='set umask to M (local and remote, default: %(default)04o)') - add_argument('--remote-path', dest='remote_path', metavar='PATH', - help='use PATH as borg executable on the remote (default: "borg")') - add_argument('--remote-ratelimit', dest='remote_ratelimit', type=int, metavar='rate', - help='set remote network upload rate limit in kiByte/s (default: 0=unlimited)') - add_argument('--consider-part-files', dest='consider_part_files', - action='store_true', default=False, - help='treat part files like normal files (e.g. to list/extract them)') - - 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) - delattr(args, map_from) - - # 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 - values = getattr(args, dest + suffix, []) - option_value.extend(values) - setattr(args, dest, option_value) + def define_common_options(add_common_option): + add_common_option('-h', '--help', action='help', help='show this help message and exit') + add_common_option('--critical', dest='log_level', + action='store_const', const='critical', default='warning', + help='work on log level CRITICAL') + add_common_option('--error', dest='log_level', + action='store_const', const='error', default='warning', + help='work on log level ERROR') + add_common_option('--warning', dest='log_level', + action='store_const', const='warning', default='warning', + help='work on log level WARNING (default)') + add_common_option('--info', '-v', '--verbose', dest='log_level', + action='store_const', const='info', default='warning', + help='work on log level INFO') + add_common_option('--debug', dest='log_level', + action='store_const', const='debug', default='warning', + help='enable debug output, work on log level DEBUG') + add_common_option('--debug-topic', dest='debug_topics', + action='append', metavar='TOPIC', default=[], + help='enable TOPIC debugging (can be specified multiple times). ' + 'The logger path is borg.debug. if TOPIC is not fully qualified.') + add_common_option('-p', '--progress', dest='progress', action='store_true', + help='show progress information') + add_common_option('--log-json', dest='log_json', action='store_true', + help='Output one JSON object per log line instead of formatted text.') + add_common_option('--lock-wait', dest='lock_wait', type=int, metavar='N', default=1, + help='wait for the lock, but max. N seconds (default: %(default)d).') + add_common_option('--show-version', dest='show_version', action='store_true', default=False, + help='show/log the borg version') + add_common_option('--show-rc', dest='show_rc', action='store_true', default=False, + help='show/log the return code (rc)') + add_common_option('--no-files-cache', dest='cache_files', action='store_false', + help='do not load/update the file metadata cache used to detect unchanged files') + add_common_option('--umask', dest='umask', type=lambda s: int(s, 8), default=UMASK_DEFAULT, metavar='M', + help='set umask to M (local and remote, default: %(default)04o)') + add_common_option('--remote-path', dest='remote_path', metavar='PATH', + help='use PATH as borg executable on the remote (default: "borg")') + add_common_option('--remote-ratelimit', dest='remote_ratelimit', type=int, metavar='rate', + help='set remote network upload rate limit in kiByte/s (default: 0=unlimited)') + add_common_option('--consider-part-files', dest='consider_part_files', + action='store_true', default=False, + help='treat part files like normal files (e.g. to list/extract them)') parser = argparse.ArgumentParser(prog=self.prog, description='Borg - Deduplicated Backups', add_help=False) - parser.common_options = CommonOptions() + parser.common_options = self.CommonOptions(define_common_options, + suffix_precedence=('_maincommand', '_midcommand', '_subcommand')) parser.add_argument('-V', '--version', action='version', version='%(prog)s ' + __version__, help='show version number and exit') parser.common_options.add_common_group(parser, '_maincommand', provide_defaults=True)