Subcommands

In these examples, we show how tyro.cli() can be used to create CLI interfaces with subcommands.

Subcommands are unions

All of tyro’s subcommand features are built using unions over struct types (typically dataclasses). Subcommands are used to choose between types in the union; arguments are then populated from the chosen type.

Note

For configuring subcommands beyond what can be expressed with type annotations, see tyro.conf.subcommand().

 1# 01_subcommands.py
 2from __future__ import annotations
 3
 4import dataclasses
 5
 6import tyro
 7
 8@dataclasses.dataclass
 9class Checkout:
10    """Checkout a branch."""
11
12    branch: str
13
14@dataclasses.dataclass
15class Commit:
16    """Commit changes."""
17
18    message: str
19
20if __name__ == "__main__":
21    cmd = tyro.cli(Checkout | Commit)
22    print(cmd)

Print the helptext. This will show the available subcommands:

$ python ./01_subcommands.py --help
usage: ./01_subcommands.py [-h] {checkout,commit}

 options ─────────────────────────────────────╮
 -h, --help    show this help message and exit 
───────────────────────────────────────────────
 subcommands ─────────────────────────────────╮
 (required)                                    
   checkout  Checkout a branch.              
   commit    Commit changes.                 
───────────────────────────────────────────────

The commit subcommand:

$ python ./01_subcommands.py commit --help
usage: ./01_subcommands.py commit [-h] --message STR

Commit changes.

 options ──────────────────────────────────────╮
 -h, --help     show this help message and exit 
 --message STR  (required)                      
────────────────────────────────────────────────
$ python ./01_subcommands.py commit --message hello
Commit(message='hello')

The checkout subcommand:

$ python ./01_subcommands.py checkout --help
usage: ./01_subcommands.py checkout [-h] --branch STR

Checkout a branch.

 options ─────────────────────────────────────╮
 -h, --help    show this help message and exit 
 --branch STR  (required)                      
───────────────────────────────────────────────
$ python ./01_subcommands.py checkout --branch main
Checkout(branch='main')

Subcommands as function arguments

A subcommand will be created for each input annotated with a union over struct types.

Note

To prevent tyro.cli() from converting a Union type into a subcommand, use tyro.conf.AvoidSubcommands.

Note

Argument ordering for subcommands can be tricky. In the example below, --shared-arg must always come before the subcommand. As an option for alleviating this, see tyro.conf.CascadeSubcommandArgs.

 1# 02_subcommands_in_func.py
 2from __future__ import annotations
 3
 4import dataclasses
 5
 6import tyro
 7
 8@dataclasses.dataclass
 9class Checkout:
10    """Checkout a branch."""
11
12    branch: str
13
14@dataclasses.dataclass
15class Commit:
16    """Commit changes."""
17
18    message: str
19
20def main(
21    shared_arg: int,
22    cmd: Checkout | Commit = Checkout(branch="default"),
23):
24    print(f"{shared_arg=}")
25    print(cmd)
26
27if __name__ == "__main__":
28    tyro.cli(main)

Print the helptext. This will show the available subcommands:

$ python ./02_subcommands_in_func.py --help
usage: ./02_subcommands_in_func.py [-h] --shared-arg INT [{cmd:checkout,cmd:commit}]

 options ─────────────────────────────────────────╮
 -h, --help        show this help message and exit 
 --shared-arg INT  (required)                      
───────────────────────────────────────────────────
 subcommands ─────────────────────────────────────╮
 (default: cmd:checkout)                           
   cmd:checkout  Checkout a branch.              
   cmd:commit    Commit changes.                 
───────────────────────────────────────────────────

Using the default subcommand:

$ python ./02_subcommands_in_func.py --shared-arg 100
shared_arg=100
Checkout(branch='default')

Choosing a different subcommand:

$ python ./02_subcommands_in_func.py --shared-arg 100 cmd:commit --cmd.message 'Hello!'
shared_arg=100
Commit(message='Hello!')

Sequenced subcommands

Multiple unions over struct types are populated using a series of subcommands.

 1# 03_multiple_subcommands.py
 2from __future__ import annotations
 3
 4import dataclasses
 5from typing import Literal
 6
 7import tyro
 8
 9# Possible dataset configurations.
