From cf8882f2bcde934119c375997e2cbefe5f35f96a Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 17 Jun 2021 20:41:44 -0700 Subject: [PATCH] Run arbitrary Borg commands with new "borgmatic borg" action (#425). --- .gitignore | 2 +- NEWS | 2 + borgmatic/borg/borg.py | 45 +++++ borgmatic/borg/create.py | 3 +- borgmatic/commands/arguments.py | 93 ++++++----- borgmatic/commands/borgmatic.py | 17 ++ docs/Dockerfile | 2 +- docs/how-to/develop-on-borgmatic.md | 2 +- docs/how-to/run-arbitrary-borg-commands.md | 94 +++++++++++ docs/how-to/upgrade.md | 2 +- tests/integration/commands/test_arguments.py | 18 ++ tests/unit/borg/test_borg.py | 123 ++++++++++++++ tests/unit/commands/test_arguments.py | 167 +++++++++---------- 13 files changed, 438 insertions(+), 132 deletions(-) create mode 100644 borgmatic/borg/borg.py create mode 100644 docs/how-to/run-arbitrary-borg-commands.md create mode 100644 tests/unit/borg/test_borg.py diff --git a/.gitignore b/.gitignore index f5883b5..c4b136e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *.pyc *.swp .cache -.coverage +.coverage* .pytest_cache .tox __pycache__ diff --git a/NEWS b/NEWS index b211395..d6dd0ab 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,8 @@ 1.5.15.dev0 * #419: Document use case of running backups conditionally based on laptop power level: https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/ + * #425: Run arbitrary Borg commands with new "borgmatic borg" action. See the documentation for + more information: https://torsion.org/borgmatic/docs/how-to/run-arbitrary-borg-commands/ 1.5.14 * #390: Add link to Hetzner storage offering from the documentation. diff --git a/borgmatic/borg/borg.py b/borgmatic/borg/borg.py new file mode 100644 index 0000000..59ffe21 --- /dev/null +++ b/borgmatic/borg/borg.py @@ -0,0 +1,45 @@ +import logging + +from borgmatic.borg.flags import make_flags +from borgmatic.execute import execute_command + +logger = logging.getLogger(__name__) + + +REPOSITORYLESS_BORG_COMMANDS = {'serve', None} + + +def run_arbitrary_borg( + repository, storage_config, options, archive=None, local_path='borg', remote_path=None +): + ''' + Given a local or remote repository path, a storage config dict, a sequence of arbitrary + command-line Borg options, and an optional archive name, run an arbitrary Borg command on the + given repository/archive. + ''' + lock_wait = storage_config.get('lock_wait', None) + + try: + options = options[1:] if options[0] == '--' else options + borg_command = options[0] + command_options = tuple(options[1:]) + except IndexError: + borg_command = None + command_options = () + + repository_archive = '::'.join((repository, archive)) if repository and archive else repository + + full_command = ( + (local_path,) + + ((borg_command,) if borg_command else ()) + + ((repository_archive,) if borg_command and repository_archive else ()) + + command_options + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + make_flags('remote-path', remote_path) + + make_flags('lock-wait', lock_wait) + ) + + return execute_command( + full_command, output_log_level=logging.WARNING, borg_local_path=local_path, + ) diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index 63f64a2..a9aa222 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -220,7 +220,8 @@ def create_archive( extra_borg_options = storage_config.get('extra_borg_options', {}).get('create', '') full_command = ( - (local_path, 'create') + tuple(local_path.split(' ')) + + ('create',) + _make_pattern_flags(location_config, pattern_file.name if pattern_file else None) + _make_exclude_flags(location_config, exclude_file.name if exclude_file else None) + (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ()) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 7516036..7600c1a 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -15,17 +15,18 @@ SUBPARSER_ALIASES = { 'restore': ['--restore', '-r'], 'list': ['--list', '-l'], 'info': ['--info', '-i'], + 'borg': [], } def parse_subparser_arguments(unparsed_arguments, subparsers): ''' - Given a sequence of arguments, and a subparsers object as returned by - argparse.ArgumentParser().add_subparsers(), give each requested action's subparser a shot at - parsing all arguments. This allows common arguments like "--repository" to be shared across - multiple subparsers. + Given a sequence of arguments and a dict from subparser name to argparse.ArgumentParser + instance, give each requested action's subparser a shot at parsing all arguments. This allows + common arguments like "--repository" to be shared across multiple subparsers. - Return the result as a dict mapping from subparser name to a parsed namespace of arguments. + Return the result as a tuple of (a dict mapping from subparser name to a parsed namespace of + arguments, a list of remaining arguments not claimed by any subparser). ''' arguments = collections.OrderedDict() remaining_arguments = list(unparsed_arguments) @@ -35,7 +36,12 @@ def parse_subparser_arguments(unparsed_arguments, subparsers): for alias in aliases } - for subparser_name, subparser in subparsers.choices.items(): + # If the "borg" action is used, skip all other subparsers. This avoids confusion like + # "borg list" triggering borgmatic's own list action. + if 'borg' in unparsed_arguments: + subparsers = {'borg': subparsers['borg']} + + for subparser_name, subparser in subparsers.items(): if subparser_name not in remaining_arguments: continue @@ -47,11 +53,11 @@ def parse_subparser_arguments(unparsed_arguments, subparsers): parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments) for value in vars(parsed).values(): if isinstance(value, str): - if value in subparsers.choices: + if value in subparsers: remaining_arguments.remove(value) elif isinstance(value, list): for item in value: - if item in subparsers.choices: + if item in subparsers: remaining_arguments.remove(item) arguments[canonical_name] = parsed @@ -59,47 +65,33 @@ def parse_subparser_arguments(unparsed_arguments, subparsers): # If no actions are explicitly requested, assume defaults: prune, create, and check. if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments: for subparser_name in ('prune', 'create', 'check'): - subparser = subparsers.choices[subparser_name] + subparser = subparsers[subparser_name] parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments) arguments[subparser_name] = parsed - return arguments - - -def parse_global_arguments(unparsed_arguments, top_level_parser, subparsers): - ''' - Given a sequence of arguments, a top-level parser (containing subparsers), and a subparsers - object as returned by argparse.ArgumentParser().add_subparsers(), parse and return any global - arguments as a parsed argparse.Namespace instance. - ''' - # Ask each subparser, one by one, to greedily consume arguments. Any arguments that remain - # are global arguments. remaining_arguments = list(unparsed_arguments) - present_subparser_names = set() - for subparser_name, subparser in subparsers.choices.items(): - if subparser_name not in remaining_arguments: + # Now ask each subparser, one by one, to greedily consume arguments. + for subparser_name, subparser in subparsers.items(): + if subparser_name not in arguments.keys(): continue - present_subparser_names.add(subparser_name) + subparser = subparsers[subparser_name] unused_parsed, remaining_arguments = subparser.parse_known_args(remaining_arguments) - # If no actions are explicitly requested, assume defaults: prune, create, and check. - if ( - not present_subparser_names - and '--help' not in unparsed_arguments - and '-h' not in unparsed_arguments - ): - for subparser_name in ('prune', 'create', 'check'): - subparser = subparsers.choices[subparser_name] - unused_parsed, remaining_arguments = subparser.parse_known_args(remaining_arguments) + # Special case: If "borg" is present in the arguments, consume all arguments after (+1) the + # "borg" action. + if 'borg' in arguments: + borg_options_index = remaining_arguments.index('borg') + 1 + arguments['borg'].options = remaining_arguments[borg_options_index:] + remaining_arguments = remaining_arguments[:borg_options_index] # Remove the subparser names themselves. - for subparser_name in present_subparser_names: + for subparser_name, subparser in subparsers.items(): if subparser_name in remaining_arguments: remaining_arguments.remove(subparser_name) - return top_level_parser.parse_args(remaining_arguments) + return (arguments, remaining_arguments) class Extend_action(Action): @@ -510,8 +502,7 @@ def parse_arguments(*unparsed_arguments): ) list_group = list_parser.add_argument_group('list arguments') list_group.add_argument( - '--repository', - help='Path of repository to list, defaults to the configured repository if there is only one', + '--repository', help='Path of repository to list, defaults to the configured repositories', ) list_group.add_argument('--archive', help='Name of archive to list (or "latest")') list_group.add_argument( @@ -601,8 +592,32 @@ def parse_arguments(*unparsed_arguments): ) info_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') - arguments = parse_subparser_arguments(unparsed_arguments, subparsers) - arguments['global'] = parse_global_arguments(unparsed_arguments, top_level_parser, subparsers) + borg_parser = subparsers.add_parser( + 'borg', + aliases=SUBPARSER_ALIASES['borg'], + help='Run an arbitrary Borg command', + description='Run an arbitrary Borg command based on borgmatic\'s configuration', + add_help=False, + ) + borg_group = borg_parser.add_argument_group('borg arguments') + borg_group.add_argument( + '--repository', + help='Path of repository to pass to Borg, defaults to the configured repositories', + ) + borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")') + borg_group.add_argument( + '--', + metavar='OPTION', + dest='options', + nargs='+', + help='Options to pass to Borg, command first ("create", "list", etc). "--" is optional. To specify the repository or the archive, you must use --repository or --archive instead of providing them here.', + ) + borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + + arguments, remaining_arguments = parse_subparser_arguments( + unparsed_arguments, subparsers.choices + ) + arguments['global'] = top_level_parser.parse_args(remaining_arguments) if arguments['global'].excludes_filename: raise ValueError( diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 1f41f4e..1c3de45 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -9,6 +9,7 @@ from subprocess import CalledProcessError import colorama import pkg_resources +from borgmatic.borg import borg as borg_borg from borgmatic.borg import check as borg_check from borgmatic.borg import create as borg_create from borgmatic.borg import environment as borg_environment @@ -543,6 +544,22 @@ def run_actions( ) if json_output: yield json.loads(json_output) + if 'borg' in arguments: + if arguments['borg'].repository is None or validate.repositories_match( + repository, arguments['borg'].repository + ): + logger.warning('{}: Running arbitrary Borg command'.format(repository)) + archive_name = borg_list.resolve_archive_name( + repository, arguments['borg'].archive, storage, local_path, remote_path + ) + borg_borg.run_arbitrary_borg( + repository, + storage, + options=arguments['borg'].options, + archive=archive_name, + local_path=local_path, + remote_path=remote_path, + ) def load_configurations(config_filenames, overrides=None): diff --git a/docs/Dockerfile b/docs/Dockerfile index d407fcc..f892730 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.8-alpine3.12 as borgmatic COPY . /app RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml RUN borgmatic --help > /command-line.txt \ - && for action in init prune create check extract mount umount restore list info; do \ + && for action in init prune create check extract export-tar mount umount restore list info borg; do \ echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \ && borgmatic "$action" --help >> /command-line.txt; done diff --git a/docs/how-to/develop-on-borgmatic.md b/docs/how-to/develop-on-borgmatic.md index 461c6cb..dc0327b 100644 --- a/docs/how-to/develop-on-borgmatic.md +++ b/docs/how-to/develop-on-borgmatic.md @@ -3,7 +3,7 @@ title: How to develop on borgmatic eleventyNavigation: key: Develop on borgmatic parent: How-to guides - order: 11 + order: 12 --- ## Source code diff --git a/docs/how-to/run-arbitrary-borg-commands.md b/docs/how-to/run-arbitrary-borg-commands.md new file mode 100644 index 0000000..c6e028e --- /dev/null +++ b/docs/how-to/run-arbitrary-borg-commands.md @@ -0,0 +1,94 @@ +--- +title: How to run arbitrary Borg commands +eleventyNavigation: + key: Run arbitrary Borg commands + parent: How-to guides + order: 10 +--- +## Running Borg with borgmatic + +Borg has several commands (and options) that borgmatic does not currently +support. Sometimes though, as a borgmatic user, you may find yourself wanting +to take advantage of these off-the-beaten-path Borg features. You could of +course drop down to running Borg directly. But then you'd give up all the +niceties of your borgmatic configuration. You could file a [borgmatic +ticket](https://torsion.org/borgmatic/#issues) or even a [pull +request](https://torsion.org/borgmatic/#contributing) to add the feature. But +what if you need it *now*? + +That's where borgmatic's support for running "arbitrary" Borg commands comes +in. Running Borg commands with borgmatic takes advantage of the following, all +based on your borgmatic configuration files or command-line arguments: + + * configured repositories (automatically runs your Borg command once for each + one) + * local and remote Borg binary paths + * SSH settings and Borg environment variables + * lock wait settings + * verbosity + + +### borg action + +The way you run Borg with borgmatic is via the `borg` action. Here's a simple +example: + +```bash +borgmatic borg break-lock +``` + +(No `borg` action in borgmatic? Time to upgrade!) + +This runs Borg's `break-lock` command once on each configured borgmatic +repository. Notice how the repository isn't present in the specified Borg +options, as that part is provided by borgmatic. + +You can also specify Borg options for relevant commands: + +```bash +borgmatic borg list --progress +``` + +This runs Borg's `list` command once on each configured borgmatic +repository. However, the native `borgmatic list` action should be preferred +for most use. + +What if you only want to run Borg on a single configured borgmatic repository +when you've got several configured? Not a problem. + +```bash +borgmatic borg --repository repo.borg break-lock +``` + +And what about a single archive? + +```bash +borgmatic borg --archive your-archive-name list +``` + +### Limitations + +borgmatic's `borg` action is not without limitations: + + * The Borg command you want to run (`create`, `list`, etc.) *must* come first + after the `borg` action. If you have any other Borg options to specify, + provide them after. For instance, `borgmatic borg list --progress` will work, + but `borgmatic borg --progress list` will not. + * borgmatic supplies the repository/archive name to Borg for you (based on + your borgmatic configuration or the `borgmatic borg --repository`/`--archive` + arguments), so do not specify the repository/archive otherwise. + * The `borg` action will not currently work for any Borg commands like `borg + serve` that do not accept a repository/archive name. + * Do not specify any global borgmatic arguments to the right of the `borg` + action. (They will be passed to Borg instead of borgmatic.) If you have + global borgmatic arguments, specify them *before* the `borg` action. + * Unlike other borgmatic actions, you cannot combine the `borg` action with + other borgmatic actions. This is to prevent ambiguity in commands like + `borgmatic borg list`, in which `list` is both a valid Borg command and a + borgmatic action. In this case, only the Borg command is run. + * Unlike normal borgmatic actions that support JSON, the `borg` action will + not disable certain borgmatic logs to avoid interfering with JSON output. + +In general, this `borgmatic borg` feature should be considered an escape +valveā€”a feature of second resort. In the long run, it's preferable to wrap +Borg commands with borgmatic actions that can support them fully. diff --git a/docs/how-to/upgrade.md b/docs/how-to/upgrade.md index 867fe26..2826b75 100644 --- a/docs/how-to/upgrade.md +++ b/docs/how-to/upgrade.md @@ -3,7 +3,7 @@ title: How to upgrade borgmatic eleventyNavigation: key: Upgrade borgmatic parent: How-to guides - order: 10 + order: 11 --- ## Upgrading diff --git a/tests/integration/commands/test_arguments.py b/tests/integration/commands/test_arguments.py index 174a316..0b19093 100644 --- a/tests/integration/commands/test_arguments.py +++ b/tests/integration/commands/test_arguments.py @@ -163,6 +163,24 @@ def test_parse_arguments_with_help_and_action_shows_action_help(capsys): assert 'create arguments:' in captured.out +def test_parse_arguments_with_action_before_global_options_parses_options(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + arguments = module.parse_arguments('prune', '--verbosity', '2') + + assert 'prune' in arguments + assert arguments['global'].verbosity == 2 + + +def test_parse_arguments_with_global_options_before_action_parses_options(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + arguments = module.parse_arguments('--verbosity', '2', 'prune') + + assert 'prune' in arguments + assert arguments['global'].verbosity == 2 + + def test_parse_arguments_with_prune_action_leaves_other_actions_disabled(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) diff --git a/tests/unit/borg/test_borg.py b/tests/unit/borg/test_borg.py new file mode 100644 index 0000000..ffd7835 --- /dev/null +++ b/tests/unit/borg/test_borg.py @@ -0,0 +1,123 @@ +import logging + +from flexmock import flexmock + +from borgmatic.borg import borg as module + +from ..test_verbosity import insert_logging_mock + + +def test_run_arbitrary_borg_calls_borg_with_parameters(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg' + ) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['break-lock'], + ) + + +def test_run_arbitrary_borg_with_log_info_calls_borg_with_info_parameter(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo', '--info'), + output_log_level=logging.WARNING, + borg_local_path='borg', + ) + insert_logging_mock(logging.INFO) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['break-lock'], + ) + + +def test_run_arbitrary_borg_with_log_debug_calls_borg_with_debug_parameter(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo', '--debug', '--show-rc'), + output_log_level=logging.WARNING, + borg_local_path='borg', + ) + insert_logging_mock(logging.DEBUG) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['break-lock'], + ) + + +def test_run_arbitrary_borg_with_lock_wait_calls_borg_with_lock_wait_parameters(): + storage_config = {'lock_wait': 5} + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo', '--lock-wait', '5'), + output_log_level=logging.WARNING, + borg_local_path='borg', + ) + + module.run_arbitrary_borg( + repository='repo', storage_config=storage_config, options=['break-lock'], + ) + + +def test_run_arbitrary_borg_with_archive_calls_borg_with_archive_parameter(): + storage_config = {} + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo::archive'), + output_log_level=logging.WARNING, + borg_local_path='borg', + ) + + module.run_arbitrary_borg( + repository='repo', storage_config=storage_config, options=['break-lock'], archive='archive', + ) + + +def test_run_arbitrary_borg_with_local_path_calls_borg_via_local_path(): + flexmock(module).should_receive('execute_command').with_args( + ('borg1', 'break-lock', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg1' + ) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['break-lock'], local_path='borg1', + ) + + +def test_run_arbitrary_borg_with_remote_path_calls_borg_with_remote_path_parameters(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo', '--remote-path', 'borg1'), + output_log_level=logging.WARNING, + borg_local_path='borg', + ) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['break-lock'], remote_path='borg1', + ) + + +def test_run_arbitrary_borg_passes_borg_specific_parameters_to_borg(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list', 'repo', '--progress'), + output_log_level=logging.WARNING, + borg_local_path='borg', + ) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['list', '--progress'], + ) + + +def test_run_arbitrary_borg_omits_dash_dash_in_parameters_passed_to_borg(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'break-lock', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg', + ) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=['--', 'break-lock'], + ) + + +def test_run_arbitrary_borg_without_borg_specific_parameters_does_not_raise(): + flexmock(module).should_receive('execute_command').with_args( + ('borg',), output_log_level=logging.WARNING, borg_local_path='borg', + ) + + module.run_arbitrary_borg( + repository='repo', storage_config={}, options=[], + ) diff --git a/tests/unit/commands/test_arguments.py b/tests/unit/commands/test_arguments.py index 3ef32c0..737cad3 100644 --- a/tests/unit/commands/test_arguments.py +++ b/tests/unit/commands/test_arguments.py @@ -5,146 +5,137 @@ from borgmatic.commands import arguments as module def test_parse_subparser_arguments_consumes_subparser_arguments_before_subparser_name(): action_namespace = flexmock(foo=True) - subparsers = flexmock( - choices={ - 'action': flexmock(parse_known_args=lambda arguments: (action_namespace, [])), - 'other': flexmock(), - } + subparsers = { + 'action': flexmock(parse_known_args=lambda arguments: (action_namespace, ['action'])), + 'other': flexmock(), + } + + arguments, remaining_arguments = module.parse_subparser_arguments( + ('--foo', 'true', 'action'), subparsers ) - arguments = module.parse_subparser_arguments(('--foo', 'true', 'action'), subparsers) - assert arguments == {'action': action_namespace} + assert remaining_arguments == [] def test_parse_subparser_arguments_consumes_subparser_arguments_after_subparser_name(): action_namespace = flexmock(foo=True) - subparsers = flexmock( - choices={ - 'action': flexmock(parse_known_args=lambda arguments: (action_namespace, [])), - 'other': flexmock(), - } + subparsers = { + 'action': flexmock(parse_known_args=lambda arguments: (action_namespace, ['action'])), + 'other': flexmock(), + } + + arguments, remaining_arguments = module.parse_subparser_arguments( + ('action', '--foo', 'true'), subparsers ) - arguments = module.parse_subparser_arguments(('action', '--foo', 'true'), subparsers) - assert arguments == {'action': action_namespace} + assert remaining_arguments == [] def test_parse_subparser_arguments_consumes_subparser_arguments_with_alias(): action_namespace = flexmock(foo=True) - action_subparser = flexmock(parse_known_args=lambda arguments: (action_namespace, [])) - subparsers = flexmock( - choices={ - 'action': action_subparser, - '-a': action_subparser, - 'other': flexmock(), - '-o': flexmock(), - } - ) + action_subparser = flexmock(parse_known_args=lambda arguments: (action_namespace, ['action'])) + subparsers = { + 'action': action_subparser, + '-a': action_subparser, + 'other': flexmock(), + '-o': flexmock(), + } flexmock(module).SUBPARSER_ALIASES = {'action': ['-a'], 'other': ['-o']} - arguments = module.parse_subparser_arguments(('-a', '--foo', 'true'), subparsers) + arguments, remaining_arguments = module.parse_subparser_arguments( + ('-a', '--foo', 'true'), subparsers + ) assert arguments == {'action': action_namespace} + assert remaining_arguments == [] def test_parse_subparser_arguments_consumes_multiple_subparser_arguments(): action_namespace = flexmock(foo=True) other_namespace = flexmock(bar=3) - subparsers = flexmock( - choices={ - 'action': flexmock( - parse_known_args=lambda arguments: (action_namespace, ['--bar', '3']) - ), - 'other': flexmock(parse_known_args=lambda arguments: (other_namespace, [])), - } - ) + subparsers = { + 'action': flexmock( + parse_known_args=lambda arguments: (action_namespace, ['action', '--bar', '3']) + ), + 'other': flexmock(parse_known_args=lambda arguments: (other_namespace, [])), + } - arguments = module.parse_subparser_arguments( + arguments, remaining_arguments = module.parse_subparser_arguments( ('action', '--foo', 'true', 'other', '--bar', '3'), subparsers ) assert arguments == {'action': action_namespace, 'other': other_namespace} + assert remaining_arguments == [] def test_parse_subparser_arguments_applies_default_subparsers(): prune_namespace = flexmock() create_namespace = flexmock(progress=True) check_namespace = flexmock() - subparsers = flexmock( - choices={ - 'prune': flexmock(parse_known_args=lambda arguments: (prune_namespace, ['--progress'])), - 'create': flexmock(parse_known_args=lambda arguments: (create_namespace, [])), - 'check': flexmock(parse_known_args=lambda arguments: (check_namespace, [])), - 'other': flexmock(), - } - ) + subparsers = { + 'prune': flexmock( + parse_known_args=lambda arguments: (prune_namespace, ['prune', '--progress']) + ), + 'create': flexmock(parse_known_args=lambda arguments: (create_namespace, [])), + 'check': flexmock(parse_known_args=lambda arguments: (check_namespace, [])), + 'other': flexmock(), + } - arguments = module.parse_subparser_arguments(('--progress'), subparsers) + arguments, remaining_arguments = module.parse_subparser_arguments(('--progress'), subparsers) assert arguments == { 'prune': prune_namespace, 'create': create_namespace, 'check': check_namespace, } + assert remaining_arguments == [] -def test_parse_global_arguments_with_help_does_not_apply_default_subparsers(): - global_namespace = flexmock(verbosity='lots') +def test_parse_subparser_arguments_passes_through_unknown_arguments_before_subparser_name(): action_namespace = flexmock() - top_level_parser = flexmock(parse_args=lambda arguments: global_namespace) - subparsers = flexmock( - choices={ - 'action': flexmock( - parse_known_args=lambda arguments: (action_namespace, ['--verbosity', 'lots']) - ), - 'other': flexmock(), - } + subparsers = { + 'action': flexmock( + parse_known_args=lambda arguments: (action_namespace, ['action', '--verbosity', 'lots']) + ), + 'other': flexmock(), + } + + arguments, remaining_arguments = module.parse_subparser_arguments( + ('--verbosity', 'lots', 'action'), subparsers ) - arguments = module.parse_global_arguments( - ('--verbosity', 'lots', '--help'), top_level_parser, subparsers - ) - - assert arguments == global_namespace + assert arguments == {'action': action_namespace} + assert remaining_arguments == ['--verbosity', 'lots'] -def test_parse_global_arguments_consumes_global_arguments_before_subparser_name(): - global_namespace = flexmock(verbosity='lots') +def test_parse_subparser_arguments_passes_through_unknown_arguments_after_subparser_name(): action_namespace = flexmock() - top_level_parser = flexmock(parse_args=lambda arguments: global_namespace) - subparsers = flexmock( - choices={ - 'action': flexmock( - parse_known_args=lambda arguments: (action_namespace, ['--verbosity', 'lots']) - ), - 'other': flexmock(), - } + subparsers = { + 'action': flexmock( + parse_known_args=lambda arguments: (action_namespace, ['action', '--verbosity', 'lots']) + ), + 'other': flexmock(), + } + + arguments, remaining_arguments = module.parse_subparser_arguments( + ('action', '--verbosity', 'lots'), subparsers ) - arguments = module.parse_global_arguments( - ('--verbosity', 'lots', 'action'), top_level_parser, subparsers - ) - - assert arguments == global_namespace + assert arguments == {'action': action_namespace} + assert remaining_arguments == ['--verbosity', 'lots'] -def test_parse_global_arguments_consumes_global_arguments_after_subparser_name(): - global_namespace = flexmock(verbosity='lots') - action_namespace = flexmock() - top_level_parser = flexmock(parse_args=lambda arguments: global_namespace) - subparsers = flexmock( - choices={ - 'action': flexmock( - parse_known_args=lambda arguments: (action_namespace, ['--verbosity', 'lots']) - ), - 'other': flexmock(), - } - ) +def test_parse_subparser_arguments_parses_borg_options_and_skips_other_subparsers(): + action_namespace = flexmock(options=[]) + subparsers = { + 'borg': flexmock(parse_known_args=lambda arguments: (action_namespace, ['borg', 'list'])), + 'list': flexmock(), + } - arguments = module.parse_global_arguments( - ('action', '--verbosity', 'lots'), top_level_parser, subparsers - ) + arguments, remaining_arguments = module.parse_subparser_arguments(('borg', 'list'), subparsers) - assert arguments == global_namespace + assert arguments == {'borg': action_namespace} + assert arguments['borg'].options == ['list'] + assert remaining_arguments == []