Search code examples
pythonexceptionsetuptoolspython-click

Python Click: Exception Handling under setuptools


I have a python click application that works great, but I want to be notified whenever a user types in an unknown command. For example, if mycli foo is valid, but they type in mycli bar, I want to override the default exception handling behavior and fire off an error to an error tracker, such as rollbar.

I found this page which describes how to override the exception handling, but it assumes I have a Command. The problem I've run into is that I've also integrated with setuptools by following this guide, and it points to my Command in the [console_scripts] section. For example, yourscript=yourscript:cli points to the cli command.

I'm not sure how to call cli.main() from within [console_scripts] or if that's even the right way of thinking about it.


Solution

  • With a custom click.Command class, you can capture the invoking command line and then report any error in the command line in an exception handler using a custom class like:

    Custom Class

    def CatchAllExceptions(cls, handler):
    
        class Cls(cls):
    
            _original_args = None
    
            def make_context(self, info_name, args, parent=None, **extra):
    
                # grab the original command line arguments
                self._original_args = ' '.join(args)
    
                try:
                    return super(Cls, self).make_context(
                        info_name, args, parent=parent, **extra)
                except Exception as exc:
                    # call the handler
                    handler(self, info_name, exc)
    
                    # let the user see the original error
                    raise
    
            def invoke(self, ctx):
                try:
                    return super(Cls, self).invoke(ctx)
                except Exception as exc:
                    # call the handler
                    handler(self, ctx.info_name, exc)
    
                    # let the user see the original error
                    raise
    
        return Cls
    
    
    def handle_exception(cmd, info_name, exc):
        # send error info to rollbar, etc, here
        click.echo(':: Command line: {} {}'.format(info_name, cmd._original_args))
        click.echo(':: Raised error: {}'.format(exc))
    

    Using the custom class

    Then to use the custom command/group, pass it as the cls argument to the click.command or click.group decorator like one of:

    @click.command(cls=CatchAllExceptions(click.Command, handler=report_exception))
    
    @click.group(cls=CatchAllExceptions(click.Group, handler=report_exception))
    
    @click.group(cls=CatchAllExceptions(click.MultiCommand, handler=report_exception))
    

    Note the need to specify which click.Command subclass is required as well as the handler to send the exception information to.

    How does this work?

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

    In this case we over ride click.Command.make_context() to grab the original command line, and click.Command.invoke() to catch the exception and then call our exception handler.

    Test Code:

    import click
    
    @click.group(cls=CatchAllExceptions(click.Group, handler=report_exception))
    def cli():
        """A wonderful test program"""
        pass
    
    @cli.command()
    def foo():
        """A fooey command"""
        click.echo('Foo!')
    
    
    if __name__ == "__main__":
        commands = (
            'foo',
            'foo --unknown',
            'foo still unknown',
            '',
            '--help',
            'foo --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())
    
            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)]
    -----------
    > foo
    Foo!
    -----------
    > foo --unknown
    Error: no such option: --unknown
    :: Command line: test.py foo --unknown
    :: Raised error: no such option: --unknown
    -----------
    > foo still unknown
    :: Command line: test.py foo still unknown
    :: Raised error: Got unexpected extra arguments (still unknown)
    Usage: test.py foo [OPTIONS]
    
    Error: Got unexpected extra arguments (still unknown)
    -----------
    > 
    Usage: test.py [OPTIONS] COMMAND [ARGS]...
    
      A wonderful test program
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      foo  A fooey command
    -----------
    > --help
    Usage: test.py [OPTIONS] COMMAND [ARGS]...
    
      A wonderful test program
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      foo  A fooey command
    -----------
    > foo --help
    Usage: test.py foo [OPTIONS]
    
      A fooey command
    
    Options:
      --help  Show this message and exit.