Call another click command from a click command
Asked Answered
L

4

66

I want to use some useful functions as commands. For that I am testing the click library. I defined my three original functions then decorated as click.command:

import click
import os, sys

@click.command()
@click.argument('content', required=False)
@click.option('--to_stdout', default=True)
def add_name(content, to_stdout=False):
    if not content:
        content = ''.join(sys.stdin.readlines())
    result = content + "\n\tadded name"
    if to_stdout is True:
        sys.stdout.writelines(result)
    return result


@click.command()
@click.argument('content', required=False)
@click.option('--to_stdout', default=True)
def add_surname(content, to_stdout=False):
    if not content:
        content = ''.join(sys.stdin.readlines())
    result = content + "\n\tadded surname"
    if to_stdout is True:
        sys.stdout.writelines(result)
    return result

@click.command()
@click.argument('content', required=False)
@click.option('--to_stdout', default=False)
def add_name_and_surname(content, to_stdout=False):
    result = add_surname(add_name(content))
    if to_stdout is True:
        sys.stdout.writelines(result)
    return result

This way I am able to generate the three commands add_name, add_surname and add_name_and_surname using a setup.py file and pip install --editable . Then I am able to pipe:

$ echo "original content" | add_name | add_surname 
original content

    added name
    added surname

However there is one slight problem I need to solve, when composing with different click commands as functions:

$echo "original content" | add_name_and_surname 
Usage: add_name_and_surname [OPTIONS] [CONTENT]

Error: Got unexpected extra arguments (r i g i n a l   c o n t e n t 
)

I have no clue why it does not work, I need this add_name_and_surname command to call add_name and add_surname not as command but as functions, else it defeats my original purpose of using functions as both library functions and commands.

Lying answered 17/10, 2016 at 16:28 Comment(1)
related: github.com/pallets/click/issues/40Promethium
F
65

Due to the click decorators the functions can no longer be called just by specifying the arguments. The Context class is your friend here, specifically:

  1. Context.invoke() - invokes another command with the arguments you supply
  2. Context.forward() - fills in the arguments from the current command

So your code for add_name_and_surname should look like:

@click.command()
@click.argument('content', required=False)
@click.option('--to_stdout', default=False)
@click.pass_context
def add_name_and_surname(ctx, content, to_stdout=False):
    result = ctx.invoke(add_surname, content=ctx.forward(add_name))
    if to_stdout is True:
        sys.stdout.writelines(result)
    return result

Reference: http://click.pocoo.org/6/advanced/#invoking-other-commands

Forsythe answered 17/10, 2016 at 23:0 Comment(3)
Seems to be the right answer but I'll keep in mind the fact that the doc does not advise using Context.invoke and Context.forward.Lying
I can't find why it is discouragedCoveney
Link: This is a pattern that is generally discouraged with Click, but possible nonetheless.Dactylography
P
69

When you call add_name() and add_surname() directly from another function, you actually call the decorated versions of them so the arguments expected may not be as you defined them (see the answers to How to strip decorators from a function in python for some details on why).

I would suggest modifying your implementation so that you keep the original functions undecorated and create thin click-specific wrappers for them, for example:

def add_name(content, to_stdout=False):
    if not content:
        content = ''.join(sys.stdin.readlines())
    result = content + "\n\tadded name"
    if to_stdout is True:
        sys.stdout.writelines(result)
    return result

@click.command()
@click.argument('content', required=False)
@click.option('--to_stdout', default=True)
def add_name_command(content, to_stdout=False):
    return add_name(content, to_stdout)

You can then either call these functions directly or invoke them via a CLI wrapper script created by setup.py.

This might seem redundant but in fact is probably the right way to do it: one function represents your business logic, the other (the click command) is a "controller" exposing this logic via command line (there could be, for the sake of example, also a function exposing the same logic via a Web service for example).

In fact, I would even advise to put them in separate Python modules - Your "core" logic and a click-specific implementation which could be replaced for any other interface if needed.

Pardoner answered 17/10, 2016 at 19:43 Comment(5)
The answer regarding passing click context seems more suitable to the question. However both this and stripping a decorator are said to be dirty and are not advised to use. In the end your controller based alternative is more 'generic' and reliable. I'll go this way thanks a lot!Lying
+1 to this one, since Click discourages calling one command from another one. This separation of concerns to business logic and cli controller wrapper IMHO makes sense.Urial
If you want the CLI version to have the same name as the "core" function, pass the name argument as in @click.command(name='add_name') above.Stannary
This is a better answer in all cases IMHO and you end up with cleaner and clearer code in all cases.Nibelung
This is a good solution for the reasons described in previous comments, but one drawback: if your command decorator defines default values, you will need your functions calling the un-decorated command function to know what those defaults are.Rolfston
F
65

Due to the click decorators the functions can no longer be called just by specifying the arguments. The Context class is your friend here, specifically:

  1. Context.invoke() - invokes another command with the arguments you supply
  2. Context.forward() - fills in the arguments from the current command

So your code for add_name_and_surname should look like:

@click.command()
@click.argument('content', required=False)
@click.option('--to_stdout', default=False)
@click.pass_context
def add_name_and_surname(ctx, content, to_stdout=False):
    result = ctx.invoke(add_surname, content=ctx.forward(add_name))
    if to_stdout is True:
        sys.stdout.writelines(result)
    return result

Reference: http://click.pocoo.org/6/advanced/#invoking-other-commands

Forsythe answered 17/10, 2016 at 23:0 Comment(3)
Seems to be the right answer but I'll keep in mind the fact that the doc does not advise using Context.invoke and Context.forward.Lying
I can't find why it is discouragedCoveney
Link: This is a pattern that is generally discouraged with Click, but possible nonetheless.Dactylography
P
5

You can also call a Click function using the callback member function under some conditions. As per this GitHub issue:

Assuming you know the given command is a direct wrapper for a function you wrote (and not a group or other type of command), you can get at the function with command.callback. However, calling it will just call the function, it won't invoke any of the Click pipeline for validation, callbacks, etc.

Your example will become:

(...)
def add_name_and_surname(content, to_stdout=False):
    result = add_surname.callback(add_name.callback(content))
    (...)
Picard answered 5/11, 2021 at 14:47 Comment(0)
M
4

I found these solutions more complicated. I wanted this function below to be called from another place in another package:

@click.command(help='Clean up')
@click.argument('path', nargs=1, default='def')
@click.option('--info', '-i', is_flag=True,
              help='some info1')
@click.option('--total', '-t', is_flag=True,
              help='some info2')
def clean(path, info, total):
#some definition, some actions

#this function will help us
def get_function(function_name):
if function_name == 'clean':
    return clean

I have another package, so, I would like click command above in this pack

import somepackage1 #here is clean up click command
from click.testing import CliRunner


@check.command(context_settings=dict(
           ignore_unknown_options=True,
          ))
@click.argument('args', nargs=-1)
@click.pass_context
def check(ctx, args):
    runner = CliRunner()

    if len(args[0]) == 0:
        logger.error('Put name of a command')

    if len(args) > 0:
        result = runner.invoke(somepackage1.get_function(args[0]), args[1:])
        logger.print(result.output)
    else:
        result = runner.invoke(somepackage1.get_function(args[0]))
    logger.print(result.output)

So, it works.

python somepackage2 check clean params1 --info
Mir answered 13/1, 2020 at 10:44 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.