|
1 | 1 | import logging |
2 | 2 | import os |
3 | | -import re |
4 | | -from argparse import ArgumentParser |
5 | 3 | from dataclasses import fields, is_dataclass |
6 | 4 | from functools import lru_cache |
7 | 5 | 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 | +) |
9 | 19 |
|
10 | 20 | from annotated_types import Ge, Gt, Le, Len, Lt, MultipleOf |
11 | 21 |
|
12 | 22 | logger = logging.getLogger(__name__) |
13 | 23 |
|
14 | 24 | 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 |
16 | 30 | except ImportError: |
17 | | - get_parser = None |
| 31 | + tyro = None |
| 32 | + _tyro_docstrings_available = False |
| 33 | + _tyro_get_callable_description = None |
18 | 34 |
|
19 | 35 | try: |
20 | 36 | from humanize import naturalsize as naturalsize_ |
@@ -70,40 +86,70 @@ def get_terminal_size(): |
70 | 86 | return 0, 0 |
71 | 87 |
|
72 | 88 |
|
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 "" |
83 | 93 |
|
84 | 94 | @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 |
88 | 141 |
|
89 | 142 |
|
90 | 143 | 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 "" |
107 | 153 |
|
108 | 154 |
|
109 | 155 | def yield_annotations(dataclass): |
|
0 commit comments