Search code examples
pythonpython-click

Python Click - display help for subcommand even if exception is raised in group command


I am writing a command-line script, mycli, with two subcommands:

  • mkcli init (to initialize an empty project with a .configrc file)
  • mkcli run (to run the main logic of the script).

In general, mycli run should not work if a .configrc file is not found in the working directory. However, my users should be able to look at the help message for run:

$ mycli run --help
Usage: mycli run [OPTIONS]

Options:
  --dryrun  Run in read-only mode
  --help    Show this message and exit.

However, this does not work if .configrc does not exist because FileNotFoundError is raised in the group command cli (and run is never reached). I can get the init subcommand to fire without first finding a .configrc file by using ctx.invoked_subcommand (see below), but I see no way to ensure that the run subcommand will always fire if it is invoked with --help.

If a user runs mkcli run and no .configrc file is found, my script exits with run "mycli init" first. But mycli run --help should work even if there is no .configrc. How can I do this? Or can anyone suggest a better way to handle init?

@click.group()
@click.pass_context
def cli(ctx):

    ctx.obj = {}
    if ctx.invoked_subcommand != "init":
        config = yaml.load(open(".configrc").read())
        ctx.obj.update({key: config[key] for key in config})

@cli.command()
@click.pass_context
def init(ctx):
    print("Initialize project.")

@cli.command()
@click.option("--dryrun", type=bool, is_flag=True, help="Run in read-only mode")
@click.pass_context
def run(ctx, dryrun):
    print("Run main program here.")

Solution

  • I would suggest changing the order in which your init code is run. That can be done with a...

    Custom Class:

    class LoadInitForCommands(click.Group):
    
        def command(self, *args, **kwargs):
    
            def decorator(f):
                # call the original decorator
                cmd = click.command(*args, **kwargs)(f)
                self.add_command(cmd)
                orig_invoke = cmd.invoke
    
                def invoke(ctx):
                    # Custom init code is here
                    ctx.obj = {}
                    if cmd.name != "init":
                        config = yaml.load(open(".configrc").read())
                        ctx.obj.update({key: config[key] for key in config})
    
                    # call the original invoke()
                    return orig_invoke(ctx)
    
                # hook the command's invoke
                cmd.invoke = invoke
                return cmd
    
            return decorator
    

    Using the Custom Class:

    Pass the Custom Class to click.group() using cls parameter like:

    @click.group(cls=LoadInitForCommands)
    def cli():
        """"""
    

    How does this work?

    This works because click is a well designed OO framework. The @click.group() decorator usually instantiates a click.Group object but allows this behavior to be over-ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over ride the desired methods.

    In this case, we hook the command() decorator and in that hook, we override the invoke() for the command. This allows the init file to be read after the --help flag has already been processed.

    Note this code is meant to make it easy to have many commands for which the --help would be available before the init is read. In the example in the question there is only one command that needs the init. If this always the case, then this answer might be appealing.

    Test Code:

    import click
    import yaml
    
    @click.group(cls=LoadInitForCommands)
    def cli():
        """"""
    
    @cli.command()
    @click.pass_context
    def init(ctx):
        print("Initialize project.")
    
    
    @cli.command()
    @click.option("--dryrun", type=bool, is_flag=True,
                  help="Run in read-only mode")
    @click.pass_context
    def run(ctx, dryrun):
        print("Run main program here.")
    
    
    if __name__ == "__main__":
        commands = (
            'init',
            'run --help',
            'run',
            '--help',
        )
    
        import sys, time
    
        time.sleep(1)
        print('Click Version: {}'.format(click.__version__))
        print('Python Version: {}'.format(sys.version))
        for cmd in commands:
            try:
                time.sleep(0.1)
                print('-----------')
                print('> ' + cmd)
                time.sleep(0.1)
                cli(cmd.split(), obj={})
    
            except BaseException as exc:
                if str(exc) != '0' and \
                        not isinstance(exc, (click.ClickException, SystemExit)):
                    raise
    

    Results:

    Click Version: 6.7
    Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]
    -----------
    > init
    Initialize project.
    -----------
    > run --help
    Usage: test.py run [OPTIONS]
    
    Options:
      --dryrun  Run in read-only mode
      --help    Show this message and exit.
    -----------
    > run
    Traceback (most recent call last):
      File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\pydevd.py", line 1741, in <module>
        main()
      File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\pydevd.py", line 1735, in main
        globals = debugger.run(setup['file'], None, None, is_module)
      File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\pydevd.py", line 1135, in run
        pydev_imports.execfile(file, globals, locals)  # execute the script
      File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\_pydev_imps\_pydev_execfile.py", line 18, in execfile
        exec(compile(contents+"\n", file, 'exec'), glob, loc)
      File "C:/Users/stephen/Documents/src/testcode/test.py", line 77, in <module>
        cli(cmd.split(), obj={})
      File "C:\Users\stephen\AppData\Local\Programs\Python\Python36\lib\site-packages\click\core.py", line 722, in __call__
        return self.main(*args, **kwargs)
      File "C:\Users\stephen\AppData\Local\Programs\Python\Python36\lib\site-packages\click\core.py", line 697, in main
        rv = self.invoke(ctx)
      File "C:\Users\stephen\AppData\Local\Programs\Python\Python36\lib\site-packages\click\core.py", line 1066, in invoke
        return _process_result(sub_ctx.command.invoke(sub_ctx))
      File "C:/Users/stephen/Documents/src/testcode/test.py", line 26, in invoke
        config = yaml.load(open(".configrc").read())
    FileNotFoundError: [Errno 2] No such file or directory: '.configrc'