Is it possible to add a global argument for all subcommands in Click based interfaces?

2024/10/6 16:28:16

I am using Click under a virtualenv and use the entry_point directive in setuptools to map the root to a function called dispatch.

My tool exposes two subcommands serve and config, I am using an option on the top level group to ensure that the user always passes a --path directive. However the usage turns out as follows:

mycommand --path=/tmp serve

both the serve and config sub commands need to ensure that the user always passes a path in and ideally I would like to present the cli as:

mycommand serve /tmp` or `mycommand config validate /tmp

current Click based implemenation is as follows:

# cli root@click.group()
@click.option('--path', type=click.Path(writable=True))
@click.version_option(__version__)
@click.pass_context
def dispatch(ctx, path):"""My project description"""ctx.obj = Project(path="config.yaml")# serve@dispatch.command()
@pass_project
def serve(project):"""Starts WSGI server using the configuration"""print "hello"# config@dispatch.group()
@pass_project
def config(project):"""Validate or initalise a configuration file"""pass@config.command("validate")
@pass_project
def config_validate(project):"""Reports on the validity of a configuration file"""pass@config.command("init")
@pass_project
def config_init(project):"""Initialises a skeleton configuration file"""pass

Is this possible without adding the path argument to each sub command?

Answer

If there is a specific argument that you would like to decorate only onto the group, but be applicable to all commands as needed, you can do that with a bit of extra plumbing like:

Custom Class:

import clickclass GroupArgForCommands(click.Group):"""Add special argument on group to front of command list"""def __init__(self, *args, **kwargs):super(GroupArgForCommands, self).__init__(*args, **kwargs)cls = GroupArgForCommands.CommandArgument# gather the special argumentsself._cmd_args = {a.name: a for a in self.params if isinstance(a, cls)}# strip out the special argumentsself.params = [a for a in self.params if not isinstance(a, cls)]# hook the original add_command methodself._orig_add_command = click.Group.add_command.__get__(self)class CommandArgument(click.Argument):"""class to allow us to find our special arguments"""@staticmethoddef command_argument(*param_decls, **attrs):"""turn argument type into type we can find later"""assert 'cls' not in attrs, "Not designed for custom arguments"attrs['cls'] = GroupArgForCommands.CommandArgumentdef decorator(f):click.argument(*param_decls, **attrs)(f)return freturn decoratordef add_command(self, cmd, name=None):# hook add_command for any sub groupsif hasattr(cmd, 'add_command'):cmd._orig_add_command = cmd.add_commandcmd.add_command = GroupArgForCommands.add_command.__get__(cmd)cmd.cmd_args = self._cmd_args# call original add_commandself._orig_add_command(cmd, name)# if this command's callback has desired parameters add themimport inspectargs = inspect.signature(cmd.callback)for arg_name in reversed(list(args.parameters)):if arg_name in self._cmd_args:cmd.params[:] = [self._cmd_args[arg_name]] + cmd.params

Using the Custom Class:

To use the custom class, pass the cls parameter to the click.group() decorator, use the @GroupArgForCommands.command_argument decorator for special arguments, and then add a parameter of the same name as the special argument to any commands as needed.

@click.group(cls=GroupArgForCommands)
@GroupArgForCommands.command_argument('special')
def a_group():"""My project description"""@a_group.command()
def a_command(special):"""a command under the group"""

How does this work?

This works because click is a well designed OO framework. The @click.group() decorator usually instantiates a click.Group object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over ride desired methods.

In this case we over ride click.Group.add_command() so that when a command is added we can examine the command callback parameters to see if they have the same name as any of our special arguments. If they match, the argument is added to the command's arguments just as if it had been decorated directly.

In addition GroupArgForCommands implements a command_argument() method. This method is used as a decorator when adding a special argument instead of using click.argument()

Test Code:

