Skip to content

Commit

Permalink
Implement custom constructor/proxy types (#82)
Browse files Browse the repository at this point in the history
* Add ability to specify custom constructor

* Tests, error refinement

* Edge cases, more comprehensive tests, examples

* Add back missing example (this commit doesn't really belong in this branch)

* Add `constructor=` and `constructor_factory=` arguments to
`tyro.conf.subcommand()`

* Fix helptext edge case

* Add mypy-compatible overload for subcommand_cli_from_dict()

* Improve help, more tests

* Optional subcommands are now handled automatically by `none_proxy`

* More tests, improve errors

* Fix test for Python 3.7

* Tweak example comments

* Add test for subcommands from dict helper
  • Loading branch information
brentyi committed Oct 26, 2023
1 parent f213383 commit 7a50cae
Show file tree
Hide file tree
Showing 19 changed files with 869 additions and 108 deletions.
76 changes: 76 additions & 0 deletions docs/source/examples/02_nesting/05_subcommands_func.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
.. Comment: this file is automatically generated by `update_example_docs.py`.
It should not be modified manually.
Subcommands from Functions
==========================================


:func:`tyro.extras.subcommand_cli_from_dict()` provides a shorthand that generates a
subcommand CLI from a dictionary.



.. code-block:: python
:linenos:
import tyro
def checkout(branch: str) -> None:
"""Check out a branch."""
print(f"{branch=}")
def commit(message: str, all: bool = False) -> None:
"""Make a commit."""
print(f"{message=} {all=}")
if __name__ == "__main__":
tyro.extras.subcommand_cli_from_dict(
{
"checkout": checkout,
"commit": commit,
}
)
------------

.. raw:: html

<kbd>python 02_nesting/05_subcommands_func.py --help</kbd>

.. program-output:: python ../../examples/02_nesting/05_subcommands_func.py --help

------------

.. raw:: html

<kbd>python 02_nesting/05_subcommands_func.py commit --help</kbd>

.. program-output:: python ../../examples/02_nesting/05_subcommands_func.py commit --help

------------

.. raw:: html

<kbd>python 02_nesting/05_subcommands_func.py commit --message hello --all</kbd>

.. program-output:: python ../../examples/02_nesting/05_subcommands_func.py commit --message hello --all

------------

.. raw:: html

<kbd>python 02_nesting/05_subcommands_func.py checkout --help</kbd>

.. program-output:: python ../../examples/02_nesting/05_subcommands_func.py checkout --help

------------

.. raw:: html

<kbd>python 02_nesting/05_subcommands_func.py checkout --branch main</kbd>

.. program-output:: python ../../examples/02_nesting/05_subcommands_func.py checkout --branch main
19 changes: 15 additions & 4 deletions docs/source/examples/04_additional/02_dictionaries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ Dictionary inputs can be specified using either a standard ``Dict[K, V]`` annota
from typing import Dict, Tuple, TypedDict
from typing_extensions import NotRequired
import tyro
class DictionarySchema(
class DictionarySchemaA(
TypedDict,
# Setting `total=False` specifies that not all keys need to exist.
total=False,
Expand All @@ -28,17 +30,26 @@ Dictionary inputs can be specified using either a standard ``Dict[K, V]`` annota
betas: Tuple[float, float]
class DictionarySchemaB(TypedDict):
learning_rate: NotRequired[float]
"""NotRequired[] specifies that a particular key doesn't need to exist."""
betas: Tuple[float, float]
def main(
typed_dict: DictionarySchema,
typed_dict_a: DictionarySchemaA,
typed_dict_b: DictionarySchemaB,
standard_dict: Dict[str, float] = {
"learning_rate": 3e-4,
"beta1": 0.9,
"beta2": 0.999,
},
) -> None:
assert isinstance(typed_dict, dict)
assert isinstance(typed_dict_a, dict)
assert isinstance(typed_dict_b, dict)
assert isinstance(standard_dict, dict)
print("Typed dict:", typed_dict)
print("Typed dict A:", typed_dict_a)
print("Typed dict B:", typed_dict_b)
print("Standard dict:", standard_dict)
Expand Down
72 changes: 72 additions & 0 deletions docs/source/examples/04_additional/10_custom_constructors.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
.. Comment: this file is automatically generated by `update_example_docs.py`.
It should not be modified manually.
Custom Constructors
==========================================


For additional flexibility, :func:`tyro.conf.arg()` accepts a ``constructor`` argument,
which makes it easier to load complex objects.



.. code-block:: python
:linenos:
import dataclasses
import json as json_
from typing import Dict
from typing_extensions import Annotated
import tyro
def dict_json_constructor(json: str) -> dict:
"""Construct a dictionary from a JSON string. Raises a ValueError if the result is
not a dictionary."""
out = json_.loads(json)
if not isinstance(out, dict):
raise ValueError(f"{json} is not a dictionary!")
return out
# A dictionary type, but `tyro` will expect a JSON string from the CLI.
JsonDict = Annotated[dict, tyro.conf.arg(constructor=dict_json_constructor)]
def main(
dict1: JsonDict,
dict2: JsonDict = {"default": None},
) -> None:
print(f"{dict1=}")
print(f"{dict2=}")
if __name__ == "__main__":
tyro.cli(main)
------------

.. raw:: html

<kbd>python 04_additional/10_custom_constructors.py --help</kbd>

.. program-output:: python ../../examples/04_additional/10_custom_constructors.py --help

------------

.. raw:: html

<kbd>python 04_additional/10_custom_constructors.py --dict1.json '{"hello": "world"}'</kbd>

.. program-output:: python ../../examples/04_additional/10_custom_constructors.py --dict1.json '{"hello": "world"}'

------------

.. raw:: html

<kbd>python 04_additional/10_custom_constructors.py --dict1.json '{"hello": "world"}`' --dict2.json '{"hello": "world"}'</kbd>

.. program-output:: python ../../examples/04_additional/10_custom_constructors.py --dict1.json '{"hello": "world"}`' --dict2.json '{"hello": "world"}'
34 changes: 34 additions & 0 deletions examples/01_basics/03_collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Multi-value Arguments
Arguments of both fixed and variable lengths can be annotated with standard Python
collection types: `typing.List[T]`, `typing.Tuple[T1, T2]`, etc. In Python >=3.9,
`list[T]` and `tuple[T]` are also supported.
Usage:
`python ./03_collections.py --help`
`python ./03_collections.py --dataset-sources ./data --image-dimensions 16 16`
`python ./03_collections.py --dataset-sources ./data`
"""

import dataclasses
import pathlib
from typing import Tuple

import tyro


@dataclasses.dataclass(frozen=True)
class TrainConfig:
# Example of a variable-length tuple. `typing.List`, `typing.Sequence`,
# `typing.Set`, `typing.Dict`, etc are all supported as well.
dataset_sources: Tuple[pathlib.Path, ...]
"""Paths to load training data from. This can be multiple!"""

# Fixed-length tuples are also okay.
image_dimensions: Tuple[int, int] = (32, 32)
"""Height and width of some image data."""


if __name__ == "__main__":
config = tyro.cli(TrainConfig)
print(config)
33 changes: 33 additions & 0 deletions examples/02_nesting/05_subcommands_func.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Subcommands from Functions
:func:`tyro.extras.subcommand_cli_from_dict()` provides a shorthand that generates a
subcommand CLI from a dictionary.
Usage:
`python ./05_subcommands_func.py --help`
`python ./05_subcommands_func.py commit --help`
`python ./05_subcommands_func.py commit --message hello --all`
`python ./05_subcommands_func.py checkout --help`
`python ./05_subcommands_func.py checkout --branch main`
"""

import tyro


def checkout(branch: str) -> None:
"""Check out a branch."""
print(f"{branch=}")


def commit(message: str, all: bool = False) -> None:
"""Make a commit."""
print(f"{message=} {all=}")


if __name__ == "__main__":
tyro.extras.subcommand_cli_from_dict(
{
"checkout": checkout,
"commit": commit,
}
)
43 changes: 43 additions & 0 deletions examples/04_additional/10_custom_constructors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Custom Constructors
For additional flexibility, :func:`tyro.conf.arg()` accepts a `constructor` argument,
which makes it easier to load complex objects.
Usage:
`python ./10_custom_constructors.py --help`
`python ./10_custom_constructors.py --dict1.json "{\"hello\": \"world\"}"`
`python ./10_custom_constructors.py --dict1.json "{\"hello\": \"world\"}"` --dict2.json "{\"hello\": \"world\"}"`
"""

import dataclasses
import json as json_
from typing import Dict

from typing_extensions import Annotated

import tyro


def dict_json_constructor(json: str) -> dict:
"""Construct a dictionary from a JSON string. Raises a ValueError if the result is
not a dictionary."""
out = json_.loads(json)
if not isinstance(out, dict):
raise ValueError(f"{json} is not a dictionary!")
return out


# A dictionary type, but `tyro` will expect a JSON string from the CLI.
JsonDict = Annotated[dict, tyro.conf.arg(constructor=dict_json_constructor)]


def main(
dict1: JsonDict,
dict2: JsonDict = {"default": None},
) -> None:
print(f"{dict1=}")
print(f"{dict2=}")


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

0 comments on commit 7a50cae

Please sign in to comment.