diff --git a/tests/assets/cli/multiapp-docs.md b/tests/assets/cli/multiapp-docs.md index 67d02568db..445bd70c0f 100644 --- a/tests/assets/cli/multiapp-docs.md +++ b/tests/assets/cli/multiapp-docs.md @@ -25,6 +25,7 @@ The end Top command + **Usage**: ```console @@ -57,6 +58,7 @@ $ multiapp sub [OPTIONS] COMMAND [ARGS]... Say Hello + **Usage**: ```console @@ -73,6 +75,7 @@ $ multiapp sub hello [OPTIONS] Say Hi + **Usage**: ```console @@ -91,6 +94,7 @@ $ multiapp sub hi [OPTIONS] [USER] Say bye + **Usage**: ```console diff --git a/tests/test_docstring_automation/__init__.py b/tests/test_docstring_automation/__init__.py new file mode 100644 index 0000000000..e303e3ca0d --- /dev/null +++ b/tests/test_docstring_automation/__init__.py @@ -0,0 +1,74 @@ +from typing import Optional + +import typer + + +def function_to_test_docstring( + param1: str, param2: int, param3: Optional[str] = None +) -> str: + """Used by docstring automation tests.""" + + +SUMMARY = "Function to test docstring styles." +PARAMS = { + "param1": "A very detailed description.", + "param2": "A small one.", + "param3": "A description with default value.", +} +DOCSTRINGS = { + "NUMPY": """ + Function to test docstring styles. + + Parameters + ---------- + param1 : str + A very detailed description. + param2 : int + A small one + param3 : Optional[str], optional + A description with default value, by default None + + Returns + ------- + str + Return information. + + """, + "GOOGLE": """ + Function to test docstring styles. + + Args: + param1 (str): A very detailed description. + param2 (int): A small one + param3 (Optional[str], optional): A description with default value. + Defaults to None. + + Returns: + str: Return information. + + """, + "SPHINX": """ + Function to test docstring styles. + + :param param1: A very detailed description. + :type param1: str + :param param2: A small one + :type param2: int + :param param3: A description with default value, defaults to None + :type param3: Optional[str], optional + :return: Return information. + :rtype: str + + """, +} + +PRIORITY_SUMMARY = "Not automated!" +PRIORITY_PARAM_DOC = "A complete different one." + + +def function_to_test_priority( + param1: str = typer.Argument(...), + param2: int = typer.Argument(..., help=PRIORITY_PARAM_DOC), + param3: Optional[str] = None, +) -> str: + """Used to test if docstring automation respects priority.""" diff --git a/tests/test_docstring_automation/test_google.py b/tests/test_docstring_automation/test_google.py new file mode 100644 index 0000000000..e8e361db83 --- /dev/null +++ b/tests/test_docstring_automation/test_google.py @@ -0,0 +1,59 @@ +import typer +import typer.core +from typer.testing import CliRunner + +from . import ( + DOCSTRINGS, + PARAMS, + PRIORITY_PARAM_DOC, + PRIORITY_SUMMARY, + SUMMARY, + function_to_test_docstring, + function_to_test_priority, +) + +runner = CliRunner() + + +def test_google_docstring_parsing(): + app = typer.Typer() + function_to_test_docstring.__doc__ = DOCSTRINGS["GOOGLE"] + app.command()(function_to_test_docstring) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Args:" not in result.output + assert "Returns:" not in result.output + assert "(str" not in result.output + assert "(int" not in result.output + assert "optional" not in result.output + assert " Defaults to" not in result.output + + assert SUMMARY in result.output + assert all(param_doc in result.output for param_doc in PARAMS.values()) + + +def test_google_docstring_parsing_priority(): + app = typer.Typer() + function_to_test_priority.__doc__ = DOCSTRINGS["GOOGLE"] + app.command(help=PRIORITY_SUMMARY)(function_to_test_priority) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Args:" not in result.output + assert "Returns:" not in result.output + assert "(str" not in result.output + assert "(int" not in result.output + assert "optional" not in result.output + assert " Defaults to" not in result.output + + assert SUMMARY not in result.output + assert PRIORITY_SUMMARY in result.output + + assert PARAMS["param1"] in result.output + assert PARAMS["param2"] not in result.output + assert PRIORITY_PARAM_DOC in result.output + assert PARAMS["param3"] in result.output + + +# TODO def test_google_docstring_parsing_with line_breaking_param_doc(): diff --git a/tests/test_docstring_automation/test_no_style.py b/tests/test_docstring_automation/test_no_style.py new file mode 100644 index 0000000000..47fb1c325a --- /dev/null +++ b/tests/test_docstring_automation/test_no_style.py @@ -0,0 +1,42 @@ +from typing import Optional + +import typer +import typer.core +from typer.testing import CliRunner + + +def function_to_test_no_style_docstring( + param1: str, param2: int, param3: Optional[str] = None +) -> str: + """Function to test No style docstring.""" + + +def function_to_test_priority( + param1: str = typer.Argument(...), + param2: int = typer.Argument(..., help="A complete different one."), + param3: Optional[str] = None, +) -> str: + """Function to test No style docstring.""" + + +runner = CliRunner() + + +def test_no_style_help(): + app = typer.Typer() + app.command()(function_to_test_no_style_docstring) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Function to test No style docstring." in result.output + + +def test_help_priority(): + app = typer.Typer() + app.command(help="Not automated!")(function_to_test_priority) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Function to test No style docstring." not in result.output + assert "Not automated!" in result.output + assert "A complete different one." in result.output diff --git a/tests/test_docstring_automation/test_numpy.py b/tests/test_docstring_automation/test_numpy.py new file mode 100644 index 0000000000..5795cf1c9f --- /dev/null +++ b/tests/test_docstring_automation/test_numpy.py @@ -0,0 +1,63 @@ +import typer +import typer.core +from typer.testing import CliRunner + +from . import ( + DOCSTRINGS, + PARAMS, + PRIORITY_PARAM_DOC, + PRIORITY_SUMMARY, + SUMMARY, + function_to_test_docstring, + function_to_test_priority, +) + +runner = CliRunner() + + +def test_numpy_docstring_parsing(): + app = typer.Typer() + function_to_test_docstring.__doc__ = DOCSTRINGS["NUMPY"] + app.command()(function_to_test_docstring) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Parameters" not in result.output + assert "----------" not in result.output + assert "Returns" not in result.output + assert "-------" not in result.output + assert ": str" not in result.output + assert ": int" not in result.output + assert "optional" not in result.output + assert ", by default" not in result.output + + assert SUMMARY in result.output + assert all(param_doc in result.output for param_doc in PARAMS.values()) + + +def test_numpy_docstring_parsing_priority(): + app = typer.Typer() + function_to_test_priority.__doc__ = DOCSTRINGS["NUMPY"] + app.command(help=PRIORITY_SUMMARY)(function_to_test_priority) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Parameters" not in result.output + assert "----------" not in result.output + assert "Returns" not in result.output + assert "-------" not in result.output + assert ": str" not in result.output + assert ": int" not in result.output + assert "optional" not in result.output + assert ", by default" not in result.output + + assert SUMMARY not in result.output + assert PRIORITY_SUMMARY in result.output + + assert PARAMS["param1"] in result.output + assert PARAMS["param2"] not in result.output + assert PRIORITY_PARAM_DOC in result.output + assert PARAMS["param3"] in result.output + + +# TODO def test_numpy_docstring_parsing_with line_breaking_param_doc(): diff --git a/tests/test_docstring_automation/test_sphinx.py b/tests/test_docstring_automation/test_sphinx.py new file mode 100644 index 0000000000..6d31dd8338 --- /dev/null +++ b/tests/test_docstring_automation/test_sphinx.py @@ -0,0 +1,63 @@ +import typer +import typer.core +from typer.testing import CliRunner + +from . import ( + DOCSTRINGS, + PARAMS, + PRIORITY_PARAM_DOC, + PRIORITY_SUMMARY, + SUMMARY, + function_to_test_docstring, + function_to_test_priority, +) + +runner = CliRunner() + + +def test_sphinx_docstring_parsing(): + app = typer.Typer() + function_to_test_docstring.__doc__ = DOCSTRINGS["SPHINX"] + app.command()(function_to_test_docstring) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert ":param" not in result.output + assert ":type" not in result.output + assert ":return:" not in result.output + assert ":rtype:" not in result.output + assert ": str" not in result.output + assert ": int" not in result.output + assert "optional" not in result.output + assert ", defaults to" not in result.output + + assert SUMMARY in result.output + assert all(param_doc in result.output for param_doc in PARAMS.values()) + + +def test_sphinx_docstring_parsing_priority(): + app = typer.Typer() + function_to_test_priority.__doc__ = DOCSTRINGS["SPHINX"] + app.command(help=PRIORITY_SUMMARY)(function_to_test_priority) + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert ":param" not in result.output + assert ":type" not in result.output + assert ":return:" not in result.output + assert ":rtype:" not in result.output + assert ": str" not in result.output + assert ": int" not in result.output + assert "optional" not in result.output + assert ", defaults to" not in result.output + + assert SUMMARY not in result.output + assert PRIORITY_SUMMARY in result.output + + assert PARAMS["param1"] in result.output + assert PARAMS["param2"] not in result.output + assert PRIORITY_PARAM_DOC in result.output + assert PARAMS["param3"] in result.output + + +# TODO def test_sphinx_docstring_parsing_with line_breaking_param_doc(): diff --git a/typer/docstring_automation.py b/typer/docstring_automation.py new file mode 100644 index 0000000000..71e03286b0 --- /dev/null +++ b/typer/docstring_automation.py @@ -0,0 +1,253 @@ +"""Docstring parser for the following styles: + +- Numpy https://numpydoc.readthedocs.io/en/latest/format.html +- Google https://google.github.io/styleguide/pyguide.html#381-docstrings +- Sphinx https://thomas-cokelaer.info/tutorials/sphinx/docstring_python.html + +""" + +import inspect +from typing import Any, Callable, List, Tuple, Union + +import click + +NUMPY = "NUMPY" +GOOGLE = "GOOGLE" +SPHINX = "SPHINX" +NUMPY_PARAMS = "Parameters" +GOOGLE_PARAMS = "Args:" +SPHINX_PARAM = ":param" +SPHINX_RETURNS = ":returns" +SPHINX_RAISES = ":raises" +DOCSTRING_SECTIONS = { + NUMPY_PARAMS: True, + "Returns": True, + "Yields": True, + "Receives": True, + "Raises": True, + "Warns": True, + "Warnings": True, + "See Also": True, + "References": True, + "Notes": True, + "Examples": True, + "Attributes": True, + "Methods": True, + GOOGLE_PARAMS: True, + "Returns:": True, + "Yields:": True, + "Raises:": True, + "Attributes:": True, +} +DOCSTRING_STYLES_PARAMS = { + NUMPY: NUMPY_PARAMS, + GOOGLE: GOOGLE_PARAMS, +} + + +def get_help_from_docstring(command: Callable[..., Any]) -> str: + """ + Get help message from callable object. + + Parameters + ---------- + command : Callable[..., Any] + Callable object (command, callback, ...). + + Returns + ------- + str + Docstring summary, if exists. + + """ + docstring = inspect.getdoc(command) + if not docstring: + return "" + docstring_lines = docstring.strip().splitlines() + help_message = "" + for line in docstring_lines: + if DOCSTRING_SECTIONS.get(line.strip()) or line.strip().startswith( + (SPHINX_PARAM, SPHINX_RETURNS, SPHINX_RAISES) + ): + break + help_message += line + "\n" if line else "" + return help_message + + +def get_index_of_sphinx_param_section( + docstring_lines: List[str], +) -> Tuple[int, str]: + """ + Get list index of Sphinx parameters section in docstring lines. + + Parameters + ---------- + docstring_lines : List[str] + Lines of the docstring. + + Returns + ------- + Tuple[int, str] + Index and docstring style. + + """ + index_param_section = 0 + docstring_style = "" + for index, line in enumerate(docstring_lines): + if line.strip().startswith(SPHINX_PARAM): + index_param_section = index - 1 + docstring_style = SPHINX + break + return index_param_section, docstring_style + + +def get_param_help_from_numpy_docstring( + docstring_lines: List[str], index_param_section: int, param_name: str +) -> str: + """ + Get parameter help message from Numpy style docstring. + + Parameters + ---------- + docstring_lines : List[str] + Lines of the docstring. + index_param_section : int + List index of the parameters section in the lines of the docstring. + param_name : str + Name of the parameter. + + Returns + ------- + str + Parameter help message, if any. + + """ + index_param_section += 1 + help_message = "" + for index, line in enumerate(docstring_lines[index_param_section:]): + if line.strip().startswith(param_name): + help_message = docstring_lines[index + index_param_section + 1] + help_message = help_message.split(", by default")[0] + if help_message[-1] != ".": + help_message += "." + break + return help_message.strip() + + +def get_param_help_from_google_docstring( + docstring_lines: List[str], index_param_section: int, param_name: str +) -> str: + """ + Get parameter help message from Google style docstring. + + Parameters + ---------- + docstring_lines : List[str] + Lines of the docstring. + index_param_section : int + List index of the parameters section in the lines of the docstring. + param_name : str + Name of the parameter. + + Returns + ------- + str + Parameter help message, if any. + + """ + help_message = "" + for index, line in enumerate(docstring_lines[index_param_section:]): + if line.strip().startswith(param_name): + help_message = docstring_lines[index + index_param_section] + help_message = help_message.split(":", maxsplit=2)[1] + help_message = help_message.split(" Defaults to", maxsplit=2)[0] + if help_message[-1] != ".": + help_message += "." + break + return help_message.strip() + + +def get_param_help_from_sphinx_docstring( + docstring_lines: List[str], index_param_section: int, param_name: str +) -> str: + """ + Get parameter help message from Sphinx style docstring. + + Parameters + ---------- + docstring_lines : List[str] + Lines of the docstring. + index_param_section : int + List index of the parameters section in the lines of the docstring. + param_name : str + Name of the parameter. + + Returns + ------- + str + Parameter help message, if any. + + """ + help_message = "" + for index, line in enumerate(docstring_lines[index_param_section:]): + if line.strip().startswith(f"{SPHINX_PARAM} {param_name}"): + help_message = docstring_lines[index + index_param_section] + help_message = help_message.split(":", maxsplit=3)[2] + help_message = help_message.split(", defaults to", maxsplit=2)[0] + if help_message[-1] != ".": + help_message += "." + break + return help_message.strip() + + +def get_param_help_from_docstring( + param: Union[click.Argument, click.Option], command: Callable[..., Any] +) -> str: + """ + Get parameter help message from from callable object's docstring. + + Parameters + ---------- + param : Union[click.Argument, click.Option] + Parameter of the command. + command : Callable[..., Any] + Callable object (command, callback, ...). + + Returns + ------- + str + Parameter help message, if any. + + """ + docstring = inspect.getdoc(command) + if not docstring: + return "" + docstring_lines = docstring.strip().splitlines() + index_param_section = 0 + docstring_style = "" + for style, param_section in DOCSTRING_STYLES_PARAMS.items(): + try: + index_param_section = docstring_lines.index(param_section) + if index_param_section: + docstring_style = style + break + except ValueError: + continue + if not docstring_style: + ( + index_param_section, + docstring_style, + ) = get_index_of_sphinx_param_section(docstring_lines) + if not docstring_style: + return "" + + get_help_message = { + NUMPY: get_param_help_from_numpy_docstring, + GOOGLE: get_param_help_from_google_docstring, + SPHINX: get_param_help_from_sphinx_docstring, + } + return get_help_message[docstring_style]( + docstring_lines=docstring_lines, + index_param_section=index_param_section, + param_name=param.name, # type: ignore + ) diff --git a/typer/main.py b/typer/main.py index 013939bf20..ec2fd6f956 100644 --- a/typer/main.py +++ b/typer/main.py @@ -23,6 +23,7 @@ TyperGroup, TyperOption, ) +from .docstring_automation import get_help_from_docstring, get_param_help_from_docstring from .models import ( AnyType, ArgumentInfo, @@ -420,14 +421,14 @@ def solve_typer_info_help(typer_info: TyperInfo) -> str: pass # Priority 4: Implicit inference from callback docstring in app.add_typer() if typer_info.callback: - doc = inspect.getdoc(typer_info.callback) + doc = get_help_from_docstring(typer_info.callback) if doc: return doc # Priority 5: Implicit inference from callback docstring in @app.callback() try: callback = typer_info.typer_instance.registered_callback.callback if not isinstance(callback, DefaultPlaceholder): - doc = inspect.getdoc(callback or "") + doc = get_help_from_docstring(callback or "") if doc: return doc except AttributeError: @@ -436,7 +437,7 @@ def solve_typer_info_help(typer_info: TyperInfo) -> str: try: instance_callback = typer_info.typer_instance.info.callback if not isinstance(instance_callback, DefaultPlaceholder): - doc = inspect.getdoc(instance_callback) + doc = get_help_from_docstring(instance_callback) if doc: return doc except AttributeError: @@ -564,6 +565,10 @@ def get_params_convertors_ctx_param_name_from_function( context_param_name = param_name continue click_param, convertor = get_click_param(param) + if not click_param.help: # type: ignore + click_param.help = get_param_help_from_docstring( # type: ignore + param=click_param, command=callback + ) if convertor: convertors[param_name] = convertor params.append(click_param) @@ -580,7 +585,7 @@ def get_command_from_info( name = command_info.name or get_command_name(command_info.callback.__name__) use_help = command_info.help if use_help is None: - use_help = inspect.getdoc(command_info.callback) + use_help = get_help_from_docstring(command_info.callback) else: use_help = inspect.cleandoc(use_help) (