Fix error parsing arguments with multiple verbosity flags (#716).

This commit is contained in:
Dan Helfman 2023-06-24 14:10:47 -07:00
parent e4e455ee45
commit 35a11559ac
7 changed files with 389 additions and 108 deletions

4
NEWS
View File

@ -6,8 +6,8 @@
configuration. configuration.
* #529: Deprecate generate-borgmatic-config in favor of new "config generate" action. * #529: Deprecate generate-borgmatic-config in favor of new "config generate" action.
* #529: Deprecate validate-borgmatic-config in favor of new "config validate" action. * #529: Deprecate validate-borgmatic-config in favor of new "config validate" action.
* #697, #712: Extract borgmatic configuration from backup via new "config bootstrap" action—even * #697, #712, #716: Extract borgmatic configuration from backup via new "config bootstrap"
when borgmatic has no configuration yet! action—even when borgmatic has no configuration yet!
* #669: Add sample systemd user service for running borgmatic as a non-root user. * #669: Add sample systemd user service for running borgmatic as a non-root user.
* #711, #713: Fix an error when "data" check time files are accessed without getting upgraded * #711, #713: Fix an error when "data" check time files are accessed without getting upgraded
first. first.

View File

@ -114,8 +114,8 @@ def parse_and_record_action_arguments(
def get_unparsable_arguments(remaining_action_arguments): def get_unparsable_arguments(remaining_action_arguments):
''' '''
Given a sequence of argument tuples (one tuple per action parser that parsed arguments), Given a sequence of argument tuples (one per action parser that parsed arguments), determine the
determine the remaining arguments that no action parsers have consumed. remaining arguments that no action parsers have consumed.
''' '''
if not remaining_action_arguments: if not remaining_action_arguments:
return () return ()
@ -129,14 +129,16 @@ def get_unparsable_arguments(remaining_action_arguments):
) )
def parse_arguments_for_actions(unparsed_arguments, action_parsers): def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parser):
''' '''
Given a sequence of arguments and a dict from action name to argparse.ArgumentParser Given a sequence of arguments, a dict from action name to argparse.ArgumentParser instance,
instance, give each requested action's parser a shot at parsing all arguments. This allows and the global parser as a argparse.ArgumentParser instance, give each requested action's
common arguments like "--repository" to be shared across multiple action parsers. parser a shot at parsing all arguments. This allows common arguments like "--repository" to be
shared across multiple action parsers.
Return the result as a tuple of: (a dict mapping from action name to an argparse.Namespace of Return the result as a tuple of: (a dict mapping from action name to an argparse.Namespace of
parsed arguments, a list of strings of remaining arguments not claimed by any action parser). parsed arguments, a tuple of argument tuples where each is the remaining arguments not claimed
by any action parser).
''' '''
arguments = collections.OrderedDict() arguments = collections.OrderedDict()
help_requested = bool('--help' in unparsed_arguments or '-h' in unparsed_arguments) help_requested = bool('--help' in unparsed_arguments or '-h' in unparsed_arguments)
@ -211,11 +213,12 @@ def parse_arguments_for_actions(unparsed_arguments, action_parsers):
) )
) )
arguments['global'], remaining = global_parser.parse_known_args(unparsed_arguments)
remaining_action_arguments.append(remaining)
return ( return (
arguments, arguments,
get_unparsable_arguments(tuple(remaining_action_arguments)) tuple(remaining_action_arguments) if arguments else unparsed_arguments,
if arguments
else unparsed_arguments,
) )
@ -235,7 +238,10 @@ class Extend_action(Action):
def make_parsers(): def make_parsers():
''' '''
Build a top-level parser and its action parsers and return them as a tuple. Build a global arguments parser, individual action parsers, and a combined parser containing
both. Return them as a tuple. The global parser is useful for parsing just global arguments
while ignoring actions, and the combined parser is handy for displaying help that includes
everything: global flags, a list of actions, etc.
''' '''
config_paths = collect.get_default_config_paths(expand_home=True) config_paths = collect.get_default_config_paths(expand_home=True)
unexpanded_config_paths = collect.get_default_config_paths(expand_home=False) unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
@ -345,7 +351,7 @@ def make_parsers():
help='Display installed version number of borgmatic and exit', help='Display installed version number of borgmatic and exit',
) )
top_level_parser = ArgumentParser( global_plus_action_parser = ArgumentParser(
description=''' description='''
Simple, configuration-driven backup software for servers and workstations. If none of Simple, configuration-driven backup software for servers and workstations. If none of
the action options are given, then borgmatic defaults to: create, prune, compact, and the action options are given, then borgmatic defaults to: create, prune, compact, and
@ -354,7 +360,7 @@ def make_parsers():
parents=[global_parser], parents=[global_parser],
) )
action_parsers = top_level_parser.add_subparsers( action_parsers = global_plus_action_parser.add_subparsers(
title='actions', title='actions',
metavar='', metavar='',
help='Specify zero or more actions. Defaults to create, prune, compact, and check. Use --help with action for details:', help='Specify zero or more actions. Defaults to create, prune, compact, and check. Use --help with action for details:',
@ -776,7 +782,7 @@ def make_parsers():
config_validate_parser = config_parsers.add_parser( config_validate_parser = config_parsers.add_parser(
'validate', 'validate',
help='Validate that borgmatic configuration files specified with --config (see borgmatic --help)', help='Validate borgmatic configuration files specified with --config (see borgmatic --help)',
description='Validate borgmatic configuration files specified with --config (see borgmatic --help)', description='Validate borgmatic configuration files specified with --config (see borgmatic --help)',
add_help=False, add_help=False,
) )
@ -1221,27 +1227,46 @@ def make_parsers():
) )
borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
return top_level_parser, action_parsers return global_parser, action_parsers, global_plus_action_parser
def parse_arguments(*unparsed_arguments): def parse_arguments(*unparsed_arguments):
''' '''
Given command-line arguments with which this script was invoked, parse the arguments and return Given command-line arguments with which this script was invoked, parse the arguments and return
them as a dict mapping from action name (or "global") to an argparse.Namespace instance. them as a dict mapping from action name (or "global") to an argparse.Namespace instance.
'''
top_level_parser, action_parsers = make_parsers()
arguments, remaining_arguments = parse_arguments_for_actions( Raise ValueError if the arguments cannot be parsed.
unparsed_arguments, action_parsers.choices Raise SystemExit with an error code of 0 if "--help" was requested.
'''
global_parser, action_parsers, global_plus_action_parser = make_parsers()
arguments, remaining_action_arguments = parse_arguments_for_actions(
unparsed_arguments, action_parsers.choices, global_parser
) )
for action_name in ('bootstrap', 'generate', 'validate'): for action_name in ('bootstrap', 'generate', 'validate'):
if action_name in arguments.keys() and len(arguments.keys()) > 1: if (
action_name in arguments.keys() and len(arguments.keys()) > 2
): # 2 = 1 for 'global' + 1 for the action
raise ValueError( raise ValueError(
'The {action_name} action cannot be combined with other actions. Please run it separately.' f'The {action_name} action cannot be combined with other actions. Please run it separately.'
) )
arguments['global'] = top_level_parser.parse_args(remaining_arguments) unknown_arguments = get_unparsable_arguments(remaining_action_arguments)
if unknown_arguments:
if '--help' in unknown_arguments or '-h' in unknown_arguments:
global_plus_action_parser.print_help()
sys.exit(0)
global_plus_action_parser.print_usage()
raise ValueError(
f"Unrecognized argument{'s' if len(unknown_arguments) > 1 else ''}: {' '.join(unknown_arguments)}"
)
# Prevent action names that follow "--config" paths from being considered as additional paths.
for argument_name in arguments.keys():
if argument_name != 'global' and argument_name in arguments['global'].config_paths:
arguments['global'].config_paths.remove(argument_name)
if arguments['global'].excludes_filename: if arguments['global'].excludes_filename:
raise ValueError( raise ValueError(

View File

@ -15,8 +15,12 @@ def bash_completion():
Return a bash completion script for the borgmatic command. Produce this by introspecting Return a bash completion script for the borgmatic command. Produce this by introspecting
borgmatic's command-line argument parsers. borgmatic's command-line argument parsers.
''' '''
top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() (
global_flags = parser_flags(top_level_parser) unused_global_parser,
action_parsers,
global_plus_action_parser,
) = borgmatic.commands.arguments.make_parsers()
global_flags = parser_flags(global_plus_action_parser)
# Avert your eyes. # Avert your eyes.
return '\n'.join( return '\n'.join(
@ -41,18 +45,18 @@ def bash_completion():
fi''' fi'''
% ( % (
action, action,
parser_flags(subparser), parser_flags(action_parser),
' '.join( ' '.join(
borgmatic.commands.completion.actions.available_actions(subparsers, action) borgmatic.commands.completion.actions.available_actions(action_parsers, action)
), ),
global_flags, global_flags,
) )
for action, subparser in reversed(subparsers.choices.items()) for action, action_parser in reversed(action_parsers.choices.items())
) )
+ ( + (
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003 ' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003
% ( % (
' '.join(borgmatic.commands.completion.actions.available_actions(subparsers)), ' '.join(borgmatic.commands.completion.actions.available_actions(action_parsers)),
global_flags, global_flags,
), ),
' (check_version &)', ' (check_version &)',

View File

@ -91,18 +91,22 @@ def fish_completion():
Return a fish completion script for the borgmatic command. Produce this by introspecting Return a fish completion script for the borgmatic command. Produce this by introspecting
borgmatic's command-line argument parsers. borgmatic's command-line argument parsers.
''' '''
top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() (
unused_global_parser,
action_parsers,
global_plus_action_parser,
) = borgmatic.commands.arguments.make_parsers()
all_subparsers = ' '.join(action for action in subparsers.choices.keys()) all_action_parsers = ' '.join(action for action in action_parsers.choices.keys())
exact_option_args = tuple( exact_option_args = tuple(
' '.join(action.option_strings) ' '.join(action.option_strings)
for subparser in subparsers.choices.values() for action_parser in action_parsers.choices.values()
for action in subparser._actions for action in action_parser._actions
if has_exact_options(action) if has_exact_options(action)
) + tuple( ) + tuple(
' '.join(action.option_strings) ' '.join(action.option_strings)
for action in top_level_parser._actions for action in global_plus_action_parser._actions
if len(action.option_strings) > 0 if len(action.option_strings) > 0
if has_exact_options(action) if has_exact_options(action)
) )
@ -144,29 +148,29 @@ def fish_completion():
return 1 return 1
end end
set --local subparser_condition "not __fish_seen_subcommand_from {all_subparsers}" set --local action_parser_condition "not __fish_seen_subcommand_from {all_action_parsers}"
set --local exact_option_condition "not __borgmatic_current_arg {' '.join(exact_option_args)}" set --local exact_option_condition "not __borgmatic_current_arg {' '.join(exact_option_args)}"
''' '''
) )
+ ('\n# subparser completions',) + ('\n# action_parser completions',)
+ tuple( + tuple(
f'''complete -c borgmatic -f -n "$subparser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(subparser.description)}''' f'''complete -c borgmatic -f -n "$action_parser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(action_parser.description)}'''
for action_name, subparser in subparsers.choices.items() for action_name, action_parser in action_parsers.choices.items()
) )
+ ('\n# global flags',) + ('\n# global flags',)
+ tuple( + tuple(
# -n is checked in order, so put faster / more likely to be true checks first # -n is checked in order, so put faster / more likely to be true checks first
f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)}{exact_options_completion(action)}''' f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)}{exact_options_completion(action)}'''
for action in top_level_parser._actions for action in global_plus_action_parser._actions
# ignore the noargs action, as this is an impossible completion for fish # ignore the noargs action, as this is an impossible completion for fish
if len(action.option_strings) > 0 if len(action.option_strings) > 0
if 'Deprecated' not in action.help if 'Deprecated' not in action.help
) )
+ ('\n# subparser flags',) + ('\n# action_parser flags',)
+ tuple( + tuple(
f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)} -n "__fish_seen_subcommand_from {action_name}"{exact_options_completion(action)}''' f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)} -n "__fish_seen_subcommand_from {action_name}"{exact_options_completion(action)}'''
for action_name, subparser in subparsers.choices.items() for action_name, action_parser in action_parsers.choices.items()
for action in subparser._actions for action in action_parser._actions
if 'Deprecated' not in (action.help or ()) if 'Deprecated' not in (action.help or ())
) )
) )

