Zionsof
Zionsof

Reputation: 1246

Invoke python click command with custom class from another command

So here's my issue:

Assume that I have built a CLI using Python Click for which I have created custom classes of groups and commands that wrap invoking to catch exceptions:

logger = logging.getLogger()
class CLICommandInvoker(click.Command):
    def invoke(self, ctx):
        command = ctx.command.name
        try:
            logger.info("Running {command} command".format(command=command))
            ret = super(CLICommandInvoker, self).invoke(ctx)
            logger.info("Completed {command} command".format(command=command))
            return ret

        except Exception as exc:
            logger.error(
                'Command {command} failed with exception: {exc}'.format(command=command, exc=exc)
            )

            """ In case command invoked from another command """
            raise Exception("Failed to invoke {command} command".format(command=command))


class CLIGroupInvoker(click.Group):
    def invoke(self, ctx):
        group = ctx.command.name
        try:
            ret = super(CLIGroupInvoker, self).invoke(ctx)
            group_subcommand = ctx.invoked_subcommand
            logger.info(
                'Command "{group}-{subcommand}" completed successfully'.format(group = group, subcommand = group_subcommand)
            )
            return ret

        except Exception:
            group_subcommand = ctx.invoked_subcommand
            logger.error(
                'Command "{group}-{subcommand}" failed'.format(group=group, subcommand=group_subcommand)
            )

Now, for example I have two commands in a certain group:

@click.group(cls=CLIGroupInvoker)
def g():
    pass

@g.command(cls=CLICommandInvoker)
def c1():
    print("C1")

@g.command(cls=CLICommandInvoker)
@click.pass_context
def c2(ctx):
    ctx.invoke(c1)
    print("C2")

So, the code runs fine, but the invoke method of the context in c2 does not run the custom invoke in my CLICommandInvoker, but goes straight to the c1 function instead. I don't see the Running c1 command or other logs that are in the custom invoke regarding c1, only those about c2.

So, what am I doing wrong here? How can I have the command invocation use the custom class when invoking commands from another command? Or is that not possible?

I know there is a solution to simply refactor the code to extract the implementation itself and simply have the commands "wrap" the actual logic, but let's say that for the moment it's not possible.

Upvotes: 2

Views: 3746

Answers (1)

Stephen Rauch
Stephen Rauch

Reputation: 49814

The trouble you are running into, is that you are calling click.Context.invoke, which does not use the click.Command.invoke. With a little DRY we can factor out your invoke wrapper and use it like:

Code:

def invoke_with_catch(self, ctx, original_invoke):

    fmt = dict(command=getattr(ctx, 'command', ctx).name)
    try:
        click.echo("Running {command} command".format(**fmt))
        result = original_invoke(self, ctx)
        click.echo("Completed {command} command".format(**fmt))
        return result

    except Exception as exc:
        click.echo(
            'Command {command} failed with exception: {exc}'.format(
                exc=exc, **fmt)
        )

        """ In case command invoked from another command """
        raise click.ClickException(
            "Failed to invoke {command} command".format(**fmt))

Calling the Code:

The wrapper can be called directly like:

invoke_with_catch(ctx, c1, click.Context.invoke)

or can be used in the inherited class like:

class CLICommandInvoker(click.Command):
    def invoke(self, ctx):
        return invoke_with_catch(self, ctx, click.Command.invoke)

Test Code:

import click

class CLICommandInvoker(click.Command):
    def invoke(self, ctx):
        return invoke_with_catch(self, ctx, click.Command.invoke)


class CLIGroupInvoker(click.Group):
    def invoke(self, ctx):
        return invoke_with_catch(self, ctx, click.Group.invoke)


@click.group(cls=CLIGroupInvoker)
def g():
    pass

@g.command(cls=CLICommandInvoker)
@click.option("--throw", is_flag=True)
def c1(throw):
    click.echo("C1")
    if throw:
        raise Exception('Throwing in C1')

@g.command(cls=CLICommandInvoker)
@click.option("--throw", is_flag=True)
@click.pass_context
def c2(ctx, throw):
    invoke_with_catch(ctx, c1, click.Context.invoke)
    click.echo("C2")
    if throw:
        raise Exception('Throwing in C2')


if __name__ == "__main__":
    commands = (
        'c1',
        'c1 --throw',
        'c2',
        'c2 --throw',
        '--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)
            g(cmd.split())

        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)]
-----------
> c1
Running g command
Running c1 command
C1
Completed c1 command
Completed g command
-----------
> c1 --throw
Running g command
Running c1 command
C1
Command c1 failed with exception: Throwing in C1
Command g failed with exception: Failed to invoke c1 command
Error: Failed to invoke g command
-----------
> c2
Running g command
Running c2 command
Running c1 command
C1
Completed c1 command
C2
Completed c2 command
Completed g command
-----------
> c2 --throw
Running g command
Running c2 command
Running c1 command
C1
Completed c1 command
C2
Command c2 failed with exception: Throwing in C2
Command g failed with exception: Failed to invoke c2 command
Error: Failed to invoke g command
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  c1
  c2
-----------
> 
Usage: test.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  c1
  c2

Upvotes: 3

Related Questions