Skip to content

Commit 18beb29

Browse files
committed
fix: replace deprecated tyro.extras.get_parser
1 parent a30e8ff commit 18beb29

6 files changed

Lines changed: 93 additions & 46 deletions

File tree

docs/Changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Changelog
22

3-
## unreleased
3+
## 1.2.2 (2026-05-15)
44
* enh: config file union support
55
* fix: invoking gui interface twice
66
* fix (logging): verbose vs existing handlers
7+
* fix: replace deprecated tyro.extras.get_parser
78

89
## 1.2.1 (2025-10-24)
910
* deps (tyro): ready for 0.10

mininterface/_lib/auxiliary.py

Lines changed: 80 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
11
import logging
22
import os
3-
import re
4-
from argparse import ArgumentParser
53
from dataclasses import fields, is_dataclass
64
from functools import lru_cache
75
from types import UnionType
8-
from typing import Any, Callable, Iterable, Optional, TypeVar, Union, Literal, get_args, get_origin, get_type_hints
6+
from typing import (
7+
Any,
8+
Annotated,
9+
Callable,
10+
Iterable,
11+
Optional,
12+
TypeVar,
13+
Union,
14+
Literal,
15+
get_args,
16+
get_origin,
17+
get_type_hints,
18+
)
919

1020
from annotated_types import Ge, Gt, Le, Len, Lt, MultipleOf
1121

1222
logger = logging.getLogger(__name__)
1323

1424
try:
15-
from tyro.extras import get_parser
25+
import tyro
26+
from tyro._docstrings import get_field_docstring as _tyro_get_field_docstring
27+
from tyro._docstrings import get_callable_description as _tyro_get_callable_description
28+
29+
_tyro_docstrings_available = True
1630
except ImportError:
17-
get_parser = None
31+
tyro = None
32+
_tyro_docstrings_available = False
33+
_tyro_get_callable_description = None
1834

1935
try:
2036
from humanize import naturalsize as naturalsize_
@@ -70,40 +86,70 @@ def get_terminal_size():
7086
return 0, 0
7187

7288

73-
def get_descriptions(parser: ArgumentParser) -> dict:
74-
"""Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form."""
75-
# clean-up tyro stuff that may have a meaning in the CLI, but not in the UI
76-
return {
77-
re.sub(r"\s\(positional\)$", "", action.dest).replace("-", "_"): re.sub(
78-
r"\((default|fixed to|required).*\)", "", action.help or ""
79-
)
80-
for action in parser._actions
81-
}
82-
89+
def get_class_description(obj) -> str:
90+
if _tyro_get_callable_description:
91+
return _tyro_get_callable_description(obj)
92+
return ""
8393

8494
@lru_cache
85-
def _get_parser(obj):
86-
if get_parser:
87-
return get_parser(obj)
95+
def _get_descriptions_from_docstring(obj) -> dict[str, str]:
96+
"""Extract field descriptions for all fields of a class.
97+
98+
Uses tyro's internal helptext extraction (tyro._docstrings.get_field_docstring),
99+
which supports the same sources and precedence as tyro's own CLI generation:
100+
1. tyro.conf.arg(help=...)
101+
2. PEP 727 Doc
102+
3. Docstrings (attribute docstrings or class docstring params)
103+
4. Comments (inline or preceding)
104+
105+
We used to rely on tyro.extras.get_parser(), but that was marked deprecated,
106+
so we call tyro's internal API directly instead.
107+
"""
108+
if not _tyro_docstrings_available:
109+
return {}
110+
111+
result = {}
112+
113+
# Highest priority: tyro.conf.arg(help=...) in Annotated metadata.
114+
try:
115+
hints = get_type_hints(obj, include_extras=True)
116+
ArgConfig = tyro.conf._confstruct._ArgConfig
117+
for field_name, hint in hints.items():
118+
if get_origin(hint) is Annotated:
119+
for meta in hint.__metadata__:
120+
if isinstance(meta, ArgConfig) and meta.help:
121+
result[field_name] = meta.help
122+
except Exception:
123+
hints = {}
124+
125+
# Mid priority: docstrings and comments via tyro's own extraction.
126+
for field_name in hints:
127+
doc = _tyro_get_field_docstring(obj, field_name, ())
128+
if doc:
129+
result.setdefault(field_name, doc)
130+
131+
# Lowest priority: field.metadata["help"] from dynamically generated
132+
# dataclasses (e.g. built from ArgumentParser via make_dataclass).
133+
try:
134+
for f in fields(obj): # type: ignore
135+
if help_text := f.metadata.get("help"):
136+
result.setdefault(f.name, help_text)
137+
except TypeError:
138+
pass
139+
140+
return result
88141

89142

