From 072e2d28f9818d136fe980c1c763d81447a7e771 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Tue, 28 Jul 2026 19:26:00 +0000 Subject: [PATCH] Improve usage statement conventions for positional arguments (#484) Two changes: - When the usage line is too long and gets abbreviated to [OPTIONS], positional metavars are now kept visible after the [OPTIONS] token instead of being dropped entirely. - Add tyro.conf.PositionalMetavarFromFieldName, which labels positional arguments with their (uppercased) field name instead of a type-based metavar, following argparse conventions. Explicit metavars from tyro.conf.arg(metavar=...) still take precedence. --- src/tyro/_arguments.py | 12 ++ .../_backends/_argparse_help_formatting.py | 7 + src/tyro/_backends/_tyro_help_formatting.py | 7 + src/tyro/conf/__init__.py | 3 + src/tyro/conf/_markers.py | 20 +++ tests/test_conf.py | 166 ++++++++++++++++++ tests/test_helptext.py | 125 +++++++++++++ .../test_conf_generated.py | 166 ++++++++++++++++++ .../test_helptext_generated.py | 125 +++++++++++++ 9 files changed, 631 insertions(+) diff --git a/src/tyro/_arguments.py b/src/tyro/_arguments.py index 1f085b00..7b8c5a07 100644 --- a/src/tyro/_arguments.py +++ b/src/tyro/_arguments.py @@ -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 == "*": diff --git a/src/tyro/_backends/_argparse_help_formatting.py b/src/tyro/_backends/_argparse_help_formatting.py index 6b63bcd5..77b80cf2 100644 --- a/src/tyro/_backends/_argparse_help_formatting.py +++ b/src/tyro/_backends/_argparse_help_formatting.py @@ -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": [], @@ -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? @@ -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) diff --git a/src/tyro/_backends/_tyro_help_formatting.py b/src/tyro/_backends/_tyro_help_formatting.py index 835a371e..2879a0f2 100644 --- a/src/tyro/_backends/_tyro_help_formatting.py +++ b/src/tyro/_backends/_tyro_help_formatting.py @@ -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. @@ -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? @@ -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) diff --git a/src/tyro/conf/__init__.py b/src/tyro/conf/__init__.py index 3ab8cf4a..5d2c1f38 100644 --- a/src/tyro/conf/__init__.py +++ b/src/tyro/conf/__init__.py @@ -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 diff --git a/src/tyro/conf/_markers.py b/src/tyro/conf/_markers.py index d2a3e863..3c8ae423 100644 --- a/src/tyro/conf/_markers.py +++ b/src/tyro/conf/_markers.py @@ -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] diff --git a/tests/test_conf.py b/tests/test_conf.py index be964992..a25538cc 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -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 @@ -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 diff --git a/tests/test_helptext.py b/tests/test_helptext.py index 5df2447d..a0e6a3f9 100644 --- a/tests/test_helptext.py +++ b/tests/test_helptext.py @@ -1687,3 +1687,128 @@ class FreshForNoPydantic: sys.modules.pop("pydantic", None) description = _docstrings.get_callable_description(FreshForNoPydantic) assert "Docstring for FreshForNoPydantic." in description + + +def test_usage_positional_shown_when_short() -> None: + """Positional metavars appear in the usage line when it's short enough to + show all arguments. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] + verbose: bool = False + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert "PATH" in usage_text + assert "[OPTIONS]" not in usage_text + + +def test_usage_positional_shown_when_abbreviated() -> None: + """Positional metavars stay visible when the usage line is abbreviated to + [OPTIONS]. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert "[OPTIONS]" in usage_text + # The positional argument follows [OPTIONS], matching argparse's + # optionals-then-positionals convention. + assert "[OPTIONS] PATH" in usage_text.replace("\n", " ") + + +def test_usage_optional_positional_shown_when_abbreviated() -> None: + """Optional positional metavars keep their brackets in the abbreviated + usage line. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] = pathlib.Path("in") + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert "[OPTIONS]" in usage_text + assert "[PATH]" in usage_text + + +def test_usage_abbreviated_with_field_name_metavar() -> None: + """The abbreviated usage line uses field-name metavars when + `PositionalMetavarFromFieldName` is set. + https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: pathlib.Path + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks( + Args, + config=( + tyro.conf.PositionalRequiredArgs, + tyro.conf.PositionalMetavarFromFieldName, + ), + ) + usage_text = helptext.partition("\n\n")[0] + assert "[OPTIONS]" in usage_text + assert "INDIR" in usage_text + + +def test_usage_abbreviated_multiple_positionals() -> None: + """All positional metavars are preserved, in order, in the abbreviated + usage line. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] + outdir: tyro.conf.Positional[pathlib.Path] + count: tyro.conf.Positional[int] + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks( + Args, config=(tyro.conf.PositionalMetavarFromFieldName,) + ) + usage_text = helptext.partition("\n\n")[0].replace("\n", " ") + assert "[OPTIONS]" in usage_text + indir_index = usage_text.index("INDIR") + outdir_index = usage_text.index("OUTDIR") + count_index = usage_text.index("COUNT") + assert usage_text.index("[OPTIONS]") < indir_index < outdir_index < count_index + + +def test_usage_no_positionals_abbreviated_unchanged() -> None: + """The abbreviated usage line is unchanged when there are no positional + arguments. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + sixth_long_option_name: int = 5 + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert usage_text.replace("\n", " ").rstrip().endswith("[OPTIONS]") diff --git a/tests/test_py311_generated/test_conf_generated.py b/tests/test_py311_generated/test_conf_generated.py index 52fb27f7..584cf858 100644 --- a/tests/test_py311_generated/test_conf_generated.py +++ b/tests/test_py311_generated/test_conf_generated.py @@ -3,6 +3,7 @@ import dataclasses import io import json as json_ +import pathlib import shlex from typing import ( Annotated, @@ -3373,3 +3374,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 diff --git a/tests/test_py311_generated/test_helptext_generated.py b/tests/test_py311_generated/test_helptext_generated.py index 1d57200a..1b2b4aae 100644 --- a/tests/test_py311_generated/test_helptext_generated.py +++ b/tests/test_py311_generated/test_helptext_generated.py @@ -1687,3 +1687,128 @@ class FreshForNoPydantic: sys.modules.pop("pydantic", None) description = _docstrings.get_callable_description(FreshForNoPydantic) assert "Docstring for FreshForNoPydantic." in description + + +def test_usage_positional_shown_when_short() -> None: + """Positional metavars appear in the usage line when it's short enough to + show all arguments. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] + verbose: bool = False + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert "PATH" in usage_text + assert "[OPTIONS]" not in usage_text + + +def test_usage_positional_shown_when_abbreviated() -> None: + """Positional metavars stay visible when the usage line is abbreviated to + [OPTIONS]. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert "[OPTIONS]" in usage_text + # The positional argument follows [OPTIONS], matching argparse's + # optionals-then-positionals convention. + assert "[OPTIONS] PATH" in usage_text.replace("\n", " ") + + +def test_usage_optional_positional_shown_when_abbreviated() -> None: + """Optional positional metavars keep their brackets in the abbreviated + usage line. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] = pathlib.Path("in") + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert "[OPTIONS]" in usage_text + assert "[PATH]" in usage_text + + +def test_usage_abbreviated_with_field_name_metavar() -> None: + """The abbreviated usage line uses field-name metavars when + `PositionalMetavarFromFieldName` is set. + https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: pathlib.Path + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks( + Args, + config=( + tyro.conf.PositionalRequiredArgs, + tyro.conf.PositionalMetavarFromFieldName, + ), + ) + usage_text = helptext.partition("\n\n")[0] + assert "[OPTIONS]" in usage_text + assert "INDIR" in usage_text + + +def test_usage_abbreviated_multiple_positionals() -> None: + """All positional metavars are preserved, in order, in the abbreviated + usage line. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + indir: tyro.conf.Positional[pathlib.Path] + outdir: tyro.conf.Positional[pathlib.Path] + count: tyro.conf.Positional[int] + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + + helptext = get_helptext_with_checks( + Args, config=(tyro.conf.PositionalMetavarFromFieldName,) + ) + usage_text = helptext.partition("\n\n")[0].replace("\n", " ") + assert "[OPTIONS]" in usage_text + indir_index = usage_text.index("INDIR") + outdir_index = usage_text.index("OUTDIR") + count_index = usage_text.index("COUNT") + assert usage_text.index("[OPTIONS]") < indir_index < outdir_index < count_index + + +def test_usage_no_positionals_abbreviated_unchanged() -> None: + """The abbreviated usage line is unchanged when there are no positional + arguments. https://github.com/brentyi/tyro/issues/484""" + + @dataclasses.dataclass + class Args: + first_long_option_name: int = 0 + second_long_option_name: int = 1 + third_long_option_name: int = 2 + fourth_long_option_name: int = 3 + fifth_long_option_name: int = 4 + sixth_long_option_name: int = 5 + + helptext = get_helptext_with_checks(Args) + usage_text = helptext.partition("\n\n")[0] + assert usage_text.replace("\n", " ").rstrip().endswith("[OPTIONS]")