Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/tyro/_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,18 @@ def _rule_positional_special_handling(

metavar = lowered.metavar

# Follow argparse conventions more closely: label the positional argument
# with its field name instead of a type-based metavar. `name_or_flags` was
# set by `_rule_set_name_or_flag_and_dest()` and still contains the
# user-facing name at this point; it's replaced with the internal name
# below. Explicit metavars from `tyro.conf.arg(metavar=...)` are applied
# afterwards, in `_rule_apply_argconf()`, and take precedence.
if (
_markers.PositionalMetavarFromFieldName in arg.field.markers
and not lowered.is_fixed()
):
metavar = lowered.name_or_flags[0].upper()

# Positional arguments with nargs="*" accept zero arguments, so they
# should never be marked as required.
if lowered.required and lowered.nargs == "*":
Expand Down
7 changes: 7 additions & 0 deletions src/tyro/_backends/_argparse_help_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def format_help(
subparser_frontier: dict[str, SubparsersSpecification],
) -> list[str]:
usage_strings = []
# Positional metavars, tracked separately so they stay visible when the
# usage line is too long and gets abbreviated to "[OPTIONS]".
positional_usage_strings: list[fmt._Text] = []
group_description: dict[str, str | fmt._Text] = {}
groups: dict[str | _MutexGroupConfig, list[tuple[str | fmt._Text, fmt._Text]]] = {
"positional arguments": [],
Expand Down Expand Up @@ -95,6 +98,8 @@ def add_args_recursive(parser: ParserSpecification) -> None:
# Populate help window.
invocation_short, invocation_long = arg.get_invocation_text()
usage_strings.append(invocation_short)
if arg.is_positional():
positional_usage_strings.append(invocation_short)
helptext = generate_argument_helptext(arg, arg.lowered)

# How should this argument be grouped?
Expand Down Expand Up @@ -321,6 +326,8 @@ def add_args_recursive(parser: ParserSpecification) -> None:
usage_parts.append(
"[OPTIONS]" if is_root else f"[{prog_parts[-1].upper()} OPTIONS]"
)
# Keep positional arguments visible in the abbreviated usage.
usage_parts.extend(positional_usage_strings)
# Add all subcommand metavars from the frontier.
for metavar in subcommand_metavars:
usage_parts.append(metavar)
Expand Down
7 changes: 7 additions & 0 deletions src/tyro/_backends/_tyro_help_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def format_help(
verbose: bool = False,
) -> list[str]:
usage_strings = []
# Positional metavars, tracked separately so they stay visible when the
# usage line is too long and gets abbreviated to "[OPTIONS]".
positional_usage_strings: list[fmt._Text] = []
group_description: dict[str, str | fmt._Text] = {}

# Compact mode is the inverse of verbose mode.
Expand Down Expand Up @@ -126,6 +129,8 @@ def _recurse_through_subparser_frontier(subparser: SubparsersSpecification) -> N
# Populate help window.
invocation_short, invocation_long = arg.get_invocation_text()
usage_strings.append(invocation_short)
if arg.is_positional():
positional_usage_strings.append(invocation_short)
helptext = generate_argument_helptext(arg, arg.lowered, compact=compact_mode)

# How should this argument be grouped?
Expand Down Expand Up @@ -447,6 +452,8 @@ def _recurse_through_subparser_frontier(subparser: SubparsersSpecification) -> N
usage_parts.append(
"[OPTIONS]" if is_root else f"[{prog_parts[-1].upper()} OPTIONS]"
)
# Keep positional arguments visible in the abbreviated usage.
usage_parts.extend(positional_usage_strings)
# Add all subcommand metavars from the frontier.
for metavar in subcommand_metavars:
usage_parts.append(metavar)
Expand Down
3 changes: 3 additions & 0 deletions src/tyro/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
from ._markers import OmitArgPrefixes as OmitArgPrefixes
from ._markers import OmitSubcommandPrefixes as OmitSubcommandPrefixes
from ._markers import Positional as Positional
from ._markers import (
PositionalMetavarFromFieldName as PositionalMetavarFromFieldName,
)
from ._markers import PositionalRequiredArgs as PositionalRequiredArgs
from ._markers import ShowSourcePath as ShowSourcePath
from ._markers import Suppress as Suppress
Expand Down
20 changes: 20 additions & 0 deletions src/tyro/conf/_markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ class Args:
args = tyro.cli(Args, config=(tyro.conf.PositionalRequiredArgs,))
"""

PositionalMetavarFromFieldName = Annotated[T, None]
"""Label positional arguments with their field name instead of a type-based metavar.

By default, positional arguments are labeled with a metavar derived from their
type, like ``PATH`` or ``INT``. This marker follows :py:mod:`argparse`
conventions more closely by using the (uppercased) field name instead, both in
the usage string and in the helptext. Metavars specified explicitly via
``tyro.conf.arg(metavar=...)`` are unaffected.

Example::

@dataclass
class Args:
input_file: tyro.conf.Positional[pathlib.Path]

args = tyro.cli(Args, config=(tyro.conf.PositionalMetavarFromFieldName,))

With this configuration, the helptext shows ``INPUT-FILE`` instead of ``PATH``.
"""

# Private marker.
_OPTIONAL_GROUP = Annotated[T, None]

Expand Down
166 changes: 166 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import dataclasses
import io
import json as json_
import pathlib
import shlex
from typing import Any, Dict, Generic, List, Sequence, Tuple, Type, TypeVar, Union

Expand Down Expand Up @@ -3368,3 +3369,168 @@ class A:
args="--x hello --x world".split(" "),
config=(tyro.conf.UseAppendAction, tyro.conf.PositionalRequiredArgs),
) == A(x=("hello", "world"))


def test_positional_metavar_from_field_name() -> None:
"""`PositionalMetavarFromFieldName` labels positionals with their field
name instead of a type-based metavar.
https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
input_file: tyro.conf.Positional[pathlib.Path]

# Without the marker: type-based metavar.
helptext = get_helptext_with_checks(Args)
assert "PATH" in helptext
assert "INPUT-FILE" not in helptext

# With the marker: field-name-based metavar.
helptext = get_helptext_with_checks(
Args, config=(tyro.conf.PositionalMetavarFromFieldName,)
)
assert "INPUT-FILE" in helptext
assert "PATH" not in helptext

# Parsing should be unaffected.
assert tyro.cli(
Args,
args=["in.txt"],
config=(tyro.conf.PositionalMetavarFromFieldName,),
) == Args(input_file=pathlib.Path("in.txt"))


def test_positional_metavar_from_field_name_consistency() -> None:
"""The field-name metavar should be used consistently in the usage line
and the helptext body. https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
input_file: tyro.conf.Positional[pathlib.Path]

helptext = get_helptext_with_checks(
Args, config=(tyro.conf.PositionalMetavarFromFieldName,)
)
# The metavar appears both in the usage line (before the first group box)
# and in the positional arguments group.
usage_text, _, body_text = helptext.partition("\n\n")
assert "INPUT-FILE" in usage_text
assert "INPUT-FILE" in body_text


def test_positional_metavar_from_field_name_required_args() -> None:
"""`PositionalMetavarFromFieldName` composes with
`PositionalRequiredArgs`. https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
indir: pathlib.Path
verbose: bool = False

helptext = get_helptext_with_checks(
Args,
config=(
tyro.conf.PositionalRequiredArgs,
tyro.conf.PositionalMetavarFromFieldName,
),
)
assert "INDIR" in helptext
assert tyro.cli(
Args,
args=["in"],
config=(
tyro.conf.PositionalRequiredArgs,
tyro.conf.PositionalMetavarFromFieldName,
),
) == Args(indir=pathlib.Path("in"))


def test_positional_metavar_from_field_name_optional_positional() -> None:
"""Optional positionals keep their brackets with the field-name metavar.
https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
opt_pos: tyro.conf.Positional[int] = 3

helptext = get_helptext_with_checks(
Args, config=(tyro.conf.PositionalMetavarFromFieldName,)
)
assert "[OPT-POS]" in helptext
assert tyro.cli(
Args, args=[], config=(tyro.conf.PositionalMetavarFromFieldName,)
) == Args(opt_pos=3)
assert tyro.cli(
Args, args=["5"], config=(tyro.conf.PositionalMetavarFromFieldName,)
) == Args(opt_pos=5)


def test_positional_metavar_from_field_name_use_underscores() -> None:
"""`use_underscores=True` should be reflected in field-name metavars.
https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
input_file: tyro.conf.Positional[pathlib.Path]

helptext = get_helptext_with_checks(
Args,
config=(tyro.conf.PositionalMetavarFromFieldName,),
use_underscores=True,
)
assert "INPUT_FILE" in helptext
assert "INPUT-FILE" not in helptext


def test_positional_metavar_from_field_name_explicit_metavar() -> None:
"""Explicit `tyro.conf.arg(metavar=...)` takes precedence over the
field-name metavar. https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
input_file: Annotated[
tyro.conf.Positional[pathlib.Path], tyro.conf.arg(metavar="SRC")
]

helptext = get_helptext_with_checks(
Args, config=(tyro.conf.PositionalMetavarFromFieldName,)
)
assert "SRC" in helptext
assert "INPUT-FILE" not in helptext


def test_positional_metavar_from_field_name_nested() -> None:
"""Field-name metavars include prefixes for nested positional fields.
https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Inner:
x: tyro.conf.Positional[int]

@dataclasses.dataclass
class Outer:
inner: Inner

helptext = get_helptext_with_checks(
Outer, config=(tyro.conf.PositionalMetavarFromFieldName,)
)
assert "INNER.X" in helptext
assert tyro.cli(
Outer, args=["7"], config=(tyro.conf.PositionalMetavarFromFieldName,)
) == Outer(inner=Inner(x=7))


def test_positional_metavar_from_field_name_per_field() -> None:
"""The marker can also be applied to individual fields via Annotated.
https://github.com/brentyi/tyro/issues/484"""

@dataclasses.dataclass
class Args:
named: tyro.conf.PositionalMetavarFromFieldName[
tyro.conf.Positional[pathlib.Path]
]
typed: tyro.conf.Positional[int]

helptext = get_helptext_with_checks(Args)
assert "NAMED" in helptext
assert "INT" in helptext
Loading
Loading