90143
def get_description(obj, param: str) -> str:
91-
if p := _get_parser(obj):
92-
try:
93-
d = get_descriptions(p)[param].strip()
94-
except KeyError: # either fetching failed or user added no description
95-
return ""
96-
else:
97-
if d.replace("-", "_") == param:
98-
# field `bot_id` is reported as `bot-id` in tyro
99-
return ""
100-
return d
101-
else:
102-
# We are missing mininterface[basic] requirement. Tyro is missing.
103-
# Without tyro, we are not able to evaluate the class: m.form(Env),
104-
# we can still evaluate its instance: m.form(Env()).
105-
# However, without descriptions.
106-
return ""
144+
desc = _get_descriptions_from_docstring(obj).get(param, "")
145+
if desc and desc.replace("-", "_") != param:
146+
return desc
147+
148+
# We are missing mininterface[basic] requirement. Tyro is missing.
149+
# Without tyro, we are not able to evaluate the class: m.form(Env),
150+
# we can still evaluate its instance: m.form(Env()).
151+
# However, without descriptions.
152+
return ""
107153

108154

109155
def yield_annotations(dataclass):

mininterface/_lib/dataclass_creation.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import re
22
import warnings
3-
from dataclasses import MISSING, asdict, dataclass, fields, is_dataclass
3+
from dataclasses import MISSING, fields, is_dataclass
44
from types import UnionType
55
from typing import Annotated, Optional, Sequence, Type, Union, get_args, get_origin, TypeVar
66

7-
87
try:
98
from tyro._singleton import MISSING_NONPROP
10-
from tyro.extras import subcommand_type_from_defaults
119

1210
from ..cli import SubcommandPlaceholder
1311
except ImportError:
@@ -19,7 +17,7 @@
1917
from ..tag import Tag
2018
from ..tag.tag_factory import tag_factory
2119
from ..validators import not_empty
22-
from .auxiliary import _get_origin, _get_parser, get_description
20+
from .auxiliary import _get_origin, get_class_description, get_description
2321
from .form_dict import DataClass, EnvClass, MissingTagValue
2422

2523
# Pydantic is not a project dependency, that is just an optional integration
@@ -362,7 +360,7 @@ def choose_subcommand(env_classes: list[Type[DataClass]], m: "Mininterface[EnvCl
362360
# NOTE make select display buttons if there is a little amount of options.
363361
env = m.select(
364362
{
365-
(to_kebab_case(cl.__name__).replace("-", " ").capitalize(), _get_parser(cl).description): cl
363+
(to_kebab_case(cl.__name__).replace("-", " ").capitalize(), get_class_description(cl)): cl
366364
for cl in env_classes
367365
if cl is not SubcommandPlaceholder
368366
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "mininterface"
7-
version = "1.2.1"
7+
version = "1.2.2"
88
description = "CLI & dialog toolkit – a minimal interface to Python application (GUI, TUI, CLI + config files, web)"
99
authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
1010
license = "LGPL-3.0-or-later"

tests/test_run.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def test_run_ask_for_missing_underscored(self):
8484
self.assertEqual("", stdout.getvalue().strip())
8585

8686
def test_wrong_fields(self):
87+
# Here, files3 and files5 have fetched longer description that tyro could.
88+
# Tyro fetches just: `files4: Positional[list[Path]] = field`
8789
with self.assertForms(
8890
(
8991
{
@@ -102,13 +104,13 @@ def test_wrong_fields(self):
102104
),
103105
"files3": PathTag(
104106
val=[],
105-
description="files4: Positional[list[Path]] = field",
107+
description="files4: Positional[list[Path]] = field(default_factory=list) # raises error files7: Annotated[list[Path], None] files8: Annotated[list[Path], Tag(annotation=str)]",
106108
annotation=list[Path],
107109
label="files3",
108110
),
109111
"files5": PathTag(
110112
val=[],
111-
description="files4: Positional[list[Path]] = field",
113+
description="files4: Positional[list[Path]] = field(default_factory=list) # raises error files7: Annotated[list[Path], None] files8: Annotated[list[Path], Tag(annotation=str)]",
112114
annotation=list[Path],
113115
label="files5",
114116
),

tests/test_subcommands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -703,9 +703,9 @@ def test_optional_subcommands(self):
703703
with self.assertForms():
704704
runm(Cl0)
705705

706-
# The docstring is not seen by tyro
707-
# but at least it won't fail on the positional state of the attribute
708-
self.assertEqual(get_description(Cl0, "subcommand"), "")
706+
# This docstring is not seen by tyro. We've added the text to check it won't fail on the positional state of the attribute at least.
707+
# Now, when having left tyro docstring parsing, we see it.
708+
self.assertEqual(get_description(Cl0, "subcommand"), "Ignored text")
709709

710710
def test_nested_config_exists(self):
711711
"""The nested classes have the same behaviour

0 commit comments

Comments
 (0)