3

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?

1 Answer 1

7

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
Sign up to request clarification or add additional context in comments.

1 Comment

Works like a charm and I went for the first option. Thanks a lot!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.