View File

@ -3,18 +3,26 @@ from borgmatic.commands.completion import actions as module
def test_available_actions_uses_only_subactions_for_action_with_subactions(): def test_available_actions_uses_only_subactions_for_action_with_subactions():
unused_top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() (
unused_global_parser,
action_parsers,
unused_combined_parser,
) = borgmatic.commands.arguments.make_parsers()
actions = module.available_actions(subparsers, 'config') actions = module.available_actions(action_parsers, 'config')
assert 'bootstrap' in actions assert 'bootstrap' in actions
assert 'list' not in actions assert 'list' not in actions
def test_available_actions_omits_subactions_for_action_without_subactions(): def test_available_actions_omits_subactions_for_action_without_subactions():
unused_top_level_parser, subparsers = borgmatic.commands.arguments.make_parsers() (
unused_global_parser,
action_parsers,
unused_combined_parser,
) = borgmatic.commands.arguments.make_parsers()
actions = module.available_actions(subparsers, 'list') actions = module.available_actions(action_parsers, 'list')
assert 'bootstrap' not in actions assert 'bootstrap' not in actions
assert 'config' in actions assert 'config' in actions

View File

@ -30,6 +30,17 @@ def test_parse_arguments_with_multiple_config_paths_parses_as_list():
assert global_arguments.log_file_verbosity == 0 assert global_arguments.log_file_verbosity == 0
def test_parse_arguments_with_action_after_config_path_omits_action():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
arguments = module.parse_arguments('--config', 'myconfig', 'list', '--json')
global_arguments = arguments['global']
assert global_arguments.config_paths == ['myconfig']
assert 'list' in arguments
assert arguments['list'].json
def test_parse_arguments_with_verbosity_overrides_default(): def test_parse_arguments_with_verbosity_overrides_default():
config_paths = ['default'] config_paths = ['default']
flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths) flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths)
@ -194,10 +205,10 @@ def test_parse_arguments_with_multiple_actions_leaves_other_action_disabled():
assert 'check' in arguments assert 'check' in arguments
def test_parse_arguments_with_invalid_arguments_exits(): def test_parse_arguments_disallows_invalid_argument():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--posix-me-harder') module.parse_arguments('--posix-me-harder')
@ -211,7 +222,7 @@ def test_parse_arguments_disallows_deprecated_excludes_option():
def test_parse_arguments_disallows_encryption_mode_without_init(): def test_parse_arguments_disallows_encryption_mode_without_init():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--config', 'myconfig', '--encryption', 'repokey') module.parse_arguments('--config', 'myconfig', '--encryption', 'repokey')
@ -231,14 +242,14 @@ def test_parse_arguments_requires_encryption_mode_with_init():
def test_parse_arguments_disallows_append_only_without_init(): def test_parse_arguments_disallows_append_only_without_init():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--config', 'myconfig', '--append-only') module.parse_arguments('--config', 'myconfig', '--append-only')
def test_parse_arguments_disallows_storage_quota_without_init(): def test_parse_arguments_disallows_storage_quota_without_init():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--config', 'myconfig', '--storage-quota', '5G') module.parse_arguments('--config', 'myconfig', '--storage-quota', '5G')
@ -287,14 +298,14 @@ def test_parse_arguments_allows_repository_with_list():
def test_parse_arguments_disallows_archive_unless_action_consumes_it(): def test_parse_arguments_disallows_archive_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--config', 'myconfig', '--archive', 'test') module.parse_arguments('--config', 'myconfig', '--archive', 'test')
def test_parse_arguments_disallows_paths_unless_action_consumes_it(): def test_parse_arguments_disallows_paths_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--config', 'myconfig', '--path', 'test') module.parse_arguments('--config', 'myconfig', '--path', 'test')
@ -380,7 +391,7 @@ def test_parse_arguments_allows_progress_and_extract():
def test_parse_arguments_disallows_progress_without_create(): def test_parse_arguments_disallows_progress_without_create():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--progress', 'list') module.parse_arguments('--progress', 'list')
@ -399,7 +410,7 @@ def test_parse_arguments_with_stats_and_prune_flags_does_not_raise():
def test_parse_arguments_with_stats_flag_but_no_create_or_prune_flag_raises_value_error(): def test_parse_arguments_with_stats_flag_but_no_create_or_prune_flag_raises_value_error():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit): with pytest.raises(ValueError):
module.parse_arguments('--stats', 'list') module.parse_arguments('--stats', 'list')
@ -535,11 +546,9 @@ def test_parse_arguments_extract_with_check_only_extract_does_not_raise():
def test_parse_arguments_bootstrap_without_config_errors(): def test_parse_arguments_bootstrap_without_config_errors():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit) as exit: with pytest.raises(ValueError):
module.parse_arguments('bootstrap') module.parse_arguments('bootstrap')
assert exit.value.code == 2
def test_parse_arguments_config_with_no_subaction_errors(): def test_parse_arguments_config_with_no_subaction_errors():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])

