diff --git a/pyproject.toml b/pyproject.toml index b09c3c4377..e92bde79a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ test = [ "pytest-sugar ~= 0.9.4", "pytest-xdist ~= 1.32.0", "shellingham ~= 1.4.0", + "types-docutils ~= 0.18.3", ] doc = [ "mdx-include ~= 1.4.1", @@ -63,6 +64,7 @@ dev = [ ] all = [ "colorama ~= 0.4.4", + "docutils ~= 0.18.1", "shellingham ~= 1.4.0", ] diff --git a/tests/test_help_docstring.py b/tests/test_help_docstring.py new file mode 100644 index 0000000000..fc3891b998 --- /dev/null +++ b/tests/test_help_docstring.py @@ -0,0 +1,68 @@ +import typer +from typer.testing import CliRunner + +runner = CliRunner() + +app = typer.Typer() + + +@app.command() +def cmd( + arg1: float, + arg2: str = typer.Argument("bar"), + opt1: str = typer.Option("foo"), + opt2: bool = typer.Option(False), + opt3: int = typer.Option(1), +): + """ + Some command + + :param opt1: First option + :param opt2: Second option + :param opt3: + Third + option + :param arg1: First argument + :param arg2: Second argument + """ + + +@app.callback() +def main(global_opt: bool = False): + """ + Callback + + :param global_opt: Global option + """ + + +def test_help_main(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Callback" in result.output + assert ":param" not in result.output + assert "--global-opt / --no-global-opt" in result.output + assert "Global option" in result.output + assert "[default: no-global-opt]" in result.output + + +def test_help_cmd(): + result = runner.invoke(app, ["cmd", "--help"]) + assert result.exit_code == 0 + assert "Some command" in result.output + assert ":param" not in result.output + assert "ARG1" in result.output + assert "First argument" in result.output + assert "[required]" in result.output + assert "[ARG2]" in result.output + assert "Second argument" in result.output + assert "[default: bar]" in result.output + assert "--opt1 TEXT" in result.output + assert "First option" in result.output + assert "[default: foo]" in result.output + assert "--opt2 / --no-opt2" in result.output + assert "Second option" in result.output + assert "[default: no-opt2]" in result.output + assert "--opt3 INTEGER" in result.output + assert "Third option" in result.output + assert "[default: 1]" in result.output diff --git a/typer/main.py b/typer/main.py index bc63301ba9..203c4192c6 100644 --- a/typer/main.py +++ b/typer/main.py @@ -310,6 +310,45 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: return get_command(self)(*args, **kwargs) +def process_help_text(command: click.Command) -> None: + import docutils.nodes + + from . import rst + + if command.help is None: + return + + doc = rst.parse(command.help) + + paragraphs = ( + node for node in doc.children if isinstance(node, docutils.nodes.paragraph) + ) + command.help = "\n\n".join(paragraph.astext() for paragraph in paragraphs) + + field_lists = doc.findall(lambda node: isinstance(node, docutils.nodes.field_list)) + for field_list in field_lists: + for field in field_list.children: + assert isinstance(field, docutils.nodes.field) + field_name = field.children[0] + assert isinstance(field_name, docutils.nodes.field_name) + field_body = field.children[1] + assert isinstance(field_body, docutils.nodes.field_body) + field_name_parts = field_name.astext().split(" ", maxsplit=1) + if field_name_parts[0].casefold() == "param": + param_name = field_name_parts[1] + param_help = field_body.astext() + param = next( + (param for param in command.params if param.name == param_name), + None, + ) + if isinstance(param, click.Option): + if not param.help: + param.help = param_help + elif isinstance(param, TyperArgument): + if not param.help: + param.help = param_help + + def get_group(typer_instance: Typer) -> click.Group: group = get_group_from_info(TyperInfo(typer_instance)) return group @@ -485,6 +524,7 @@ def get_group_from_info(group_info: TyperInfo) -> click.Group: hidden=solved_info.hidden, deprecated=solved_info.deprecated, ) + process_help_text(group) return group @@ -544,6 +584,7 @@ def get_command_from_info(command_info: CommandInfo) -> click.Command: hidden=command_info.hidden, deprecated=command_info.deprecated, ) + process_help_text(command) return command diff --git a/typer/rst.py b/typer/rst.py new file mode 100644 index 0000000000..3e42451437 --- /dev/null +++ b/typer/rst.py @@ -0,0 +1,14 @@ +import docutils.frontend +import docutils.nodes +import docutils.parsers.rst +import docutils.utils + + +def parse(text: str) -> docutils.nodes.document: + parser = docutils.parsers.rst.Parser() + settings = docutils.frontend.OptionParser( + components=(docutils.parsers.rst.Parser,) + ).get_default_values() + document = docutils.utils.new_document("", settings=settings) + parser.parse(text, document) + return document