Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allowing a 'default subcommand value' for a custom constructor which returns a Union #221

Open
mirceamironenco opened this issue Jan 5, 2025 · 20 comments

Comments

@mirceamironenco
Copy link
Contributor

mirceamironenco commented Jan 5, 2025

Hi! I have the following use-case:

# tyro_ex.py
from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam")],
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")],
    ]
    return Union[*cfgs]  # type: ignore


@dataclass
class Config:
    optimizer: Annotated[
        AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor)
    ]
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    tyro.cli(main, config=(tyro.conf.ConsolidateSubcommandArgs,))

py tyro_ex.py --help will force me to choose a subcommand (great):

usage: tyro_ex.py [-h] {cfg.optimizer:adam,cfg.optimizer:sgd}

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
╰───────────────────────────────────────────────────╯
╭─ subcommands ─────────────────────────────────────╮
│ {cfg.optimizer:adam,cfg.optimizer:sgd}            │
│     cfg.optimizer:adam                            │
│     cfg.optimizer:sgd                             │
╰───────────────────────────────────────────────────╯

Once I choose an optimizer via a subcommand (py tyro_ex.py cfg.optimizer:adam --help), I can then manipulate (and get helptext for) the arguments of both the parent config, and the nested optimizer config:

usage: tyro_ex.py cfg.optimizer:adam [-h] [CFG.OPTIMIZER:ADAM OPTIONS]

╭─ options ────────────────────────────────────────────╮
│ -h, --help           show this help message and exit │
╰──────────────────────────────────────────────────────╯
╭─ cfg.optimizer options ──────────────────────────────╮
│ --cfg.optimizer.lr FLOAT                             │
│                      (default: 0.1)                  │
│ --cfg.optimizer.adam-foo FLOAT                       │
│                      (default: 1.0)                  │
╰──────────────────────────────────────────────────────╯
╭─ cfg options ────────────────────────────────────────╮
│ --cfg.foo INT        (default: 1)                    │
│ --cfg.bar STR        (default: abc)                  │
╰──────────────────────────────────────────────────────╯

My question is, how to set a "default subcommand value" that can also possibly be changed? i.e. I'd like to be able to do something like:

@dataclass
class Config:
    optimizer: Annotated[
        AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor)
    ] = AdamConfig()
    foo: int = 1
    bar: str = "abc"

and if the user invokes py tyro_ex.py --help, the behaviour+helptext would be identical to the second example above (py tyro_ex.py cfg.optimizer:adam --help), but it would still be possible to choose a different optimizer config e.g. py tyro_ex.py cfg.optimizer:sgd --help should adapt the choice. The above case (= AdamConfg()) will still force me choose a subcommand (I assume due to the custom constructor). Things I've tried:

  • using tyro.conf.AvoidSubcommands for both the AdamConfig | SGDConfig union, or the return of the _constructor. The constructors I'm working with are a bit more involved but having a solution even for this case would be great.
  • I assume it would be possible to just capture the cli call and modify the sys.argv to append cfg.optimizer:adam even when the user invokes only py tyro_ex.py --help however I would then still need a way to show to the user passing --help that the cfg.optimizer is still a choice which can be modified.

I don't have a hard requirement for how the default is delivered. Preferably it would be either in the Config dataclass or the custom constructor, but any working solution would be a good starting point. I'm using constructor_factory since it's possible the choices are dynamically modified by the user before tyro.cli is invoked (think a registry system of name: str -> OptimizerConfig where someone adds a custom optimizer, and then invokes the cli entrypoint).

I assume it should be clear from the docs already whether this is possible or not, but I wasn't able to make it work - I assumed it was possible since py tyro_ex.py cfg.optimizer:adam --help does such a good job of propagating the options 'post-command choice' that it would make sense for a default command setting to allow the same behavior. Maybe it would be possible to open the 'Discussions' feature on the repo for questions like this? Thanks!

small additional question: is it possible to modify the title of a subcategory in the helptext? i.e. for py tyro_ex.py cfg.optimizer:adam --help as above, to have something like:

usage: tyro_ex.py cfg.optimizer:adam [-h] [CFG.OPTIMIZER:ADAM OPTIONS]

╭─ options ────────────────────────────────────────────╮
│ -h, --help           show this help message and exit │
╰──────────────────────────────────────────────────────╯
╭─ CUSTOM_TITLE_HERE_ADAM_RELATED options ─────────────────────
│ --cfg.optimizer.lr FLOAT                             │
│                      (default: 0.1)                  │
│ --cfg.optimizer.adam-foo FLOAT                       │
│                      (default: 1.0)                  │
╰──────────────────────────────────────────────────────╯
╭─ cfg options ────────────────────────────────────────╮
│ --cfg.foo INT        (default: 1)                    │
│ --cfg.bar STR        (default: abc)                  │
╰──────────────────────────────────────────────────────╯

