1

I have a click application with three commands:

import click

@click.group(chain=True)
def cli():
    print("MAIN")

@cli.command()
def initialize():
    print("INITIALIZING")
    
@cli.command()
def update():
    print("UPDATING")

@cli.command()
def process():
    print("PROCESSING")

Defined this way, all of the commands can be chained.

But, how can I make initialize and update mutually exclusive? IE: it should be:

legal to run:

initialize -> process

and

update -> process

not legal to run:

initialize -> update -> process

1 Answer 1

3

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.
Sign up to request clarification or add additional context in comments.

2 Comments

Thanks for this very detailed an clear answer! One minor thing though: Using this implementation, the "main" command cli is always executed. Is there a way to prevent that (or fail at the very beginning of it) in case 2 mutually exclusive sub-commands are requested? The reason being some heavy initialization I would like to avoid if the next step is going to fail.
Sadly, the way the framework does the processing, the parsing which the exclusive check is analyzing is done after cli() is run.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.