Python Click: make option depend on previous option
Asked Answered
M

1

7

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.

Matsuyama answered 25/2, 2016 at 16:7 Comment(0)
B
2

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
Buote answered 8/10, 2017 at 23:33 Comment(1)
thanks a lot, using inheritance for this is a really good idea! the only thing I don't particularly like is having a function return the class instead of just adding encoding_option_name as a parameter to __init__(). I think it would look cleaner and more in line with how stock Click ParamTypes work to have type=CustomFile("r") rather than type=encoded_file('encoding')("r"). I'll submit an edit :)Matsuyama

© 2022 - 2024 — McMap. All rights reserved.