edit:
The following also doesn't work:

if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        config=(tyro.conf.ConsolidateSubcommandArgs,),
        default=Config(optimizer=AdamConfig()),
    )
    main(cfg)

I am still forced to choose an optimizer.

edit 2: Should this line say "... use tyro.confAvoidSubcommands[]"?

"`avoid_subparsers=` is deprecated! use `tyro.conf.AvoidSubparsers[]`"

@mirceamironenco
Copy link
Contributor Author

mirceamironenco commented Jan 6, 2025

(please see last edit at the end of this message)
Some other attempts with strange (?) behavior (tyro 0.9.5):

def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam")],
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")],
    ]
    return cfgs  # type: ignore


CLIOptimizer = tyro.conf.AvoidSubcommands[Union[*_constructor()]]


@dataclass
class Config:
    optimizer: CLIOptimizer
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

For py tyro_ex.py --help I get:

usage: tyro_ex.py [-h] {optimizer:adam,optimizer:sgd}

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
╰───────────────────────────────────────────────────╯
╭─ subcommands ─────────────────────────────────────╮
│ {optimizer:adam,optimizer:sgd}                    │
│     optimizer:adam                                │
│     optimizer:sgd                                 │
╰───────────────────────────────────────────────────╯

I can now make it aware some default is being passed if I add a default for the dataclass field (optimizer: CLIOptimizer = SGDConfig()):

def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam")],
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")],
    ]
    return cfgs  # type: ignore


CLIOptimizer = tyro.conf.AvoidSubcommands[Union[*_constructor()]]


@dataclass
class Config:
    optimizer: CLIOptimizer = SGDConfig()
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

Running py tyro_ex.py --help:

usage: tyro_ex.py [-h] [OPTIONS]

╭─ options ───────────────────────────────────────────────╮
│ -h, --help              show this help message and exit │
│ --foo INT               (default: 1)                    │
│ --bar STR               (default: abc)                  │
╰─────────────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.adam-foo FLOAT                              │
│                         (default: 1.0)                  │
╰─────────────────────────────────────────────────────────╯

Notice that the default that was passed in the dataclass field and the one in the CLI are different - optimizer: CLIOptimizer = SGDConfig(), while it thinks AdamConfig() (or optimizer:adam) was passed as default (since --optimizer.adam-foo FLOAT shows up). I thought this was strange so after testing it a bit, it seems it's just picking up the first type in the list produced by _constructor. In other words, if I change to:

def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")],  # order changed!
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam")],
    ]
    return cfgs  # type: ignore


CLIOptimizer = tyro.conf.AvoidSubcommands[Union[*_constructor()]]


@dataclass
class Config:
    optimizer: CLIOptimizer = AdamConfig()
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

then for py tyro_ex.py --help:

usage: tyro_ex.py [-h] [OPTIONS]

╭─ options ───────────────────────────────────────────────╮
│ -h, --help              show this help message and exit │
│ --foo INT               (default: 1)                    │
│ --bar STR               (default: abc)                  │
╰─────────────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.sgd-foo FLOAT                               │
│                         (default: 1.0)                  │
╰─────────────────────────────────────────────────────────╯

(notice again AdamConfig() as default, but it thinks I passed optimizer:sgd).

edit:
For the following example:

from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")],
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam")],
    ]
    return Union[*cfgs]  # type: ignore


@dataclass
class Config:
    optimizer: Annotated[
        OptimizerConfig, tyro.conf.arg(constructor_factory=_constructor)
    ] = AdamConfig()
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        # config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

Once tyro.conf.ConsolidateSubcommandArgs is commented out, the helptext shows the wrong default being picked up:

py tyro_ex.py --help
usage: tyro_ex.py [-h] [--foo INT] [--bar STR]
                  [{optimizer:sgd,optimizer:adam}]

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
│ --foo INT         (default: 1)                    │
│ --bar STR         (default: abc)                  │
╰───────────────────────────────────────────────────╯
╭─ optional subcommands ────────────────────────────╮
│ (default: optimizer:sgd)                          │
│ ────────────────────────────────                  │
│ [{optimizer:sgd,optimizer:adam}]                  │
│     optimizer:sgd                                 │
│     optimizer:adam                                │
╰───────────────────────────────────────────────────╯

Note that running this will not error out (and the AdamConfg() is assigned). So the question is how to make it pick up the correct default being passed, and importantly I would like to have both:

╭─ optional subcommands ────────────────────────────╮
│ (default: optimizer:sgd)                          │
│ ────────────────────────────────                  │
│ [{optimizer:sgd,optimizer:adam}]                  │
│     optimizer:sgd                                 │
│     optimizer:adam                                │
╰───────────────────────────────────────────────────╯