View File

@ -130,6 +130,175 @@ def test_parse_and_record_action_arguments_with_borg_action_consumes_arguments_a
assert borg_parsed_arguments.options == ('list',) assert borg_parsed_arguments.options == ('list',)
@pytest.mark.parametrize(
'arguments, expected',
[
# A global flag remaining from each parsed action.
(
(
('--latest', 'archive', 'prune', 'extract', 'list', '--test-flag'),
('--latest', 'archive', 'check', 'extract', 'list', '--test-flag'),
('prune', 'check', 'list', '--test-flag'),
('prune', 'check', 'extract', '--test-flag'),
),
('--test-flag',),
),
# No global flags remaining.
(
(
('--latest', 'archive', 'prune', 'extract', 'list'),
('--latest', 'archive', 'check', 'extract', 'list'),
('prune', 'check', 'list'),
('prune', 'check', 'extract'),
),
(),
),
# Multiple of the same value across global flags.
(
(
('--verbosity', '2', '--syslog-verbosity', '2', '--monitoring-verbosity', '2'),
('--verbosity', '2', '--syslog-verbosity', '2', '--monitoring-verbosity', '2'),
),
('--verbosity', '2', '--syslog-verbosity', '2', '--monitoring-verbosity', '2'),
),
# Multiple of the same value across action and global flags.
(
(
('list', '--archive', 'test', '--log-file', 'test'),
('prune', '--log-file', 'test'),
),
('--log-file', 'test'),
),
# No flags.
((), ()),
],
)
def test_get_unparsable_arguments_returns_remaining_arguments_that_no_action_can_parse(
arguments, expected
):
assert module.get_unparsable_arguments(arguments) == expected
def test_get_subaction_parsers_with_subactions_returns_one_entry_per_subaction():
foo_parser = flexmock()
bar_parser = flexmock()
baz_parser = flexmock()
assert module.get_subaction_parsers(
flexmock(
_subparsers=flexmock(
_group_actions=(
flexmock(choices={'foo': foo_parser, 'bar': bar_parser}),
flexmock(choices={'baz': baz_parser}),
)
)
)
) == {'foo': foo_parser, 'bar': bar_parser, 'baz': baz_parser}
def test_get_subactions_for_actions_with_no_subactions_returns_empty_result():
assert module.get_subactions_for_actions({'action': flexmock(_subparsers=None)}) == {}
def test_get_subactions_for_actions_with_subactions_returns_one_entry_per_action():
assert module.get_subactions_for_actions(
{
'action': flexmock(
_subparsers=flexmock(
_group_actions=(
flexmock(choices={'foo': flexmock(), 'bar': flexmock()}),
flexmock(choices={'baz': flexmock()}),
)
)
),
'other': flexmock(
_subparsers=flexmock(_group_actions=(flexmock(choices={'quux': flexmock()}),))
),
}
) == {'action': ('foo', 'bar', 'baz'), 'other': ('quux',)}
def test_omit_values_colliding_with_action_names_drops_action_names_that_have__been_parsed_as_values():
assert module.omit_values_colliding_with_action_names(
('check', '--only', 'extract', '--some-list', 'borg'),
{'check': flexmock(only='extract', some_list=['borg'])},
) == ('check', '--only', '--some-list')
def test_parse_and_record_action_arguments_without_action_name_leaves_arguments_untouched():
unparsed_arguments = ('--foo', '--bar')
flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return(
unparsed_arguments
)
assert (
module.parse_and_record_action_arguments(
unparsed_arguments, flexmock(), flexmock(), 'action'
)
== unparsed_arguments
)
def test_parse_and_record_action_arguments_updates_parsed_arguments_and_returns_remaining():
unparsed_arguments = ('action', '--foo', '--bar', '--verbosity', '1')
other_parsed_arguments = flexmock()
parsed_arguments = {'other': other_parsed_arguments}
action_parsed_arguments = flexmock()
flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return(
unparsed_arguments
)
action_parser = flexmock()
flexmock(action_parser).should_receive('parse_known_args').and_return(
action_parsed_arguments, ('action', '--verbosity', '1')
)
assert module.parse_and_record_action_arguments(
unparsed_arguments, parsed_arguments, action_parser, 'action'
) == ('--verbosity', '1')
assert parsed_arguments == {'other': other_parsed_arguments, 'action': action_parsed_arguments}
def test_parse_and_record_action_arguments_with_alias_updates_canonical_parsed_arguments():
unparsed_arguments = ('action', '--foo', '--bar', '--verbosity', '1')
other_parsed_arguments = flexmock()
parsed_arguments = {'other': other_parsed_arguments}
action_parsed_arguments = flexmock()
flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return(
unparsed_arguments
)
action_parser = flexmock()
flexmock(action_parser).should_receive('parse_known_args').and_return(
action_parsed_arguments, ('action', '--verbosity', '1')
)
assert module.parse_and_record_action_arguments(
unparsed_arguments, parsed_arguments, action_parser, 'action', canonical_name='doit'
) == ('--verbosity', '1')
assert parsed_arguments == {'other': other_parsed_arguments, 'doit': action_parsed_arguments}
def test_parse_and_record_action_arguments_with_borg_action_consumes_arguments_after_action_name():
unparsed_arguments = ('--verbosity', '1', 'borg', 'list')
parsed_arguments = {}
borg_parsed_arguments = flexmock(options=flexmock())
flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return(
unparsed_arguments
)
borg_parser = flexmock()
flexmock(borg_parser).should_receive('parse_known_args').and_return(
borg_parsed_arguments, ('--verbosity', '1', 'borg', 'list')
)
assert module.parse_and_record_action_arguments(
unparsed_arguments,
parsed_arguments,
borg_parser,
'borg',
) == ('--verbosity', '1')
assert parsed_arguments == {'borg': borg_parsed_arguments}
assert borg_parsed_arguments.options == ('list',)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'arguments, expected', 'arguments, expected',
[ [
@ -167,63 +336,74 @@ def test_parse_arguments_for_actions_consumes_action_arguments_before_action_nam
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace} {action: action_namespace}
) )
or ()
).and_return(()) ).and_return(())
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = {'action': flexmock(), 'other': flexmock()} action_parsers = {'action': flexmock(), 'other': flexmock()}
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('--foo', 'true', 'action'), action_parsers ('--foo', 'true', 'action'), action_parsers, global_parser
) )
assert arguments == {'action': action_namespace} assert arguments == {'global': global_namespace, 'action': action_namespace}
assert remaining_arguments == () assert remaining_action_arguments == ((), ())
def test_parse_arguments_for_actions_consumes_action_arguments_after_action_name(): def test_parse_arguments_for_actions_consumes_action_arguments_after_action_name():
action_namespace = flexmock(foo=True) action_namespace = flexmock(foo=True)
remaining = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace} {action: action_namespace}
) )
).and_return(()) or remaining
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = {'action': flexmock(), 'other': flexmock()} action_parsers = {'action': flexmock(), 'other': flexmock()}
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('action', '--foo', 'true'), action_parsers ('action', '--foo', 'true'), action_parsers, global_parser
) )
assert arguments == {'action': action_namespace} assert arguments == {'global': global_namespace, 'action': action_namespace}
assert remaining_arguments == () assert remaining_action_arguments == (remaining, ())
def test_parse_arguments_for_actions_consumes_action_arguments_with_alias(): def test_parse_arguments_for_actions_consumes_action_arguments_with_alias():
action_namespace = flexmock(foo=True) action_namespace = flexmock(foo=True)
remaining = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{canonical or action: action_namespace} {canonical or action: action_namespace}
) )
).and_return(()) or remaining
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = { action_parsers = {
'action': flexmock(), 'action': flexmock(),
'-a': flexmock(), '-a': flexmock(),
'other': flexmock(), 'other': flexmock(),
'-o': flexmock(), '-o': flexmock(),
} }
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
flexmock(module).ACTION_ALIASES = {'action': ['-a'], 'other': ['-o']} flexmock(module).ACTION_ALIASES = {'action': ['-a'], 'other': ['-o']}
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('-a', '--foo', 'true'), action_parsers ('-a', '--foo', 'true'), action_parsers, global_parser
) )
assert arguments == {'action': action_namespace} assert arguments == {'global': global_namespace, 'action': action_namespace}
assert remaining_arguments == () assert remaining_action_arguments == (remaining, ())
def test_parse_arguments_for_actions_consumes_multiple_action_arguments(): def test_parse_arguments_for_actions_consumes_multiple_action_arguments():
@ -234,20 +414,27 @@ def test_parse_arguments_for_actions_consumes_multiple_action_arguments():
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace if action == 'action' else other_namespace} {action: action_namespace if action == 'action' else other_namespace}
) )
or ()
).and_return(('other', '--bar', '3')).and_return('action', '--foo', 'true') ).and_return(('other', '--bar', '3')).and_return('action', '--foo', 'true')
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = { action_parsers = {
'action': flexmock(), 'action': flexmock(),
'other': flexmock(), 'other': flexmock(),
} }
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('action', '--foo', 'true', 'other', '--bar', '3'), action_parsers ('action', '--foo', 'true', 'other', '--bar', '3'), action_parsers, global_parser
) )
assert arguments == {'action': action_namespace, 'other': other_namespace} assert arguments == {
assert remaining_arguments == () 'global': global_namespace,
'action': action_namespace,
'other': other_namespace,
}
assert remaining_action_arguments == ((), (), ())
def test_parse_arguments_for_actions_respects_command_line_action_ordering(): def test_parse_arguments_for_actions_respects_command_line_action_ordering():
@ -258,26 +445,31 @@ def test_parse_arguments_for_actions_respects_command_line_action_ordering():
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: other_namespace if action == 'other' else action_namespace} {action: other_namespace if action == 'other' else action_namespace}
) )
or ()
).and_return(('action',)).and_return(('other', '--foo', 'true')) ).and_return(('action',)).and_return(('other', '--foo', 'true'))
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = { action_parsers = {
'action': flexmock(), 'action': flexmock(),
'other': flexmock(), 'other': flexmock(),
} }
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('other', '--foo', 'true', 'action'), action_parsers ('other', '--foo', 'true', 'action'), action_parsers, global_parser
) )
assert arguments == collections.OrderedDict( assert arguments == collections.OrderedDict(
[('other', other_namespace), ('action', action_namespace)] [('other', other_namespace), ('action', action_namespace), ('global', global_namespace)]
) )
assert remaining_arguments == () assert remaining_action_arguments == ((), (), ())
def test_parse_arguments_for_actions_applies_default_action_parsers(): def test_parse_arguments_for_actions_applies_default_action_parsers():
global_namespace = flexmock()
namespaces = { namespaces = {
'global': global_namespace,
'prune': flexmock(), 'prune': flexmock(),
'compact': flexmock(), 'compact': flexmock(),
'create': flexmock(progress=True), 'create': flexmock(progress=True),
@ -289,9 +481,9 @@ def test_parse_arguments_for_actions_applies_default_action_parsers():
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: namespaces.get(action)} {action: namespaces.get(action)}
) )
or ()
).and_return(()) ).and_return(())
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = { action_parsers = {
'prune': flexmock(), 'prune': flexmock(),
'compact': flexmock(), 'compact': flexmock(),
@ -299,13 +491,41 @@ def test_parse_arguments_for_actions_applies_default_action_parsers():
'check': flexmock(), 'check': flexmock(),
'other': flexmock(), 'other': flexmock(),
} }
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('--progress'), action_parsers ('--progress'), action_parsers, global_parser
) )
assert arguments == namespaces assert arguments == namespaces
assert remaining_arguments == () assert remaining_action_arguments == ((), (), (), (), ())
def test_parse_arguments_for_actions_consumes_global_arguments():
action_namespace = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace}
)
or ('--verbosity', 'lots')
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
'action': flexmock(),
'other': flexmock(),
}
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('action', '--verbosity', 'lots'), action_parsers, global_parser
)
assert arguments == {'global': global_namespace, 'action': action_namespace}
assert remaining_action_arguments == (('--verbosity', 'lots'), ())
def test_parse_arguments_for_actions_passes_through_unknown_arguments_before_action_name(): def test_parse_arguments_for_actions_passes_through_unknown_arguments_before_action_name():
@ -315,20 +535,23 @@ def test_parse_arguments_for_actions_passes_through_unknown_arguments_before_act
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace} {action: action_namespace}
) )
).and_return(('--verbosity', 'lots')) or ('--wtf', 'yes')
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(('--verbosity', 'lots'))
action_parsers = { action_parsers = {
'action': flexmock(), 'action': flexmock(),
'other': flexmock(), 'other': flexmock(),
} }
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('--verbosity', 'lots', 'action'), action_parsers ('--wtf', 'yes', 'action'), action_parsers, global_parser
) )
assert arguments == {'action': action_namespace} assert arguments == {'global': global_namespace, 'action': action_namespace}
assert remaining_arguments == ('--verbosity', 'lots') assert remaining_action_arguments == (('--wtf', 'yes'), ())
def test_parse_arguments_for_actions_passes_through_unknown_arguments_after_action_name(): def test_parse_arguments_for_actions_passes_through_unknown_arguments_after_action_name():
@ -338,20 +561,23 @@ def test_parse_arguments_for_actions_passes_through_unknown_arguments_after_acti
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace} {action: action_namespace}
) )
).and_return(('--verbosity', 'lots')) or ('--wtf', 'yes')
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(('--verbosity', 'lots'))
action_parsers = { action_parsers = {
'action': flexmock(), 'action': flexmock(),
'other': flexmock(), 'other': flexmock(),
} }
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('action', '--verbosity', 'lots'), action_parsers ('action', '--wtf', 'yes'), action_parsers, global_parser
) )
assert arguments == {'action': action_namespace} assert arguments == {'global': global_namespace, 'action': action_namespace}
assert remaining_arguments == ('--verbosity', 'lots') assert remaining_action_arguments == (('--wtf', 'yes'), ())
def test_parse_arguments_for_actions_with_borg_action_skips_other_action_parsers(): def test_parse_arguments_for_actions_with_borg_action_skips_other_action_parsers():
@ -361,20 +587,23 @@ def test_parse_arguments_for_actions_with_borg_action_skips_other_action_parsers
lambda unparsed, parsed, parser, action, canonical=None: parsed.update( lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace} {action: action_namespace}
) )
or ()
).and_return(()) ).and_return(())
flexmock(module).should_receive('get_subactions_for_actions').and_return({}) flexmock(module).should_receive('get_subactions_for_actions').and_return({})
flexmock(module).should_receive('get_unparsable_arguments').and_return(())
action_parsers = { action_parsers = {
'borg': flexmock(), 'borg': flexmock(),
'list': flexmock(), 'list': flexmock(),
} }
global_namespace = flexmock()
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((global_namespace, ()))
arguments, remaining_arguments = module.parse_arguments_for_actions( arguments, remaining_action_arguments = module.parse_arguments_for_actions(
('borg', 'list'), action_parsers ('borg', 'list'), action_parsers, global_parser
) )
assert arguments == {'borg': action_namespace} assert arguments == {'global': global_namespace, 'borg': action_namespace}
assert remaining_arguments == () assert remaining_action_arguments == ((), ())
def test_parse_arguments_for_actions_raises_error_when_no_action_is_specified(): def test_parse_arguments_for_actions_raises_error_when_no_action_is_specified():
@ -384,6 +613,8 @@ def test_parse_arguments_for_actions_raises_error_when_no_action_is_specified():
{'config': ['bootstrap']} {'config': ['bootstrap']}
) )
action_parsers = {'config': flexmock()} action_parsers = {'config': flexmock()}
global_parser = flexmock()
global_parser.should_receive('parse_known_args').and_return((flexmock(), ()))
with pytest.raises(ValueError): with pytest.raises(ValueError):
module.parse_arguments_for_actions(('config',), action_parsers) module.parse_arguments_for_actions(('config',), action_parsers, global_parser)