brentyi / tyro

CLI interfaces & config objects, from types
https://brentyi.github.io/tyro
MIT License
504 stars 27 forks source link

Mutually exclusive arguments? #129

Closed dsedivec closed 1 month ago

dsedivec commented 8 months ago

Hi! Thanks for making Tyro! I'd like to use it, but I can't figure out how to do mutually exclusive options. I've searched the docs and issues and I haven't turned anything up. Is it possible to make mutually exclusive options with Tyro?

brentyi commented 8 months ago

Hi!

We don't support this right now, primarily because there's no standard Python syntax that this behavior would correspond to.

If you feel there's a clean way to integrate this I'd be open to suggestions! We could, as an example, add something to tyro.conf or tyro.conf.arg().

dsedivec commented 8 months ago

We don't support this right now, primarily because there's no standard Python syntax that this behavior would correspond to.

If you feel there's a clean way to integrate this I'd be open to suggestions! We could, as an example, add something to tyro.conf o tyro.conf.arg().

OK, thanks! IMHO they're an essential part of argument parsing, so they're worth adding even at the cost of adding something clunky. I use them often enough in argparse that I consider it a downgrade to switch to an argument parsing library that lacks support for them (and most do lack support for them).

All that said... I don't really have a great suggestion even for something clunky. Click doesn't really have these, but Cloup sits on top of Click and implements it by adding "constraints" to option groups. I think the closest Tyro gets to groups is probably "hierarchical configs", and I take a strong moral position against option names with . (dot) in them. 😉

Typer doesn't really implement these either, though I believe the author suggested using a validator to enforce mutual exclusivity. You can't really inspect a validator method to produce your help message, though; you'd be unable to show the mutually exclusive options as mutually exclusive in --help.

In C, if I was implementing my options in a struct, I'd probably define mutually exclusive arguments in a union. I can't think of how that really translates to Python, since C unions crucially have differently-named members—it starts to look a lot like hierarchical configs.

You could do it with subclasses, where you've got a base class with common options then subclasses, one per mutually exclusive option. Aside from being kind of annoying to work with, god help you if you want two or more sets of mutually exclusive options. I guess you could use multiple inheritance?

As you suggested, I was originally thinking along the lines of using tyro.conf.arg to group mutually exclusive options together, kind of like Cloup does with option groups and constraints. That does not translate to Python's type system, but it accomplishes the end goal of leaving Tyro able to both enforce mutual exclusivity, and also to document this in the --help.


Anyway, thank you for your fast answer! You may feel free close this now if you want, as my question has been answered. 🙂

brentyi commented 8 months ago

Thanks for your thoughts! I'll keep this issue open while I'm thinking about this.

tyro's likely not the best tool if you need this soon, but your C-style union note is actually a really interesting one; since we can already rename arguments with tyro.conf.arg(), semantics like this could make sense:

# (mockup, not a real runnable example)

from typing import Annotated

import tyro
from tyro.conf import arg

def main(
    mutually_exclusive: (
        Annotated[int, arg(name="foo")]
        | Annotated[str, arg(name="bar")]
    )
) -> None:
    ...

if __name__ == "__main__":
    tyro.cli(main)

where mutually_exclusive can take the value of either --foo INT or --bar STR, but not both.

Another option is to build on typing.TypedDict's total=False: we could have something like a tyro.conf.MutuallyExclusiveTypedDictKeys[] to communicate that only one key in the dictionary should be populated.

In either case, the question becomes whether the advantages here outweigh the complexity + maintenance effort. I'm not sure, since (as per the Typer suggestion) this kind of mutual exclusivity can also be added to the helptext + validated after the tyro.cli() call, it's just much worse UX.