From d83f6a0cae30e2d2e90ed15e80acccab317439b0 Mon Sep 17 00:00:00 2001 From: Tom Ritchford Date: Thu, 14 May 2026 13:58:36 +0200 Subject: [PATCH 1/3] Use docstring_parser.description for help (fix #469) --- src/tyro/_docstrings.py | 11 +---------- tests/test_helptext.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/tyro/_docstrings.py b/src/tyro/_docstrings.py index d020ba76..8e1107c1 100644 --- a/src/tyro/_docstrings.py +++ b/src/tyro/_docstrings.py @@ -364,8 +364,6 @@ def get_callable_description(f: Callable) -> str: if docstring is None: return "" - docstring = _strings.dedent(docstring) - if dataclasses.is_dataclass(f): default_doc = f.__name__ + str(inspect.signature(f)).replace(" -> None", "") # type: ignore if docstring == default_doc: @@ -373,11 +371,4 @@ def get_callable_description(f: Callable) -> str: import docstring_parser - parsed_docstring = docstring_parser.parse(docstring) - - parts: List[str] = [] - if parsed_docstring.short_description is not None: - parts.append(parsed_docstring.short_description) - if parsed_docstring.long_description is not None: - parts.append(parsed_docstring.long_description) - return "\n".join(parts) + return docstring_parser.parse(docstring).description diff --git a/tests/test_helptext.py b/tests/test_helptext.py index 92e271e7..64ea8a17 100644 --- a/tests/test_helptext.py +++ b/tests/test_helptext.py @@ -39,6 +39,22 @@ class Helptext: assert "Documentation 3 (default: 3)" in helptext +def test_helptext_paragraphs() -> None: + @dataclasses.dataclass + class Helptext: + """ + First + + Second + + Third + """ + + usage, *lines = get_helptext_with_checks(Helptext).split("\n") + assert usage.startswith("usage:") + assert lines[:7] == ["", "First", "", "Second", "", "Third", ""] + + def test_helptext_sphinx_autodoc_style() -> None: @dataclasses.dataclass class Helptext: From e30416ea7198ee8f0231a3ceb2da54d55cab83bc Mon Sep 17 00:00:00 2001 From: brentyi Date: Sat, 23 May 2026 01:18:03 -0700 Subject: [PATCH 2/3] Nits --- src/tyro/_docstrings.py | 5 +- tests/test_helptext.py | 41 +++++++++ .../test_conf_generated.py | 21 +++++ .../test_dcargs_generated.py | 91 +++++++++++++++++++ .../test_helptext_generated.py | 57 ++++++++++++ 5 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/tyro/_docstrings.py b/src/tyro/_docstrings.py index 8e1107c1..c3e99c63 100644 --- a/src/tyro/_docstrings.py +++ b/src/tyro/_docstrings.py @@ -371,4 +371,7 @@ def get_callable_description(f: Callable) -> str: import docstring_parser - return docstring_parser.parse(docstring).description + description = docstring_parser.parse(docstring).description + if description is None: + return "" + return description diff --git a/tests/test_helptext.py b/tests/test_helptext.py index 64ea8a17..f53f88f6 100644 --- a/tests/test_helptext.py +++ b/tests/test_helptext.py @@ -55,6 +55,47 @@ class Helptext: assert lines[:7] == ["", "First", "", "Second", "", "Third", ""] +def test_helptext_backslash_n_literal() -> None: + @dataclasses.dataclass + class Helptext: + """First paragraph with \\n literal. + + Second paragraph. + """ + + helptext = get_helptext_with_checks(Helptext) + assert "First paragraph with \\n literal." in helptext + assert "Second paragraph." in helptext + + +def test_helptext_empty_docstring() -> None: + @dataclasses.dataclass + class Helptext: + """ """ + + x: int = 3 + + helptext = get_helptext_with_checks(Helptext) + assert "usage:" in helptext + assert "x INT" in helptext + + +def test_helptext_non_indented_first_line() -> None: + @dataclasses.dataclass + class Helptext: + """Summary line, not indented. + + Body paragraph with indentation that should be stripped. + """ + + x: int = 3 + + helptext = get_helptext_with_checks(Helptext) + assert "Summary line, not indented." in helptext + assert "Body paragraph with indentation that should be stripped." in helptext + assert " Body paragraph" not in helptext + + def test_helptext_sphinx_autodoc_style() -> None: @dataclasses.dataclass class Helptext: diff --git a/tests/test_py311_generated/test_conf_generated.py b/tests/test_py311_generated/test_conf_generated.py index 427bd447..cec0084d 100644 --- a/tests/test_py311_generated/test_conf_generated.py +++ b/tests/test_py311_generated/test_conf_generated.py @@ -536,6 +536,27 @@ class A: ) == A(True) +def test_fixed_value_error_attribution(capsys) -> None: + """When a value is passed for a fixed flag, the error should name the flag. + + Regression test for the second half of issue #462: previously the user got + an opaque "Unrecognized options: " message with no flag attribution. + """ + + @dataclasses.dataclass + class A: + a: int = 0 + b: tyro.conf.Fixed[int] = 0 + + with pytest.raises(SystemExit): + tyro.cli(A, args=["--a", "1", "--b", "5"]) + + err = capsys.readouterr().err + # The error should reference --b, not just say "Unrecognized options: 5". + assert "--b" in err + assert "Unrecognized options" not in err + + def test_fixed_recursive() -> None: """When an argument is fixed, we shouldn't be able to override it from the CLI.""" diff --git a/tests/test_py311_generated/test_dcargs_generated.py b/tests/test_py311_generated/test_dcargs_generated.py index e919fa96..ddd9a47a 100644 --- a/tests/test_py311_generated/test_dcargs_generated.py +++ b/tests/test_py311_generated/test_dcargs_generated.py @@ -1235,6 +1235,97 @@ def main(dt: datetime.time) -> datetime.time: tyro.cli(main, args=["--dt", "25:00:00"]) +def test_timedelta_parsing() -> None: + # Plain numeric values are interpreted as seconds. + assert tyro.cli(datetime.timedelta, args=["30"]) == datetime.timedelta(seconds=30) + assert tyro.cli(datetime.timedelta, args=["1.5"]) == datetime.timedelta(seconds=1.5) + # ISO 8601 durations. + assert tyro.cli(datetime.timedelta, args=["PT30S"]) == datetime.timedelta( + seconds=30 + ) + assert tyro.cli(datetime.timedelta, args=["P1DT2H30M"]) == datetime.timedelta( + days=1, hours=2, minutes=30 + ) + assert tyro.cli(datetime.timedelta, args=["P1W"]) == datetime.timedelta(weeks=1) + assert tyro.cli(datetime.timedelta, args=["PT0.5S"]) == datetime.timedelta( + microseconds=500_000 + ) + # Negative durations. + assert tyro.cli(datetime.timedelta, args=["-30"]) == datetime.timedelta(seconds=-30) + + def main(td: datetime.timedelta) -> datetime.timedelta: + return td + + assert tyro.cli(main, args=["--td=-PT5M"]) == datetime.timedelta(minutes=-5) + + +def test_timedelta_parsing_with_dataclass() -> None: + @dataclasses.dataclass + class Audio: + a: int = 0 + b: datetime.timedelta = datetime.timedelta(seconds=0) + + # Issue #462: passing a value should not be rejected as "fixed". + assert tyro.cli(Audio, args=["--a", "1", "--b", "60"]) == Audio( + a=1, b=datetime.timedelta(seconds=60) + ) + assert tyro.cli(Audio, args=["--b", "P1DT2H"]) == Audio( + b=datetime.timedelta(days=1, hours=2) + ) + # Default round-trips through the help text. + assert tyro.cli(Audio, args=[]) == Audio() + + +def test_timedelta_parsing_harder_format() -> None: + def main(td: datetime.timedelta) -> datetime.timedelta: + return td + + with pytest.raises(SystemExit): + tyro.cli(main, args=["--td", "nonsense"]) + + # 'P' alone has no components. + with pytest.raises(SystemExit): + tyro.cli(main, args=["--td", "P"]) + + +def test_timedelta_default_formatting() -> None: + """Defaults round-trip through `_format_timedelta` when shown in help text.""" + + @dataclasses.dataclass + class Zero: + td: datetime.timedelta = datetime.timedelta(0) + + @dataclasses.dataclass + class Mixed: + td: datetime.timedelta = datetime.timedelta( + days=1, hours=2, minutes=3, seconds=4 + ) + + @dataclasses.dataclass + class Subseconds: + td: datetime.timedelta = datetime.timedelta(microseconds=500) + + @dataclasses.dataclass + class Negative: + td: datetime.timedelta = datetime.timedelta(seconds=-30) + + @dataclasses.dataclass + class DaysOnly: + td: datetime.timedelta = datetime.timedelta(days=14) + + helptext_zero = get_helptext_with_checks(Zero) + helptext_mixed = get_helptext_with_checks(Mixed) + helptext_subseconds = get_helptext_with_checks(Subseconds) + helptext_negative = get_helptext_with_checks(Negative) + helptext_days = get_helptext_with_checks(DaysOnly) + + assert "PT0S" in helptext_zero + assert "P1DT2H3M4S" in helptext_mixed + assert "PT0.0005S" in helptext_subseconds + assert "-PT30S" in helptext_negative + assert "P14D" in helptext_days + + def test_numeric_tower() -> None: @dataclasses.dataclass(frozen=True) class NumericTower: diff --git a/tests/test_py311_generated/test_helptext_generated.py b/tests/test_py311_generated/test_helptext_generated.py index 092c8c19..5dc0dcb6 100644 --- a/tests/test_py311_generated/test_helptext_generated.py +++ b/tests/test_py311_generated/test_helptext_generated.py @@ -51,6 +51,63 @@ class Helptext: assert "Documentation 3 (default: 3)" in helptext +def test_helptext_paragraphs() -> None: + @dataclasses.dataclass + class Helptext: + """ + First + + Second + + Third + """ + + usage, *lines = get_helptext_with_checks(Helptext).split("\n") + assert usage.startswith("usage:") + assert lines[:7] == ["", "First", "", "Second", "", "Third", ""] + + +def test_helptext_backslash_n_literal() -> None: + @dataclasses.dataclass + class Helptext: + """First paragraph with \\n literal. + + Second paragraph. + """ + + helptext = get_helptext_with_checks(Helptext) + assert "First paragraph with \\n literal." in helptext + assert "Second paragraph." in helptext + + +def test_helptext_empty_docstring() -> None: + @dataclasses.dataclass + class Helptext: + """ """ + + x: int = 3 + + helptext = get_helptext_with_checks(Helptext) + assert "usage:" in helptext + assert "x INT" in helptext + + +def test_helptext_non_indented_first_line() -> None: + @dataclasses.dataclass + class Helptext: + """Summary line, not indented. + + Body paragraph with indentation that should be stripped. + """ + + x: int = 3 + + helptext = get_helptext_with_checks(Helptext) + assert "Summary line, not indented." in helptext + assert "Body paragraph with indentation that should be stripped." in helptext + assert " Body paragraph" not in helptext + + def test_helptext_sphinx_autodoc_style() -> None: @dataclasses.dataclass class Helptext: From e92659807f0500e5d839f496e1d5351e8004facc Mon Sep 17 00:00:00 2001 From: brentyi Date: Sat, 23 May 2026 01:31:51 -0700 Subject: [PATCH 3/3] setup-uv bump --- .github/workflows/coverage.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/pyright.yml | 2 +- .github/workflows/pytest.yml | 4 ++-- .github/workflows/ruff.yml | 2 +- .github/workflows/ty.yml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1e2c6518..6b897ec1 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Generate coverage report run: uv run --extra dev-nn --python 3.12 pytest --cov=tyro --cov-report=xml - name: Upload to Codecov diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cc1eddd9..2a02dce6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Building documentation run: | uv run --extra dev-nn --python 3.12 --with-requirements docs/requirements.txt \ diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index de627c50..327963e3 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -12,6 +12,6 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Test with mypy (test directory only) run: uv run --extra dev-nn --python 3.14 --with types-PyYAML mypy tests diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4fc404fa..75d1ba2e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Strip unsupported tags in README run: | sed -i '//,//d' README.md diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 2e63c8f5..5cba79af 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -16,6 +16,6 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Run pyright run: uv run --extra dev-nn --python ${{ matrix.python-version }} pyright . diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7b5f088c..f35b73bd 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Install zsh for completion tests run: | sudo apt-get update @@ -33,6 +33,6 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Test with pytest run: uv run --extra dev --python ${{ matrix.python-version }} pytest -v diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 431d7156..52cc7dec 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Ruff check run: uvx ruff check --output-format github - name: Ruff format diff --git a/.github/workflows/ty.yml b/.github/workflows/ty.yml index 164dd99c..698dae05 100644 --- a/.github/workflows/ty.yml +++ b/.github/workflows/ty.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Run ty # We type-check the test suite (not src/) because tyro's public # surface is exercised most realistically through tests, and ty