You can mark chainable commands as mutually exclusive by creating a custom click.Group class.
Custom Class
class MutuallyExclusiveCommandGroup(click.Group):
def __init__(self, *args, **kwargs):
kwargs['chain'] = True
self.mutually_exclusive = []
super().__init__(*args, **kwargs)
def command(self, *args, mutually_exclusive=False, **kwargs):
"""Track the commands marked as mutually exclusive"""
super_decorator = super().command(*args, **kwargs)
def decorator(f):
command = super_decorator(f)
if mutually_exclusive:
self.mutually_exclusive.append(command)
return command
return decorator
def resolve_command(self, ctx, args):
"""Hook the command resolving and verify mutual exclusivity"""
cmd_name, cmd, args = super().resolve_command(ctx, args)
# find the commands which are going to be run
if not hasattr(ctx, 'resolved_commands'):
ctx.resolved_commands = set()
ctx.resolved_commands.add(cmd_name)
# if args is empty we have have found all of the commands to be run
if not args:
mutually_exclusive = ctx.resolved_commands & set(
cmd.name for cmd in self.mutually_exclusive)
if len(mutually_exclusive) > 1:
raise click.UsageError(
"Illegal usage: commands: `{}` are mutually exclusive".format(
', '.join(mutually_exclusive)))
return cmd_name, cmd, args
def get_help(self, ctx):
"""Extend the short help for the mutually exclusive commands"""
for cmd in self.mutually_exclusive:
mutually_exclusive = set(self.mutually_exclusive)
if not cmd.short_help:
cmd.short_help = 'mutually exclusive with: {}'.format(', '.join(
c.name for c in mutually_exclusive if c.name != cmd.name))
return super().get_help(ctx)
Using the Custom Class:
To use the custom class, pass it as the cls argument to the click.group decorator like:
@click.group(cls=MutuallyExclusiveCommandGroup)
@click.pass_context
def cli(ctx):
...
Then use the mutually_exclusive argument to the cli.command decorator to mark commands
as part of the mutually exclusive group.
@cli.command(mutually_exclusive=True)
def update():
...
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 the desired methods.
In this case we override three methods: command(), resolve_command() & get_help(). The overridden
command() method allows us to track which commands are marked with the mutually_exclusive flag. The
overridden resolve_command() method is used to watch the command resolution process, and note which
commands are going to be run. If mutually exclusive commands are going to be run, it throws an error. The
overridden get_help method sets the short_help attribute to show which commands are mutually exclusive.
Test Code:
import click
@click.group(chain=True, cls=MutuallyExclusiveCommandGroup)
@click.pass_context
def cli(ctx):
print("MAIN")
@cli.command()
def initialize():
print("INITIALIZING")
@cli.command(mutually_exclusive=True)
def update():
print("UPDATING")
@cli.command(mutually_exclusive=True)
def process():
print("PROCESSING")
if __name__ == "__main__":
commands = (
'',
'initialize',
'update',
'process',
'initialize process',
'update process',
'initialize update process',
'--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
Test Results:
Click Version: 7.1.2
Python Version: 3.8.5 (tags/v3.8.5:580fbb0, Jul 20 2020, 15:57:54) [MSC v.1924 64 bit (AMD64)]
-----------
>
Usage: test_code.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
Options:
--help Show this message and exit.
Commands:
initialize
process mutually exclusive with: update
update mutually exclusive with: process
-----------
> initialize
MAIN
INITIALIZING
-----------
> update
MAIN
UPDATING
-----------
> process
MAIN
PROCESSING
-----------
> initialize process
MAIN
INITIALIZING
PROCESSING
-----------
> update process
MAIN
Error: Illegal usage: commands: `update, process` are mutually exclusive
-----------
> initialize update process
MAIN
Error: Illegal usage: commands: `update, process` are mutually exclusive
-----------
> --help
Usage: test_code.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
Options:
--help Show this message and exit.
Commands:
initialize
process mutually exclusive with: update
update mutually exclusive with: process.