10
11@dataclasses.dataclass
12class Mnist:
13    binary: bool = False
14    """Set to load binary version of MNIST dataset."""
15
16@dataclasses.dataclass
17class ImageNet:
18    subset: Literal[50, 100, 1000]
19    """Choose between ImageNet-50, ImageNet-100, ImageNet-1000, etc."""
20    binaries: bool = False
21
22# Possible optimizer configurations.
23
24@dataclasses.dataclass
25class Adam:
26    learning_rate: float = 1e-3
27    betas: tuple[float, float] = (0.9, 0.999)
28
29@dataclasses.dataclass
30class Sgd:
31    learning_rate: float = 3e-4
32
33# Train script.
34
35def train(
36    dataset: Mnist | ImageNet = Mnist(),
37    optimizer: Adam | Sgd = Adam(),
38) -> None:
39    """Example training script.
40
41    Args:
42        dataset: Dataset to train on.
43        optimizer: Optimizer to train with.
44
45    Returns:
46        None:
47    """
48    print(dataset)
49    print(optimizer)
50
51if __name__ == "__main__":
52    tyro.cli(train, config=(tyro.conf.CascadeSubcommandArgs,))

We apply the tyro.conf.CascadeSubcommandArgs flag. This allows arguments to be intermixed more flexibly:

$ python ./03_multiple_subcommands.py --help
usage: ./03_multiple_subcommands.py [-h] [OPTIONS] [DATASET] [OPTIMIZER]

Example training script.

 options ────────────────────────────────────────────────────────────────────╮
 -h, --help             show this help message and exit                       
──────────────────────────────────────────────────────────────────────────────
 dataset options ────────────────────────────────────────────────────────────╮
 (source subcommand: dataset:mnist)                                           
 ──────────────────────────────────────────────────────────────────────────── 
 --dataset.binary, --dataset.no-binary                                        
                        Set to load binary version of MNIST dataset.          
                        (default: False)                                      
──────────────────────────────────────────────────────────────────────────────
 optimizer options ──────────────────────────────────────────────────────────╮
 (source subcommand: optimizer:adam)                                          
 ──────────────────────────────────────────────────────────────────────────── 
 --optimizer.learning-rate FLOAT                                              
                        (default: 0.001)                                      
 --optimizer.betas FLOAT FLOAT                                                
                        (default: 0.9 0.999)                                  
──────────────────────────────────────────────────────────────────────────────
 subcommands ────────────────────────────────────────────────────────────────╮
 Dataset to train on. (default: dataset:mnist)                                
   dataset:mnist                                                            
   dataset:image-net                                                        
 ──────────────────────────────────────────────────────────────────────────── 
 Optimizer to train with. (default: optimizer:adam)                           
   optimizer:adam                                                           
   optimizer:sgd                                                            
──────────────────────────────────────────────────────────────────────────────
$ python ./03_multiple_subcommands.py dataset:mnist --help
usage: ./03_multiple_subcommands.py dataset:mnist [-h] [DATASET:MNIST OPTIONS] [{optimizer:adam,optimizer:sgd}]

 options ────────────────────────────────────────────────────────────────────╮
 -h, --help          show this help message and exit                          
──────────────────────────────────────────────────────────────────────────────
 dataset options ────────────────────────────────────────────────────────────╮
 --dataset.binary, --dataset.no-binary                                        
                     Set to load binary version of MNIST dataset. (default:   
                     False)                                                   
──────────────────────────────────────────────────────────────────────────────
 optimizer options ──────────────────────────────────────────────────────────╮
 (source subcommand: optimizer:adam)                                          
 ──────────────────────────────────────────────────────────────────────────── 
 --optimizer.learning-rate FLOAT                                              
                     (default: 0.001)                                         
 --optimizer.betas FLOAT FLOAT                                                
                     (default: 0.9 0.999)                                     
──────────────────────────────────────────────────────────────────────────────
 subcommands ────────────────────────────────────────────────────────────────╮
 Optimizer to train with. (default: optimizer:adam)                           
   optimizer:adam                                                           
   optimizer:sgd                                                            
──────────────────────────────────────────────────────────────────────────────
$ python ./03_multiple_subcommands.py dataset:mnist optimizer:adam --help
usage: ./03_multiple_subcommands.py dataset:mnist optimizer:adam [-h] [OPTIMIZER:ADAM OPTIONS]

 options ─────────────────────────────────────────────────────────────────╮
 -h, --help  show this help message and exit                               
───────────────────────────────────────────────────────────────────────────
 dataset options ─────────────────────────────────────────────────────────╮
 --dataset.binary, --dataset.no-binary                                     
             Set to load binary version of MNIST dataset. (default: False) 
───────────────────────────────────────────────────────────────────────────
 optimizer options ───────────────────────────────────────────────────────╮
 --optimizer.learning-rate FLOAT                                           
             (default: 0.001)                                              
 --optimizer.betas FLOAT FLOAT                                             
             (default: 0.9 0.999)                                          
