3

I am using Python 3.7 and Cement 3.0.4.

What I have is a base app with a controller and the controller has a command that takes a single optional argument. What I'm seeing is if I pass the command an invalid argument I get an invalid argument error as I expect but I am getting the "usage" output for the app itself rather than the command on the controller. Here is an example:

from cement import App, Controller, ex

class Base(Controller):
    class Meta:
        label = 'base'


    @ex(help='example sub-command')
    def cmd1(self):
        print('Inside Base.cmd1()')

    @ex(
        arguments=[
            (['-n', '--name'],
             {'help': ''': The name you want printed out''',
              'dest': 'name',
              'required': False}),
            ],
        help=' the help for cmd2.')
    def cmd2(self):
        print(self.app.pargs.name)

This app has a command called cmd2 and it takes an optional argument of -n which, as the help states, will be printed out. So if I do this:

with MyApp(argv=['cmd2', '-n', 'bob']) as app:
    app.run()

I will the output: bob as expected. However if I pass an invalid argument to cmd2:

with MyApp(argv=['cmd2', '-a', 'bob']) as app:
    app.run()

I get:

usage: myapp [-h] [-d] [-q] {cmd1,cmd2} ...
myapp: error: unrecognized arguments: -a bob

What I would like to see, instead of the usage for myapp, would be the usage for the cmd2 command, similar to if I did -h on the command:

with MyApp(argv=['cmd2', '-h']) as app:
    app.run()

outputs

usage: myapp cmd2 [-h] [-n NAME]

optional arguments:
  -h, --help            show this help message and exit
  -n NAME, --name NAME  : The name you want printed out

I realize much of this is delegated to Argparse and is not handled by Cement. I've done some debugging and I'm seeing there are multiple ArgparseArgumentHandler classes nested. So in the case above there is an ArgparseArgumentHandler for myapp and it has in it's actions a SubParsersAction that has a choices field that has a map containing my two commands on the controller, cmd1 and cmd2 mapped to their own ArgparseArgumentHandler.

When the invalid argument is detected it is within the ArgparseArgumentHandler for myapp and thus it calls print_usage() on myapp rather than on the ArgparseArgumentHandler for the invoked command, cmd2.

My knowledge of Argparse is limited and I do find it a bit complex to navigate. The only workaround I can think of right now is subclassing ArgparseArgumentHandler, and overriding error() and trying to determine if the error is due to recognized arguments and if so try to find the parser for it.. something like this pseudocode:

class ArgparseArgumentOverride(ext_argparse.ArgparseArgumentHandler):

    def error(self, message):
         # determine if there are unknown args
        args, argv = self.parse_known_args(self.original_arguments, self.original_namespace)
        # we are in an error state and have unrecognized args
        if argv:
            controller_namespace = args.__controller_namespace__


            for action in self._actions:
                if action.choices is not None:
                    # we found an choice with our namespace
                    if action.choices[controller_namespace]:
                        command_parser= action.choices[controller_namespace]
                        # this should be the show_usage for the command
                        complete_command.print_usage(sys.stderr)

Again above is pseudocode and actually doing something like that would feel very fragile, error prone, and unpredictable. I know there has to be an better way to do this, I'm just not finding it. I've been digging through the docs and source for hours and still haven't found what I'm looking for. Could anyone tell me what I'm missing? Any advice on how to proceed here would be really appreciated. Thanks much!

TheMethod
  • 2,893
  • 9
  • 41
  • 72

1 Answers1

2

I'm not familiar cement, but as you deduce the usage is generated by argparse:

In [235]: parser = argparse.ArgumentParser(prog='myapp')                                                             
In [236]: parser.add_argument('-d');                                                                                 
In [237]: sp = parser.add_subparsers(dest='cmd')                                                                     
In [238]: sp1 = sp.add_parser('cmd1')                                                                                
In [239]: sp2 = sp.add_parser('cmd2')                                                                                
In [240]: sp2.add_argument('-n','--name');                                                                           

In [241]: parser.parse_args('cmd2 -a'.split())                                                                       
usage: myapp [-h] [-d D] {cmd1,cmd2} ...
myapp: error: unrecognized arguments: -a

If the error is tied to a sp2 argument, then the usage reflects that:

In [242]: parser.parse_args('cmd2 -n'.split())                                                                       
usage: myapp cmd2 [-h] [-n NAME]
myapp cmd2: error: argument -n/--name: expected one argument

But unknown args are handled by the main parser. For example if we use parse_known_args instead:

In [245]: parser.parse_known_args('cmd2 foobar'.split())                                                             
Out[245]: (Namespace(cmd='cmd2', d=None, name=None), ['foobar'])
In [246]: parser.parse_known_args('cmd2 -a'.split())                                                                 
Out[246]: (Namespace(cmd='cmd2', d=None, name=None), ['-a'])

The unknown args are returned as the extras list. parse_args returns the error instead of the extras.

In _SubParsers_Action.__call__, the relevant code is:

    # parse all the remaining options into the namespace
    # store any unrecognized options on the object, so that the top
    # level parser can decide what to do with them

    # In case this subparser defines new defaults, we parse them
    # in a new namespace object and then update the original
    # namespace for the relevant parts.
    subnamespace, arg_strings = parser.parse_known_args(arg_strings, None)
    for key, value in vars(subnamespace).items():
        setattr(namespace, key, value)

    if arg_strings:
        vars(namespace).setdefault(_UNRECOGNIZED_ARGS_ATTR, [])
        getattr(namespace, _UNRECOGNIZED_ARGS_ATTR).extend(arg_strings)

In theory you could construct an alternate _SubParsersAction class (or subclass), that handles that arg_strings differently. Changing the parse_known_args call to parse_args might be enough:

    subnamespace = parser.parse_args(arg_strings, None)

Note that parse_args calls parse_known_args, and raises the error if there are unknowns:

def parse_args(self, args=None, namespace=None):
    args, argv = self.parse_known_args(args, namespace)
    if argv:
        msg = _('unrecognized arguments: %s')
        self.error(msg % ' '.join(argv))
    return args
hpaulj
  • 221,503
  • 14
  • 230
  • 353