def process_path_to_project(ctx, cmd, value):"""param callback example to convert path to project"""# Use 'path' to construct a project.# For this example we will just annotate and pass throughreturn 'converted {}'.format(value)@click.group(cls=GroupArgForCommands)
@GroupArgForCommands.command_argument('path',callback=process_path_to_project)
def dispatch():"""My project description"""@dispatch.command()
def serve(path):"""Starts WSGI server using the configuration"""click.echo('serve {}'.format(path))@dispatch.group()
def config():"""Validate or initalise a configuration file"""pass@config.command("validate")
def config_validate():"""Reports on the validity of a configuration file"""click.echo('config_validate')@config.command("init")
def config_init(path):"""Initialises a skeleton configuration file"""click.echo('config_init {}'.format(path))if __name__ == "__main__":commands = ('config init a_path','config init','config validate a_path','config validate','config a_path','config','serve a_path','serve','config init --help','config validate --help','',)import sys, timetime.sleep(1)print('Click Version: {}'.format(click.__version__))print('Python Version: {}'.format(sys.version))for command in commands:try:time.sleep(0.1)print('-----------')print('> ' + command)time.sleep(0.1)dispatch(command.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)]
-----------
> config init a_path
config_init converted a_path
-----------
> config init
Usage: test.py config init [OPTIONS] PATHError: Missing argument "path".
-----------
> config validate a_path
Usage: test.py config validate [OPTIONS]Error: Got unexpected extra argument (a_path)
-----------
> config validate
config_validate
-----------
> config a_path
Usage: test.py config [OPTIONS] COMMAND [ARGS]...Error: No such command "a_path".
-----------
> config
Usage: test.py config [OPTIONS] COMMAND [ARGS]...Validate or initalise a configuration fileOptions:--help  Show this message and exit.Commands:init      Initialises a skeleton configuration filevalidate  Reports on the validity of a configuration...
-----------
> serve a_path
serve converted a_path
-----------
> serve
Usage: test.py serve [OPTIONS] PATHError: Missing argument "path".
-----------
> config init --help
Usage: test.py config init [OPTIONS] PATHInitialises a skeleton configuration fileOptions:--help  Show this message and exit.
-----------
> config validate --help
Usage: test.py config validate [OPTIONS]Reports on the validity of a configuration fileOptions:--help  Show this message and exit.
-----------
> 
Usage: test.py [OPTIONS] COMMAND [ARGS]...My project descriptionOptions:--help  Show this message and exit.Commands:config  Validate or initalise a configuration fileserve   Starts WSGI server using the configuration
https://en.xdnf.cn/q/70350.html

Related Q&A

Error importing h5py

Ive been trying to import h5py to read this type of file.Here is my code:import h5pyfile_1 = h5py.File("Out_fragment.h5py")print file_1The output is:Traceback (most recent call last):File &qu…

Tensorflow leaks 1280 bytes with each session opened and closed?

It seems that each Tensorflow session I open and close consumes 1280 bytes from the GPU memory, which are not released until the python kernel is terminated. To reproduce, save the following python scr…

Python Pandas -- Forward filling entire rows with value of one previous column

New to pandas development. How do I forward fill a DataFrame with the value contained in one previously seen column?Self-contained example:import pandas as pd import numpy as np O = [1, np.nan, 5, np.…

Microsoft Visual C++ 14.0 is required - error - pip install fbprophet

I am trying pip install fbprophet. I am getting that error: "Microsoft Visual C++ 14.0 is required" It has been discussed many times (e.g. Microsoft Visual C++ 14.0 is required (Unable to fin…

find position of item in for loop over a sequence [duplicate]

This question already has answers here:Closed 12 years ago.Possible Duplicate:Accessing the index in Python for loops list = [1,2,2,3,5,5,6,7]for item in mylist:...How can I find the index of the item…

How to convert BeautifulSoup.ResultSet to string

So I parsed a html page with .findAll (BeautifulSoup) to variable named result. If I type result in Python shell then press Enter, I see normal text as expected, but as I wanted to postprocess this res…

How can I manually place networkx nodes using the mouse?

I have a fairly large and messy network of nodes that I wish to display as neatly as possible. This is how its currently being displayed:First, I tried playing with the layout to see if it could genera…

How to create a vertical scroll bar with Plotly?

I would like to create a vertical scroll for a line chart in Plotly. For visualisation, the vertical scroll is something depicted in the figure below.Assume, we have 6 line chart as below, then how ca…

Django 1.7 makemigrations renaming tables to None

I had to move a few models from one app to another, and I followed the instructions on this answer https://stackoverflow.com/a/26472482/188614. Basically I used the CreateModel migrations generated by …

TypeError on CORS for flask-restful

While trying the new CORS feature on flask-restful, I found out that the decorator can be only applied if the function returns a string. For example, modifying the Quickstart example:class HelloWorld(r…