Repository: brentyi/tyro

Issue:
[Clarification] Precedence of helptext generation.
Comments on lines above attributes take precedence over the docstrings on the classes for those attributes. Example:

```python
@dataclass
class Cli:
    application: ApplicationInformation
    configfile: Optional[ str ] = None
    # display: ConsoleDisplay
    inscription: InscriptionControl = (
        InscriptionControl( mode = InscriptionModes.Rich ) )
    command: Union[
        Annotated[
            _create.Command,
            tyro.conf.subcommand( # pyright: ignore
                'create', prefix_name = False ),
        ],
        Annotated[
            _apply.Command,
            tyro.conf.subcommand( # pyright: ignore
                'apply', prefix_name = False ),
        ],
    ]
```

results in:

```text
╭─ options ────────────────────────────────────────────────────────────────────╮
│ -h, --help              show this help message and exit                      │
│ --configfile {None}|STR                                                      │
│                         (default: None)                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ application options ────────────────────────────────────────────────────────╮
│ Information about an application.                                            │
│ ──────────────────────────────────────────────────────────────────────────── │
│ --application.name STR  (default: foo)                                       │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ inscription options ────────────────────────────────────────────────────────╮
│ display: ConsoleDisplay                                                      │
│ ──────────────────────────────────────────────────────────────────────────── │
│ --inscription.mode {null,pass,rich}                                          │
│                         (default: rich)                                      │
│ --inscription.level {None,debug,info,warn,error,critical}                    │
│                         (default: None)                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ subcommands ────────────────────────────────────────────────────────────────╮
│ display: ConsoleDisplay                                                      │
│ ──────────────────────────────────────────────────────────────────────────── │
│ {create,apply} 
```

However, similar to `ApplicationInformation`, `InscriptionControl` has a docstring on the class:
```python
@dataclass
class ApplicationInformation:
    ''' Information about an application. '''
```
```python
@dataclass
class InscriptionControl:
    ''' Logging and debug printing behavior. '''
```
But, only the docstring for `ApplicationInformation` shows correctly. The docstring for `InscriptionControl` is occluded by the commented field.

Is this desired behavior? If so, should it be clarified in https://brentyi.github.io/tyro/helptext_generation/?

Also, would you consider it useful to have another `tyro.conf` annotation to suppress this behavior? As someone who frequently scaffolds code and leaves in TODO comments, I've been annoyed by scaffolding comments showing up as helptext on more than one occasion.

Discussion:
@brentyi: Hi @emcd! This is as-designed, but I see why it's annoying. The reason is that the comment is treated as a "docstring" for the field, and we treat field docstrings as "more specific" than class docstrings.

For moving forward: adding a flag to turn off comment scraping is non-invasive and I'm happy to do it. We could also revisit the specific default behaviors, although my guess is that all possible choices here just leads to different tradeoffs and there's no objective right thing to do.@emcd: @brentyi : You have an amazing turnaround time. Thanks for the PR. I was going to offer to make it if you were amenable to the new flag, but am not going to complain if the project maintainer spontaneously produces patches instead. :)

To be clear, I am not interested in changing the defaults. I agree that there is no objective right thing to do here. Was mainly seeking clarification on the order of precedence... attribute-specific over class docstring makes sense. I don't recall that being explicitly mentioned in the documentation on helptext generation though.

Solution patch:
diff --git a/docs/source/helptext_generation.md b/docs/source/helptext_generation.md
index 5efffdef..31f2fd75 100644
--- a/docs/source/helptext_generation.md
+++ b/docs/source/helptext_generation.md
@@ -55,6 +55,8 @@ class Args:
     field2: int = 3  # A numeric field, with a default value.
 ```
 
+This can be turned off using :data:`tyro.conf.HelptextFromCommentsOff`.
+
 **(3) Preceding comments**
 
 These can also be handy for semantically grouping fields, such as the two string
@@ -71,3 +73,5 @@ class Args:
     # Multi-line comments are supported.
     field3: int
 ```
+
+This can be turned off using :data:`tyro.conf.HelptextFromCommentsOff`.
diff --git a/src/tyro/_docstrings.py b/src/tyro/_docstrings.py
index a5918bd6..26e654f8 100644
--- a/src/tyro/_docstrings.py
+++ b/src/tyro/_docstrings.py
@@ -171,7 +171,9 @@ def parse_docstring_from_object(obj: object) -> Dict[str, str]:
 
 
 @_unsafe_cache.unsafe_cache(1024)
-def get_field_docstring(cls: Type, field_name: str) -> Optional[str]:
+def get_field_docstring(
+    cls: Type, field_name: str, helptext_from_comments: bool
+) -> Optional[str]:
     """Get docstring for a field in a class."""
 
     # NoneType will break docstring_parser.
@@ -189,6 +191,8 @@ def get_field_docstring(cls: Type, field_name: str) -> Optional[str]:
             ).strip()
 
     # If docstring_parser failed, let's try looking for comments.
+    if not helptext_from_comments:
+        return None
     tokenization = get_class_tokenization_with_field(cls, field_name)
     if tokenization is None:  # Currently only happens for dynamic dataclasses.
         return None
