python click, make option value optional
Asked Answered
G

3

9

I am working on a small command line tool using Python 2 and click. My tool either needs to write a value, read it, or do not change it. It would be nice if I could do the following:

mytool --r0=0xffff..........Set value r0 to 0xffff
mytool --r0......................Read value r0
mytool...............................Don't do anything with r0

Based on the documentation, it doesn't seem possible, but I could have missed it. So is it possible or do I have to find a different approach?

Gaberlunzie answered 23/11, 2016 at 0:15 Comment(8)
@click.option('--count', default=1, help='Number of greetings.')Concupiscence
@Concupiscence Yeah, I had the same thought, but I was wondering if I could do something like that without using defaultGaberlunzie
Why would you not want to use default?Concupiscence
@Concupiscence The tool is to read/write register values in hardware. So if I give it a default value, the meaning would be set register to a value of 1. In other words calling just mytool would be equivalent to mytool --r0=1, which means set register 0 to 1 which is not my intent.Gaberlunzie
Haven't worked with click, but could you use a non-integer default? What I'm not sure about is how to do nothing when it's not specified, but something when it's specified bare.Customable
@Customable That was another thought that I had. Right now I'm using -2 to mean nothing, -1 to mean read and 0 to 0xffffffff to mean a write. But I think it is a bit ugly. Say I want to read r0. mytool --r0 looks much better rather than mytool --r0=-1. It just doesn't look clean.Gaberlunzie
It would be problematic with an option that takes an 'optional' parameter. The syntax for the command line could easily end up being ambiguous, and it would not follow the Unix command line conventions. @Customable idea seems like a good solution. You could also maybe divide it up into two options, one boolean and one with the value. Or look at using commands instead.Immesh
Something that just occurred to me: what happens if you read from two registers? How do you format that? I don't know for sure, but maybe you should have a flag to read, that takes a register as an argument?Customable
A
4

Building on @Stephen Rauch's answer, I think I found a simpler solution

Code

class CommandWithOptionalFlagValues(click.Command):
    def parse_args(self, ctx, args):
        """ Translate any flag `--opt=value` as flag `--opt` with changed flag_value=value """
        # filter flags
        flags = [o for o in self.params if isinstance(o, click.Option) and o.is_flag and not isinstance(o.flag_value, bool)]
        prefixes = {p: o for o in flags for p in o.opts if p.startswith('--')}
        for i, flag in enumerate(args):
            flag = flag.split('=')
            if flag[0] in prefixes and len(flag) > 1:
                prefixes[flag[0]].flag_value = flag[1]
                args[i] = flag[0]

        return super(CommandWithOptionalFlagValues, self).parse_args(ctx, args)

Test

@click.command(cls=CommandWithOptionalFlagValues)
@click.option('--r0', is_flag=True, help='set or use default r0 value', flag_value=3.0)
@click.option('--r1', is_flag=True, help='Enable r1')
def cli(r0, r1):
    click.echo('r0: {} r1: {}'.format(r0, r1))

cli(['--r1'])
cli(['--r0', '--r1'])
cli(['--r0=5.0'])

Result

r0: None r1: True
r0: 3.0 r1: True
r0: 5.0 r1: False
Acrodont answered 20/8, 2021 at 19:40 Comment(2)
Great answer! There is a small error if @click.argument() parameters are present: AttributeError: 'Argument' object has no attribute 'is_flag' Fixed by adding: options = [o for o in self.params if isinstance(o, click.Option)] Fixed by adding:Gereron
@Gereron I edited the answer to add that check.Retrorocket
A
1

One way to solve this problem is to introduce another parameter named r0_set. And then to preserve the desired command line, we can inherit from click.Command and over ride parse_args to turn the user entered r0=0xffff into r0_set=0xffff

Code:

class RegisterReaderOption(click.Option):
    """ Mark this option as getting a _set option """
    register_reader = True

class RegisterWriterOption(click.Option):
    """ Fix the help for the _set suffix """
    def get_help_record(self, ctx):
        help = super(RegisterWriterOption, self).get_help_record(ctx)
        return (help[0].replace('_set ', '='),) + help[1:]

class RegisterWriterCommand(click.Command):
    def parse_args(self, ctx, args):
        """ Translate any opt= to opt_set= as needed """
        options = [o for o in ctx.command.params
                   if getattr(o, 'register_reader', None)]
        prefixes = {p for p in sum([o.opts for o in options], [])
                    if p.startswith('--')}
        for i, a in enumerate(args):
            a = a.split('=')
            if a[0] in prefixes and len(a) > 1:
                a[0] += '_set'
                args[i] = '='.join(a)

        return super(RegisterWriterCommand, self).parse_args(ctx, args)

Test Code:

@click.command(cls=RegisterWriterCommand)
@click.option('--r0', cls=RegisterReaderOption, is_flag=True,
              help='Read the r0 value')
@click.option('--r0_set', cls=RegisterWriterOption,
              help='Set the r0 value')
def cli(r0, r0_set):
    click.echo('r0: {}  r0_set: {}'.format(r0, r0_set))

cli(['--r0=0xfff', '--r0'])
cli(['--help'])

Results:

r0: True  r0_set: 0xfff

Usage: test.py [OPTIONS]

Options:
  --r0       Read the r0 value
  --r0=TEXT  Set the r0 value
  --help     Show this message and exit.
Argentinaargentine answered 23/5, 2017 at 20:6 Comment(7)
Isn't this solution rather complicated, given the use case?Impersonal
@Te-jéRodgers, It is less than 20 lines of code. There was a non-standard command line desired, so not sure how one goes about measuring complexity here.Argentinaargentine
Also, doesn't work as of today. Who knows what changed in Click, but the code above doesn't really make any difference in how Click's options behave.Gladiolus
Oh, I now realize that... it never actually worked. Well, the problem here is that I didn't even consider using =, but then reading the code I realized that this was essential. Also, I didn't use the long option names. Well. This is broken in far too many ways to be an actual solution.Gladiolus
@wvxvw, I just ran this code with click 8 and python 3.8 and got the exact same result as 4 years ago. So I am very unclear what you are getting at here.Argentinaargentine
Well, it doesn't work as OP intended it to work. For instance, your code doesn't work if both short and long option names are used, it doesn't work subsequently if short option names are given together or separately, it doesn't work when space is used instead of equals. Basically, it doesn't work in most cases. It only works sometimes, perhaps, in the least likely way it will be used.Gladiolus
Finally, the help message is also wrong. It shouldn't list it as two separate options. It should be the same option with the default argument. I've researched this subject in Click's issue tracker, and inability to do this seems like a pain point that was mentioned to them multiple times, yet it doesn't seem to be solved. github.com/pallets/click/pull/1618 was supposed to solve it, instead, it seems to do some useless nonsense.Gladiolus
E
0

The feature you describe was added in Click 8.0. See the Optional Value section on the Options page of the Click Docs.

Quick mockup similar to the others.

# test.py
import click

@click.command
@click.option(
    "--r0",
    is_flag=False,
    flag_value="<value if flagged>",
    default="<value if not mentioned>",
)
def cli(r0):
    click.echo(f"r0: {r0}")

cli()

Exercising it:

$ python test.py
r0: <value if not mentioned>

$ python test.py --r0
r0: <value if flagged>

$ python test.py --r0=something
r0: something

$ python test.py --r0 otherthing  # can use a space or =, works the same.
r0: otherthing

The --help won't show the flagged-default or unflagged-default, so you'll need to write it yourself.

Eritrea answered 27/6 at 20:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.