Search code examples
pythonpython-click

Click: Use another function in chained commands with context object


I recently have been using the click package to build command line interfaces which has worked perfectly, so far.

Now I got into some trouble when using chained commands in combination with the context object. The problem is that I somehow get an error when I want to call the function of another command from within another command.

It is probably somehow related to the usage of decorators within click but I don't see the error right now.

This is a minimal example of my code:

import click


@click.group(chain=True)
@click.option('--some_common_option', type=float, default=1e-10)
@click.pass_context
def cli(ctx, some_common_option):
    # save shared params within context object for different commands
    for k, v in locals().items():
        if 'ctx' not in k:
            ctx.obj[k] = v

    return True


@cli.command()
@click.argument('some_argument', type=str)
@click.pass_context
def say_something(ctx, some_argument):
    print(some_argument)

    return True


@cli.command()
@click.argument('some_other_argument', type=str)
@click.pass_context
def say_more(ctx, some_other_argument):
    ctx.obj['text'] = some_other_argument
    say_something(ctx, ctx.obj['text'])

    return True


if __name__ == '__main__':
    cli(obj={})

And this is the error that is provided on the terminal:

$ python test.py say_something 'Hello!'
Hello!
$ python test.py say_more 'How are you?'
Traceback (most recent call last):
  File "test.py", line 36, in <module>
    cli(obj={})
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 697, in main
    rv = self.invoke(ctx)
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 1092, in invoke
    rv.append(sub_ctx.command.invoke(sub_ctx))
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 895, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 535, in invoke
    return callback(*args, **kwargs)
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/decorators.py", line 17, in new_func
    return f(get_current_context(), *args, **kwargs)
  File "test.py", line 30, in say_more
    say_something(ctx, ctx.obj['text'])
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "/home/user/.anaconda3/lib/python3.6/site-packages/click/core.py", line 683, in main
    args = list(args)
TypeError: 'Context' object is not iterable
$ 

I am wondering why and where the iteration over the context object takes place.

Any hints how I can fix this and use the function from within another command?


Solution

  • If you can edit your click command functions, you can organize them like this:

    @cli.command()
    @click.argument('some_argument', type=str)
    @click.pass_context
    def say_something(ctx, some_argument):
        return _say_something(ctx, some_argument):
    
    def _say_something(ctx, some_argument):
        print(some_argument)
    

    If built like this, then you can call _say_something() function as an undecorated (normal) Python function.

    If you can not edit your commands

    Building on this answer you can pass the context to another click command using this function:

    Code:

    def call_click_command_with_ctx(cmd, ctx, *args, **kwargs):
        """ Wrapper to call a click command with a Context object
    
        :param cmd: click cli command function to call
        :param ctx: click context
        :param args: arguments to pass to the function
        :param kwargs: keyword arguments to pass to the function
        :return: None
        """
    
        # monkey patch make_context
        def make_context(*some_args, **some_kwargs):
            child_ctx = click.Context(cmd, parent=ctx)
            with child_ctx.scope(cleanup=False):
                cmd.parse_args(child_ctx, list(args))
            return child_ctx
    
        cmd.make_context = make_context
        prev_make_context = cmd.make_context
    
        # call the command
        call_click_command(cmd, *args, **kwargs)
    
        # restore make_context
        cmd.make_context = prev_make_context
    

    How does this work?

    This works because click is a well designed OO framework. The @click.Command object can be introspected to determine what parameters it is expecting. Then a command line can be constructed that will look like the command line that click is expecting. In addition, the make_context method of the command can be overridden to allow the command context to used by the command.

    Code from Previous Answer:

    def call_click_command(cmd, *args, **kwargs):
        """ Wrapper to call a click command
    
        :param cmd: click cli command function to call
        :param args: arguments to pass to the function
        :param kwargs: keywrod arguments to pass to the function
        :return: None
        """
    
        # Get positional arguments from args
        arg_values = {c.name: a for a, c in zip(args, cmd.params)}
        args_needed = {c.name: c for c in cmd.params
                       if c.name not in arg_values}
    
        # build and check opts list from kwargs
        opts = {a.name: a for a in cmd.params if isinstance(a, click.Option)}
        for name in kwargs:
            if name in opts:
                arg_values[name] = kwargs[name]
            else:
                if name in args_needed:
                    arg_values[name] = kwargs[name]
                    del args_needed[name]
                else:
                    raise click.BadParameter(
                        "Unknown keyword argument '{}'".format(name))
    
    
        # check positional arguments list
        for arg in (a for a in cmd.params if isinstance(a, click.Argument)):
            if arg.name not in arg_values:
                raise click.BadParameter("Missing required positional"
                                         "parameter '{}'".format(arg.name))
    
        # build parameter lists
        opts_list = sum(
            [[o.opts[0], str(arg_values[n])] for n, o in opts.items()], [])
        args_list = [str(v) for n, v in arg_values.items() if n not in opts]
    
        # call the command
        cmd(opts_list + args_list)
    

    Test Code:

    import click
    
    @click.group(chain=True)
    @click.option('--some_common_option', type=float, default=1e-10)
    @click.pass_context
    def cli(ctx, some_common_option):
        # save shared params within context object for different commands
        for k, v in locals().items():
            if 'ctx' not in k:
                ctx.obj[k] = v
    
    
    @cli.command()
    @click.argument('some_argument', type=str)
    @click.pass_context
    def say_something(ctx, some_argument):
        print(some_argument)
    
    
    @cli.command()
    @click.argument('some_other_argument', type=str)
    @click.pass_context
    def say_more(ctx, some_other_argument):
        ctx.obj['text'] = some_other_argument
        call_click_command_with_ctx(say_something, ctx, ctx.obj['text'])
    
    
    if __name__ == "__main__":
        commands = (
            'say_something something',
            'say_more more',
            '--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)]
    -----------
    > say_something something
    something
    -----------
    > say_more more
    more
    -----------
    > --help
    Usage: test.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
    
    Options:
      --some_common_option FLOAT
      --help                      Show this message and exit.
    
    Commands:
      say_more
      say_something