diff --git a/src/tyro/_fields.py b/src/tyro/_fields.py
index 0e505924..27b49f7c 100644
--- a/src/tyro/_fields.py
+++ b/src/tyro/_fields.py
@@ -237,13 +237,14 @@ def field_list_from_type_or_callable(
                 return _field_list_from_function(
                     type_info.type,  # This will have typing.Annotated metadata stripped.
                     default_instance,
+                    markers=type_info.markers,
                 )
 
     return UnsupportedStructTypeMessage(f"{f} is not a valid struct type!")
 
 
 def _field_list_from_function(
-    f: Callable, default_instance: Any
+    f: Callable, default_instance: Any, markers: tuple[_markers.Marker, ...]
 ) -> UnsupportedStructTypeMessage | tuple[Callable, list[FieldDefinition]]:
     """Generate field lists from non-class callables."""
 
@@ -369,7 +370,11 @@ def _field_list_from_function(
 
         # TODO: re-add.
         if helptext is None and inspect.isclass(f_before_init_unwrap):
-            helptext = _docstrings.get_field_docstring(f_before_init_unwrap, param.name)
+            helptext = _docstrings.get_field_docstring(
+                f_before_init_unwrap,
+                param.name,
+                helptext_from_comments=_markers.HelptextFromCommentsOff not in markers,
+            )
 
         if param.name not in hints:
             out = UnsupportedStructTypeMessage(
@@ -384,15 +389,15 @@ def _field_list_from_function(
             return out
 
         # Set markers for positional + variadic arguments.
-        markers: Tuple[Any, ...] = ()
+        func_markers: Tuple[Any, ...] = ()
         typ: Any = hints[param.name]
         if param.kind is inspect.Parameter.POSITIONAL_ONLY:
-            markers = (_markers.Positional, _markers._PositionalCall)
+            func_markers = (_markers.Positional, _markers._PositionalCall)
         elif param.kind is inspect.Parameter.VAR_POSITIONAL:
             # Handle *args signatures.
             #
             # This will create a `--args T [T ...]` CLI argument.
-            markers = (_markers._UnpackArgsCall,)
+            func_markers = (_markers._UnpackArgsCall,)
             typ = Tuple[(typ, ...)]  # type: ignore
             default = ()
         elif param.kind is inspect.Parameter.VAR_KEYWORD:
@@ -404,11 +409,11 @@ def _field_list_from_function(
             # positional, omitting the --args/--kwargs prefix, but we are
             # choosing not to because it would make *args and **kwargs
             # difficult to use in conjunction.
-            markers = (_markers._UnpackKwargsCall,)
+            func_markers = (_markers._UnpackKwargsCall,)
             typ = Dict[str, typ]  # type: ignore
             default = {}
 
-        with FieldDefinition.marker_context(markers):
+        with FieldDefinition.marker_context(func_markers):
             field_list.append(
                 FieldDefinition.make(
                     name=param.name,
diff --git a/src/tyro/conf/__init__.py b/src/tyro/conf/__init__.py
index 97d830dc..fdd775d6 100644
--- a/src/tyro/conf/__init__.py
+++ b/src/tyro/conf/__init__.py
@@ -18,6 +18,7 @@ from ._markers import ConsolidateSubcommandArgs as ConsolidateSubcommandArgs
 from ._markers import EnumChoicesFromValues as EnumChoicesFromValues
 from ._markers import Fixed as Fixed
 from ._markers import FlagConversionOff as FlagConversionOff
+from ._markers import HelptextFromCommentsOff as HelptextFromCommentsOff
 from ._markers import OmitArgPrefixes as OmitArgPrefixes
 from ._markers import OmitSubcommandPrefixes as OmitSubcommandPrefixes
 from ._markers import Positional as Positional
diff --git a/src/tyro/conf/_markers.py b/src/tyro/conf/_markers.py
index 0e8a6e98..6ee5655d 100644
--- a/src/tyro/conf/_markers.py
+++ b/src/tyro/conf/_markers.py
@@ -167,6 +167,24 @@ Enum aliases are not relevant when this marker is present. The first entry match
 chosen value will be selected.
 """
 
+HelptextFromCommentsOff = Annotated[T, None]
+"""For helptext generation, ignore comments in the source code.
+
+By default, tyro will treat comments that precede field definitions as
+docstrings:
+
+.. code-block:: python
+
+    # Comment.
+    field1: str
+    field2: str # Other comment.
+
+This will produce CLI arguments with the comments as helptext. If you have code
+with a lot of comments, however, this behavior can be annoying.
+``HelptextIgnoreComments`` will turn this behavior off. Triple-quoted
+docstrings are unaffected.
+"""
+
 
 CallableType = TypeVar("CallableType", bound=Callable)
 
diff --git a/src/tyro/constructors/_struct_spec.py b/src/tyro/constructors/_struct_spec.py
index 49cb51e7..79c21a44 100644
--- a/src/tyro/constructors/_struct_spec.py
+++ b/src/tyro/constructors/_struct_spec.py
@@ -183,7 +183,12 @@ def apply_default_struct_rules(registry: ConstructorRegistry) -> None:
             # Try to get helptext from docstrings. This can't be generated
             # dynamically.
             if helptext is None:
-                helptext = _docstrings.get_field_docstring(info.type, dc_field.name)
+                helptext = _docstrings.get_field_docstring(
+                    info.type,
+                    dc_field.name,
+                    helptext_from_comments=_markers.HelptextFromCommentsOff
+                    not in info.markers,
+                )
 
             assert not isinstance(dc_field.type, str)
             field_list.append(
@@ -255,7 +260,12 @@ def apply_default_struct_rules(registry: ConstructorRegistry) -> None:
                     name=name,
                     type=typ,
                     default=default,
-                    helptext=_docstrings.get_field_docstring(cls, name),
+                    helptext=_docstrings.get_field_docstring(
+                        cls,
+                        name,
+                        helptext_from_comments=_markers.HelptextFromCommentsOff
+                        not in info.markers,
+                    ),
                 )
             )
         return StructConstructorSpec(instantiate=info.type, fields=tuple(field_list))
@@ -309,7 +319,12 @@ def apply_default_struct_rules(registry: ConstructorRegistry) -> None:
                     name=name,
                     type=our_hints[name],
                     default=default,
-                    helptext=_docstrings.get_field_docstring(info.type, name),
+                    helptext=_docstrings.get_field_docstring(
+                        info.type,
+                        name,
+                        helptext_from_comments=_markers.HelptextFromCommentsOff
+                        not in info.markers,
+                    ),
                 )
             )
         return StructConstructorSpec(instantiate=info.type, fields=tuple(field_list))
@@ -376,7 +391,12 @@ def apply_default_struct_rules(registry: ConstructorRegistry) -> None:
                     name=name,
                     type=typ,
                     default=default,
-                    helptext=_docstrings.get_field_docstring(info.type, name),
+                    helptext=_docstrings.get_field_docstring(
+                        info.type,
+                        name,
+                        helptext_from_comments=_markers.HelptextFromCommentsOff
+                        not in info.markers,
+                    ),
                 )
             )
 
@@ -547,7 +567,10 @@ def apply_default_struct_rules(registry: ConstructorRegistry) -> None:
                 helptext = pd1_field.field_info.description
                 if helptext is None:
                     helptext = _docstrings.get_field_docstring(
-                        info.type, pd1_field.name
+                        info.type,
+                        pd1_field.name,
+                        helptext_from_comments=_markers.HelptextFromCommentsOff
+                        not in info.markers,
                     )
 
                 default = _get_pydantic_v1_field_default(
@@ -566,7 +589,12 @@ def apply_default_struct_rules(registry: ConstructorRegistry) -> None:
             for name, pd2_field in cast(Any, info.type).model_fields.items():
                 helptext = pd2_field.description
                 if helptext is None:
-                    helptext = _docstrings.get_field_docstring(info.type, name)
+                    helptext = _docstrings.get_field_docstring(
+                        info.type,
+                        name,
+                        helptext_from_comments=_markers.HelptextFromCommentsOff
+                        not in info.markers,
+                    )
 
                 default = _get_pydantic_v2_field_default(name, pd2_field, info.default)
                 field_list.append(


Test patch:
diff --git a/tests/test_conf.py b/tests/test_conf.py
index 11fbfa59..7648b568 100644
--- a/tests/test_conf.py
+++ b/tests/test_conf.py
@@ -1899,3 +1899,16 @@ def test_attribute_inheritance_2() -> None:
     assert "[{optimizer:adam-config,optimizer:sgd-config}]" in get_helptext_with_checks(
         CLITrainerConfig
     )
+
+
+@dataclasses.dataclass
+class Config:
+    # Comment in helptext.
+    y: int = 0
+
+
+def test_helptext_from_contents_off() -> None:
+    assert "Comment in helptext." in get_helptext_with_checks(Config)
+    assert "Comment in helptext." not in get_helptext_with_checks(
+        tyro.conf.HelptextFromCommentsOff[Config]
+    )
diff --git a/tests/test_py311_generated/test_conf_generated.py b/tests/test_py311_generated/test_conf_generated.py
index d81d1d3f..49adc71d 100644
--- a/tests/test_py311_generated/test_conf_generated.py
+++ b/tests/test_py311_generated/test_conf_generated.py
@@ -1906,3 +1906,16 @@ def test_attribute_inheritance_2() -> None:
     assert "[{optimizer:adam-config,optimizer:sgd-config}]" in get_helptext_with_checks(
         CLITrainerConfig
     )
+
+
+@dataclasses.dataclass
+class Config:
+    # Comment in helptext.
+    y: int = 0
+
+
+def test_helptext_from_contents_off() -> None:
+    assert "Comment in helptext." in get_helptext_with_checks(Config)
+    assert "Comment in helptext." not in get_helptext_with_checks(
+        tyro.conf.HelptextFromCommentsOff[Config]
+    )