pythonpython-cmd

multi-word commands in custom python interactive shell


I'm trying to build a small interactive shell in Python using the cmd module. Is there an easy way to allow for multi-word commands?

For example, it's easy to process the hello command

class FooShell(Cmd):
    def do_hello(self, args):
        print("Hi")

But what if I wanted something more complicated. Let's say I'm trying to implement an SQL shell and want to write show tables. The show command can take multiple targets such as show track_counts or show bonjour. If I wanted to process something like this in the cmd module, it looks like I would have to write the following:

class FooShell(Cmd):
    def do_show(self, line):
        args = line.strip().split(" ")
        if args == []:
            print("Error: show command requires arguments")
        else:
            if args[0] == "tables":
                pass # logic here
            elif args[0] == "bonjour":
                pass # logic here
            elif args[0] == "track_counts":
                pass # logic here
            else:
                print("{} is not a valid target for the 'show' command".format(args[0]))
                print("Valid targets are tables, bonjour, track_counts")

There are a few problems with this approach:

Another way of writing the above would be like this:

class FooShell(Cmd):
    def do_show_tables(self, args):
        pass

    def do_show_bonjour(self, args):
        pass

    def do_show_track_counts(self, args):
        pass

    def do_show(self, line):
        args = line.strip().split(" ")
        if args == []:
            print("Error: show command requires arguments")
        else:
            handlers = {
                "tables": self.do_show_tables,
                "bonjour": self.do_show_bonjour,
                "track_counts": self.do_show_track_counts
            }
            handler = handlers.get(args[0], None)
            if handler:
                handler(args[1:])
            else:
                print("{} is not a valid target for the 'show' command".format(args[0]))
                targets = ", ".join([key for key in handlers])
                print("Valid targets are: {}".format(targets))

But this still does not give tab completion after the 'show' command. Additionally, it now feels like I'm basically rewriting the core functionality of the cmd module.

Is there a simpler way to do this? Should I be using another module instead of cmd?

EDIT: to be clear, I am not actually writing an SQL shell, just using that as an example of how I want multi-word commands to be parsed.


Solution

  • This can be achieved using argparse subparsers in the cmd2 module.

    Here's an official example: https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py

    It boils down to four things:

    If all of this is present, you can modify your command to call the subcommand functions (taken from the official example in case the link goes down):

        @cmd2.with_argparser(base_parser)
        def do_base(self, args):
            """Base command help"""
            func = getattr(args, 'func', None)
            if func is not None:
                # Call whatever subcommand function was selected
                func(self, args)
            else:
                # No subcommand was provided, so call help
                self.do_help('base')
    

    It supports:

    Here's a sample with an error message from running the official example:

    (Cmd) base bar apple
    Usage: base bar [-h] {apple, artichoke, cranberries} ... z
    Error: the following arguments are required: z
    

    Following this here's the original answer, with how to achieve this in vanilla cmd, in case you don't want to add a dependency.

    Command parsing itself does indeed look ugly, but tab completion can be achieved.

    The recon-ng module uses the same approach using the cmd module, it can be made less ugly by using arrays for the command lists and separate functions for the sub-command parsing.

    Here's a simple example written using the cmd module for tab completion (without separate functions to keep it simple), to show how it works:

    def do_example(self, params):
        'Example action command'
    
        subs = params.split(' ')
    
        if subs[0] == 'first-l2' and subs[1] == 'second-l3':
            print("Second l3 called in first l2")
        if subs[0] == 'second-l2' and subs[1] == 'first-l3':
            print("First l3 called in second l2")
    
    def complete_example(self, text, line, *ignored):
        'Interactive tab completion for example command'
    
        subs = ['first-l2', 'second-l2']
    
        args = line.split(' ')
    
        if args[1] in subs:
            subsubs = ['first-l3', 'second-l3']
    
            if args[2] in subsubs:
                return []
    
            return [sub for sub in subsubs if sub.startswith(text)]
    
        return [sub for sub in subs if sub.startswith(text)]
    

    Basically, when you're writing the complete function, you should only return suggestions about the current level. The previous levels will already be there.

    The cliff package does not have autocompletion in interactive mode.