Skip to content

Commit f7d7502

Browse files
committed
deps (tyro): 2× faster CLI parser
1 parent 888335d commit f7d7502

8 files changed

Lines changed: 261 additions & 484 deletions

File tree

docs/Changelog.md

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

3+
## unreleased
4+
* deps (tyro): new (2× faster) backend for CLI parser
5+
36
## 1.3.1 (2026-06-13)
47
* fix: catch all tkinter import/startup failures as InterfaceNotAvailable
58
* fix (security): replace eval() with ast.literal_eval in file picker
69

7-
810
## 1.3.0 (2026-06-11)
911
* feat: tkinter and textual in a separated processes
1012
* feat: robust exception propagation from child processes

mininterface/_lib/cli_flags.py

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
from argparse import ArgumentParser
21
import logging
32
import sys
43
from typing import Optional, Sequence
54

6-
from tyro.conf import FlagConversionOff
5+
from tyro.conf import FlagConversionOff, FlagCreatePairsOff, UseCounterAction
76

87
from .form_dict import EnvClass
98

@@ -70,7 +69,6 @@ def __init__(
7069
self.field_list: list[FieldDefinition] = []
7170
""" List of FieldDefinitions corresponding to the arguments added via this helper"""
7271

73-
self.arguments_prepared: list[dict[str, Any]] = []
7472
self.setup_done = False
7573
""" Setup might be called multiple times – ex. parsing fails and we call tyro.cli in recursion. """
7674

@@ -143,25 +141,34 @@ def add_typed_argument(
143141
default: Any = False,
144142
helptext: Optional[str] = None,
145143
metavar: Optional[str] = None,
146-
version: Optional[str] = None,
147144
) -> FieldDefinition:
148145
# Prepare FieldDefinition
149146
name = aliases[0]
150147
aliases_ = tuple((prefix * (1 if len(n) == 1 else 2) + n) for n in aliases) if aliases else None
151148
typ_ = bool if action in ("store_true", "store_false") else int if action == "count" else str
152149

150+
# Markers drive the lowering when the FieldDefinition is parsed by the native
151+
# tyro backend (with the argparse backend, parsing is done by parser.add_argument
152+
# and the FieldDefinition is used for the helptext only).
153+
if action == "count":
154+
markers = {UseCounterAction}
155+
elif action in ("store_true", "store_false"):
156+
markers = {FlagCreatePairsOff}
157+
else:
158+
markers = {FlagConversionOff}
159+
153160
field = FieldDefinition(
154161
intern_name=name,
155162
extern_name=name,
156163
type=typ_,
157164
type_stripped=typ_,
158165
default=default,
159166
helptext=helptext,
160-
markers={FlagConversionOff},
167+
markers=markers,
161168
custom_constructor=False,
162169
argconf=_ArgConfig(
163170
name=aliases_[0],
164-
metavar="",
171+
metavar=metavar or "",
165172
help=helptext,
166173
help_behavior_hint="",
167174
aliases=aliases_[1:] or None,
@@ -175,29 +182,16 @@ def add_typed_argument(
175182

176183
self.field_list.append(field)
177184

178-
# prepare argparse
179-
self.arguments_prepared.append(
180-
{
181-
"field": field,
182-
"names": aliases_,
183-
"kwargs": {
184-
"action": action,
185-
"default": default,
186-
"help": helptext,
187-
"metavar": metavar,
188-
"version": version,
189-
},
190-
}
191-
)
192-
193185
return field
194186

195-
def setup(self, parser: ArgumentParser):
187+
def setup(self):
188+
"""Build the field_list; the fields are then injected into the ParserSpecification
189+
in tyro_patches.tyro_parse_args."""
196190
if self.setup_done:
197191
# tyro.cli might be called multiple times if some missing required fields
198192
return
199193
self.setup_done = True
200-
prefix = "-" if "-" in parser.prefix_chars else parser.prefix_chars[0]
194+
prefix = "-"
201195
if self.add_verbose:
202196
self.add_typed_argument(
203197
prefix,
@@ -209,11 +203,11 @@ def setup(self, parser: ArgumentParser):
209203
)
210204

211205
if self.add_version:
206+
# The flag itself is handled by a pre-scan in tyro_patches.tyro_parse_args,
207+
# the field serves the helptext.
212208
self.add_typed_argument(
213209
prefix,
214210
"version",
215-
action="version",
216-
version=self.version,
217211
default="",
218212
helptext=f"show program's version number ({self.version}) and exit",
219213
)
@@ -228,7 +222,39 @@ def setup(self, parser: ArgumentParser):
228222
prefix, "config", helptext=f"path to config file to fetch the defaults from", metavar="PATH"
229223
)
230224

231-
def apply_to_parser(self, parser):
232-
for item in self.arguments_prepared:
233-
kwargs = {k: v for k, v in item["kwargs"].items() if v is not None}
234-
parser.add_argument(*item["names"], **kwargs)
225+
def consume_output(self, out: dict):
226+
"""Pop our injected flags from the parsed output dict
227+
(so that they do not reach the env dataclass construction) and apply them."""
228+
if self.add_verbose and "verbose" in out:
229+
verbose = out.pop("verbose") or 0
230+
self.apply_verbosity(verbose)
231+
if self.add_quiet and "quiet" in out:
232+
if out.pop("quiet"):
233+
self.apply_verbosity(-1, quiet=True)
234+
if self.add_version:
235+
out.pop("version", None)
236+
if self.add_config:
237+
out.pop("config", None)
238+
239+
def apply_verbosity(self, count: int, quiet=False):
240+
"""Set up the root logger according to the number of -v flags (or -q for count=-1)."""
241+
root = logging.getLogger()
242+
if quiet:
243+
new_level = self.get_log_level(-1)
244+
if not root.handlers:
245+
logging.basicConfig(level=new_level, format="%(message)s", stream=self.orig_stream)
246+
else:
247+
root.setLevel(new_level)
248+
for handler in root.handlers:
249+
if handler.level < new_level: # edit just benevolent handlers
250+
handler.setLevel(new_level)
251+
return
252+
if not root.handlers:
253+
level = self.get_log_level(count) if count > 0 else self.default_verbosity
254+
logging.basicConfig(level=level, format="%(message)s", stream=self.orig_stream)
255+
elif count > 0:
256+
level = self.get_log_level(count)
257+
root.setLevel(level)
258+
for handler in root.handlers:
259+
if handler.level > level: # increase verbosity for strict handlers
260+
handler.setLevel(level)

mininterface/_lib/cli_parser.py

Lines changed: 41 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -34,31 +34,24 @@
3434
try:
3535
from .cli_flags import CliFlags
3636
from tyro import cli
37-
38-
try: # tyro >= 0.10
39-
from tyro import _experimental_options
40-
41-
_experimental_options["backend"] = "argparse"
42-
from tyro._backends._argparse import _SubParsersAction, ArgumentParser
43-
from tyro._backends._argparse_formatter import TyroArgumentParser
44-
except ImportError:
45-
from tyro._argparse import _SubParsersAction, ArgumentParser
46-
from tyro._argparse_formatter import TyroArgumentParser
47-
from tyro._parsers import ParserSpecification
37+
from tyro import _experimental_options
38+
from tyro._backends import _tyro_help_formatting
39+
from tyro._backends._tyro_backend import TyroBackend
40+
from tyro._parsers import ParserSpecification, SubparsersSpecification, ArgWithContext
4841

4942
from tyro.conf import OmitArgPrefixes, OmitSubcommandPrefixes, DisallowNone, FlagCreatePairsOff
5043

5144
from .tyro_patches import (
5245
_crawling,
53-
custom_error,
54-
custom_init,
55-
custom_parse_known_args,
5646
failed_fields,
57-
patched__parse_known_args,
58-
patched__format_help,
59-
subparser_call,
60-
argparse_init,
47+
tyro_required_args_error,
48+
tyro_error_and_exit,
49+
tyro_parse_args,
6150
)
51+
52+
# The native backend (tyro >= 1.0) is ~2-3× faster than the argparse one
53+
# (HeavyNesting: 15 ms → 5 ms) and is the only one mininterface patches support.
54+
_experimental_options["backend"] = "tyro"
6255
except ImportError:
6356
from ..exceptions import DependencyRequired
6457

@@ -250,7 +243,7 @@ def annot(type_form):
250243
if sys.version_info < (3, 11):
251244
raise
252245
# Form did not work, cancelled or run through minadaptor.
253-
# We use the original tyro exception message, caught in tyro_patches.custom_error
246+
# We use the original tyro exception message, noted in tyro_patches.tyro_required_args_error
254247
# instead of a validation error the minadaptor might produce.
255248
# NOTE We might add minadaptor validation error. But it seems too similar to the better tyro's one.
256249
# if str(e):
@@ -297,7 +290,7 @@ def _try_with_subcommands(kwargs, m, args, type_form, env_classes, _custom_regis
297290
old_defs = kwargs.get("default", {})
298291
if old_defs:
299292
old_defs = asdict(old_defs)
300-
passage = [cl_name for _, cl_name, _ in _crawling.get()]
293+
passage = [cl_name for cl_name, _ in _crawling.get()]
301294

302295
if len(env_classes) > 1:
303296
if len(passage):
@@ -322,42 +315,21 @@ def _try_with_subcommands(kwargs, m, args, type_form, env_classes, _custom_regis
322315

323316

324317
def _apply_patches(cf: Optional[CliFlags], ask_for_missing, env_classes, kwargs):
325-
patches = []
326-
327-
patches.append(patch.object(_SubParsersAction, "__call__", subparser_call))
328-
patches.append(patch.object(TyroArgumentParser, "_parse_known_args", patched__parse_known_args))
329-
kw = {
330-
k: v for k, v in kwargs.items() if k != "default"
331-
} # NOTE I might separate kwargs['default'] and do not do this filtering
332-
if kw:
333-
patches.append(patch.object(ArgumentParser, "__init__", argparse_init(kw)))
334-
335-
if ask_for_missing: # Get the missing flags from the parser
336-
patches.append(patch.object(TyroArgumentParser, "error", custom_error))
337-
if cf and cf.should_add(env_classes):
338-
# Mock parser to add some flags
339-
# Flags are added only if neither the env_class nor any of the subcommands have the same-name flag already
340-
patches.extend(
341-
(
342-
patch.object(
343-
TyroArgumentParser,
344-
"__init__",
345-
custom_init(cf),
346-
),
347-
patch.object(
348-
TyroArgumentParser,
349-
"format_help",
350-
patched__format_help(cf),
351-
),
352-
patch.object(
353-
TyroArgumentParser,
354-
"parse_known_args",
355-
custom_parse_known_args(cf),
356-
),
357-
)
358-
)
359-
360-
return patches
318+
"""Patches for the native tyro backend. See tyro_patches for details.
319+
CliFlags are added only if neither the env_class nor any of the subcommands
320+
have the same-name flag already."""
321+
return [
322+
patch.object(_tyro_help_formatting, "required_args_error", tyro_required_args_error(ask_for_missing)),
323+
patch.object(_tyro_help_formatting, "error_and_exit", tyro_error_and_exit(ask_for_missing)),
324+
patch.object(
325+
TyroBackend,
326+
"parse_args",
327+
tyro_parse_args(
328+
cf if cf and cf.should_add(env_classes) else None,
329+
allow_abbrev=kwargs.get("allow_abbrev", False),
330+
),
331+
),
332+
]
361333

362334

363335
def _dialog_missing(
@@ -375,7 +347,7 @@ def _dialog_missing(
375347
376348
* kwargs["default"]. Struct (dataclass). The fields that must be filled are marked as MISSING_NONPROP.
377349
If marked directly with `tag._make_default_value()`, tyro would resolve CLI instantly with no further problem but we would never known which were missing CLI flags were missing.
378-
* failed_fields – Argparse Actions. Parser needs them filled. (It might not tell us about all of them. There is a use-case when superparser is resolved after subparser. And if whole subparser command is missing, its fields are not there either.)
350+
* failed_fields – ArgWithContext / SubparsersSpecification. Parser needs them filled. (It might not tell us about all of them. There is a use-case when superparser is resolved after subparser. And if whole subparser command is missing, its fields are not there either.)
379351
* req_fields – Tags. The same form as kwargs["default"]. Recursively all fields, needed to build up a dataclass for mininterface. The fields that must be filled are marked as MissingTagValue().
380352
* missing_req – Tags. Those req_fields which are missing from CLI. Merge of failed_fields and req_fields. (Subset of req_fields.)
381353
Their values are `tag._make_default_value()`.
@@ -408,7 +380,7 @@ def _dialog_missing(
408380
req_fields,
409381
m,
410382
subc=kwargs.get("subcommands_default"),
411-
subc_passage=[cl_name for _, cl_name, _ in _crawling.get()],
383+
subc_passage=[cl_name for cl_name, _ in _crawling.get()],
412384
)
413385

414386
missing_req = _fetch_currently_failed(req_fields)
@@ -475,16 +447,24 @@ def _fetch_currently_failed(requireds) -> TagDict:
475447
who pose problem for tyro (through implanted failed_fields)."""
476448
missing_req = {}
477449
for field in failed_fields.get():
478-
# ex: `_subcommands._nested_subcommands (positional)`
450+
# Determine the dest-like dotted name, ex: `_subcommands._nested_subcommands (positional)`
451+
if isinstance(field, SubparsersSpecification):
452+
# a whole subcommand is missing
453+
dest = field.intern_prefix
454+
else:
455+
# ArgWithContext, a required argument is missing
456+
# (get_output_key gives lowered.dest, or the positional name)
457+
dest = field.arg.get_output_key()
458+
479459
fname = (
480-
field.dest.replace(" (positional)", "")
460+
dest.replace(" (positional)", "")
481461
.replace("-", "_")
482462
.replace("__tyro_dummy_inner__.", "")
483463
.replace("__tyro_dummy_inner__", "")
484464
) # `_subcommands._nested_subcommands`
485465
fname_raw = fname.rsplit(".", 1)[-1] # `_nested_subcommands`
486466

487-
if isinstance(field, _SubParsersAction):
467+
if isinstance(field, SubparsersSpecification):
488468
# The function create_with_missing don't makes every encountered field a wrong field
489469
# (with the exception of the config fields, defined in the kwargs["default"] earlier).
490470
# The CLI options are unknown to it.

0 commit comments

Comments
 (0)