'How can I create a continuous / infinite CLI with Click?

I'm trying to use Click to create a CLI for my Python 3 app. Basically I need the app to run continuously, waiting for user commands and executing them, and quitting if a specific command (say, "q") is entered. Couldn't find an example in Click docs or elsewhere.

An example of interactive shell would be like this:

myapp.py
> PLEASE ENTER LOGIN: 
mylogin
> PLEASE ENTER PASSWORD:
mypwd
> ENTER COMMAND: 
a
> Wrong command! 
> USAGE: COMMAND [q|s|t|w|f] OPTIONS ARGUMENTS
> ENTER COMMAND:
f
> (output of "f" command...)
> ENTER COMMAND:
q
> QUITTING APP...

I've tried like so:

import click

quitapp = False # global flag

@click.group()
def cli():
    pass

@cli.command(name='c')
@click.argument('username')
def command1(uname):
    pass # do smth

# other commands...

@cli.command(name='q')
def quitapp():
    global quitapp
    quitapp = True

def main():    
    while not quitapp:
        cli()

if __name__ == '__main__':
    main()

But the console just runs the app once all the same.



Solution 1:[1]

I've actually switched to fire and managed to make a shell-like continuous function like so:

COMMAND_PROMPT = '\nCOMMAND? [w to quit] >'
CAPTCHA_PROMPT = '\tEnter captcha text (see your browser) >'
BYE_MSG = 'QUITTING APP...'
WRONG_CMD_MSG = 'Wrong command! Type "h" for help.'
EMPTY_CMD_MSG = 'Empty command!'

class MyClass:
    def __init__(self):
        # dict associating one-letter commands to methods of this class
        self.commands = {'r': self.reset, 'q': self.query, 'l': self.limits_next, 'L': self.limits_all, 
                'y': self.yandex_logo, 'v': self.view_params, 'h': self.showhelp, 'c': self.sample_captcha, 'w': None}
        # help (usage) strings
        self.usage = '\nUSAGE:\t[{}] [value1] [value2] [--param3=value3] [--param4=value4]'.format('|'.join(sorted(self.commands.keys())))
        self.usage2 = '\t' + '\n\t'.join(['{}:{}'.format(fn, self.commands[fn].__doc__) for fn in self.commands if fn != 'w'])

    def run(self):
        """
        Provides a continuously running commandline shell.        
        The one-letter commands used are listed in the commands dict.
        """
        entered = ''
        while True:
            try:
                print(COMMAND_PROMPT, end='\t')
                entered = str(input())
                if not entered:
                    print(EMPTY_CMD_MSG)
                    continue
                e = entered[0]
                if e in self.commands:
                    if self.commands[e] is None: 
                        print(BYE_MSG)
                        break
                    cmds = entered.split(' ')
                    # invoke Fire to process command & args
                    fire.Fire(self.commands[e], ' '.join(cmds[1:]) if len(cmds) > 1 else '-')
                else:
                    print(WRONG_CMD_MSG)
                    self.showhelp()
                    continue     
            except KeyboardInterrupt:
                print(BYE_MSG)
                break

            except Exception:
                continue

    # OTHER METHODS...

if __name__ == '__main__':
    fire.Fire(MyClass)

Still, I'd appreciate if someone showed how to do that with click (which appears to me to be more feature-rich than fire).

Solution 2:[2]

I've finally found out other libraries for interactive shells in Python: cmd2 and prompt, which are way more advanced for REPL-like shells out of the box...

Solution 3:[3]

There's a quick example of how to do a continuous CLI application with Click here: python click module input for each function

It only has a way of running click commands on a loop, but you can put in any custom logic you want, either in commands or the main body of the loop. Hope it helps!

Solution 4:[4]

Here I found click in the loop but it is error prone when we try to use different commands with different options

!Caution: This is not a perfect solution

import click
import cmd
import sys

from click import BaseCommand, UsageError


class REPL(cmd.Cmd):
    def __init__(self, ctx):
        cmd.Cmd.__init__(self)
        self.ctx = ctx

    def default(self, line):
        subcommand = line.split()[0]
        args = line.split()[1:]

        subcommand = cli.commands.get(subcommand)
        if subcommand:
            try:
                subcommand.parse_args(self.ctx, args)
                self.ctx.forward(subcommand)
            except UsageError as e:
                print(e.format_message())
        else:
            return cmd.Cmd.default(self, line)


@click.group(invoke_without_command=True)
@click.pass_context
def cli(ctx):
    if ctx.invoked_subcommand is None:
        repl = REPL(ctx)
        repl.cmdloop()

# Both commands has --foo but if it had different options,
# it throws error after using other command
@cli.command()
@click.option('--foo', required=True)
def a(foo):
    print("a")
    print(foo)
    return 'banana'


@cli.command()
@click.option('--foo', required=True)
def b(foo):
    print("b")
    print(foo)

# Throws c() got an unexpected keyword argument 'foo' after executing above commands
@cli.command()
@click.option('--bar', required=True)
def c(bar):
    print("b")
    print(bar)

if __name__ == "__main__":
    cli()

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 s0mbre
Solution 2 s0mbre
Solution 3 afterburner
Solution 4 Nannigalaxy