and the options for the default config being shown whenever --help is passed, i.e. it would look something like:

py tyro_ex.py --help
usage: tyro_ex.py [-h] [--foo INT] [--bar STR]
                  [{optimizer:sgd,optimizer:adam}]

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
│ --foo INT         (default: 1)                    │
│ --bar STR         (default: abc)                  │
╰───────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.adam-foo FLOAT                              │
│                         (default: 1.0)                  │
╰──────────────────────────────────────────────────────
╭─ optional subcommands ────────────────────────────╮
│ (default: optimizer:adam) (corrected manually)                          │
│ ────────────────────────────────                  │
│ [{optimizer:sgd,optimizer:adam}]                  │
│     optimizer:sgd                                 │
│     optimizer:adam                                │
╰───────────────────────────────────────────────────╯

preferably with tyro.conf.ConsolidateSubcommandArgs being enabled. Note that the 'wrong default bug' will appear also in the case where the default is passed via tyro.cli, i.e.:

    cfg = tyro.cli(
        Config,
        # config=(tyro.conf.ConsolidateSubcommandArgs,),
        default=Config(optimizer=AdamConfig()),
    )

will have the same effect just described.

later edit:
It seems the problem is this function https://github.com/brentyi/tyro/blob/main/src/tyro/_subcommand_matching.py#L15. In this case it doesn't seem to handle the default well enough and ends up calling https://github.com/brentyi/tyro/blob/main/src/tyro/_subcommand_matching.py#L56 which will match a default like AdamConfig() with the command optimizer:sgd. Making use of https://github.com/brentyi/tyro/blob/main/src/tyro/_subcommand_matching.py#L39 I specified a default instance for each subcommand 😅 :

from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd", default=SGDConfig())],
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam", default=AdamConfig())],
    ]
    return Union[*cfgs]  # type: ignore


@dataclass
class Config:
    optimizer: Annotated[
        OptimizerConfig, tyro.conf.arg(constructor_factory=_constructor)
    ] = AdamConfig()
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        # config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

we get:

py tyro_ex.py --help
usage: tyro_ex.py [-h] [--foo INT] [--bar STR]
                  [{optimizer:sgd,optimizer:adam}]

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
│ --foo INT         (default: 1)                    │
│ --bar STR         (default: abc)                  │
╰───────────────────────────────────────────────────╯
╭─ optional subcommands ────────────────────────────╮
│ (default: optimizer:adam)                         │
│ ────────────────────────────────                  │
│ [{optimizer:sgd,optimizer:adam}]                  │
│     optimizer:sgd                                 │
│     optimizer:adam                                │
╰───────────────────────────────────────────────────╯

the default is now correctly assigned and if I run py tyro_ex.py optimizer:sgd the optimizer config is changed!. Remaining problems:

  • I can't see the options for the default optimizer cfg.
  • If I use tyro.conf.ConsolidateSubcommandArgs the default disappears from the helptext (and I stil can't see the options for the default config). In fact, with this option set, even though the default exists calling py tyro_ex.py will exit with "The following arguments are required: {optimizer:sgd, optimizer:adam}".

@mirceamironenco
Copy link
Contributor Author

mirceamironenco commented Jan 6, 2025

Hi, thanks for the fix and sorry for the very long messages! After staring at the docs and playing around with the debugger for a long time I've realized it's possible this is intended behavior, so this might be a feature request (?). I guess I'm looking for a weaker version of tyro.conf.ConsolidateSubcommandArgs which can automatically place ([1]) the specified 'subcommand defaults' at the beginning of the sysv args (e.g. py tyro_ex.py optimizer:adam even though the user only invoked py tyro_ex.py) and also show the subcommand choices with the default specified (as they are shown without ConsolidateSubcommandArgs). Then if the user actually specifies e.g. py tyro_ex.py optimizer:sgd it would be optimizer:sgd used. Hopefully that makes sense, if not please let me know.

[1] Or more precisely, the effect of the Marker would be as if the the argv was modified in the way described.

@brentyi
Copy link
Owner

brentyi commented Jan 6, 2025

Hi @mirceamironenco!

Yeah, this is working as-designed but I do agree that what you've described would be ideal.

We currently set all subcommands to required when tyro.conf.ConsolidateSubcommandArgs is passed in:

tyro/src/tyro/_parsers.py

Lines 600 to 602 in 1704284

# Required if all args are pushed to the final subcommand.
if _markers.ConsolidateSubcommandArgs in field.markers:
required = True

It's also in the tests:

tyro/tests/test_conf.py

Lines 833 to 835 in 1704284

# Despite all defaults being set, a subcommand should be required.
with pytest.raises(SystemExit):
tyro.cli(tyro.conf.ConsolidateSubcommandArgs[DefaultInstanceSubparser], args=[])

And hinted at in the docs ("at the cost of support for optional subcommands"), although I can see that this is vague:

ConsolidateSubcommandArgs = Annotated[T, None]
"""Consolidate arguments applied to subcommands. Makes CLI less sensitive to argument
ordering, at the cost of support for optional subcommands.

I'm looking into relaxing this, which seems simple but is actually a little bit tricky (this was part of the reason for the current design). If we visualize the possible subcommands as a tree:

  • If a required argument is present in the "root" of the tree, we now need to mark all of the leaves as required. Because the argument will actually be added to the leaves.
  • We lower everything to argparse, which doesn't actually support default subcommands. As a result, the relevant code here has hacks and is slightly brittle.

I can get back (hopefully today) once I've made some progress!

@brentyi
Copy link
Owner

brentyi commented Jan 6, 2025

small additional question: is it possible to modify the title of a subcategory in the helptext? i.e. for py tyro_ex.py cfg.optimizer:adam --help as above, to have something like:

On this: unfortunately not! It's hardcoded here:

tyro/src/tyro/_parsers.py

Lines 241 to 249 in 1704284

def group_name_from_arg(arg: _arguments.ArgumentDefinition) -> str:
prefix = arg.lowered.name_or_flags[0]
if prefix.startswith("--"):
prefix = prefix[2:]
if "." in prefix:
prefix = prefix.rpartition(".")[0]
else:
prefix = ""
return prefix

@mirceamironenco
Copy link
Contributor Author

mirceamironenco commented Jan 7, 2025

Thank you for adding this! I think the tradeoff with required arguments makes complete sense (and it's hard to even think of a use-case where this would be a blocker for anything).

It seems right now it is possible to run py tyro_ex.py with ConsolidateSubcommandArgs set assuming the subcommand defaults are set and there are no required arguments:

from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")],
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam")],
    ]
    return Union[*cfgs]  # type: ignore


CLIOptimizer = Annotated[
    OptimizerConfig,
    tyro.conf.arg(constructor_factory=_constructor),
]


@dataclass
class Config:
    optimizer: CLIOptimizer = AdamConfig(adam_foo=0.5)
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(Config, config=(tyro.conf.ConsolidateSubcommandArgs,))
    main(cfg)

The only question is, is there any way to add an option which allows the user to 'mark the defaults as having been chosen explicitly'? i.e. so that py tyro_ex.py --help behaves as py tyro_ex.py optimizer:adam --help and I can actually also modify the optimizer arguments directly? i.e. py tyro_ex.py --optimizer.adam-foo 0.1 would behave as py tyro_ex.py optimizer:adam --optimizer.adam-foo 0.1 (and ideally the opimizer/subcommand choices are still shown in the helptext). Right now, even for arguments that are not related to the optimizer I would need to explicitly append the subcommand, i.e. py tyro_ex.py --foo 5 would crash, even though everything has a default.

I would have no issue if this is some explicit opt-in/additional flag that goes with ConsolidateSubcommandArgs (or maybe in tyro.conf.subcommand something like use_default: bool = False which I can set to true?) since it likely would not be backwards-compatible.

As a small side-note, for the following:

from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd", default=SGDConfig())],
        Annotated[AdamConfig, tyro.conf.subcommand(name="adam", default=AdamConfig())],
    ]
    return Union[*cfgs]  # type: ignore


CLIOptimizer = Annotated[
    OptimizerConfig,
    tyro.conf.arg(constructor_factory=_constructor),
]


@dataclass
class Config:
    optimizer: CLIOptimizer = AdamConfig(adam_foo=0.5)
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(Config, config=(tyro.conf.ConsolidateSubcommandArgs,))
    main(cfg)

running py tyro_ex.py we get Config(optimizer=AdamConfig(lr=0.1, adam_foo=0.5), foo=1, bar='abc'), however for py tyro_ex.py optimizer:adam we get Config(optimizer=AdamConfig(lr=0.1, adam_foo=1.0), foo=1, bar='abc') (notice adam_foo in both cases). I guess this makes sense, I was wondering if it might be useful to warn the user about it, or mention something like this in the doc of the default argument of subcommand.

@brentyi
Copy link
Owner

brentyi commented Jan 7, 2025

The only question is, is there any way to add an option which allows the user to 'mark the defaults as having been chosen explicitly'? i.e. so that py tyro_ex.py --help behaves as py tyro_ex.py optimizer:adam --help and I can actually also modify the optimizer arguments directly?

Unfortunately I can't think of a clean way to implement this without rewriting the argparse backend. I think this is plausible (especially given our increasingly comprehensive test suite) and I'd be interested, but it'd be a large undertaking. For the foreseeable future it seems like the subcommand verbosity will just need to be a tradeoff of the ConsolidateSubcommandArgs flag.

The second discrepancy you pointed out: this behavior makes sense, but not as much sense as python tyro_ex.py and python tyro_ex.py optimizer:adam behaving the same when optimizer:adam is the default subcommand. I'll fix this + add a note in the documentation.

Thanks for helping me sort out all of these kinks...!

@mirceamironenco
Copy link
Contributor Author

That makes sense, it seems the limitation is on the side of argparse. Thanks for taking a look at this! One last question:

small additional question: is it possible to modify the title of a subcategory in the helptext? i.e. for py tyro_ex.py cfg.optimizer:adam --help as above, to have something like:

On this: unfortunately not! It's hardcoded here:

tyro/src/tyro/_parsers.py

Lines 241 to 249 in 1704284

def group_name_from_arg(arg: _arguments.ArgumentDefinition) -> str:
prefix = arg.lowered.name_or_flags[0]
if prefix.startswith("--"):
prefix = prefix[2:]
if "." in prefix:
prefix = prefix.rpartition(".")[0]
else:
prefix = ""
return prefix

I found an alternate behavior that would be just as good for me, but I can only seem to trigger it if I turn off subcommands, however I'm not sure why that would be the case. For the following:

from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd", default=SGDConfig())],
        Annotated[
            AdamConfig,
            tyro.conf.subcommand(name="adam", default=AdamConfig()),
        ],
    ]
    return Union[*cfgs]  # type: ignore


CLIOptimizer = Annotated[
    OptimizerConfig,
    tyro.conf.arg(constructor_factory=_constructor, help="Optimizer Hello"),  # doc via help!
]


@dataclass
class Config:
    optimizer: tyro.conf.AvoidSubcommands[CLIOptimizer] = AdamConfig()  # avoid
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

for py tyro_ex.py optimizer:adam --help I get this 'group subtitle' ('Optimizer hello'):

usage: tyro_ex.py [-h] [OPTIONS]

╭─ options ───────────────────────────────────────────────╮
│ -h, --help              show this help message and exit │
│ --foo INT               (default: 1)                    │
│ --bar STR               (default: abc)                  │
╰─────────────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ Optimizer Hello                                         │
│ ──────────────────────────────────────                  │
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.adam-foo FLOAT                              │
│                         (default: 1.0)                  │
╰─────────────────────────────────────────────────────────╯

however if I remove the AvoidSubcommands marker (and change nothing else):

@dataclass
class Config:
    optimizer: CLIOptimizer = AdamConfig()
    foo: int = 1
    bar: str = "abc"

it goes away:

usage: tyro_ex.py optimizer:adam [-h] [OPTIMIZER:ADAM OPTIONS]

╭─ options ───────────────────────────────────────────────╮
│ -h, --help              show this help message and exit │
│ --foo INT               (default: 1)                    │
│ --bar STR               (default: abc)                  │
╰─────────────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.adam-foo FLOAT                              │
│                         (default: 1.0)                  │
╰─────────────────────────────────────────────────────────╯

even though the same help text is still specified:

CLIOptimizer = Annotated[
    OptimizerConfig,
    tyro.conf.arg(constructor_factory=_constructor, help="Optimizer Hello"),
]

Is there any way to keep it?

@brentyi
Copy link
Owner

brentyi commented Jan 8, 2025

Sorry for the opaqueness here, this unfortunately also isn't configurable.

To explain the current behavior:

When you have AvoidSubcommands specified we parse your example ~equivalently to

optimizer: AdamConfig = AdamConfig()
"""Optimizer Hello"""

, where the help string unambiguously applies to just the arguments of AdamConfig. This is why the helptext will show up above the argument group.

When you don't have AvoidSubcommands, we parse it ~equivalently to

optimizer: AdamConfig | SgdConfig = AdamConfig()
"""Optimizer Hello"""

, where the help string applies to the union / subparser group as a whole. In this case, the helptext will show up above the subcommand list (in python example.py --help) but not for just the arguments of AdamConfig (in python example.py optimizer:adam-config --help).

I don't feel strongly about this design. The tradeoffs are relatively minor:

  • There's currently a risk of false negatives, when the helptext makes sense for both the union as a whole and as a description within the subcommands. (for example: """Configuration for the optimizer.""")
  • If we propagate the helptext down, there's a risk of false positives, when the helptext makes sense for the union as a whole but not the individual subcommands. (it might be confusing, for example, to have """We support Adam and SGD optimizers."" written above the Adam arguments)

