fastapi / typer

Typer, build great CLIs. Easy to code. Based on Python type hints.
https://typer.tiangolo.com/
MIT License
15.44k stars 656 forks source link

Does Typer support Tuples as Multiple Multi Value Options - List[Tuple[str, str]] - like Click does? #387

Open jtfidje opened 2 years ago

jtfidje commented 2 years ago

First Check

Commit to Help

Example Code

import typer
from typing import List, Tuple

app = typer.Typer()

@app.command()
def test(opts: List[Tuple[str, str]]):
    ...

Description

I'm converting a small Click app to Typer and hit the following issue; I can't seem to get Tuples as Multiple Multi Value Options working.

I would like to achieve the following:

python main.py test --opt "--some-arg" "value" --opt "--another-arg" "value"

This is achieved in Click with the following option:

@click.option(
    "--opt",
    "opts",
    multiple=True,
    type=(str, str),
    required=False,
    default=(),
    help=(
        "Append to current config string. Options are given in the following format: "
        '[--opt "--force" "true" --opt "--overwrite" "true"]'
    ),
)

Operating System

Linux

Operating System Details

No response

Typer Version

0.4.1

Python Version

3.7.3

Additional Context

No response

jonatasleon commented 2 years ago

I was struggling with same problem here. I'll post how I solved my problem, this could be useful for others.

Once I tried to define option type as List[Tuple[str, str]] was raised an AssertionError as following:

AssertionError: List types with complex sub-types are not currently supported

To workaround this situation I had to use List[str] as type for the option, then set a callback function to transform the input. Once I would like to use option as that:

my-command --opts key1 value1 --opts key2 value2

I had to use this other pattern:

my-command --opts key1=value1 --opts key2=value2

At the end, my code looks like:

# Since there is a `_parse_option`
def _parse_option(value):
    result = defaultdict(list)
    for value in values:
        k, v = value.split('=')
        result[k.strip()].append(v.strip())
    return result.items()

@app.command
def my_command(opts: List[str] = typer.Option(..., callback=_parse_option):
    print(opts)

# [('key1', ['value1']), ('key2', ['value2'])]

Note 1: _parse_option returns result.items() in order to provide a list to the option. Otherwise, opts would receive only a list with the keys from result.

Note 2: I'm using defaultdict(list) because in my use case I would like to allow repeated keys in --opts option.

nealian commented 2 years ago

Another, possibly naive way of handling this would be to use the Click Context and a bit of custom parsing, though as mentioned in my own question, I don't know how to add that to the help output.

This would look something like:

def _parse_extras(extras: List[str]):
    _extras = extras[:]
    extra_options = {
        'complex_option1': [],
        'complex_option2': [],
    }
    while len(_extras) > 0:
        if _extras[0] == '--complex-option1' and len(_extras) > 2:
            complex_values = _extras[1:2]
            _extras = _extras[3:]
            extra_options['complex_option1'].append(_parse_opt1(*complex_values))
        elif _extras[0] == '--complex-option2' and len(_extras) > 3:
            complex_values = _extras[1:3]
            _extras = _extras[4:]
            extra_options['complex_option2'].append(_parse_opt2(*complex_values))
        else:
            raise click.NoSuchOption(_extras[0])
    return extra_options

@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def my_command(ctx: typer.Context, other_normal_opts):
    extra_options = _parse_extras(ctx.args)
    complex_option1 = extra_options['complex_option1']
    complex_option2 = extra_options['complex_option2']
    print(dir())
noctuid commented 1 year ago

It was pretty simple to get this working locally for something simple like List[Tuple[str, str]]. However I couldn't get the recursive type conversion working. I tried just doing generate_list_convertor(generate_tuple_convertor(main_type.__args__)) and actually adding a new convertor, but neither worked (tests still fail). I didn't have time to look more deeply into how the conversion works. Commit linked if anyone else wants to look at it.

MitriSan commented 1 year ago

@noctuid Hi! I think i have a simple usecase in my project - List[Tuple[str, Optional[str]]]. Could you please briefly describe how did you manage to make it work? Or maybe what direction i should look for. Is it possible using click context as described above? Thx!

noctuid commented 1 year ago

I changed the Typer code itself (see WIP Support specifying options as a list of tuples). I'm not sure it will work with Optional[str] though. Does typer support Tuple[str, Optional[str]]? I'm not sure how parsing that would even work. It seems like you could run into ambiguous cases where it wouldn't be clear if a string was for that option or a later argument.

MitriSan commented 1 year ago

@noctuid thanks for sharing this, hope it will be merged at some point! I re-thinked my approach - tuple with optional second argument is ambiguous indeed. For now it's working for me with using callback and List[str] param with a bit of parsing, as was suggested by @jonatasleon.

ankostis commented 12 months ago

This minor fix to make @jonatasleon's worthy workaround to support also values containing the = char:

k, v = value.split('=', 1)
alex-janss commented 7 months ago

Using click_type seemed to work for me:

@app.command()
def foo(pairs: list[click.Tuple] = typer.Option(click_type=click.Tuple([str, str]))):
    for x, y in pairs:
        print(f'x: {x}, y: {y}')
diegoquintanav commented 6 months ago

Using click_type seemed to work for me:

@app.command()
def foo(pairs: list[click.Tuple] = typer.Option(click_type=click.Tuple([str, str]))):
    for x, y in pairs:
        print(f'x: {x}, y: {y}')

What version of typer are you using @alex-janss? I'm getting TypeError: Option() got an unexpected keyword argument 'click_type' with typer 0.6.1.

alex-janss commented 6 months ago

Using click_type seemed to work for me:

@app.command()
def foo(pairs: list[click.Tuple] = typer.Option(click_type=click.Tuple([str, str]))):
    for x, y in pairs:
        print(f'x: {x}, y: {y}')

What version of typer are you using @alex-janss? I'm getting TypeError: Option() got an unexpected keyword argument 'click_type' with typer 0.6.1.

0.9.0

mgab commented 1 month ago

Using click_type seemed to work for me:

@app.command()
def foo(pairs: list[click.Tuple] = typer.Option(click_type=click.Tuple([str, str]))):
    for x, y in pairs:
        print(f'x: {x}, y: {y}')

This workaround works, but still it's a pitty that it does not allow type hinting to guess the type of the variable, thought. With this example both mypy (1.10.0) and pylance complain about the line with the for loop with:

error: "click.types.Tuple" object is not iterable  [misc]