───────────────────────────────────────────────────────────────────────────
$ python ./03_multiple_subcommands.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4 --dataset.binary
Mnist(binary=True)
Adam(learning_rate=0.0003, betas=(0.9, 0.999))

Decorator-based subcommands

tyro.extras.SubcommandApp() provides a decorator-based API for subcommands, inspired by Typer and cyclopts.

 1# 04_decorator_subcommands.py
 2from tyro.extras import SubcommandApp
 3
 4app = SubcommandApp()
 5
 6@app.command
 7def greet(name: str, loud: bool = False) -> None:
 8    """Greet someone."""
 9    greeting = f"Hello, {name}!"
10    if loud:
11        greeting = greeting.upper()
12    print(greeting)
13
14@app.command(name="addition", aliases=["sum"])
15def add(a: int, b: int) -> None:
16    """Add two numbers."""
17    print(f"{a} + {b} = {a + b}")
18
19if __name__ == "__main__":
20    app.cli()
$ python 04_decorator_subcommands.py --help
usage: 04_decorator_subcommands.py [-h] {greet,addition}

 options ─────────────────────────────────────╮
 -h, --help    show this help message and exit 
───────────────────────────────────────────────
 subcommands ─────────────────────────────────╮
 (required)                                    
   greet     Greet someone.                  
   addition (sum)                            
               Add two numbers.                
───────────────────────────────────────────────
$ python 04_decorator_subcommands.py greet --help
usage: 04_decorator_subcommands.py greet [-h] --name STR [--loud | --no-loud]

Greet someone.

 options ──────────────────────────────────────────╮
 -h, --help         show this help message and exit 
 --name STR         (required)                      
 --loud, --no-loud  (default: False)                
────────────────────────────────────────────────────
$ python 04_decorator_subcommands.py greet --name Alice
Hello, Alice!
$ python 04_decorator_subcommands.py greet --name Bob --loud
HELLO, BOB!
$ python 04_decorator_subcommands.py addition --help
usage: 04_decorator_subcommands.py addition [-h] --a INT --b INT

Add two numbers.

 options ───────────────────────────────────╮
 -h, --help  show this help message and exit 
 --a INT     (required)                      
 --b INT     (required)                      
─────────────────────────────────────────────
$ python 04_decorator_subcommands.py addition --a 5 --b 3
5 + 3 = 8
$ python 04_decorator_subcommands.py sum --a 5 --b 3 '#' via alias
 Unrecognized options ────────────────────────────────────╮
 Unrecognized options: #, via, alias                       
 ───────────────────────────────────────────────────────── 
 For full helptext, run 04_decorator_subcommands.py --help 
───────────────────────────────────────────────────────────

Subcommands from functions

We provide a shorthand for generating a subcommand CLI from a dictionary. This is a thin wrapper around tyro.cli()’s more verbose, type-based API. If more generality is needed, the internal working are explained in the docs for tyro.extras.subcommand_cli_from_dict().

 1# 05_subcommands_func.py
 2import tyro
 3
 4def checkout(branch: str) -> None:
 5    """Check out a branch."""
 6    print(f"{branch=}")
 7
 8def commit(message: str, all: bool = False) -> None:
 9    """Make a commit."""
10    print(f"{message=} {all=}")
11
12if __name__ == "__main__":
13    tyro.extras.subcommand_cli_from_dict(
14        {
15            "checkout": checkout,
16            "commit": commit,
17        }
18    )
$ python ./05_subcommands_func.py --help
usage: ./05_subcommands_func.py [-h] {checkout,commit}

 options ─────────────────────────────────────╮
 -h, --help    show this help message and exit 
───────────────────────────────────────────────
 subcommands ─────────────────────────────────╮
 (required)                                    
   checkout  Check out a branch.             
   commit    Make a commit.                  
───────────────────────────────────────────────
$ python ./05_subcommands_func.py commit --help
usage: ./05_subcommands_func.py commit [-h] --message STR [--all | --no-all]

Make a commit.

 options ────────────────────────────────────────╮
 -h, --help       show this help message and exit 
 --message STR    (required)                      
 --all, --no-all  (default: False)                
──────────────────────────────────────────────────
$ python ./05_subcommands_func.py commit --message hello --all
message='hello' all=True
$ python ./05_subcommands_func.py checkout --help
usage: ./05_subcommands_func.py checkout [-h] --branch STR

Check out a branch.

 options ─────────────────────────────────────╮
 -h, --help    show this help message and exit 
 --branch STR  (required)                      
───────────────────────────────────────────────
$ python ./05_subcommands_func.py checkout --branch main
branch='main'

Nested subcommands

