Call a click command from code
Asked Answered
C

5

44

I have a function which is wrapped as a command using click. So it looks like this:

@click.command()
@click.option('-w', '--width', type=int, help="Some helping message", default=0)
[... some other options ...]
def app(width, [... some other option arguments...]):
    [... function code...]

I have different use cases for this function. Sometimes, calling it through the command line is fine, but sometime I would also like to call directly the function

from file_name import app
width = 45
app(45, [... other arguments ...]) 

How can we do that? How can we call a function that has been wrapped as a command using click? I found this related post, but it is not clear to me how to adapt it to my case (i.e., build a Context class from scratch and use it outside of a click command function).

EDIT: I should have mentioned: I cannot (easily) modify the package that contains the function to call. So the solution I am looking for is how to deal with it from the caller side.

Considered answered 5/2, 2018 at 9:40 Comment(1)
It's not clear enough (for me) what you have given externally.Vevay
A
19

I tried with Python 3.7 and Click 7 the following code:

import click

@click.command()
@click.option('-w', '--width', type=int, default=0)
@click.option('--option2')
@click.argument('argument')
def app(width, option2, argument):
    click.echo("params: {} {} {}".format(width, option2, argument))
    assert width == 3
    assert option2 == '4'
    assert argument == 'arg'


app(["arg", "--option2", "4", "-w", 3], standalone_mode=False)

app(["arg", "-w", 3, "--option2", "4" ], standalone_mode=False)

app(["-w", 3, "--option2", "4", "arg"], standalone_mode=False)

All the app calls are working fine!

Atrice answered 18/1, 2019 at 17:48 Comment(4)
I get only one call: params: 3 4 arg in py 3.6.8, click 7.0. Inconvenient, that one cannot call app(1,2,3) anymore...Pocked
@Pocked Do you mean that after the first app call the program exits? I had this issue and fixed it by surrounding each app call with a try except statement. In particular except SystemExitAtrice
Nice. This was the simplest solution for me. TLutyens
Run with standalone_mode=False to run multiple commands: example: app(["arg", "--option2", "4", "-w", 3], standalone_mode=False)Cork
P
13

This use-case is described in the docs.

Sometimes, it might be interesting to invoke one command from another command. This is a pattern that is generally discouraged with Click, but possible nonetheless. For this, you can use the Context.invoke() or Context.forward() methods.

cli = click.Group()

@cli.command()
@click.option('--count', default=1)
def test(count):
    click.echo('Count: %d' % count)

@cli.command()
@click.option('--count', default=1)
@click.pass_context
def dist(ctx, count):
    ctx.forward(test)
    ctx.invoke(test, count=42)

They work similarly, but the difference is that Context.invoke() merely invokes another command with the arguments you provide as a caller, whereas Context.forward() fills in the arguments from the current command.

Perryperryman answered 24/12, 2020 at 10:10 Comment(1)
There is a valid use case for this: setting up automatic scripts using setuptools.py, which will require some wrapped invocation (since they do not accept args).Truism
G
12

You can call a click command function from regular code by reconstructing the command line from parameters. Using your example it could look somthing like this:

call_click_command(app, width, [... other arguments ...])

Code:

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)

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.

Test Code:

import click

@click.command()
@click.option('-w', '--width', type=int, default=0)
@click.option('--option2')
@click.argument('argument')
def app(width, option2, argument):
    click.echo("params: {} {} {}".format(width, option2, argument))
    assert width == 3
    assert option2 == '4'
    assert argument == 'arg'


width = 3
option2 = 4
argument = 'arg'

if __name__ == "__main__":
    commands = (
        (width, option2, argument, {}),
        (width, option2, dict(argument=argument)),
        (width, dict(option2=option2, argument=argument)),
        (dict(width=width, option2=option2, argument=argument),),
    )

    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('> {}'.format(cmd))
            time.sleep(0.1)
            call_click_command(app, *cmd[:-1], **cmd[-1])

        except BaseException as exc:
            if str(exc) != '0' and \
                    not isinstance(exc, (click.ClickException, SystemExit)):
                raise

Test 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)]
-----------
> (3, 4, 'arg', {})
params: 3 4 arg
-----------
> (3, 4, {'argument': 'arg'})
params: 3 4 arg
-----------
> (3, {'option2': 4, 'argument': 'arg'})
params: 3 4 arg
-----------
> ({'width': 3, 'option2': 4, 'argument': 'arg'},)
params: 3 4 arg
Guest answered 11/2, 2018 at 0:51 Comment(2)
Click is fundamentally insane. The only way to call a simple Click-decorated function is to define a non-trivial 50-line sys.argv factory function running in O(n**2) time for n the number of positional arguments that dynamically (and expensively) decompiles a Python argument list into a POSIX-compliant argument list? This is truly awful – yet I have no doubt this is what Click demands. Click devs: your core premise needs a hard rethink.Kery
@CecilCurry. I remember you from my time contributing to pyinstaller in early 2017! The thing to understand is that click is the plumbing for a command line handler. If you need callables that are not just being hooked into click's handler tree you should build those explicitly. The above code is really only meant for those who can't or won't separate the plumbing from the business logic.Guest
S
10

If you just want to call the underlying function, you can directly access it as click.Command.callback. Click stores the underlying wrapped Python function as a class member. Note that directly calling the function will bypass all Click validation and any Click context information won't be there.

Here is an example code that iterates all click.Command objects in the current Python module and makes a dictionary of callable functions out from them.

from functools import partial
from inspect import getmembers

import click


all_functions_of_click_commands = {}

def _call_click_command(cmd: click.Command, *args, **kwargs):
    result = cmd.callback(*args, **kwargs)
    return result

# Pull out all Click commands from the current module
module = sys.modules[__name__]
for name, obj in getmembers(module):
    if isinstance(obj, click.Command) and not isinstance(obj, click.Group):
        # Create a wrapper Python function that calls click Command.
        # Click uses dash in command names and dash is not valid Python syntax
        name = name.replace("-", "_") 
        # We also set docstring of this function correctly.
        func = partial(_call_click_command, obj)
        func.__doc__ = obj.__doc__
        all_functions_of_click_commands[name] = func

A full example can be found in binance-api-test-tool source code.

Susiesuslik answered 23/4, 2021 at 9:8 Comment(0)
E
1

Building on Pierre Monico's answer: if you don't want to call the command from another command, you can also create the Context object yourself:

@click.command()
@click.option('--count', default=1)
def test(count):
    click.echo('Count: %d' % count)


ctx = click.Context(test)
ctx.forward(test, count=42)

Using invoke() is possible too, of course. As mentioned before, the difference is that forward() also fills in defaults.

Embayment answered 1/3 at 10:12 Comment(1)
Worked like a charm :)Aristotelianism

© 2022 - 2024 — McMap. All rights reserved.