diff --git a/NEWS b/NEWS index 781e30dd..f9d27518 100644 --- a/NEWS +++ b/NEWS @@ -6,8 +6,8 @@ configuration. * #529: Deprecate generate-borgmatic-config in favor of new "config generate" 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 - when borgmatic has no configuration yet! + * #697, #712, #716: Extract borgmatic configuration from backup via new "config bootstrap" + action—even when borgmatic has no configuration yet! * #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 first. diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index fe8c1dd1..eac88c8a 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -114,8 +114,8 @@ def parse_and_record_action_arguments( def get_unparsable_arguments(remaining_action_arguments): ''' - Given a sequence of argument tuples (one tuple per action parser that parsed arguments), - determine the remaining arguments that no action parsers have consumed. + Given a sequence of argument tuples (one per action parser that parsed arguments), determine the + remaining arguments that no action parsers have consumed. ''' if not remaining_action_arguments: 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 - instance, give each requested action's parser a shot at parsing all arguments. This allows - common arguments like "--repository" to be shared across multiple action parsers. + Given a sequence of arguments, a dict from action name to argparse.ArgumentParser instance, + and the global parser as a argparse.ArgumentParser instance, give each requested action's + 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 - 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() 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 ( arguments, - get_unparsable_arguments(tuple(remaining_action_arguments)) - if arguments - else unparsed_arguments, + tuple(remaining_action_arguments) if arguments else unparsed_arguments, ) @@ -235,7 +238,10 @@ class Extend_action(Action): 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) 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', ) - top_level_parser = ArgumentParser( + global_plus_action_parser = ArgumentParser( description=''' 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 @@ -354,7 +360,7 @@ def make_parsers(): parents=[global_parser], ) - action_parsers = top_level_parser.add_subparsers( + action_parsers = global_plus_action_parser.add_subparsers( title='actions', metavar='', 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( '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)', 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') - return top_level_parser, action_parsers + return global_parser, action_parsers, global_plus_action_parser def parse_arguments(*unparsed_arguments): ''' 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. - ''' - top_level_parser, action_parsers = make_parsers() - arguments, remaining_arguments = parse_arguments_for_actions( - unparsed_arguments, action_parsers.choices + Raise ValueError if the arguments cannot be parsed. + 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'): - 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( - '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: raise ValueError( diff --git a/borgmatic/commands/completion/bash.py b/borgmatic/commands/completion/bash.py index d20eca42..7bf28a42 100644 --- a/borgmatic/commands/completion/bash.py +++ b/borgmatic/commands/completion/bash.py @@ -15,8 +15,12 @@ def bash_completion(): Return a bash completion script for the borgmatic command. Produce this by introspecting 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. return '\n'.join( @@ -41,18 +45,18 @@ def bash_completion(): fi''' % ( action, - parser_flags(subparser), + parser_flags(action_parser), ' '.join( - borgmatic.commands.completion.actions.available_actions(subparsers, action) + borgmatic.commands.completion.actions.available_actions(action_parsers, action) ), 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 % ( - ' '.join(borgmatic.commands.completion.actions.available_actions(subparsers)), + ' '.join(borgmatic.commands.completion.actions.available_actions(action_parsers)), global_flags, ), ' (check_version &)', diff --git a/borgmatic/commands/completion/fish.py b/borgmatic/commands/completion/fish.py index 599617ce..edca0226 100644 --- a/borgmatic/commands/completion/fish.py +++ b/borgmatic/commands/completion/fish.py @@ -91,18 +91,22 @@ def fish_completion(): Return a fish completion script for the borgmatic command. Produce this by introspecting 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( ' '.join(action.option_strings) - for subparser in subparsers.choices.values() - for action in subparser._actions + for action_parser in action_parsers.choices.values() + for action in action_parser._actions if has_exact_options(action) ) + tuple( ' '.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 has_exact_options(action) ) @@ -144,29 +148,29 @@ def fish_completion(): return 1 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)}" ''' ) - + ('\n# subparser completions',) + + ('\n# action_parser completions',) + tuple( - f'''complete -c borgmatic -f -n "$subparser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(subparser.description)}''' - for action_name, subparser in subparsers.choices.items() + 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, action_parser in action_parsers.choices.items() ) + ('\n# global flags',) + tuple( # -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)}''' - 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 if len(action.option_strings) > 0 if 'Deprecated' not in action.help ) - + ('\n# subparser flags',) + + ('\n# action_parser flags',) + 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)}''' - for action_name, subparser in subparsers.choices.items() - for action in subparser._actions + for action_name, action_parser in action_parsers.choices.items() + for action in action_parser._actions if 'Deprecated' not in (action.help or ()) ) ) diff --git a/tests/integration/commands/completion/test_actions.py b/tests/integration/commands/completion/test_actions.py index 1a8d69e6..2e6fde9b 100644 --- a/tests/integration/commands/completion/test_actions.py +++ b/tests/integration/commands/completion/test_actions.py @@ -3,18 +3,26 @@ from borgmatic.commands.completion import actions as module 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 'list' not in actions 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 'config' in actions diff --git a/tests/integration/commands/test_arguments.py b/tests/integration/commands/test_arguments.py index 47ec72fa..89141215 100644 --- a/tests/integration/commands/test_arguments.py +++ b/tests/integration/commands/test_arguments.py @@ -30,6 +30,17 @@ def test_parse_arguments_with_multiple_config_paths_parses_as_list(): 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(): config_paths = ['default'] 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 -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']) - with pytest.raises(SystemExit): + with pytest.raises(ValueError): 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(): 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') @@ -231,14 +242,14 @@ def test_parse_arguments_requires_encryption_mode_with_init(): def test_parse_arguments_disallows_append_only_without_init(): 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') def test_parse_arguments_disallows_storage_quota_without_init(): 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') @@ -287,14 +298,14 @@ def test_parse_arguments_allows_repository_with_list(): def test_parse_arguments_disallows_archive_unless_action_consumes_it(): 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') def test_parse_arguments_disallows_paths_unless_action_consumes_it(): 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') @@ -380,7 +391,7 @@ def test_parse_arguments_allows_progress_and_extract(): def test_parse_arguments_disallows_progress_without_create(): 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') @@ -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(): 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') @@ -535,11 +546,9 @@ def test_parse_arguments_extract_with_check_only_extract_does_not_raise(): def test_parse_arguments_bootstrap_without_config_errors(): 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') - assert exit.value.code == 2 - def test_parse_arguments_config_with_no_subaction_errors(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) diff --git a/tests/unit/commands/test_arguments.py b/tests/unit/commands/test_arguments.py index 3c32a7fa..4a7ef432 100644 --- a/tests/unit/commands/test_arguments.py +++ b/tests/unit/commands/test_arguments.py @@ -130,6 +130,175 @@ def test_parse_and_record_action_arguments_with_borg_action_consumes_arguments_a 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( '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( {action: action_namespace} ) + or () ).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()} + 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( - ('--foo', 'true', 'action'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('--foo', 'true', 'action'), action_parsers, global_parser ) - assert arguments == {'action': action_namespace} - assert remaining_arguments == () + assert arguments == {'global': global_namespace, 'action': action_namespace} + assert remaining_action_arguments == ((), ()) def test_parse_arguments_for_actions_consumes_action_arguments_after_action_name(): action_namespace = flexmock(foo=True) + remaining = 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} ) - ).and_return(()) + or remaining + ) 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()} + 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( - ('action', '--foo', 'true'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('action', '--foo', 'true'), action_parsers, global_parser ) - assert arguments == {'action': action_namespace} - assert remaining_arguments == () + assert arguments == {'global': global_namespace, 'action': action_namespace} + assert remaining_action_arguments == (remaining, ()) def test_parse_arguments_for_actions_consumes_action_arguments_with_alias(): action_namespace = flexmock(foo=True) + remaining = 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( {canonical or action: action_namespace} ) - ).and_return(()) + or remaining + ) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) - flexmock(module).should_receive('get_unparsable_arguments').and_return(()) action_parsers = { 'action': flexmock(), '-a': flexmock(), 'other': 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']} - arguments, remaining_arguments = module.parse_arguments_for_actions( - ('-a', '--foo', 'true'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('-a', '--foo', 'true'), action_parsers, global_parser ) - assert arguments == {'action': action_namespace} - assert remaining_arguments == () + assert arguments == {'global': global_namespace, 'action': action_namespace} + assert remaining_action_arguments == (remaining, ()) 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( {action: action_namespace if action == 'action' else other_namespace} ) + or () ).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_unparsable_arguments').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_arguments = module.parse_arguments_for_actions( - ('action', '--foo', 'true', 'other', '--bar', '3'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('action', '--foo', 'true', 'other', '--bar', '3'), action_parsers, global_parser ) - assert arguments == {'action': action_namespace, 'other': other_namespace} - assert remaining_arguments == () + assert 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(): @@ -258,26 +445,31 @@ def test_parse_arguments_for_actions_respects_command_line_action_ordering(): lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: other_namespace if action == 'other' else action_namespace} ) + or () ).and_return(('action',)).and_return(('other', '--foo', 'true')) 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(), } + 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( - ('other', '--foo', 'true', 'action'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('other', '--foo', 'true', 'action'), action_parsers, global_parser ) 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(): + global_namespace = flexmock() namespaces = { + 'global': global_namespace, 'prune': flexmock(), 'compact': flexmock(), '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( {action: namespaces.get(action)} ) + or () ).and_return(()) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) - flexmock(module).should_receive('get_unparsable_arguments').and_return(()) action_parsers = { 'prune': flexmock(), 'compact': flexmock(), @@ -299,13 +491,41 @@ def test_parse_arguments_for_actions_applies_default_action_parsers(): 'check': 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( - ('--progress'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('--progress'), action_parsers, global_parser ) 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(): @@ -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( {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_unparsable_arguments').and_return(('--verbosity', 'lots')) 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( - ('--verbosity', 'lots', 'action'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('--wtf', 'yes', 'action'), action_parsers, global_parser ) - assert arguments == {'action': action_namespace} - assert remaining_arguments == ('--verbosity', 'lots') + assert arguments == {'global': global_namespace, 'action': action_namespace} + assert remaining_action_arguments == (('--wtf', 'yes'), ()) 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( {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_unparsable_arguments').and_return(('--verbosity', 'lots')) 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( - ('action', '--verbosity', 'lots'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('action', '--wtf', 'yes'), action_parsers, global_parser ) - assert arguments == {'action': action_namespace} - assert remaining_arguments == ('--verbosity', 'lots') + assert arguments == {'global': global_namespace, 'action': action_namespace} + assert remaining_action_arguments == (('--wtf', 'yes'), ()) 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( {action: action_namespace} ) + or () ).and_return(()) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) - flexmock(module).should_receive('get_unparsable_arguments').and_return(()) action_parsers = { 'borg': 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( - ('borg', 'list'), action_parsers + arguments, remaining_action_arguments = module.parse_arguments_for_actions( + ('borg', 'list'), action_parsers, global_parser ) - assert arguments == {'borg': action_namespace} - assert remaining_arguments == () + assert arguments == {'global': global_namespace, 'borg': action_namespace} + assert remaining_action_arguments == ((), ()) 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']} ) action_parsers = {'config': flexmock()} + global_parser = flexmock() + global_parser.should_receive('parse_known_args').and_return((flexmock(), ())) with pytest.raises(ValueError): - module.parse_arguments_for_actions(('config',), action_parsers) + module.parse_arguments_for_actions(('config',), action_parsers, global_parser)