Unions over struct types can be nested to create hierarchical subcommand structures. By default, nested unions wrapped in Annotated are flattened into the parent union. To create a named subcommand group, annotate the nested union with tyro.conf.subcommand(name=...)().

 1# 06_nested_subcommands.py
 2from __future__ import annotations
 3
 4import dataclasses
 5
 6from typing_extensions import Annotated
 7
 8import tyro
 9
10@dataclasses.dataclass
11class Checkout:
12    """Checkout a branch."""
13
14    branch: str
15
16@dataclasses.dataclass
17class Commit:
18    """Commit changes."""
19
20    message: str
21
22@dataclasses.dataclass
23class Push:
24    """Push commits to a remote."""
25
26    remote: str = "origin"
27    branch: str = "main"
28
29@dataclasses.dataclass
30class Pull:
31    """Pull commits from a remote."""
32
33    remote: str = "origin"
34
35Remote = Annotated[
36    Push | Pull,
37    tyro.conf.subcommand(name="remote", description="Remote operations."),
38]
39
40if __name__ == "__main__":
41    cmd = tyro.cli(Checkout | Commit | Remote)
42    print(cmd)

Show top-level subcommands:

$ python ./06_nested_subcommands.py --help
usage: ./06_nested_subcommands.py [-h] {checkout,commit,remote}

 options ─────────────────────────────────────╮
 -h, --help    show this help message and exit 
───────────────────────────────────────────────
 subcommands ─────────────────────────────────╮
 (required)                                    
   checkout  Checkout a branch.              
   commit    Commit changes.                 
   remote    Remote operations.              
───────────────────────────────────────────────

The checkout subcommand:

$ python ./06_nested_subcommands.py checkout --branch main
Checkout(branch='main')

remote is a named group containing push and pull:

$ python ./06_nested_subcommands.py remote --help
usage: ./06_nested_subcommands.py remote [-h] {push,pull}

Remote operations.

 options ───────────────────────────────────╮
 -h, --help  show this help message and exit 
─────────────────────────────────────────────
 subcommands ───────────────────────────────╮
 (required)                                  
   push    Push commits to a remote.       
   pull    Pull commits from a remote.     
─────────────────────────────────────────────
$ python ./06_nested_subcommands.py remote push --remote origin --branch main
Push(remote='origin', branch='main')
$ python ./06_nested_subcommands.py remote pull --remote origin
Pull(remote='origin')

Nested decorator-based subcommands

A SubcommandApp can be registered as a subcommand on another SubcommandApp, producing a hierarchical CLI. Use aliases= for short names and is_default=True to pick which subcommand runs when none is named.

 1# 07_decorator_nested.py
 2from tyro.extras import SubcommandApp
 3
 4# Nested app for database commands.
 5db = SubcommandApp()
 6
 7@db.command(aliases=["m"])
 8def migrate(version: int = 1) -> None:
 9    """Apply schema migrations."""
10    print(f"migrating to version {version}")
11
12@db.command
13def seed(rows: int = 10) -> None:
14    """Populate with seed data."""
15    print(f"seeding {rows} rows")
16
17# Top-level app.
18app = SubcommandApp()
19app.command(db, name="db")
20
21@app.command(is_default=True)
22def greet(name: str = "world") -> None:
23    """Default action when no subcommand is given."""
24    print(f"hello {name}")
25
26if __name__ == "__main__":
27    app.cli()
$ python 07_decorator_nested.py --help
usage: 07_decorator_nested.py [-h] [{db,greet}]

 options ────────────────────────────────────────────────────────────────────╮
 -h, --help  show this help message and exit                                  
──────────────────────────────────────────────────────────────────────────────
 subcommands ────────────────────────────────────────────────────────────────╮
 (default: greet)                                                             
   db      This class provides a decorator-based API for subcommands in     
                 :mod:`tyro`, inspired by `Typer                              
             <https://typer.tiangolo.com/>`_ and                              
                 `cyclopts <https://cyclopts.readthedocs.io/>`_.              
             Under-the-hood, this is a light wrapper                          
                 over :func:`tyro.cli`.                                       
                                                                              
                                                                              
                 Example:                                                     
                                                                              
                                                                              
                 .. code-block:: python                                       
                                                                              
                                                                              
                     from tyro.extras import SubcommandApp                    
                                                                              
                                                                              
                     app = SubcommandApp()                                    
                                                                              
                                                                              
                     @app.command                                             
                     def greet(name: str, loud: bool = False):                
                         '''Greet someone.'''                                 
                         greeting = f"Hello, {name}!"                         
                         if loud:                                             
                             greeting = greeting.upper()                      
                         print(greeting)                                      
                                                                              
                                                                              
                     @app.command(name="addition", aliases=["sum"])           
                     def add(a: int, b: int):                                 
                         '''Add two numbers.'''                               
                         print(f"{a} + {b} = {a + b}")                        
                                                                              
                                                                              
                     if __name__ == "__main__":                               
                         app.cli()                                            
                                                                              
                                                                              
                 Subcommand groups can be nested by registering one           
                 :class:`SubcommandApp` as a subcommand on another:           
                                                                              
                                                                              
                 .. code-block:: python                                       
                                                                              
                                                                              
                     db = SubcommandApp()                                     
                                                                              
                                                                              
                     @db.command                                              
                     def migrate(): ...                                       
                                                                              
                                                                              
                     @db.command                                              
                     def seed(): ...                                          
                                                                              
                                                                              
                     app = SubcommandApp()                                    
                     app.command(db, name="db")  # `mycli db migrate`, `mycli 
             db seed`                                                         
                                                                              
                                                                              
                 To make one subcommand the default when no subcommand is     
             given, pass                                                      
                 ``is_default=True``:                                         
                                                                              
                                                                              
                 .. code-block:: python                                       
                                                                              
                                                                              
                     @app.command(is_default=True)                            
                     def run(name: str = "world"):                            
                         print(f"hello {name}")                               
                                                                              
                                                                              
                     # `mycli` runs `run("world")`; `mycli --name alice` runs 
             `run("alice")`;                                                  
                     # `mycli run --name alice` also works.                   
                                                                              
                                                                              
                 Usage:                                                       
                                                                              
                                                                              
                 .. code-block:: bash                                         
                                                                              
                                                                              
                     python my_script.py greet Alice                          
                     python my_script.py greet Bob --loud                     
                     python my_script.py addition 5 3                         
                     python my_script.py sum 5 3        # via alias           
   greet   Default action when no subcommand is given.                      
──────────────────────────────────────────────────────────────────────────────
$ python 07_decorator_nested.py db --help
usage: 07_decorator_nested.py db [-h] {migrate,seed}

This class provides a decorator-based API for subcommands in
    :mod:`tyro`, inspired by `Typer <https://typer.tiangolo.com/>`_ and
    `cyclopts <https://cyclopts.readthedocs.io/>`_. Under-the-hood, this is a light wrapper
    over :func:`tyro.cli`.


    Example:


    .. code-block:: python


        from tyro.extras import SubcommandApp


        app = SubcommandApp()


        @app.command
        def greet(name: str, loud: bool = False):
            '''Greet someone.'''
            greeting = f"Hello, {name}!"
            if loud:
                greeting = greeting.upper()
            print(greeting)


        @app.command(name="addition", aliases=["sum"])
        def add(a: int, b: int):
            '''Add two numbers.'''
            print(f"{a} + {b} = {a + b}")


        if __name__ == "__main__":
            app.cli()


    Subcommand groups can be nested by registering one
    :class:`SubcommandApp` as a subcommand on another:


    .. code-block:: python



        db = SubcommandApp()


        @db.command
        def migrate(): ...


        @db.command
        def seed(): ...


        app = SubcommandApp()
        app.command(db, name="db")  # `mycli db migrate`, `mycli db seed`


    To make one subcommand the default when no subcommand is given, pass
    ``is_default=True``:


    .. code-block:: python


        @app.command(is_default=True)
        def run(name: str = "world"):
            print(f"hello {name}")


        # `mycli` runs `run("world")`; `mycli --name alice` runs `run("alice")`;
        # `mycli run --name alice` also works.


    Usage:


    .. code-block:: bash


        python my_script.py greet Alice
        python my_script.py greet Bob --loud
        python my_script.py addition 5 3
        python my_script.py sum 5 3        # via alias

 options ────────────────────────────────────╮
 -h, --help   show this help message and exit 
──────────────────────────────────────────────
 subcommands ────────────────────────────────╮
 (required)                                   
   migrate (m)                              
              Apply schema migrations.        
   seed     Populate with seed data.        
──────────────────────────────────────────────
$ python 07_decorator_nested.py db migrate --version 7
migrating to version 7
$ python 07_decorator_nested.py db seed --rows 5
seeding 5 rows
$ python 07_decorator_nested.py greet --name Alice
hello Alice
$ python 07_decorator_nested.py '#' runs the is_default branch
 Unrecognized options ─────────────────────────────────╮
 Unrecognized options: #, runs, the, is_default, branch 
 ────────────────────────────────────────────────────── 
 Available subcommands: db, greet                       
 ────────────────────────────────────────────────────── 
 For full helptext, run 07_decorator_nested.py --help   
────────────────────────────────────────────────────────