diff --git a/argh/assembling.py b/argh/assembling.py index b0bd755..0d96ad2 100644 --- a/argh/assembling.py +++ b/argh/assembling.py @@ -21,7 +21,8 @@ from argh.compat import OrderedDict from argh.constants import (ATTR_ALIASES, ATTR_ARGS, ATTR_NAME, ATTR_EXPECTS_NAMESPACE_OBJECT, - PARSER_FORMATTER, DEFAULT_ARGUMENT_TEMPLATE) + PARSER_FORMATTER, DEFAULT_ARGUMENT_TEMPLATE, + ATTR_TOGGLEABLES) from argh.utils import get_subparsers, get_arg_spec from argh.exceptions import AssemblingError @@ -201,6 +202,8 @@ def set_default_command(parser, function): declared_args = getattr(function, ATTR_ARGS, []) inferred_args = list(_get_args_from_signature(function)) + toggleables = getattr(function, ATTR_TOGGLEABLES, []) + if inferred_args and declared_args: # We've got a mixture of declared and inferred arguments @@ -271,6 +274,22 @@ def set_default_command(parser, function): # pack the modified data back into a list inferred_args = dests.values() + opt_string_togmap = {} + + for toggleable, inv_prefix in [(t, i) for t,i in toggleables]: + if toggleable.find('_') >= 0: + raise AssemblingError("Toggleable destinations cannot contain underscores") + + #for each toggleable, verify there exists a matching destination + matched_dest = False + for arg in inferred_args: + for opt_str in arg['option_strings']: + if opt_str == toggleable: + matched_dest = True + opt_string_togmap[opt_str] = (toggleable[2:], inv_prefix) + if not matched_dest: + raise AssemblingError("Unrecognized destination for toggleable: {}".format(toggleable)) + command_args = inferred_args or declared_args # add types, actions, etc. (e.g. default=3 implies type=int) @@ -280,12 +299,33 @@ def set_default_command(parser, function): draft = draft.copy() if 'help' not in draft: draft.update(help=DEFAULT_ARGUMENT_TEMPLATE) + dest_or_opt_strings = draft.pop('option_strings') if parser.add_help and '-h' in dest_or_opt_strings: dest_or_opt_strings = [x for x in dest_or_opt_strings if x != '-h'] completer = draft.pop('completer', None) try: - action = parser.add_argument(*dest_or_opt_strings, **draft) + if dest_or_opt_strings[-1] in opt_string_togmap: + # if we're working with a toggleable list of opt_strings, make mutually exclusive + # and set to opposite defaults & storing actions + toggleable, inv_prefix = opt_string_togmap[dest_or_opt_strings[-1]] + group = parser.add_mutually_exclusive_group() + + draft['action'] = 'store_true' + draft['dest'] = toggleable.replace('-', '_') + + # XXX unsure about desired behavior in autocompletion of toggleables case + action = group.add_argument(*dest_or_opt_strings, **draft) + + not_dest_or_opt_strings = tuple(map(lambda x : '--%s-%s' % (inv_prefix, x.lstrip('-')), + dest_or_opt_strings)) + + draft['action'] = 'store_false' + + group.add_argument(*not_dest_or_opt_strings, **draft) + else: + action = parser.add_argument(*dest_or_opt_strings, **draft) + if COMPLETION_ENABLED and completer: action.completer = completer except Exception as e: diff --git a/argh/constants.py b/argh/constants.py index f304d8b..7f0cd43 100644 --- a/argh/constants.py +++ b/argh/constants.py @@ -13,7 +13,7 @@ __all__ = ( 'ATTR_NAME', 'ATTR_ALIASES', 'ATTR_ARGS', 'ATTR_WRAPPED_EXCEPTIONS', 'ATTR_WRAPPED_EXCEPTIONS_PROCESSOR', 'ATTR_EXPECTS_NAMESPACE_OBJECT', - 'PARSER_FORMATTER', 'DEFAULT_ARGUMENT_TEMPLATE' + 'PARSER_FORMATTER', 'DEFAULT_ARGUMENT_TEMPLATE', 'ATTR_TOGGLEABLES' ) @@ -39,6 +39,9 @@ # forcing argparse.Namespace object instead of signature introspection ATTR_EXPECTS_NAMESPACE_OBJECT = 'argh_expects_namespace_object' +#toggleable +ATTR_TOGGLEABLES = 'argh_toggleables' + # # Other library-wide stuff # diff --git a/argh/decorators.py b/argh/decorators.py index 526f43d..beddb49 100644 --- a/argh/decorators.py +++ b/argh/decorators.py @@ -15,11 +15,13 @@ from argh.constants import (ATTR_ALIASES, ATTR_ARGS, ATTR_NAME, ATTR_WRAPPED_EXCEPTIONS, ATTR_WRAPPED_EXCEPTIONS_PROCESSOR, - ATTR_EXPECTS_NAMESPACE_OBJECT) + ATTR_EXPECTS_NAMESPACE_OBJECT, + ATTR_TOGGLEABLES) -__all__ = ['aliases', 'named', 'arg', 'wrap_errors', 'expects_obj'] +__all__ = ['aliases', 'named', 'arg', 'wrap_errors', 'expects_obj', 'set_toggleable', 'set_all_toggleable'] +from argh.utils import get_arg_spec def named(new_name): """ @@ -173,3 +175,45 @@ def foo(bar, quux=123): """ setattr(func, ATTR_EXPECTS_NAMESPACE_OBJECT, True) return func + +def set_toggleable(name, inv_prefix = 'no'): + """ + Marks given name as toggleable, creating an additional, + mutually exclusive parser argument to toggle a boolean. + + :param name: + Name of boolean argument for which to create a toggleable argument + :param inv_prefix: + Prefix of inversion argument + + Usage:: + + @arg('--do-foo', '--do-foo-alias') + @set_toggleable('--do-foo') + @set_toggleable('--do-foo-2', inv_prefix = 'invert') + def foo(x, do_foo = True, do_foo_2 = False): + print x, do_foo, do_foo_2 + """ + + def wrapper(func): + toggleables = getattr(func, ATTR_TOGGLEABLES, []) + toggleables.append((name, inv_prefix)) + setattr(func, ATTR_TOGGLEABLES, toggleables) + return func + return wrapper + +def set_all_toggleable(inv_prefix = 'no'): + + def wrapper(func): + spec = get_arg_spec(func) + + toggleables = getattr(func, ATTR_TOGGLEABLES, []) + + for (dest, default) in zip(spec.args[-len(spec.defaults):], spec.defaults): + if isinstance(default, bool): + cmd_dest = dest.replace('_', '-') + toggleables.append(('--' + cmd_dest, inv_prefix)) + + setattr(func, ATTR_TOGGLEABLES, toggleables) + return func + return wrapper diff --git a/argh/dispatching.py b/argh/dispatching.py index 93ad434..f7f980f 100644 --- a/argh/dispatching.py +++ b/argh/dispatching.py @@ -109,7 +109,7 @@ def dispatch(parser, argv=None, add_help_command=True, # this will raise SystemExit if parsing fails args = parse_args(argv, namespace=namespace) - + if hasattr(args, 'function'): if pre_call: # XXX undocumented because I'm unsure if it's OK # Actually used in real projects: diff --git a/test/test_integration.py b/test/test_integration.py index 7229a0a..c70850d 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -15,7 +15,6 @@ from .base import DebugArghParser, run, CmdResult as R - @pytest.mark.xfail(reason='TODO') def test_guessing_integration(): "guessing is used in dispatching" @@ -760,8 +759,89 @@ def func(): assert func.__doc__ in p.format_help() -def test_unknown_args(): +def test_simple_toggle(): + @argh.set_toggleable('--toggle') + def cmd(toggle = False): + return "Toggle is {0}".format(toggle) + + p = DebugArghParser() + p.set_default_command(cmd) + + assert run(p, '') == R(out='Toggle is False\n', err='') + assert run(p, '--toggle') == R(out='Toggle is True\n', err='') + assert run(p, '--no-toggle') == R(out='Toggle is False\n', err='') + assert run(p, '--not-toggle', exit = True) == \ + 'unrecognized arguments: --not-toggle' + assert run(p, '--toggle --no-toggle', exit = True) == \ + 'argument --no-t/--no-toggle: not allowed with argument -t/--toggle' + +def test_multiarg_toggle(): + @argh.set_toggleable('--toggle1') + @argh.set_toggleable('--toggle2', inv_prefix = 'dont') + def cmd(toggle1 = False, + toggle2 = True, + toggle3 = False, + foo = 3): + return "toggle1: {0}, toggle2: {1}, toggle3: {2}, foo: {3}".format(\ + toggle1, toggle2, toggle3, foo) + p = DebugArghParser() + p.set_default_command(cmd) + + assert run(p, '') == R(out="toggle1: False, toggle2: True, toggle3: False, foo: 3\n", + err='') + assert run(p, '--dont-toggle2') == \ + R(out="toggle1: False, toggle2: False, toggle3: False, foo: 3\n", + err='') + assert run(p, '--foo 10 --toggle1 --dont-toggle2') == \ + R(out="toggle1: True, toggle2: False, toggle3: False, foo: 10\n", + err='') + + +def test_multitoggle(): + + @argh.set_all_toggleable(inv_prefix = 'temper') + @argh.arg('usa_nuke_count', type = int) + @argh.arg('russia_nuke_count', type = int) + def faceoff(usa_nuke_count, + russia_nuke_count, + scenario = 'cold_war', + usa_aggression = True, + russia_aggression = False): + + + if usa_aggression and russia_aggression and usa_nuke_count >= 1 and russia_nuke_count >= 1: + result = 'M.A.D.' + elif usa_aggression and usa_nuke_count >= 1: + result = 'Russia is destroyed :(' + elif russia_aggression and russia_nuke_count >= 1: + result = 'USA is destroyed :(' + elif usa_nuke_count + russia_nuke_count >= 1: + result = 'isolationism' + elif usa_aggression and russia_aggression: + result = 'impotence' + else: + result = 'peace' + + return result + + p = DebugArghParser() + p.set_default_command(faceoff) + + assert run(p, '1 1') == \ + R(out='Russia is destroyed :(\n', + err='') + assert run(p, '0 0 --temper-usa-aggression') == R(out='peace\n', err='') + assert run(p, '0 1 --temper-usa-aggression --russia-aggression') == \ + R(out='USA is destroyed :(\n', err='') + assert run(p, '0 0 --usa-aggression --russia-aggression') == \ + R(out='impotence\n', err='') + assert run(p, '100 100 --usa-aggression --russia-aggression') == \ + R(out='M.A.D.\n', err='') + assert run(p, '0 0 --temper-usa-aggression --temper-russia-aggression') == \ + R(out='peace\n', err='') + +def test_unknown_args(): def cmd(foo=1): return foo @@ -770,6 +850,7 @@ def cmd(foo=1): assert run(p, '--foo 1') == R(out='1\n', err='') assert run(p, '--bar 1', exit=True) == 'unrecognized arguments: --bar 1' + assert run(p, '--bar 1', exit=False, kwargs={'skip_unknown_args': True}) == \ R(out='usage: py.test [-h] [-f FOO]\n\n', err='')