pythoncommand-line-interfacepython-click

Make option depend on previous option


Is there an idiomatic way, using the Python Click library, to create a command where one option depends on a value set by a previous option?

A concrete example (my use case) would be that a command takes an option of type click.File as input, but also an encoding option which specifies the encoding of the input stream:

import click

@click.command()
@click.option("--encoding", type=str, default="utf-8")
@click.option("--input",
              type=click.File("r", encoding="CAN I SET THIS DYNAMICALLY BASED ON --encoding?"))
def cli(encoding, input):
    pass

I guess it would have to involve some kind of deferred evaluation using a callable, but I'm not sure if it's even possible given the current Click API.

I've figured out I can do something along the following lines:

import click

@click.command()
@click.pass_context
@click.option("--encoding", type=str, default="utf-8")
@click.option("--input", type=str, default="-")
def cli(ctx, encoding, input):
    input = click.File("r", encoding=encoding)(input, ctx=ctx)

But it somehow feels less readable / maintainable to decouple the option decorator from the semantically correct type constraint that applies to it, and put str in there instead as a dummy. So if there's a way to keep these two together, please enlighten me.

A proposed workaround:

I guess I could use the click.File type twice, making it lazy in the decorator so that the file isn't actually left opened, the first time around:

@click.option("--input", type=click.File("r", lazy=True), default="-")

This feels semantically more satisfying, but also redundant.


Solution

  • It is possible to inherit from the click.File class and override the .convert() method to allow it to gather the encoding value from the context.

    Using a Custom Class

    It should look something like:

    @click.command()
    @click.option("--my_encoding", type=str, default="utf-8")
    @click.option("--in_file", type=CustomFile("r", encoding_option_name="my_encoding"))
    def cli(my_encoding, in_file):
        ....
    

    CustomFile should allow the user to specify whichever name they want for the parameter from which the encoding value should be collected, but there can be a reasonable default such as "encoding".

    Custom File Class

    This CustomFile class can be used in association with an encoding option:

    import click
    
    class CustomFile(click.File):
        """
        A custom `click.File` class which will set its encoding to
        a parameter.
    
        :param encoding_option_name: The 'name' of the encoding parameter
        """
        def __init__(self, *args, encoding_option_name="encoding", **kwargs):
            # enforce a lazy file, so that opening the file is deferred until after
            # all of the command line parameters have been processed (--encoding
            # might be specified after --in_file)
            kwargs['lazy'] = True
            # Python 3 can use just super()
            super(CustomFile, self).__init__(*args, **kwargs)
            self.lazy_file = None
            self.encoding_option_name = encoding_option_name
    
        def convert(self, value, param, ctx):
            """During convert, get the encoding from the context."""
            if self.encoding_option_name not in ctx.params:
                # if the encoding option has not been processed yet, wrap its
                # convert hook so that it also retroactively modifies the encoding
                # attribute on self and self.lazy_file
                encoding_opt = [
                    c for c in ctx.command.params
                    if self.encoding_option_name == c.human_readable_name]
                assert encoding_opt, \
                    "option '{}' not found for encoded_file".format(
                        self.encoding_option_name)
    
                encoding_type = encoding_opt[0].type
                encoding_convert = encoding_type.convert
    
                def encoding_convert_hook(*convert_args):
                    encoding_type.convert = encoding_convert
                    self.encoding = encoding_type.convert(*convert_args)
                    self.lazy_file.encoding = self.encoding
                    return self.encoding
    
                encoding_type.convert = encoding_convert_hook
            else:
                # if it has already been processed, just use the value
                self.encoding = ctx.params[self.encoding_option_name]
    
            # Python 3 can use just super()
            self.lazy_file = super(CustomFile, self).convert(value, param, ctx)
            return self.lazy_file