Preserving the order of user-provided parameters with Python Click

Viewed 638

I am trying to port an argparse command-line interface (CLI) to click. This CLI must maintain the order in which parameters are provided by the user. With the argparse version, I used this StackOverflow answer to maintain order. With click, I am not sure how to do this.

I tried creating a custom callback to store params and values in order, but if a parameter is used multiple times, the callback fires the first time it sees a matching parameter.

import click
import typing

class OrderParams:
    _options: typing.List[typing.Tuple[click.Parameter, typing.Any]] = []

    @classmethod
    def append(cls, ctx: click.Context, param: click.Parameter, value: typing.Any):
        cls._options.append((param, value))

@click.command()
@click.option("--animal", required=True, multiple=True, callback=OrderParams.append)
@click.option("--thing", required=True, multiple=True, callback=OrderParams.append)
def cli(*, animal, thing):
    click.echo("Got this order of parameters:")
    for param, value in OrderParams._options:
        print("  ", param.name, value)

if __name__ == "__main__":
    cli()

Current output:

$ python cli.py --animal cat --thing rock --animal dog
Got this order of parameters:
   animal ('cat', 'dog')
   thing ('rock',)

Desired output:

$ python cli.py --animal cat --thing rock --animal dog
Got this order of parameters:
   animal 'cat'
   thing 'rock'
   animal 'dog'

The click documentation describes the behavior that a callback is called once the first time a parameter is encountered, even if the parameter is used multiple times.

If an option or argument is split up on the command line into multiple places because it is repeated [...] the callback will fire based on the position of the first option.

2 Answers

This can be done by over riding the click.Command argument parser invocation using a custom class like:

Custom Class:

class OrderedParamsCommand(click.Command):
    _options = []

    def parse_args(self, ctx, args):
        # run the parser for ourselves to preserve the passed order
        parser = self.make_parser(ctx)
        opts, _, param_order = parser.parse_args(args=list(args))
        for param in param_order:
            type(self)._options.append((param, opts[param.name].pop(0)))

        # return "normal" parse results
        return super().parse_args(ctx, args)

Using Custom Class:

Then to use the custom command, pass it as the cls argument to the command decorator like:

@click.command(cls=OrderedParamsCommand)
@click.option("--animal", required=True, multiple=True)
@click.option("--thing", required=True, multiple=True)
def cli(*, animal, thing):
    ....

How does this work?

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

In this case we over ride click.Command.parse_args() and run the parser ourselves to preserve the order passed in.

Test Code:

import click

class OrderedParamsCommand(click.Command):
    _options = []

    def parse_args(self, ctx, args):
        parser = self.make_parser(ctx)
        opts, _, param_order = parser.parse_args(args=list(args))
        for param in param_order:
            type(self)._options.append((param, opts[param.name].pop(0)))

        return super().parse_args(ctx, args)


@click.command(cls=OrderedParamsCommand)
@click.option("--animal", required=True, multiple=True)
@click.option("--thing", required=True, multiple=True)
def cli(*, animal, thing):
    click.echo("Got this order of parameters:")
    for param, value in OrderedParamsCommand._options:
        print("  ", param.name, value)


if __name__ == "__main__":
    cli('--animal cat --thing rock --animal dog'.split())

Results:

Got this order of parameters:
   animal cat
   thing rock
   animal dog

@StephenRauch's answer is correct but it misses one minor detail. Calling the cli function i.e. the script without any arguments will result in an error:

type(self)._options.append((param, opts[param.name].pop(0)))
AttributeError: 'NoneType' object has no attribute 'pop'

The fix for this is checking opts[param.name] if it is not None. The modified OrderedParamsCommand looks like:

class OrderedParamsCommand(click.Command):
    _options = []

    def parse_args(self, ctx, args):
        # run the parser for ourselves to preserve the passed order
        parser = self.make_parser(ctx)
        opts, _, param_order = parser.parse_args(args=list(args))
        for param in param_order:
            # Type check
            option = opts[param.name]
            if option != None:
                type(self)._options.append((param, option.pop(0)))

        # return "normal" parse results
        return super().parse_args(ctx, args)

EDIT: I posted an answer as I could not propose an edit

EDIT 2: Uhh, this should not be the solution. I just tried --help with this and it crashed with this error:

type(self)._options.append((param, option.pop(0)))
AttributeError: 'bool' object has no attribute 'pop'

Too bad, I wished there is a proper solution

Related