@mirceamironenco
Copy link
Contributor Author

I understand, thanks! I guess my usecase is complicated enough that I'll have to change _cli_impl myself, maybe keep a registry of all subcommand producing unions and adapt the parser_spec or formatter based on that. If I get a working solution I'll post it here for anyone else, as I've found the situation easy to run into.

@brentyi
Copy link
Owner

brentyi commented Jan 8, 2025

I haven't decided whether to merge, but could you see if #227 does what you're looking for? The changes are small but there might also be bugs, the parser + subparser tree structures get complicated and (as you may have noticed) there are often edge cases I don't think of.

@mirceamironenco
Copy link
Contributor Author

mirceamironenco commented Jan 8, 2025

In terms of behavior this is what I want (I assume you mean the group doc) but your concern relating False positives I think is valid. With the current implementation I can also see someone asking for the help text to update depending on the element of the Union that was chosen. I'm still playing around a bit, but I've noticed in this scenario 3 spots where a helptext/doc can be provided so maybe there is a way to keep the behavior the same when a subcommand choice hasn't been made, i.e. py tyro_ex.py --help shows 'Optimizer Hello`:

usage: tyro_ex.py [-h] [{optimizer:sgd,optimizer:adam}]

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
╰───────────────────────────────────────────────────╯
╭─ optional subcommands ────────────────────────────╮
│ Optimizer hello (default: optimizer:adam)         │
│ ─────────────────────────────────────────         │
│ [{optimizer:sgd,optimizer:adam}]                  │
│     optimizer:sgd                                 │
│     optimizer:adam                                │
╰───────────────────────────────────────────────────╯

and if a choice has been made I see 2 options which can be used either for the command description or the group subtitle (where 'Optimizer Hello' is now):

from dataclasses import dataclass
from typing import Annotated, Union

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    """Adam dataclass DOC"""  # doc dataclass

    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def _constructor() -> type[OptimizerConfig]:
    cfgs = [
        Annotated[SGDConfig, tyro.conf.subcommand(name="sgd", default=SGDConfig())],
        Annotated[
            AdamConfig,
            tyro.conf.subcommand(
                name="adam",
                default=AdamConfig(),
                description="Adam subcommand description",  # doc subcommand
            ),
        ],
    ]
    return Union[*cfgs]  # type: ignore


CLIOptimizer = Annotated[
    OptimizerConfig,
    tyro.conf.arg(constructor_factory=_constructor, help="Optimizer hello"),  # doc union
]


@dataclass
class Config:
    optimizer: CLIOptimizer = AdamConfig()
    foo: int = 1
    bar: str = "abc"


def main(cfg: Config):
    print(cfg)


if __name__ == "__main__":
    cfg = tyro.cli(
        Config,
        config=(tyro.conf.ConsolidateSubcommandArgs,),
    )
    main(cfg)

for py tyro_ex.py --help we get:

usage: tyro_ex.py [-h] [{optimizer:sgd,optimizer:adam}]

╭─ options ──────────────────────────────────────────────────────────────────╮
│ -h, --help        show this help message and exit                          │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ optional subcommands ─────────────────────────────────────────────────────╮
│ Optimizer hello (default: optimizer:adam)                                  │
│ ────────────────────────────────────────────────────────────────────────── │
│ [{optimizer:sgd,optimizer:adam}]                                           │
│     optimizer:sgd                                                          │
│     optimizer:adam                                                         │
│                   Adam subcommand description                              │
╰────────────────────────────────────────────────────────────────────────────╯

for py tyro_ex.py optimizer:adam --help we get:

usage: tyro_ex.py optimizer:adam [-h] [OPTIMIZER:ADAM OPTIONS]

Adam subcommand description

╭─ options ───────────────────────────────────────────────╮
│ -h, --help              show this help message and exit │
│ --foo INT               (default: 1)                    │
│ --bar STR               (default: abc)                  │
╰─────────────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ Optimizer hello                                         │
│ ──────────────────────────────────────                  │
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.adam-foo FLOAT                              │
│                         (default: 1.0)                  │
╰─────────────────────────────────────────────────────────╯

notice that """Adam dataclass DOC""" is not used anywhere, so I was thinking either that or "Adam subcommand description" could be propagated, making all 3 choices always opt-in for the user. The "Adam dataclass DOC" does show up if I remove the description= from the subcommand definition in _constructor (but it shows up as the command description):

usage: tyro_ex.py optimizer:adam [-h] [OPTIMIZER:ADAM OPTIONS]

Adam dataclass DOC

╭─ options ───────────────────────────────────────────────╮
│ -h, --help              show this help message and exit │
│ --foo INT               (default: 1)                    │
│ --bar STR               (default: abc)                  │
╰─────────────────────────────────────────────────────────╯
╭─ optimizer options ─────────────────────────────────────╮
│ Optimizer hello                                         │
│ ──────────────────────────────────────                  │
│ --optimizer.lr FLOAT    (default: 0.1)                  │
│ --optimizer.adam-foo FLOAT                              │
│                         (default: 1.0)                  │
╰─────────────────────────────────────────────────────────╯

Again, sorry if this not possible with the underlying implementation. I would understand not including this change. I'll try to also learn a bit more about tyro internals since I understand there is also a maintenance burden here with every proposal.

edit: Just an idea, I'm also realizing a lot of the behavior I'm looking for occurs in the scenario where "multiple subcommands are possible (think CLIScheduler, CLIOptimizer, etc) + tyro.conf.ConsolidateSubcommandArgs specified + all subcommands have defaults / are provided". Maybe there is some way to pick up on this? So it would be the setting where in a larger config example, the user is specifying py tyro_ex.py optimizer:adam scheduler:cosine foo:bar, and there are no options left (all Unions have been specified with a choice). IIUC right now a lot of behavior is customized by the last 'leaf' (i.e. foo:bar in the previous example), but with all options specified + ConsolidateSubcommandArgs, maybe there is a way to consider all options 1 single subcommand (this is moreso related to the previous feature request)?

@mirceamironenco
Copy link
Contributor Author

mirceamironenco commented Jan 8, 2025

By the way, is this a bug?

from dataclasses import dataclass
from typing import Annotated, Literal

import tyro


@dataclass(frozen=True)
class OptimizerConfig:
    lr: float = 1e-1


@dataclass(frozen=True)
class AdamConfig(OptimizerConfig):
    """Adam dataclass DOC"""

    adam_foo: float = 1.0


@dataclass(frozen=True)
class SGDConfig(OptimizerConfig):
    sgd_foo: float = 1.0


def name_to_cfg(name: Literal["adam", "sgd"]) -> OptimizerConfig:
    if name == "adam":
        return AdamConfig()

    if name == "sgd":
        return SGDConfig()


CLIConfig = Annotated[OptimizerConfig, tyro.conf.arg(constructor=name_to_cfg)]


@dataclass
class Config:
    optimizer: CLIConfig = AdamConfig()


if __name__ == "__main__":
    print(tyro.cli(Config))

py filename.py --help will now show the str(instance) as a doc (group subtitle) when no helptext is specified:

usage: new_tyro_bug.py [-h] [--optimizer.name {adam,sgd}]

╭─ options ─────────────────────────────────────────╮
│ -h, --help        show this help message and exit │
╰───────────────────────────────────────────────────╯
╭─ optimizer options ───────────────────────────────╮
│ Default: AdamConfig(lr=0.1, adam_foo=1.0)         │
│ ─────────────────────────────────────────         │
│ --optimizer.name {adam,sgd}                       │
│                   (optional)                      │
╰───────────────────────────────────────────────────╯

edit: actually it seems to be the same for 0.9.5 (?). Is there any way to turn it off in this case? Modifying help= or adding a doc to CLIOptimizer seems to still append str(instance).

@brentyi
Copy link
Owner

brentyi commented Jan 8, 2025

maybe there is a way to consider all options 1 single subcommand

This I didn't fully follow, do you mean to have on subcommand per possible combination of the Union options?

For the "Default:" behavior, the code is here:

tyro/src/tyro/_parsers.py

Lines 158 to 171 in 0c2cb26

# If arguments are in an optional group, it indicates that the default_instance
# will be used if none of the arguments are passed in.
if (
len(nested_parser.args) >= 1
and _markers._OPTIONAL_GROUP in nested_parser.args[0].field.markers
):
current_helptext = helptext_from_intern_prefixed_field_name[
class_field_name
]
helptext_from_intern_prefixed_field_name[class_field_name] = (
("" if current_helptext is None else current_helptext + "\n\n")
+ "Default: "
+ str(field.default)
)

The reason is just to document what happens if --optimizer.name isn't passed in. For example, in this case:

def name_to_cfg(name: Literal["adam", "sgd"]) -> OptimizerConfig:
    if name == "adam":
        return AdamConfig()

    if name == "sgd":
        return SGDConfig()

CLIConfig = Annotated[OptimizerConfig, tyro.conf.arg(constructor=name_to_cfg)]

@dataclass
class Config:
    optimizer: CLIConfig = AdamConfig(10.0, 10.0)

it should show Default: AdamConfig(lr=10.0, adam_foo=10.0).

@mirceamironenco
Copy link
Contributor Author

maybe there is a way to consider all options 1 single subcommand

This I didn't fully follow, do you mean to have on subcommand per possible combination of the Union options?

Sorry that was a bit unclear yeah. That edit was in reference to the previous request which you mentioned needed a rewrite of the argparse backend. What I meant was, in the scenario where we are using tyro.conf.ConsolidateSubcommandArgs and every Union has either a default or a choice passed in by the user via the CLI (so either adam is default, or e.g. the user passes py tyro_ex.py optimizer:sgd), we can traverse the tree root -> leaf (last choice), so just merge all the choices as if no Unions exist (I guess this would be another marker). And now --help or --config.{subcommand}.attribute behaves as if the user passed all the choices manually.

@brentyi
Copy link
Owner

brentyi commented Jan 10, 2025

I see, so the end goal is for both:

python tyro_ex.py optimizer:sgd --optimizer.sgd_foo 2.0

and

python tyro_ex.py --optimizer.sgd_foo 2.0

to work, right? Currently we can only choose between the first (default) or second (via tyro.conf.AvoidSubcommands) behavior, but ideally we want both?

If we retain the argparse backend, the low-level changes that would need to happen are:

  • We need to call .add_argument() multiple times for each field; more times for arguments that occur later in the subparser tree.
  • We need to check the value of multiple corresponding arguments for each field.
  • We need to figure out how to handle required fields, which should throw an error only if none of the corresponding arguments are passed in.
  • We need to handle conflicts when arguments corresponding to the same field are passed in at multiple levels of the subparser tree.

Overall I think this is doable; at a high-level we already have the tree traversal scaffolding to make this happen. But the maintenance overhead does seem high. :/

@mirceamironenco
Copy link
Contributor Author

Currently we can only choose between the first (default) or second (via tyro.conf.AvoidSubcommands) behavior, but ideally we want both?

Yes, that would be ideal. I understand it's much more involved than it seems, so right now I am using a more hacky approach where I'm capturing sys.argv checking it myself if the user passed a choice that should go to a union object and then calling tyro.cli(..., args=...).

I think the idea of propagating the doc for a union choice (mentioned at the start of #221 (comment)) is possibly separate? Is there a way to make the text passed to subcommand(description=...) or the doc of the dataclass go to the 'group subtitle' rather then at the top below the CLI command? With this + the argv patching it would be a working solution for me.

@brentyi
Copy link
Owner

brentyi commented Jan 10, 2025

Is there a way to make the text passed to subcommand(description=...) or the doc of the dataclass go to the 'group subtitle' rather then at the top below the CLI command?

I think if you want to hack this in it should be doable, but making it the default behavior might also be complicated:

  • For a subcommand like optimizer:sgd, there's no guarantee that the optimizer argument group actually exists. For example, when:
    • tyro.conf.OmitArgPrefixes is used
    • tyro.conf.OmitSubcommandPrefixes is used
    • There are no arguments directly into the SGDConfig dataclass, like if sgd only contains more subparsers or more nested dataclasses resulting in args like --optimizer.scheduler.learning-rate
  • Less critically:
    • When there's more nesting, like the --optimizer.scheduler.* example, it can be more intuitive for the docstring to apply to all arguments displayed in the help message and not just the optimizer group.
    • Some projects have really long descriptions/dataclass docstrings, the extra space is nice.

I think there's probably some better API / set of default behaviors for displaying or configuring the helptext headings, it's just a matter of balancing with complexity... does that answer your question?

@mirceamironenco
Copy link
Contributor Author

I understand, thanks.

btw I think some of the CI will run in forks every time they sync, e.g. https://github.com/mirceamironenco/tyro/actions/runs/12707047668. adding if: github.repository_owner == 'brentyi' should prevent this I think.

@brentyi
Copy link
Owner

brentyi commented Jan 10, 2025

Added!

@mirceamironenco
Copy link
Contributor Author

mirceamironenco commented Jan 10, 2025

Thanks again and sorry for the wild-goose chase! If you are curious (and maybe someone else has a similar use-case): this branch implements a work-in-progress solution for the original problem (that works for the use-case presented above; it also changes how columns are formatted to be less restrictive with min height requirements). I still need to figure out how to show the user the helptext for subcommand choices and add group subtitles.

The TL;DR of the approach is: rely on tyro's match_subcommand to identify the correct default_name for each subcommand which has a default. Collect all subparser default names from the tree in post-order fashion, propagating all the choices in visit order up to the root ParserSpecification object (https://github.com/mirceamironenco/tyro/blob/mircea_dev/src/tyro/_parsers.py#L196-L216). The reason being that this object is used in _cli.py where we have both the user args and now all of the default subcommand choices. We will need a way to match user arguments with possible choices for a subcommand. For this I am using a DSU singleton (probably overkill), and treating the choices as a connected component, so the user can specify e.g. optimizer:sgd and I know I am supposed to replace a default optimizer:* (which can be any other choice). The DSU is built together with the ordered dict of choices. Then everything is used here: https://github.com/mirceamironenco/tyro/blob/mircea_dev/src/tyro/_cli.py#L392-L450, where after the parser_spec is constructed, we have the defaults and potentially replace them with the user choices.

(Cannot promise this doesn't have bugs. Also links to specific file lines in the comment above might not be valid as I'm still working on this.)

edit: Above solution could not deal with nested choices, and required installing from a different branch. The following is more robust and can be used as a patch on top of current tyro installation:

https://gist.github.com/mirceamironenco/b3c6f0e7a6c44f4de78007bd5a133b6e

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants