tyro

repository·main·Indexed 22 days ago

https://github.com/brentyi/tyro

A tool for generating command-line interfaces (CLIs) from type-annotated Python code. It allows developers to define configurable scripts using functions or configuration objects such as dataclasses, Pydantic models, and attrs. Key features include automatic helptext generation from docstrings, support for nested structures, subcommands, shell completion, and integration with typing.Literal, Enums, and Unions.

Tokens
35.2K
Snippets
92
Records
129
Agent score
77%

What's inside tyro

  1. Core features of tyro

    main

    tyro is designed to generate robust CLI interfaces from type-annotated Python code. Its core capabilities include:

    • Helptext Generation: Automatically creates formatted help messages from docstrings.
    • Nested Structures: Supports hierarchical configurations via nested dataclasses.
    • Subcommands: Enables complex CLI tools with multiple subcommands.
    • Shell Completion: Provides support for shell autocompletion.
    • Static Type Integration: Unlike dictionary-based CLI parsers, tyro works with typed objects, making it compatible with IDEs and static analysis tools like mypy and pyright.
  2. Benefits of using `tyro` over `argparse`

    main

    Using tyro instead of manual argparse boilerplate provides several advantages:

    1. Static type checking: Parameters use real Python types that can be validated by type checkers.
    2. IDE support: Because the CLI is defined via standard Python functions or classes, you get full IDE features like jump-to-definition, find references, docstring inspection, and refactoring.
    3. Automatic helptext: Help messages are automatically generated from your function/class docstrings and type annotations.
    4. Less boilerplate: You don't need to manually call add_argument() for every parameter.
    5. Hierarchical configuration: Supports complex, nested structures through standard Python types.
  3. Convert positional-only arguments to CLI arguments

    main

    In tyro, positional-only arguments in a function signature (defined using the / syntax in Python) are automatically converted into positional CLI arguments. This is useful for creating a clean CLI where certain arguments must be provided in a specific order without flags.

    Note: If a double-dash (--) appears in a command line, everything following it will be treated as a positional argument.

    from __future__ import annotations
    import pathlib
    import tyro
    
    def main(
        source: pathlib.Path,
        dest: pathlib.Path,
        /,  # Mark the end of positional arguments.
        verbose: bool = False,
    ) -> None:
        """Command-line interface defined using a function signature. This
        docstring is parsed to generate helptext.
    
        Args:
            source: Source path.
            dest: Destination path.
            verbose: Explain what is being done.
        """
        print(f"{source=}\n{dest=}\n{verbose=}")
    
    if __name__ == "__main__":
        tyro.cli(main)
  4. Use user-defined parameterized types with tyro

    main
    tyro supports Python's parameterized types (Generics). This allows you to define reusable, type-safe structures that can be parameterized with different types, reducing boilerplate when defining CLI interfaces or configuration schemas that share a common shape but use different underlying data types.
  5. Limitations and unsupported type patterns in tyro

    main

    While tyro supports most standard typing features, there are specific limitations to be aware of:

    Unsupported Patterns

    • Self-referential types: Types that refer to themselves, such as type RecursiveList[T] = T | list[RecursiveList[T]].
    • Variable-length sequences over nested structures: For types like list[Dataclass], a default value must be provided so tyro can infer the length. Note that the length of these fields cannot be changed via the CLI.
    • Type parameters in class and static methods: tyro cannot distinguish between a method on a specialized class (e.g., MyClass[int].method1) and a method on the base class (MyClass.method1) at runtime. Consequently, the type parameter (e.g., int) will be ignored.

    Workarounds

    For cases involving unsupported patterns, you can define a custom constructor to achieve the desired behavior.

    class MyClass[T: int | str]:
      @staticmethod
      def method1(arg: T) -> T:
        return arg
    
      @classmethod
      def method2(cls, arg: T) -> T:
        return arg
    
    # The `int` type parameter will be ignored.
    tyro.cli(MyClass[int].method1)
    tyro.cli(MyClass[int].method2)
  6. Configure CLI behavior with tyro.conf and Annotated

    main

    The tyro.conf module provides utilities to extend CLI configuration beyond static type annotations using typing.Annotated. These configurations can be applied to specific fields or globally via the config argument in tyro.cli.

    Common configuration types include:

    • tyro.conf.Positional[T]: Parses a field as a positional argument.
    • tyro.conf.Fixed[T]: A field that cannot be changed via the CLI.
    • tyro.conf.UsePythonSyntaxForLiteralCollections[T]: Parses a collection (like a tuple) as a single argument using Python-like syntax.
    • tyro.conf.arg(...): Allows manual overrides of argument properties like name, metavar, and help.

    Note: These features should be used sparingly as they go beyond standard type-driven configuration.

    import dataclasses
    from typing_extensions import Annotated
    import tyro
    
    @dataclasses.dataclass
    class Args:
        # A numeric field parsed as a positional argument.
        positional: tyro.conf.Positional[int]
    
        # A boolean field.
        boolean: bool = False
    
        # A numeric field that can't be changed via the CLI.
        fixed: tyro.conf.Fixed[int] = 5
    
        # A tuple field parsed as a single argument.
        tuple_arg: tyro.conf.UsePythonSyntaxForLiteralCollections[tuple[int, float]] = (1, 2.0)
    
        # A field with manually overridden properties.
        manual: Annotated[
            str,
            tyro.conf.arg(
                name="renamed",
                metavar="STRING",
                help="A field with manually overridden properties!",
            ),
        ] = "Hello"
    
    if __name__ == "__main__":
        # Using config argument to apply global settings like FlagConversionOff
        print(tyro.cli(Args, config=(tyro.conf.FlagConversionOff,)))
  7. Define custom primitive rules using `ConstructorRegistry`

    main

    You can use tyro.constructors.ConstructorRegistry to define global rules for how specific types are handled during CLI parsing. This is useful when you want all instances of a certain type (e.g., dict[str, Any]) to be parsed from a specific format, such as a JSON string, rather than the default behavior.

    To implement a custom primitive rule:

    1. Instantiate tyro.constructors.ConstructorRegistry().
    2. Use the @custom_registry.primitive_rule decorator on a function that accepts tyro.constructors.PrimitiveTypeInfo.
    3. Inside the function, check type_info.type to see if the rule applies. Return None if it doesn't.
    4. If it applies, return a tyro.constructors.PrimitiveConstructorSpec containing:
      • nargs: The number of CLI arguments to consume.
      • metavar: The name used in help messages.
      • instance_from_str: A function that converts the CLI string arguments into the target type instance.
      • is_instance: A predicate to verify the resulting instance is of the expected type.
      • str_from_instance: A function to convert an instance back into a list of strings (useful for help messages or serialization).
    5. Pass the registry to tyro.cli(..., registry=custom_registry).
    import json
    from typing import Any
    import tyro
    
    # Create a custom registry
    custom_registry = tyro.constructors.ConstructorRegistry()
    
    # Define a rule for dict[str, Any]
    @custom_registry.primitive_rule
    def _(type_info: tyro.constructors.PrimitiveTypeInfo) -> tyro.constructors.PrimitiveConstructorSpec | None:
        if type_info.type != dict[str, Any]:
            return None
    
        return tyro.constructors.PrimitiveConstructorSpec(
            nargs=1,
            metavar="JSON",
            instance_from_str=lambda args: json.loads(args[0]),
            is_instance=lambda instance: isinstance(instance, dict),
            str_from_instance=lambda instance: [json.dumps(instance)],
        )
    
    def main(dict1: dict[str, Any], dict2: dict[str, Any] = {"default": None}) -> None:
        print(f"{dict1=}")
        print(f"{dict2=}")
    
    if __name__ == "__main__":
        tyro.cli(main, registry=custom_registry)
  8. Use sequenced subcommands for multiple unions

    main

    When a function takes multiple arguments that are themselves unions of different types (e.g., a dataset union and an optimizer union), tyro treats them as a sequence of subcommands. By default, the user must provide the subcommands in the order they appear in the function signature.

    To allow for more flexible argument intermixing (where arguments for different subcommands can be provided in a more fluid way), use the tyro.conf.CascadeSubcommandArgs configuration flag in tyro.cli().

    import dataclasses
    from typing import Literal
    import tyro
    
    @dataclasses.dataclass
    class Mnist:
        binary: bool = False
    
    @dataclasses.dataclass
    class ImageNet:
        subset: Literal[50, 100, 1000]
        binaries: bool = False
    
    @dataclasses.dataclass
    class Adam:
        learning_rate: float = 1e-3
        betas: tuple[float, float] = (0.9, 0.999)
    
    @dataclasses.dataclass
    class Sgd:
        learning_rate: float = 3e-4
    
    def train(
        dataset: Mnist | ImageNet = Mnist(),
        optimizer: Adam | Sgd = Adam(),
    ) -> None:
        print(dataset)
        print(optimizer)
    
    if __name__ == "__main__":
        # Use CascadeSubcommandArgs to allow flexible argument intermixing
        tyro.cli(train, config=(tyro.conf.CascadeSubcommandArgs,))
  9. Supported type annotations in tyro

    main

    To minimize boilerplate for CLIs, tyro supports a wide range of Python typing features for input annotation. This allows you to define complex CLI interfaces using standard Python type hints.

    Supported Basic and Container Types

    • Basic types: int, str, float, bool, pathlib.Path, None.
    • Path types: upath.UPath.
    • Datetime types: datetime.date, datetime.datetime, datetime.time, and datetime.timedelta.
    • Containers: list, dict, tuple, and set.
    • Unions: X | Y, typing.Union, and typing.Optional.
    • Literals and Enums: typing.Literal and enum.Enum.

    Advanced Typing Support

    • Type Aliases: Supports Python 3.12's type statement (PEP 695).
    • Generics: Supports typing.TypeVar and Python 3.12's type parameter syntax.
    • Compositions: Complex nested types like tuple[int | str, ...] | None are supported.

    Supported Data Structures

    Types can be nested within or used as:

    • dataclasses.dataclass.
    • attrs, pydantic, ml_collections, msgspec, and flax.linen models.
    • typing.NamedTuple.
    • typing.TypedDict (including total=, typing.Required, typing.NotRequired, and typing.ReadOnly).
  10. How helptext is generated in tyro

    main
    The tyro.cli() function automatically generates helptext for CLI arguments by inspecting docstrings, comments, and annotations. It supports both general callables (functions, objects with __call__) and "struct" types (such as dataclasses.dataclass, NamedTuple, TypedDict, attrs, or pydantic models).
  11. Handle Booleans and Flags in tyro

    main

    In tyro, boolean arguments behave differently depending on whether they have a default value:

    1. Explicit Booleans: If a boolean field has no default value (or is required), it expects an explicit value like True or False via the CLI (e.g., --boolean True).
    2. Automatic Flags: If a boolean field has a default value, tyro automatically converts it into a flag.
      • To set a False default to True, use --flag-name.
      • To set a True default to False, use --no-flag-name.

    To disable this automatic flag conversion and force explicit True/False values for all booleans, use tyro.conf.FlagConversionOff.

    from dataclasses import dataclass
    import tyro
    
    @dataclass
    class Args:
        # Requires explicit --boolean True or --boolean False
        boolean: bool
    
        # Optional; requires explicit --optional-boolean True/False/None
        optional_boolean: bool | None = None
    
        # Becomes a flag: --flag_a sets to True, --no-flag_a sets to False
        flag_a: bool = False
    
        # Becomes a flag: --flag_b sets to True, --no-flag_b sets to False
        flag_b: bool = True
    
    if __name__ == "__main__":
        args = tyro.cli(Args)
        print(args)
  12. Nest structures inside standard containers

    main

    You can nest dataclasses or other structures inside standard Python containers like list, tuple, or dict.

    Important Constraint: When using containers like list or tuple, the length of the container must be inferrable from the type annotation or the default value so tyro knows how many elements to expect.

    For dict containers, tyro uses the dictionary keys to generate hierarchical CLI flags (e.g., --container-name.key.field).

    import dataclasses
    import tyro
    
    @dataclasses.dataclass
    class RGB:
        r: int
        g: int
        b: int
    
    @dataclasses.dataclass
    class Args:
        # Length is inferrable from the type annotation (tuple of 2 RGBs)
        color_tuple: tuple[RGB, RGB]
        
        # Length is inferrable from the default_factory
        color_dict: dict[str, RGB] = dataclasses.field(
            default_factory=lambda: {
                "red": RGB(255, 0, 0),
                "green": RGB(0, 255, 0),
                "blue": RGB(0, 0, 255),
            }
        )
    
    if __name__ == "__main__":
        args = tyro.cli(Args)
        print(args)