From 3315c83b64499c98a6af4a982a1eb557352fd20f Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 6 Jan 2025 11:43:34 -0800 Subject: [PATCH 1/5] Initial implementation (has bugs) --- src/tyro/_calling.py | 39 ++++++++++++++++++++++++++++++--------- src/tyro/_cli.py | 2 +- src/tyro/_parsers.py | 6 +++++- tests/test_conf.py | 10 ++++++---- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/tyro/_calling.py b/src/tyro/_calling.py index 185f7147..4e74374c 100644 --- a/src/tyro/_calling.py +++ b/src/tyro/_calling.py @@ -47,13 +47,30 @@ def callable_with_args( kwargs: Dict[str, Any] = {} consumed_keywords: Set[str] = set() - def get_value_from_arg(prefixed_field_name: str) -> Any: + def get_value_from_arg( + prefixed_field_name: str, field_def: _fields.FieldDefinition + ) -> tuple[Any, bool]: """Helper for getting values from `value_from_arg` + doing some extra - asserts.""" - assert ( - prefixed_field_name in value_from_prefixed_field_name - ), f"{prefixed_field_name} not in {value_from_prefixed_field_name}" - return value_from_prefixed_field_name[prefixed_field_name] + asserts. + + Returns: + - The value from `value_from_prefixed_field_name`. + - If the value was found. If True, we found the value (and it will + be returned as a string or list of strings). If False, we've just + returned the default. + """ + + if prefixed_field_name not in value_from_prefixed_field_name: + # When would the value not be found? Only if we have + # `tyro.conf.ConslidateSubcommandArgs` for one of the contained + # subparsers. + assert ( + parser_definition.subparsers is not None + and parser_definition.consolidate_subcommand_args + ), "Field value is unexpectedly missing. This is likely a bug in tyro." + return field_def.default, False + else: + return value_from_prefixed_field_name[prefixed_field_name], True arg_from_prefixed_field_name: Dict[str, _arguments.ArgumentDefinition] = {} for arg in parser_definition.args: @@ -79,7 +96,7 @@ def get_value_from_arg(prefixed_field_name: str) -> Any: name_maybe_prefixed = prefixed_field_name consumed_keywords.add(name_maybe_prefixed) if not arg.lowered.is_fixed(): - value = get_value_from_arg(name_maybe_prefixed) + value, value_found = get_value_from_arg(name_maybe_prefixed, field) if value in _fields.MISSING_AND_MISSING_NONPROP: value = arg.field.default @@ -97,7 +114,8 @@ def get_value_from_arg(prefixed_field_name: str) -> Any: and arg.lowered.nargs in ("?", "*") ): value = [] - else: + elif value_found: + # Value was found from the CLI, so we need to cast it with instance_from_str. any_arguments_provided = True if arg.lowered.nargs == "?": # Special case for optional positional arguments: this is the @@ -144,7 +162,10 @@ def get_value_from_arg(prefixed_field_name: str) -> Any: subparser_dest = _strings.make_subparser_dest(name=prefixed_field_name) consumed_keywords.add(subparser_dest) if subparser_dest in value_from_prefixed_field_name: - subparser_name = get_value_from_arg(subparser_dest) + subparser_name, subparser_name_found = get_value_from_arg( + subparser_dest, field + ) + assert subparser_name_found else: assert ( subparser_def.default_instance diff --git a/src/tyro/_cli.py b/src/tyro/_cli.py index 93e3afca..30fa2f0e 100644 --- a/src/tyro/_cli.py +++ b/src/tyro/_cli.py @@ -283,7 +283,7 @@ def _cli_impl( if deprecated_kwargs.get("avoid_subparsers", False): f = conf.AvoidSubcommands[f] # type: ignore warnings.warn( - "`avoid_subparsers=` is deprecated! use `tyro.conf.AvoidSubparsers[]`" + "`avoid_subparsers=` is deprecated! use `tyro.conf.AvoidSubcommands[]`" " instead.", stacklevel=2, ) diff --git a/src/tyro/_parsers.py b/src/tyro/_parsers.py index 023907f8..b3b5872d 100644 --- a/src/tyro/_parsers.py +++ b/src/tyro/_parsers.py @@ -598,7 +598,11 @@ def from_field( required = True # Required if all args are pushed to the final subcommand. - if _markers.ConsolidateSubcommandArgs in field.markers: + if ( + _markers.ConsolidateSubcommandArgs in field.markers + and default_parser is not None + and default_parser.has_required_args + ): required = True # Make description. diff --git a/tests/test_conf.py b/tests/test_conf.py index 9eda1cb3..59a834d0 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -8,10 +8,10 @@ from typing import Any, Dict, Generic, List, Tuple, Type, TypeVar, Union import pytest -from helptext_utils import get_helptext_with_checks +import tyro from typing_extensions import Annotated, TypedDict -import tyro +from helptext_utils import get_helptext_with_checks def test_suppress_subcommand() -> None: @@ -830,9 +830,11 @@ class DefaultInstanceSubparser: == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8)) ) - # Despite all defaults being set, a subcommand should be required. - with pytest.raises(SystemExit): + # All defaults are set, not default is needed. + assert ( tyro.cli(tyro.conf.ConsolidateSubcommandArgs[DefaultInstanceSubparser], args=[]) + == DefaultInstanceSubparser(3) + ) def test_omit_subcommand_prefix_and_consolidate_subcommand_args_in_function() -> None: From 8b4a5ad096a9637e8aa118fa63e1956e08174702 Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 6 Jan 2025 16:32:02 -0800 Subject: [PATCH 2/5] Fix required, tests --- src/tyro/_cli.py | 2 +- src/tyro/_parsers.py | 78 +++++++++++++++----------- src/tyro/conf/_markers.py | 10 +++- tests/test_conf.py | 115 +++++++++++++++++++++++++++++++++++--- 4 files changed, 164 insertions(+), 41 deletions(-) diff --git a/src/tyro/_cli.py b/src/tyro/_cli.py index 30fa2f0e..cd74f257 100644 --- a/src/tyro/_cli.py +++ b/src/tyro/_cli.py @@ -398,7 +398,7 @@ def _cli_impl( parser._parsing_known_args = return_unknown_args parser._console_outputs = console_outputs parser._args = args - parser_spec.apply(parser) + parser_spec.apply(parser, force_required_subparsers=False) # Print help message when no arguments are passed in. (but arguments are # expected) diff --git a/src/tyro/_parsers.py b/src/tyro/_parsers.py index b3b5872d..8ca9db82 100644 --- a/src/tyro/_parsers.py +++ b/src/tyro/_parsers.py @@ -190,17 +190,24 @@ def from_callable_or_type( ) def apply( - self, parser: argparse.ArgumentParser + self, parser: argparse.ArgumentParser, force_required_subparsers: bool ) -> Tuple[argparse.ArgumentParser, ...]: """Create defined arguments and subparsers.""" # Generate helptext. parser.description = self.description + # `force_required_subparsers`: if we have required arguments and we're + # consolidating all arguments into the leaves of the subparser trees, a + # required argument in one node of this tree means that all of its + # descendants are required. + if self.consolidate_subcommand_args and self.has_required_args: + force_required_subparsers = True + # Create subparser tree. subparser_group = None if self.subparsers is not None: - leaves = self.subparsers.apply(parser) + leaves = self.subparsers.apply(parser, force_required_subparsers) subparser_group = parser._action_groups.pop() else: leaves = (parser,) @@ -408,6 +415,7 @@ class SubparsersSpecification: name: str description: str | None parser_from_name: Dict[str, ParserSpecification] + default_name: str | None default_parser: ParserSpecification | None intern_prefix: str required: bool @@ -581,13 +589,13 @@ def from_field( ) parser_from_name[subcommand_name] = subparser - # Required if a default is missing. - required = field.default in _fields.MISSING_AND_MISSING_NONPROP - # Required if a default is passed in, but the default value has missing # parameters. default_parser = None - if default_name is not None: + if default_name is None: + required = True + else: + required = False default_parser = parser_from_name[default_name] if any(map(lambda arg: arg.lowered.required, default_parser.args)): required = True @@ -597,33 +605,14 @@ def from_field( ): required = True - # Required if all args are pushed to the final subcommand. - if ( - _markers.ConsolidateSubcommandArgs in field.markers - and default_parser is not None - and default_parser.has_required_args - ): - required = True - - # Make description. - description_parts = [] - if field.helptext is not None: - description_parts.append(field.helptext) - if not required and field.default not in _fields.MISSING_AND_MISSING_NONPROP: - description_parts.append(f" (default: {default_name})") - description = ( - # We use `None` instead of an empty string to prevent a line break from - # being created where the description would be. - " ".join(description_parts) if len(description_parts) > 0 else None - ) - return SubparsersSpecification( name=field.intern_name, # If we wanted, we could add information about the default instance # automatically, as is done for normal fields. But for now we just rely on # the user to include it in the docstring. - description=description, + description=field.helptext, parser_from_name=parser_from_name, + default_name=default_name, default_parser=default_parser, intern_prefix=intern_prefix, required=required, @@ -632,19 +621,42 @@ def from_field( ) def apply( - self, parent_parser: argparse.ArgumentParser + self, + parent_parser: argparse.ArgumentParser, + force_required_subparsers: bool, ) -> Tuple[argparse.ArgumentParser, ...]: title = "subcommands" metavar = "{" + ",".join(self.parser_from_name.keys()) + "}" - if not self.required: + + required = self.required or force_required_subparsers + + if not required: title = "optional " + title metavar = f"[{metavar}]" + # Make description. + description_parts = [] + if self.description is not None: + description_parts.append(self.description) + if not required and self.default_name is not None: + description_parts.append(f"(default: {self.default_name})") + + # If this subparser is required because of a required argument in a + # parent (tyro.conf.ConsolidateSubcommandArgs). + if not self.required and force_required_subparsers: + description_parts.append("(required to specify parent argument)") + + description = ( + # We use `None` instead of an empty string to prevent a line break from + # being created where the description would be. + " ".join(description_parts) if len(description_parts) > 0 else None + ) + # Add subparsers to every node in previous level of the tree. argparse_subparsers = parent_parser.add_subparsers( dest=_strings.make_subparser_dest(self.intern_prefix), - description=self.description, - required=self.required, + description=description, + required=required, title=title, metavar=metavar, ) @@ -671,7 +683,9 @@ def apply( subparser._console_outputs = parent_parser._console_outputs subparser._args = parent_parser._args - subparser_tree_leaves.extend(subparser_def.apply(subparser)) + subparser_tree_leaves.extend( + subparser_def.apply(subparser, force_required_subparsers) + ) return tuple(subparser_tree_leaves) diff --git a/src/tyro/conf/_markers.py b/src/tyro/conf/_markers.py index 774b5420..0e8a6e98 100644 --- a/src/tyro/conf/_markers.py +++ b/src/tyro/conf/_markers.py @@ -64,7 +64,7 @@ ConsolidateSubcommandArgs = Annotated[T, None] """Consolidate arguments applied to subcommands. Makes CLI less sensitive to argument -ordering, at the cost of support for optional subcommands. +ordering, with some tradeoffs. By default, :mod:`tyro` will generate a traditional CLI interface where args are applied to the directly preceding subcommand. When we have two subcommands ``s1`` and ``s2``: @@ -87,6 +87,14 @@ This is more robust to reordering of options, ensuring that any new options can simply be placed at the end of the command. + +The tradeoff is in required arguments. In the above example, if any ``--root.*`` options +are required (no default is specified), all subcommands will need to be specified in order to +provide the required argument. + +.. code-block:: bash + + python x.py s1 s2 {required --root.* arguments} """ OmitSubcommandPrefixes = Annotated[T, None] diff --git a/tests/test_conf.py b/tests/test_conf.py index 59a834d0..2cc28f31 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -8,10 +8,10 @@ from typing import Any, Dict, Generic, List, Tuple, Type, TypeVar, Union import pytest -import tyro +from helptext_utils import get_helptext_with_checks from typing_extensions import Annotated, TypedDict -from helptext_utils import get_helptext_with_checks +import tyro def test_suppress_subcommand() -> None: @@ -830,11 +830,11 @@ class DefaultInstanceSubparser: == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8)) ) - # All defaults are set, not default is needed. - assert ( - tyro.cli(tyro.conf.ConsolidateSubcommandArgs[DefaultInstanceSubparser], args=[]) - == DefaultInstanceSubparser(3) - ) + # Missing a default for --x. + with pytest.raises(SystemExit): + assert tyro.cli( + tyro.conf.ConsolidateSubcommandArgs[DefaultInstanceSubparser], args=[] + ) def test_omit_subcommand_prefix_and_consolidate_subcommand_args_in_function() -> None: @@ -1602,3 +1602,104 @@ class Train: # Subcommand should be created. assert "STR|{True,False}" not in get_helptext_with_checks(Train) assert "person:person-str" in get_helptext_with_checks(Train) + + +def test_consolidate_subcommand_args_optional() -> None: + """Adapted from @mirceamironenco: https://github.com/brentyi/tyro/issues/221""" + + @dataclasses.dataclass(frozen=True) + class OptimizerConfig: + lr: float = 1e-1 + + @dataclasses.dataclass(frozen=True) + class AdamConfig(OptimizerConfig): + adam_foo: float = 1.0 + + @dataclasses.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.__getitem__(tuple(cfgs)) # type: ignore + + # Required because of --x. + @dataclasses.dataclass + class Config1: + x: int + optimizer: Annotated[ + AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + ] = AdamConfig() + + with pytest.raises(SystemExit): + tyro.cli(Config1, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + + # Required because of optimizer. + @dataclasses.dataclass + class Config2: + optimizer: Annotated[ + AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + ] + + with pytest.raises(SystemExit): + tyro.cli(Config2, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + + # Optional! + @dataclasses.dataclass + class Config3: + x: int = 3 + optimizer: Annotated[ + AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + ] = AdamConfig() + + assert ( + tyro.cli(Config3, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + == Config3() + ) + + +def test_consolidate_subcommand_args_optional_harder() -> None: + """Adapted from @mirceamironenco: https://github.com/brentyi/tyro/issues/221""" + + @dataclasses.dataclass(frozen=True) + class Leaf1: + x: int = 5 + + @dataclasses.dataclass(frozen=True) + class Leaf2: + x: int = 5 + + @dataclasses.dataclass(frozen=True) + class Branch1: + x: int = 5 + leaf: Union[Leaf1, Leaf2] = Leaf2() + + @dataclasses.dataclass(frozen=True) + class Branch2: + x: int = 5 + leaf: Union[Leaf1, Leaf2] = Leaf2() + + @dataclasses.dataclass(frozen=True) + class Trunk: + branch: Union[Branch1, Branch2] = Branch2() + + assert ( + tyro.cli(Trunk, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + == Trunk() + ) + + with pytest.raises(SystemExit): + tyro.cli( + Trunk, + default=Trunk(Branch2(leaf=Leaf1(x=tyro.MISSING))), + ) + + with pytest.raises(SystemExit): + tyro.cli(Trunk, default=Trunk(Branch2(x=tyro.MISSING)), args=["branch:branch2"]) + + assert tyro.cli( + Trunk, default=Trunk(Branch2(x=tyro.MISSING)), args=["branch:branch1"] + ) == Trunk(Branch1()) From d75b1ace7db58668f74051cda390d91d678a3743 Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 6 Jan 2025 16:32:41 -0800 Subject: [PATCH 3/5] Test generation, ruff --- tests/test_conf.py | 2 +- .../test_conf_generated.py | 108 +++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/tests/test_conf.py b/tests/test_conf.py index 2cc28f31..c7469f77 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -1619,7 +1619,7 @@ class AdamConfig(OptimizerConfig): class SGDConfig(OptimizerConfig): sgd_foo: float = 1.0 - def _constructor() -> type[OptimizerConfig]: + def _constructor() -> Type[OptimizerConfig]: cfgs = [ Annotated[AdamConfig, tyro.conf.subcommand(name="adam")], Annotated[SGDConfig, tyro.conf.subcommand(name="sgd")], diff --git a/tests/test_py311_generated/test_conf_generated.py b/tests/test_py311_generated/test_conf_generated.py index aa88c825..7014f52c 100644 --- a/tests/test_py311_generated/test_conf_generated.py +++ b/tests/test_py311_generated/test_conf_generated.py @@ -15,6 +15,7 @@ Type, TypedDict, TypeVar, + Union, ) import pytest @@ -837,9 +838,11 @@ class DefaultInstanceSubparser: == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8)) ) - # Despite all defaults being set, a subcommand should be required. + # Missing a default for --x. with pytest.raises(SystemExit): - tyro.cli(tyro.conf.ConsolidateSubcommandArgs[DefaultInstanceSubparser], args=[]) + assert tyro.cli( + tyro.conf.ConsolidateSubcommandArgs[DefaultInstanceSubparser], args=[] + ) def test_omit_subcommand_prefix_and_consolidate_subcommand_args_in_function() -> None: @@ -1605,3 +1608,104 @@ class Train: # Subcommand should be created. assert "STR|{True,False}" not in get_helptext_with_checks(Train) assert "person:person-str" in get_helptext_with_checks(Train) + + +def test_consolidate_subcommand_args_optional() -> None: + """Adapted from @mirceamironenco: https://github.com/brentyi/tyro/issues/221""" + + @dataclasses.dataclass(frozen=True) + class OptimizerConfig: + lr: float = 1e-1 + + @dataclasses.dataclass(frozen=True) + class AdamConfig(OptimizerConfig): + adam_foo: float = 1.0 + + @dataclasses.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.__getitem__(tuple(cfgs)) # type: ignore + + # Required because of --x. + @dataclasses.dataclass + class Config1: + x: int + optimizer: Annotated[ + AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + ] = AdamConfig() + + with pytest.raises(SystemExit): + tyro.cli(Config1, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + + # Required because of optimizer. + @dataclasses.dataclass + class Config2: + optimizer: Annotated[ + AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + ] + + with pytest.raises(SystemExit): + tyro.cli(Config2, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + + # Optional! + @dataclasses.dataclass + class Config3: + x: int = 3 + optimizer: Annotated[ + AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + ] = AdamConfig() + + assert ( + tyro.cli(Config3, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + == Config3() + ) + + +def test_consolidate_subcommand_args_optional_harder() -> None: + """Adapted from @mirceamironenco: https://github.com/brentyi/tyro/issues/221""" + + @dataclasses.dataclass(frozen=True) + class Leaf1: + x: int = 5 + + @dataclasses.dataclass(frozen=True) + class Leaf2: + x: int = 5 + + @dataclasses.dataclass(frozen=True) + class Branch1: + x: int = 5 + leaf: Leaf1 | Leaf2 = Leaf2() + + @dataclasses.dataclass(frozen=True) + class Branch2: + x: int = 5 + leaf: Leaf1 | Leaf2 = Leaf2() + + @dataclasses.dataclass(frozen=True) + class Trunk: + branch: Branch1 | Branch2 = Branch2() + + assert ( + tyro.cli(Trunk, config=(tyro.conf.ConsolidateSubcommandArgs,), args=[]) + == Trunk() + ) + + with pytest.raises(SystemExit): + tyro.cli( + Trunk, + default=Trunk(Branch2(leaf=Leaf1(x=tyro.MISSING))), + ) + + with pytest.raises(SystemExit): + tyro.cli(Trunk, default=Trunk(Branch2(x=tyro.MISSING)), args=["branch:branch2"]) + + assert tyro.cli( + Trunk, default=Trunk(Branch2(x=tyro.MISSING)), args=["branch:branch1"] + ) == Trunk(Branch1()) From eb4af81400aaeafeb74d09d868e5a9c19b13ad05 Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 6 Jan 2025 16:59:55 -0800 Subject: [PATCH 4/5] Pass tests for `Struct | None` --- src/tyro/_parsers.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/tyro/_parsers.py b/src/tyro/_parsers.py index 8ca9db82..08549be0 100644 --- a/src/tyro/_parsers.py +++ b/src/tyro/_parsers.py @@ -498,17 +498,24 @@ def from_field( subcommand_type_from_name[subcommand_name] = cast(type, option) # If a field default is provided, try to find a matching subcommand name. - if ( - field.default is None - or field.default in _singleton.MISSING_AND_MISSING_NONPROP - ): - default_name = None - else: - default_name = _subcommand_matching.match_subcommand( - field.default, - subcommand_config_from_name, - subcommand_type_from_name, - ) + default_name = None + if field.default not in _singleton.MISSING_AND_MISSING_NONPROP: + # Subcommand matcher won't work with `none_proxy`. + if field.default is None: + default_name = next( + iter( + filter( + lambda pair: pair[1] is none_proxy, + subcommand_type_from_name.items(), + ) + ) + )[0] + else: + default_name = _subcommand_matching.match_subcommand( + field.default, + subcommand_config_from_name, + subcommand_type_from_name, + ) assert default_name is not None, ( f"`{extern_prefix}` was provided a default value of type" From 980f2b1584f22314937c7441a60255929f3fc6fc Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 6 Jan 2025 17:15:12 -0800 Subject: [PATCH 5/5] Fix tests Python 3.8 --- tests/test_conf.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_conf.py b/tests/test_conf.py index c7469f77..524d8503 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -1631,7 +1631,8 @@ def _constructor() -> Type[OptimizerConfig]: class Config1: x: int optimizer: Annotated[ - AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + Union[AdamConfig, SGDConfig], + tyro.conf.arg(constructor_factory=_constructor), ] = AdamConfig() with pytest.raises(SystemExit): @@ -1641,7 +1642,8 @@ class Config1: @dataclasses.dataclass class Config2: optimizer: Annotated[ - AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + Union[AdamConfig, SGDConfig], + tyro.conf.arg(constructor_factory=_constructor), ] with pytest.raises(SystemExit): @@ -1652,7 +1654,8 @@ class Config2: class Config3: x: int = 3 optimizer: Annotated[ - AdamConfig | SGDConfig, tyro.conf.arg(constructor_factory=_constructor) + Union[AdamConfig, SGDConfig], + tyro.conf.arg(constructor_factory=_constructor), ] = AdamConfig() assert (