Why doesn't python-click pass context between subcommands? - python

I'm trying to pass contexts between two subcommands with python-click. Here's an MWE:
import click
#click.group(chain=True)
def cli() -> None:
pass
#cli.command()
#click.pass_context
def fn1(cxt):
cxt.obj = 1
#cli.command()
#click.pass_context
def fn2(cxt):
print(f'{cxt.obj=}')
if __name__ == '__main__':
cli()
If I call this with cli.py fn1 fn2, I expect to get "cxt.obj=1", whereas I get "cxt.obj=None".
While trying to debug, I also noticed I can access the cli's context from fn1 and fn2, but not fn1's context from fn2. I can therefore, set the object of cli's context in fn1, and read from cli's context in fn2, but there must be a better way, where the obj is directly accessible in fn2's context.
What is wrong with my mental model, why doesn't the context persist between subcommands? And, what is the best pattern to use when one needs to pass data between subcommands?

The context is copied from a parent to a child. In your case context.obj for cli() which you do not expose in your example, is None. The None is copied into the child context for fn1() and fn2(), but then in fn1() you change the copy to 1. Finally, in fn2() you receive a copy of the context for cli() in which obj is still None
So to achieve your desired result there are (at least) two options.
Make context.obj on the parent context a mutable object, and then mutate said object
As you indicated was possible, directly access the parent context via something like ctx.parent.obj = 1
An example of the first (my preferred) method:
Example
import click, sys
#click.group(chain=True)
#click.pass_context
def cli(ctx) -> None:
ctx.obj = {}
#cli.command()
#click.pass_context
def fn1(ctx):
ctx.obj['fn1'] = 1
#cli.command()
#click.pass_context
def fn2(ctx):
click.echo(f'obj: {ctx.obj}')
Test Code
if __name__ == "__main__":
print('Click Version: {}'.format(click.__version__))
print('Python Version: {}'.format(sys.version))
print('-----------')
cmd = 'fn1 fn2'
print('> ' + cmd)
cli(cmd.split())
Results:
Click Version: 8.1.3
Python Version: 3.7.8 (tags/v3.7.8:4b47a5b6ba, Jun 28 2020, 08:53:46) [MSC v.1916 64 bit (AMD64)]
-----------
> fn1 fn2
obj: {'fn1': 1}

Related

Can I use a context value as a click.option() default?

