-
-
Notifications
You must be signed in to change notification settings - Fork 699
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Specifying help for arguments & options in function docstrings #336
Comments
This is quite big feature that you propose. Do you want to generate documentation based on docstrings? You can check typer-cli. You could consider other alternative: machine_learning_model = typer.Argument(..., help="Specifiy predictive model that you'd like to use")
save_in_database=typer.Option(True, help="State your desire to save prediction result in our database for future reference") ...
@app.command()
def predict(model: str = machine_learning_model, to_save: bool=save_in_database):
... This solution would also allow you to reuse some more elaborate setup of CLI. On the other side If really you need this for your case, that should not be difficult to overwrite in your project. There are two functions to overwrite: These are used at
Another approach for that would be to modify import typer
import typing
import functools
app = typer.Typer()
def update_help(the_default):
the_default.help = "arbitraty value"
return the_default
def my_command(app):
@functools.wraps(app.command)
def wrapper_0(*args, **kwargs):
def wrapper_1(f):
f.__defaults__ = tuple([update_help(arg) for arg in f.__defaults__])
return app.command(*args, **kwargs)(f)
return wrapper_1
return wrapper_0
help_overwriting_command = my_command(app)
@help_overwriting_command()
def main(
a: str = typer.Argument(..., help="oaneutsoua"),
b: bool = typer.Option(False, help="I'd like this to be something else"),
):
"""
HOHHOHO
:param a: str My first arg
:param b: bool My first option
:return:
"""
pass
if __name__ == "__main__":
app() with |
@captaincapitalism Thanks for your suggestion and the implementation info, which is useful indeed. I should have mentioned in my original report that I considered the same alternative solution that you did, although I still find it a little unwieldy, and it still suffers from mixing up help text with configuration for arguments and options. But it's still a reasonable solution in certain circumstances. I'll probably have a look at your suggested way or overriding the default behaviour re help strings, and see what I can come up with. Appreciate it. |
This would be super useful! It's best practice to write docstrings, and also best practice to not repeat yourself. Much of this project aims to remove the repetition that Click brings. I see this feature as another extension of that philosophy |
@pbarker Glad you agree. Given this project doesn't see a lot of activity these days, I'm thinking of forking it and adding this feature along with a few others, so stay tuned... |
@alexreg yeah thats been my concern with trying to put PRs in, it looks like most of them sit. If you fork please ping me! |
Resolves issue fastapi#336.
@pbarker Hey. So, I've gone ahead and forked to alexreg/typer, where I've fixed a few bugs, and added this feature. (See |
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
Resolves issue fastapi#336.
This is an alternative solution based on a simple decorator, leveraging docstring_parser. Save this into from docstring_parser import parse
def typer_easy_cli(func):
"""
A decorator that takes a fully-annotated function and transforms it into a
Typer command.
At the moment, the function needs to have only keywords at the moment, so this is ok:
def fun(*, par1: int, par2: float):
...
but this is NOT ok:
def fun(par1: int, par2: float):
...
"""
# Parse docstring
docstring = parse(func.__doc__)
# Collect information about the parameters of the function
parameters = {}
# Parse the annotations first, so we have every parameter in the
# dictionary
for par, par_type in func.__annotations__.items():
parameters[par] = {'default': ...}
# Now loop over the parameters defined in the docstring to extract the
# help message (if present)
for par in docstring.params:
parameters[par.arg_name]["help"] = par.description
# Finally loop over the defaults to populate that
for par, default in func.__kwdefaults__.items():
parameters[par]["default"] = default
# Transform the parameters into typer.Option instances
typer_args = {par: typer.Option(**kw) for par, kw in parameters.items()}
# Assign them to the function
func.__kwdefaults__ = typer_args
# Only keep the main description as docstring so the CLI won't print
# the whole docstring, including the parameters
func.__doc__ = "\n\n".join([docstring.short_description, docstring.long_description])
return func Then you can use it like this: from docstring_parser import parse
from typer_easy_cli import typer_easy_cli
import typer
app = typer.Typer(add_completion=False)
@typer_easy_cli
@app.command()
def my_function(
*,
one: int,
two: float = 3,
):
"""
This is the description.
This is the long description.
:param one: the first parameter
:param two: the second parameter
"""
# Do something
return one * two
if __name__ == "__main__":
app() This will give: > python cli.py --help
Usage: cli.py [OPTIONS]
This is the description.
This is the long description.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ * --one INTEGER the first parameter [default: None] [required] │
│ --two FLOAT the second parameter [default: 3] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯ With a little more effort it could be made to accept arguments (instead of only options). @tiangolo if this is something useful I could make a PR and add it to the repo as a utility. |
@giacomov That's a nice library. I may adapt my fork to use it instead of my 'homemade' solution. |
Resolves issue fastapi#336.
This feature would be very useful. Can we get this merged in |
Since I started following this issue, I found cyclopts which is inspired by Typer, but does parse docstrings. |
@sherbang Good to know about, thanks. My own fork of typer also supports this, in case people are curious to try. |
Inspired by @giacomov's solution, I tried to optimize the capabilities and the way of using the decorator. I especially disliked the usage of the keywords operator in my functions. Also it keeps existing annotated arguments and options and do not overwrite the help texts specified in them. Is there a reason why this feature has yet not been added to Here is my solution: from functools import wraps # for python 3.8
from typing_extensions import get_args, get_origin, Annotated
import typer
import docstring_parser
import inspect
# for python 3.9 +
from typing import Callable, Any, Annotated
def from_docstring(command: Callable) -> Callable:
"""
A decorator that applies help texts from the function's docstring to Typer arguments/options.
It will only apply the help text if no help text has been explicitly set in the Typer argument/option.
Args:
command (Callable): The function to decorate.
Returns:
Callable: The decorated function with help texts applied, without overwriting existing settings.
"""
if command.__doc__ is None:
return command
# Parse the docstring and extract parameter descriptions
docstring = docstring_parser.parse(command.__doc__)
param_help = {param.arg_name: param.description for param in docstring.params}
# The commands's full help text (summary + long description)
command_help = f"{docstring.short_description or ''}\n\n{docstring.long_description or ''}"
# Get the signature of the original function
sig = inspect.signature(command)
parameters = sig.parameters
@wraps(command)
def wrapper(**kwargs: Any) -> Any:
return command(**kwargs)
# Prepare a new mapping for parameters
new_parameters = []
for name, param in parameters.items():
help_text = param_help.get(name, "") # Get help text from docstring if available
param_type = (
param.annotation if param.annotation is not inspect.Parameter.empty else str
) # Default to str if no annotation
# Handle Annotated (e.g., Annotated[int, typer.Argument()] or Annotated[str, typer.Option()])
# Check if the parameter uses Annotated
if get_origin(param_type) is Annotated:
param_type, *metadata = get_args(param_type)
# Iterate through the metadata to find Typer's Argument or Option
new_metadata = []
for m in metadata:
if isinstance(m, (typer.models.ArgumentInfo, typer.models.OptionInfo)):
# Only add help text if it's not already set
if not m.help:
m.help = help_text
new_metadata.append(m)
# Rebuild the annotated type with updated metadata
new_param = param.replace(annotation=Annotated[param_type, *new_metadata])
# for python 3.8, this is not perfect ...
# new_param = param.replace(annotation=Annotated[param_type, new_metadata[0]])
# If it's an Option or Argument directly (e.g., a: int = typer.Option(...))
elif isinstance(param.default, (typer.models.ArgumentInfo, typer.models.OptionInfo)):
if not param.default.help:
param.default.help = help_text
new_param = param
else:
# If the parameter has no default, treat it as an Argument
if param.default is inspect.Parameter.empty:
new_param = param.replace(default=typer.Argument(..., help=help_text), annotation=param_type)
else:
# If the parameter has a default, treat it as an Option
new_param = param.replace(
default=typer.Option(param.default, help=help_text), annotation=param_type
)
new_parameters.append(new_param)
# Create a new signature with updated parameters
new_sig = sig.replace(parameters=new_parameters)
# Apply the new signature to the wrapper function
wrapper.__signature__ = new_sig
wrapper.__doc__ = command_help.strip()
return wrapper And it can be applied like this: from typing import Annotated
app = typer.Typer()
@app.command()
@from_docstring
def goodbye(name, formal: bool = False):
"""Say goodbye to someone.
Also long docstring.
Args:
name: The name of the person you want to say goodbye to.
formal (bool): If set to True, a formal goodbye will be used.
"""
if formal:
print(f"Goodbye Ms. {name}. Have a good day.")
else:
print(f"Bye {name}!")
@app.command()
@from_docstring
def foo(
a: Annotated[int, typer.Argument()] = 5,
b: Annotated[str, typer.Option()] = "default",
c: int = 42,
d="no_type_annotation",
e: bool = False,
f=typer.Option(None, "--flag", help="Existing help"),
):
"""
Function with mixed parameter types.
Args:
a: An integer argument with no explicit help text.
b: A string option with no explicit help text.
c: An integer option with a default value.
d: A parameter without a type annotation.
e: A boolean option.
f: A flag with an existing help text.
"""
print(f"a={a}, b={b}, c={c}, d={d}, e={e}, f={f}")
|
Hello I just came across this and love the idea of using docstring! Any plans on merging the PR or implementing this into typer? |
As far as I understood it the maintainer wasn't interested, please correct me if I am wrong. I would be happy to implement this solution and submit a PR, if it is wanted. |
If you are not tied to typer per se, this is implemented in my aforementioned fork. 😊 |
It's for a course I will be teaching, I think it's best to keep it as vanilla as possible. But good to see a good community involvement :) |
First Check
Commit to Help
Example Code
This is the current way to add help text for arguments (and similarly for options).
Description
It would be convenient to be able to specify help text for arguments and options via the function's docstring in addition to the current method. At the moment, of course, help can only be specified for the command itself via the function's docstring.
Wanted Solution
To be able to specify help text for command arguments and options via the function docstring, as below.
Wanted Code
This should behave in a totally equivalent way to the above example of code that already works.
Note, the other standard syntax for parameter descriptions in functions is
@param name: The name of the user to greet
, and this should also be supported, I would think.Alternatives
Just use the current method of specifying help text in the function signature, via
arg = typer.Argument(..., help="<text>")
oropt = typer.Option(..., help="<text>")
. I argue that a) this can be seen as "polluting" the function signature and making it harder to read quickly/easily, b) it is most consistent to be able to specify help text for a command and its arguments 100% through docstrings (if so desired).Operating System
macOS
Operating System Details
No response
Typer Version
0.4.0
Python Version
3.9.6
Additional Context
No response
The text was updated successfully, but these errors were encountered: