3

I have a program that needs to have an option to either test a list of server ids OR issue a command against the server. This means, if I issue --test, then nothing else is required. It runs the whole gamut of tests against each server and prints the results.

However, if I do NOT specify --test, then it should require a few options such as --id and --command.

However, I'm not sure that argparse can handle required options within mutually exclusive groups. Code (modified for simplicity) is as follows. I've modified the options so if you specify -a, then you SHOULD be good to go and no other options be necessary.

import argparse

parser = argparse.ArgumentParser()

test_or_not = parser.add_mutually_exclusive_group(required=True)
test_or_not.add_argument('-a', action='store_true')
or_not = test_or_not.add_argument_group()
target = or_not.add_mutually_exclusive_group(required=True)
target.add_argument('-b',action="store_true")
target.add_argument('-c',action="store_true")
target.add_argument('-d',action="store_true")
target.add_argument('-e',action="store_true")
group = or_not.add_mutually_exclusive_group(required=True)
group.add_argument('-f',action="store_true")
group.add_argument('-g',action="store_true")
or_not.add_argument('-i',action="store_true")
or_not.add_argument('-j',action="store_true")
or_not.add_argument('-k',action="store_true")
or_not.add_argument('-l',action="store_true")

args = parser.parse_args()

The resulting error is produced because argparse is still requiring individual options even though they're in a mutually exclusive group. Is there a way that argparse can accommodate this set of options or do I need to add a bit of programming outside of argparse?

$ python3 ~/tmp/groups.py -a
usage: groups.py [-h] -a (-b | -c | -d | -e) (-f | -g) [-i] [-j] [-k] [-l]
groups.py: error: one of the arguments -b -c -d -e is required

Edit: I could add a new option that entirely works OUTSIDE of argparse as below, but I'd like to keep it structured within argparse if at all possible.

import argparse
import sys

if '--test' in sys.argv:
    go_do_testing()
    sys.exit(0)

parser = argparse.ArgumentParser()
<snip>
UtahJarhead
  • 2,091
  • 1
  • 14
  • 21
  • Couldn't you make the `test` option a `subparser` and tie the `subparser` to the function that checks those servers? – Abdou Oct 16 '18 at 14:50
  • I did not know this was a thing. I swear I've read through and referenced the argparse docs a hundred times over, but Hand to God, I've never noticed that. I do feel kinda dumb... – UtahJarhead Oct 16 '18 at 15:08
  • haha. If it comes close to helping with what you're trying to do and that you could not find a duplicate of this on here, then please feel free to write an answer to share your findings. – Abdou Oct 16 '18 at 15:15
  • I was just going to say the same to you. Please make this an answer so I can properly accept it. – UtahJarhead Oct 16 '18 at 15:37
  • If [the solution here](https://gist.github.com/AbdouSeck/cb50fbbb2c99b4d9cd3e62305b3c6716) looks good, it can be posted here as well. – Abdou Oct 16 '18 at 16:50
  • Argument_group and mutually_exclusive_groups are not designed to be nested. A mutually_exclusive_group can be put in an argument_group for the purpose of help line labeling, but that's it. Argument_group is a help line grouping mechanism. Mutually_exclusive_group is a testing mechanism (and can show up in the usage line). Other than `subparsers` there isn't a mechanism in `argparse` for 'mutually-inclusive' testing or other more complex logical combinations. You have to do that kind of testing yourself, after parsing. – hpaulj Oct 17 '18 at 05:49
  • @hpaulj, I agree with you and that's exactly what I found with Abdou's answer. Now, if only I could get him to post it as a real answer so I could accept it.... ;) – UtahJarhead Oct 17 '18 at 14:13
  • @UtahJarhead, apologies about the delay. Please see posted answer. Thanks! – Abdou Oct 18 '18 at 00:30

1 Answers1

5

As suggested in the comments, the way to go, if you wish to have mutually exclusive test and run logics, would be to use subparsers. The following is an illustration of the idea:

#!/usr/bin/env python3
"""
Script to test or run commands on given servers.
./the_script.py test  # To test all servers
./the_script.py run --id 127.0.0.1 --command "echo hello world"
"""
from argparse import ArgumentParser, RawDescriptionHelpFormatter as RDHF


def test_servers(servers):
    """
    Given a list of servers, let's test them!
    """
    for server in servers:
        print('Just tested server {s}'.format(s=server))

def do_actual_work(server_id, command):
    """
    Given a server ID and a command, let's run the command on that server!
    """
    print('Connected to server {s}'.format(s=server_id))
    print('Ran command {c} successfully'.format(c=command))


if __name__ == '__main__':
    parser = ArgumentParser(description=__doc__, formatter_class=RDHF)
    subs = parser.add_subparsers()
    subs.required = True
    subs.dest = 'run or test'
    test_parser = subs.add_parser('test', help='Test all servers')
    test_parser.set_defaults(func=test_servers)
    run_parser = subs.add_parser('run', help='Run a command on the given server')
    run_parser.add_argument('-i', '--id',
                            help='The ID of the server to connect to and run commands',
                            required=True)
    run_parser.add_argument('-c', '--command',
                            help='The command to run',
                            required=True)
    run_parser.set_defaults(func=do_actual_work)
    args = parser.parse_args()
    
    if args.func.__name__ == 'test_servers':
        all_servers = ['127.0.0.1', '127.0.0.2']
        test_servers(all_servers)
    else:
        do_actual_work(args.id, args.command)

The script sets up both mutually exclusive and required subparsers test and run. For the test subparser, nothing else is required. However, for the run subparser, both --id and --command would be required. Each of these subparsers is associated with its designated target function. For simplicity I had the test_parser tied to test_servers; while run_parser is associated with do_actual_work.

Further, you should be able to call the script as follows to run all tests:

./the_script.py test

To run a specific command on a specific server, you call the script as follows:

./the_script.py run --id 127 --command "echo hello world"

I hope this proves useful.

Abdou
  • 12,931
  • 4
  • 39
  • 42