Skip to content

Commit

Permalink
More complete custom constructor docs, refinements (#201)
Browse files Browse the repository at this point in the history
* Constructors + examples refinements

* formatting/regenerate docs

* Move test

* ruff

* Python 3.7

* ruff
  • Loading branch information
brentyi authored Nov 16, 2024
1 parent 13d622e commit 5eba34c
Show file tree
Hide file tree
Showing 23 changed files with 619 additions and 158 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ Other features include helptext generation, nested structures, subcommands, and
shell completion. For examples and the API reference, see our
[documentation](https://brentyi.github.io/tyro).

### Why `tyro`?

1. **Define things once.** Standard Python type annotations, docstrings, and default values are parsed to automatically generate command-line interfaces with informative helptext.

2. **Static types.** Unlike tools dependent on dictionaries, YAML, or dynamic
namespaces, arguments populated by `tyro` benefit from IDE and language
server-supported operations — tab completion, rename, jump-to-def,
docstrings on hover — as well as static checking tools like `pyright` and
`mypy`.

3. **Modularity.** `tyro` supports hierarchical configuration structures, which
make it easy to decentralize definitions, defaults, and documentation.

### In the wild

`tyro` is designed to be lightweight enough for throwaway scripts, while
Expand Down
263 changes: 237 additions & 26 deletions docs/source/examples/custom_constructors.rst

Large diffs are not rendered by default.

34 changes: 11 additions & 23 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,18 @@ shell completion.

#### Why `tyro`?

1. **Types.**
1. **Define things once.** Standard Python type annotations, docstrings, and
default values are parsed to automatically generate command-line interfaces
with informative helptext.

Unlike tools dependent on dictionaries, YAML, or dynamic namespaces,
arguments populated by `tyro` benefit from IDE and language server-supported
operations — think tab completion, rename, jump-to-def, docstrings on hover —
as well as static checking tools like `pyright` and `mypy`.
2. **Static types.** Unlike tools dependent on dictionaries, YAML, or dynamic
namespaces, arguments populated by `tyro` benefit from IDE and language
server-supported operations — tab completion, rename, jump-to-def,
docstrings on hover — as well as static checking tools like `pyright` and
`mypy`.

2. **Define things once.**

Standard Python type annotations, docstrings, and default values are parsed
to automatically generate command-line interfaces with informative helptext.

`tyro` works seamlessly with tools you already use: examples are included for
[dataclasses](https://docs.python.org/3/library/dataclasses.html),
[attrs](https://www.attrs.org/),
[pydantic](https://pydantic-docs.helpmanual.io/),
[flax.linen](https://flax.readthedocs.io/en/latest/api_reference/flax.linen.html),
and more.

3. **Modularity.**

`tyro` supports hierarchical configuration structures, which make it easy to
distribute definitions, defaults, and documentation of configurable fields
across modules or source files.
3. **Modularity.** `tyro` supports hierarchical configuration structures, which
make it easy to decentralize definitions, defaults, and documentation.

<!-- prettier-ignore-start -->

Expand All @@ -89,7 +77,7 @@ shell completion.

installation
your_first_cli
supported_types
whats_supported

.. toctree::
:caption: Examples
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
What's supported
================

For minimum-boilerplate CLIs, `tyro` aims to maximize support of
For minimum-boilerplate CLIs, :mod:`tyro` aims to maximize support of
Python's standard :mod:`typing` features.

As a partial list, inputs can be annotated with:
Expand All @@ -13,9 +13,8 @@ As a partial list, inputs can be annotated with:
- :py:data:`typing.Literal` and :class:`enum.Enum`.
- Type aliases, for example using Python 3.12's `PEP 695 <https://peps.python.org/pep-0695/>`_ `type` statement.
- Generics, such as those annotated with :py:class:`typing.TypeVar` or with the type parameter syntax introduced by Python 3.12's `PEP 695 <https://peps.python.org/pep-0695/>`_.
- etc
- Compositions of the above types, like ``tuple[int | str, ...] | None``.

Compositions of the above types, like ``tuple[int | str, ...] | None``, are also supported.

Types can also be placed and nested in various structures, such as:

Expand Down
43 changes: 43 additions & 0 deletions examples/06_custom_constructors/01_simple_constructors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Simple Constructors
For simple custom constructors, we can pass a constructor function into
:func:`tyro.conf.arg` or :func:`tyro.conf.subcommand`. Arguments for will be
generated by parsing the signature of the constructor function.
In this example, we use this pattern to define custom behavior for
instantiating a NumPy array.
Usage:
python ./01_simple_constructors.py --help
python ./01_simple_constructors.py --array.values 1 2 3
python ./01_simple_constructors.py --array.values 1 2 3 4 5 --array.dtype float32
"""

from typing import Literal

import numpy as np
from typing_extensions import Annotated

import tyro


def construct_array(
values: tuple[float, ...], dtype: Literal["float32", "float64"] = "float64"
) -> np.ndarray:
"""A custom constructor for 1D NumPy arrays."""
return np.array(
values,
dtype={"float32": np.float32, "float64": np.float64}[dtype],
)


def main(
# We can specify a custom constructor for an argument in `tyro.conf.arg()`.
array: Annotated[np.ndarray, tyro.conf.arg(constructor=construct_array)],
) -> None:
print(f"{array=}")


if __name__ == "__main__":
tyro.cli(main)
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
"""Custom Primitive
.. note::
This is an advanced feature, which should not be needed for the vast
majority of use cases. If :mod:`tyro` is missing support for a built-in
Python type, please open an issue on `GitHub <http://github.com/brentyi/tyro/issues>`_.
For additional flexibility, :mod:`tyro.constructors` exposes tyro's API for
defining behavior for different types. There are two categories of types:
primitive types can be instantiated from a single commandline argument, while
struct types are broken down into multiple.
In this example, we attach a custom constructor via a runtime annotation.
In this example, we use :mod:`tyro.constructors` to attach a primitive
constructor via a runtime annotation.
Usage:
python ./01_primitive_annotation.py --help
python ./01_primitive_annotation.py --dict1 '{"hello": "world"}'
python ./01_primitive_annotation.py --dict1 '{"hello": "world"}' --dict2 '{"hello": "world"}'
python ./02_primitive_annotation.py --help
python ./02_primitive_annotation.py --dict1 '{"hello": "world"}'
python ./02_primitive_annotation.py --dict1 '{"hello": "world"}' --dict2 '{"hello": "world"}'
"""

import json
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
"""Custom Primitive (Registry)
In this example, we use :class:`tyro.constructors.PrimitiveConstructorSpec` to
In this example, we use a :class:`tyro.constructors.ConstructorRegistry` to
define a rule that applies to all types that match ``dict[str, Any]``.
Usage:
python ./02_primitive_registry.py --help
python ./02_primitive_registry.py --dict1 '{"hello": "world"}'
python ./02_primitive_registry.py --dict1 '{"hello": "world"}' --dict2 '{"hello": "world"}'
python ./03_primitive_registry.py --help
python ./03_primitive_registry.py --dict1 '{"hello": "world"}'
python ./03_primitive_registry.py --dict1 '{"hello": "world"}' --dict2 '{"hello": "world"}'
"""

import json
from typing import Any

import tyro

# Create a custom registry, which stores constructor rules.
custom_registry = tyro.constructors.ConstructorRegistry()


# Define a rule that applies to all types that match `dict[str, Any]`.
@custom_registry.primitive_rule
def _(
type_info: tyro.constructors.PrimitiveTypeInfo,
Expand All @@ -40,11 +42,12 @@ def main(
dict1: dict[str, Any],
dict2: dict[str, Any] = {"default": None},
) -> None:
"""A function with two arguments, which can be populated from the CLI via JSON."""
print(f"{dict1=}")
print(f"{dict2=}")


if __name__ == "__main__":
# The custom registry is used as a context.
# To activate a custom registry, we should use it as a context manager.
with custom_registry:
tyro.cli(main)
88 changes: 88 additions & 0 deletions examples/06_custom_constructors/04_struct_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Custom Structs (Registry)
In this example, we use a :class:`tyro.constructors.ConstructorRegistry` to
add support for a custom type.
.. warning::
This will be complicated!
Usage:
python ./03_primitive_registry.py --help
python ./03_primitive_registry.py --dict1 '{"hello": "world"}'
python ./03_primitive_registry.py --dict1 '{"hello": "world"}' --dict2 '{"hello": "world"}'
"""

import tyro


# A custom type that we'll add support for to tyro.
class Bounds:
def __init__(self, lower: int, upper: int):
self.bounds = (lower, upper)


# Create a custom registry, which stores constructor rules.
custom_registry = tyro.constructors.ConstructorRegistry()


# Define a rule that applies to all types that match `Bounds`.
@custom_registry.struct_rule
def _(
type_info: tyro.constructors.StructTypeInfo,
) -> tyro.constructors.StructConstructorSpec | None:
# We return `None` if the rule does not apply.
if type_info.type != Bounds:
return None

# We can extract the default value of the field from `type_info`.
if isinstance(type_info.default, Bounds):
# If the default value is a `Bounds` instance, we don't need to generate a constructor.
default = (type_info.default.bounds[0], type_info.default.bounds[1])
is_default_overridden = True
else:
# Otherwise, the default value is missing. We'll mark the child defaults as missing as well.
assert type_info.default in (
tyro.constructors.MISSING,
tyro.constructors.MISSING_NONPROP,
)
default = (tyro.MISSING, tyro.MISSING)
is_default_overridden = False

# If the rule applies, we return the constructor spec.
return tyro.constructors.StructConstructorSpec(
# The instantiate function will be called with the fields as keyword arguments.
instantiate=Bounds,
fields=(
tyro.constructors.StructFieldSpec(
name="lower",
type=int,
default=default[0],
is_default_overridden=is_default_overridden,
helptext="Lower bound." "",
),
tyro.constructors.StructFieldSpec(
name="upper",
type=int,
default=default[1],
is_default_overridden=is_default_overridden,
helptext="Upper bound." "",
),
),
)


def main(
bounds: Bounds,
bounds_with_default: Bounds = Bounds(0, 100),
) -> None:
"""A function with two `Bounds` instances as input."""
print(f"{bounds=}")
print(f"{bounds_with_default=}")


if __name__ == "__main__":
# To activate a custom registry, we should use it as a context manager.
with custom_registry:
tyro.cli(main)
13 changes: 12 additions & 1 deletion examples/06_custom_constructors/README.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
Custom constructors
===================

In these examples, we show how custom types can be parsed by and registered with :func:`tyro.cli`.
:func:`tyro.cli` is designed for comprehensive support of standard Python type
constructs. In some cases, however, it can be useful to extend the set of types
supported by :mod:`tyro`.

We provide two complementary approaches for doing so:

- :mod:`tyro.conf` provides a simple API for specifying custom constructor
functions.
- :mod:`tyro.constructors` provides a more flexible API for defining behavior
for different types. There are two categories of types: *primitive* types are
instantiated from a single commandline argument, while *struct* types are
broken down into multiple arguments.
4 changes: 2 additions & 2 deletions src/tyro/_argparse_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,9 @@ def _check_value(self, action, value):
This solves a choices error raised by argparse in a very specific edge case:
literals in containers as positional arguments.
"""
from ._fields import MISSING_SINGLETONS
from ._fields import MISSING_AND_MISSING_NONPROP

if value in MISSING_SINGLETONS:
if value in MISSING_AND_MISSING_NONPROP:
return
return super()._check_value(action, value)

Expand Down
Loading

0 comments on commit 5eba34c

Please sign in to comment.