I want to use a value in my configuration (which I load into my context) as the default value for a click command option. I've read this section of the documentation, and I don't think I'm understanding what I need do.
This is my example script:
import sys
import click
#click.group()
#click.pass_context
def cli(ctx):
"""
CLI
"""
ctx.ensure_object(dict)
ctx.obj['DEFAULT_ENVIRONMENT'] = "dev"
#cli.command()
#click.option('-e', '--environment', required=True, default=click.get_current_context().obj['DEFAULT_ENVIRONMENT'])
def show_env(environment):
click.echo(environment)
if __name__ == '__main__':
cli()
The goal, if I run python cli.py show-env, is to get it to output dev (because I didn't pass the parameter as it's loaded from context).
This fails with
Traceback (most recent call last):
File "testcli.py", line 15, in <module>
#click.option('-e', '--environment', required=True, default=click.get_current_context().obj['DEFAULT_ENVIRONMENT'])
File "/home/devuser/.virtualenvs/cli/lib/python3.6/site-packages/click/globals.py", line 26, in get_current_context
raise RuntimeError('There is no active click context.')
RuntimeError: There is no active click context.
I have also tried by using #pass_context on my show_env command like so:
#cli.command()
#click.option('-e', '--environment', required=True, default=ctx.obj['DEFAULT_ENVIRONMENT'])
#click.pass_context
def show_env(ctx, environment):
click.echo(environment)
Which fails because ctx isn't defined at that point.
Traceback (most recent call last):
File "testcli.py", line 15, in <module>
#click.option('-e', '--environment', required=True, default=ctx.obj['DEFAULT_ENVIRONMENT'])
NameError: name 'ctx' is not defined
Am I able to use my context to set a command option default value?
As you noted, the context does not yet exist at the time you are trying to examine it. You can delay the default lookup from context until the context does exist, by using a custom class like:
Custom Class
def default_from_context(default_name):
class OptionDefaultFromContext(click.Option):
def get_default(self, ctx):
self.default = ctx.obj[default_name]
return super(OptionDefaultFromContext, self).get_default(ctx)
return OptionDefaultFromContext
Using the Custom Class
To use the custom class pass it to click.option via the cls parameter like:
#click.option('-e', '--environment', required=True,
cls=default_from_context('DEFAULT_ENVIRONMENT'))
How does this work?
This works because click is a well designed OO framework. The #click.option() decorator usually instantiates a click.Option object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Option in our own class and over ride desired methods.
In this case, we override click.Option.get_default(). In our get_default() we examine the context and set the default value. We then call the parent get_default() to continue further processing.
Test Code:
import click
#click.group()
#click.pass_context
def cli(ctx):
"""
CLI
"""
ctx.ensure_object(dict)
ctx.obj['DEFAULT_ENVIRONMENT'] = "dev"
#cli.command()
#click.option('-e', '--environment', required=True,
cls=default_from_context('DEFAULT_ENVIRONMENT'))
def show_env(environment):
click.echo(environment)
if __name__ == "__main__":
commands = (
'show_env',
'--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.5 (v3.6.5:f59c0932b4, Mar 28 2018, 05:52:31)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.57)]
-----------
> show_env
dev
-----------
> --help
Usage: click_prog.py [OPTIONS] COMMAND [ARGS]...
CLI
Options:
--help Show this message and exit.
Commands:
show_env
To expand on Stephen Rauch's answer above, for Click 8, the signature of click.Option.get_default is get_default(self, ctx: Context, call: bool = True), so you might need to add the call argument.

How do I create an exception to a group action in click

I have a case where I'd like to run a common function (check_upgrade()) for most of my click commands, but there are a few cases where I don't want to run it. Rather than relying on developers to remember to add a decorator or function call to explicitly run this check, I'd prefer to instead run it by default and then have a decorator that one can add (e.g. #bypass_upgrade_check) for commands where check_upgrade() should not run.
I was hoping for something like:
class State(object):
def __init__(self):
self.bypass_upgrade_check = False
pass_state = click.make_pass_decorator(State, ensure=True)
def bypass_upgrade_check(func):
#pass_state
def wrapper(state, *args, **kwargs):
state.bypass_upgrade_check = True
func(*args, **kwargs)
return wrapper
#click.group()
#pass_state
def common(state):
if not state.bypass_upgrade_check:
check_upgrade()
#common.command()
def cmd1():
# check_upgrade() runs here
pass
#bypass_upgrade_check
#common.command()
def cmd2():
# don't run check_upgrade() here
pass
But this doesn't work. It doesn't actually ever call the bypass_upgrade_check() function.
Is there a way to decorate a command in such a way that I can modify the state before the group code runs? Or another method altogether that accomplishes this?
To keep track of which commands bypass the upgrade check, I suggest that in the bypass marking decorator you store that state on the click.Command object. Then if you pass the click.Context to your group, you can then look at the command object to see if it is marked to allow skipping upgrade like:
Code:
def bypass_upgrade_check(func):
setattr(func, 'do_upgrade_check', False)
#click.group()
#click.pass_context
def cli(ctx):
sub_cmd = ctx.command.commands[ctx.invoked_subcommand]
if getattr(sub_cmd, 'do_upgrade_check', True):
check_upgrade()
Test Code:
import click
def check_upgrade():
click.echo('Checking Upgrade!')
def bypass_upgrade_check(func):
setattr(func, 'do_upgrade_check', False)
#click.group()
#click.pass_context
def cli(ctx):
sub_cmd = ctx.command.commands[ctx.invoked_subcommand]
if getattr(sub_cmd, 'do_upgrade_check', True):
check_upgrade()
#cli.command()
def cmd1():
# check_upgrade() runs here
click.echo('cmd1')
#bypass_upgrade_check
#cli.command()
def cmd2():
# don't run check_upgrade() here
click.echo('cmd2')
if __name__ == "__main__":
commands = (
'cmd1',
'cmd2',
)
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)]
-----------
> cmd1
Checking Upgrade!
cmd1
-----------
> cmd2
cmd2

How can I document click commands using Sphinx?

Click is a popular Python library for developing CLI applications with. Sphinx is a popular library for documenting Python packages with. One problem that some have faced is integrating these two tools so that they can generate Sphinx documentation for their click-based commands.
I ran into this problem recently. I decorated some of my functions with click.command and click.group, added docstrings to them and then generated HTML documentation for them using Sphinx's autodoc extension. What I found is that it omitted all documentation and argument descriptions for these commands because they had been converted into Command objects by the time autodoc got to them.
How can I modify my code to make the documentation for my commands available to both the end user when they run --help on the CLI, and also to people browsing the Sphinx-generated documentation?
You can use a sphinx extension sphinx-click for this now. It can generate docs for nested commands with options and arguments description. The output will be like when you run --help.
Usage
Install the extension
pip install sphinx-click
Enable the plugin in your Sphinx conf.py file:
extensions = ['sphinx_click.ext']
Use plugin wherever necessary in the documentation
.. click:: module:parser
:prog: hello-world
:show-nested:
Example
There is simple click application, which is defined in the hello_world module:
import click
#click.group()
def greet():
"""A sample command group."""
pass
#greet.command()
#click.argument('user', envvar='USER')
def hello(user):
"""Greet a user."""
click.echo('Hello %s' % user)
#greet.command()
def world():
"""Greet the world."""
click.echo('Hello world!')
For documenting all subcommands we will use code below with the :show-nested: option
.. click:: hello_world:greet
:prog: hello-world
:show-nested:
Before building docs make sure that your module and any additional dependencies are available in sys.path either by installing package with setuptools or by manually including it.
After building we will get this:
generated docs
More detailed information on various options available is provided in documentation of the extension
Decorating command containers
One possible solution to this problem that I've recently discovered and seems to work would be to start off defining a decorator that can be applied to classes. The idea is that the programmer would define commands as private members of a class, and the decorator creates a public function member of the class that's based on the command's callback. For example, a class Foo containing a command _bar would gain a new function bar (assuming Foo.bar does not already exist).
This operation leaves the original commands as they are, so it shouldn't break existing code. Because these commands are private, they should be omitted in generated documentation. The functions based on them, however, should show up in documentation on account of being public.
def ensure_cli_documentation(cls):
"""
Modify a class that may contain instances of :py:class:`click.BaseCommand`
to ensure that it can be properly documented (e.g. using tools such as Sphinx).
This function will only process commands that have private callbacks i.e. are
prefixed with underscores. It will associate a new function with the class based on
this callback but without the leading underscores. This should mean that generated
documentation ignores the command instances but includes documentation for the functions
based on them.
This function should be invoked on a class when it is imported in order to do its job. This
can be done by applying it as a decorator on the class.
:param cls: the class to operate on
:return: `cls`, after performing relevant modifications
"""
for attr_name, attr_value in dict(cls.__dict__).items():
if isinstance(attr_value, click.BaseCommand) and attr_name.startswith('_'):
cmd = attr_value
try:
# noinspection PyUnresolvedReferences
new_function = copy.deepcopy(cmd.callback)
except AttributeError:
continue
else:
new_function_name = attr_name.lstrip('_')
assert not hasattr(cls, new_function_name)
setattr(cls, new_function_name, new_function)
return cls
Avoiding issues with commands in classes
The reason that this solution assumes commands are inside classes is because that's how most of my commands are defined in the project I'm currently working on - I load most of my commands as plugins contained within subclasses of yapsy.IPlugin.IPlugin. If you want to define the callbacks for commands as class instance methods, you may run into a problem where click doesn't supply the self parameter to your command callbacks when you try to run your CLI. This can be solved by currying your callbacks, like below:
class Foo:
def _curry_instance_command_callbacks(self, cmd: click.BaseCommand):
if isinstance(cmd, click.Group):
commands = [self._curry_instance_command_callbacks(c) for c in cmd.commands.values()]
cmd.commands = {}
for subcommand in commands:
cmd.add_command(subcommand)
try:
if cmd.callback:
cmd.callback = partial(cmd.callback, self)
if cmd.result_callback:
cmd.result_callback = partial(cmd.result_callback, self)
except AttributeError:
pass
return cmd
Example
Putting this all together:
from functools import partial
import click
from click.testing import CliRunner
from doc_inherit import class_doc_inherit
def ensure_cli_documentation(cls):
"""
Modify a class that may contain instances of :py:class:`click.BaseCommand`
to ensure that it can be properly documented (e.g. using tools such as Sphinx).
This function will only process commands that have private callbacks i.e. are
prefixed with underscores. It will associate a new function with the class based on
this callback but without the leading underscores. This should mean that generated
documentation ignores the command instances but includes documentation for the functions
based on them.
This function should be invoked on a class when it is imported in order to do its job. This
can be done by applying it as a decorator on the class.
:param cls: the class to operate on
:return: `cls`, after performing relevant modifications
"""
for attr_name, attr_value in dict(cls.__dict__).items():
if isinstance(attr_value, click.BaseCommand) and attr_name.startswith('_'):
cmd = attr_value
try:
# noinspection PyUnresolvedReferences
new_function = cmd.callback
except AttributeError:
continue
else:
new_function_name = attr_name.lstrip('_')
assert not hasattr(cls, new_function_name)
setattr(cls, new_function_name, new_function)
return cls
#ensure_cli_documentation
#class_doc_inherit
class FooCommands(click.MultiCommand):
"""
Provides Foo commands.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._commands = [self._curry_instance_command_callbacks(self._calc)]
def list_commands(self, ctx):
return [c.name for c in self._commands]
def get_command(self, ctx, cmd_name):
try:
return next(c for c in self._commands if c.name == cmd_name)
except StopIteration:
raise click.UsageError('Undefined command: {}'.format(cmd_name))
#click.group('calc', help='mathematical calculation commands')
def _calc(self):
"""
Perform mathematical calculations.
"""
pass
#_calc.command('add', help='adds two numbers')
#click.argument('x', type=click.INT)
#click.argument('y', type=click.INT)
def _add(self, x, y):
"""
Print the sum of x and y.
:param x: the first operand
:param y: the second operand
"""
print('{} + {} = {}'.format(x, y, x + y))
#_calc.command('subtract', help='subtracts two numbers')
#click.argument('x', type=click.INT)
#click.argument('y', type=click.INT)
def _subtract(self, x, y):
"""
Print the difference of x and y.
:param x: the first operand
:param y: the second operand
"""
print('{} - {} = {}'.format(x, y, x - y))
def _curry_instance_command_callbacks(self, cmd: click.BaseCommand):
if isinstance(cmd, click.Group):
commands = [self._curry_instance_command_callbacks(c) for c in cmd.commands.values()]
cmd.commands = {}
for subcommand in commands:
cmd.add_command(subcommand)
if cmd.callback:
cmd.callback = partial(cmd.callback, self)
return cmd
#click.command(cls=FooCommands)
def cli():
pass
def main():
print('Example: Adding two numbers')
runner = CliRunner()
result = runner.invoke(cli, 'calc add 1 2'.split())
print(result.output)
print('Example: Printing usage')
result = runner.invoke(cli, 'calc add --help'.split())
print(result.output)
if __name__ == '__main__':
main()
Running main(), I get this output:
Example: Adding two numbers
1 + 2 = 3
Example: Printing usage
Usage: cli calc add [OPTIONS] X Y
adds two numbers
Options:
--help Show this message and exit.
Process finished with exit code 0
Running this through Sphinx, I can view the documentation for this in my browser:

python click usage of standalone_mode

This question is about the Python Click library.
I want click to gather my commandline arguments. When gathered, I want to reuse these values. I dont want any crazy chaining of callbacks, just use the return value. By default, click disables using the return value and calls sys.exit().
I was wondering how to correctly invoke standalone_mode (http://click.pocoo.org/5/exceptions/#what-if-i-don-t-want-that) in case I want to use the decorator style. The above linked doc only shows the usage when (manually) creating Commands using click.
Is it even possible? A minimal example is shown below. It illustrates how click calls sys.exit() directly after returning from gatherarguments
import click
#click.command()
#click.option('--name', help='Enter Name')
#click.pass_context
def gatherarguments(ctx, name):
return ctx
def usectx(ctx):
print("Name is %s" % ctx.params.name)
if __name__ == '__main__':
ctx = gatherarguments()
print(ctx) # is never called
usectx(ctx) # is never called
$ python test.py --name Your_Name
I would love this to be stateless, meaning, without any click.group functionality - I just want the results, without my application exiting.
Just sending standalone_mode as a keyword argument worked for me:
from __future__ import print_function
import click
#click.command()
#click.option('--name', help='Enter Name')
#click.pass_context
def gatherarguments(ctx, name):
return ctx
def usectx(ctx):
print("Name is %s" % ctx.params['name'])
if __name__ == '__main__':
ctx = gatherarguments(standalone_mode=False)
print(ctx)
usectx(ctx)
Output:
./clickme.py --name something
<click.core.Context object at 0x7fb671a51690>
Name is something

Creating a shell command line application with Python and Click

I'm using click (http://click.pocoo.org/3/) to create a command line application, but I don't know how to create a shell for this application.
Suppose I'm writing a program called test and I have commands called subtest1 and subtest2
I was able to make it work from terminal like:
$ test subtest1
$ test subtest2
But what I was thinking about is a shell, so I could do:
$ test
>> subtest1
>> subtest2
Is this possible with click?
This is not impossible with click, but there's no built-in support for that either. The first you would have to do is making your group callback invokable without a subcommand by passing invoke_without_command=True into the group decorator (as described here). Then your group callback would have to implement a REPL. Python has the cmd framework for doing this in the standard library. Making the click subcommands available there involves overriding cmd.Cmd.default, like in the code snippet below. Getting all the details right, like help, should be doable in a few lines.
import click
import cmd
class REPL(cmd.Cmd):
def __init__(self, ctx):
cmd.Cmd.__init__(self)
self.ctx = ctx
def default(self, line):
subcommand = cli.commands.get(line)
if subcommand:
self.ctx.invoke(subcommand)
else:
return cmd.Cmd.default(self, line)
#click.group(invoke_without_command=True)
#click.pass_context
def cli(ctx):
if ctx.invoked_subcommand is None:
repl = REPL(ctx)
repl.cmdloop()
#cli.command()
def a():
"""The `a` command prints an 'a'."""
print "a"
#cli.command()
def b():
"""The `b` command prints a 'b'."""
print "b"
if __name__ == "__main__":
cli()
There is now a library called click_repl that does most of the work for you. Thought I'd share my efforts in getting this to work.
The one difficulty is that you have to make a specific command the repl command, but we can repurpose #fpbhb's approach to allow calling that command by default if another one isn't provided.
This is a fully working example that supports all click options, with command history, as well as being able to call commands directly without entering the REPL:
import click
import click_repl
import os
from prompt_toolkit.history import FileHistory
#click.group(invoke_without_command=True)
#click.pass_context
def cli(ctx):
"""Pleasantries CLI"""
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
#cli.command()
#click.option('--name', default='world')
def hello(name):
"""Say hello"""
click.echo('Hello, {}!'.format(name))
#cli.command()
#click.option('--name', default='moon')
def goodnight(name):
"""Say goodnight"""
click.echo('Goodnight, {}.'.format(name))
#cli.command()
def repl():
"""Start an interactive session"""
prompt_kwargs = {
'history': FileHistory(os.path.expanduser('~/.repl_history'))
}
click_repl.repl(click.get_current_context(), prompt_kwargs=prompt_kwargs)
if __name__ == '__main__':
cli(obj={})
Here's what it looks like to use the REPL:
$ python pleasantries.py
> hello
Hello, world!
> goodnight --name fpbhb
Goodnight, fpbhb.
And to use the command line subcommands directly:
$ python pleasntries.py goodnight
Goodnight, moon.
I know this is super old, but I've been working on fpbhb's solution to support options as well. I'm sure this could use some more work, but here is a basic example of how it could be done:
import click
import cmd
import sys
from click import BaseCommand, UsageError
class REPL(cmd.Cmd):
def __init__(self, ctx):
cmd.Cmd.__init__(self)
self.ctx = ctx
def default(self, line):
subcommand = line.split()[0]
args = line.split()[1:]
subcommand = cli.commands.get(subcommand)
if subcommand:
try:
subcommand.parse_args(self.ctx, args)
self.ctx.forward(subcommand)
except UsageError as e:
print(e.format_message())
else:
return cmd.Cmd.default(self, line)
#click.group(invoke_without_command=True)
#click.pass_context
def cli(ctx):
if ctx.invoked_subcommand is None:
repl = REPL(ctx)
repl.cmdloop()
#cli.command()
#click.option('--foo', required=True)
def a(foo):
print("a")
print(foo)
return 'banana'
#cli.command()
#click.option('--foo', required=True)
def b(foo):
print("b")
print(foo)
if __name__ == "__main__":
cli()
I was trying to do something similar to the OP, but with additional options / nested sub-sub-commands. The first answer using the builtin cmd module did not work in my case; maybe with some more fiddling.. But I did just run across click-shell. Haven't had a chance to test it extensively, but so far, it seems to work exactly as expected.

Categories

Resources