Repository: tox-dev/sphinx-autodoc-typehints (python)

Issue:
Also annotate parameter defaults.
In numpy docstrings I often see

```rst
Parameters
----------
myparam : `Type`, optional (default: `None`)
    My param does things.
```

I prefer styling the parameter docs as definition list like numpydoc, where there is enough space for type and defaults:

<blockquote>
<dl>
<dt><strong>myparam</strong>: <code>Type</code>, optional (default: <code>None</code>)</dt>
<dd>My param does things.</dd>
</dl>
</blockquote>

However, the defaults get lost when migrating to sphinx-autodoc-typehints. I know the defaults are in the function signature above the param docs, but for long signatures (and therefor long lists of params), it would be best to also have them in the param docs.

Ideally we would parse and use the source code for the defaults instead of going from python objects back to strings, but using `repr` to get OK representations for most defaults would also be OK.

Pull request description:
Add defaults
Supersedes #103, fixes #48

In the previous PR, I added them to types, this time I’m adding it to the string. I think both have arguments pro and contra. Should it be configurable?

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a7e418..172e546 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## dev
+
+- Added `document_defaults` config option allowing to automatically annotate parameter defaults.
+
 ## 1.13.1
 
 - Fixed ``NewType`` inserts a reference as first argument instead of a string
diff --git a/README.md b/README.md
index 0c6def9..835f98a 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,12 @@ The following configuration options are accepted:
   `True`, add stub documentation for undocumented parameters to be able to add type info.
 - `typehints_document_rtype` (default: `True`): If `False`, never add an `:rtype:` directive. If `True`, add the
   `:rtype:` directive if no existing `:rtype:` is found.
+- `document_defaults` (default: `None`): If `None`, defaults are not added. Otherwise adds a default annotation:
+
+  - `'comma'` adds it after the type, changing Sphinx’ default look to “**param** (*int*, default: `1`) -- text”.
+  - `'braces'` adds `(default: ...)` after the type (useful for numpydoc like styles).
+  - `'braces-after'` adds `(default: ...)` at the end of the parameter documentation text instead.
+
 - `simplify_optional_unions` (default: `True`): If `True`, optional parameters of type \"Union\[\...\]\" are simplified
   as being of type Union\[\..., None\] in the resulting documention (e.g. Optional\[Union\[A, B\]\] -\> Union\[A, B,
   None\]). If `False`, the \"Optional\"-type is kept. Note: If `False`, **any** Union containing `None` will be
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index c05b966..7aca011 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -2,8 +2,9 @@ import inspect
 import sys
 import textwrap
 import typing
-from typing import Any, AnyStr, NewType, Tuple, TypeVar, get_type_hints
+from typing import Any, AnyStr, Dict, NewType, Optional, Tuple, TypeVar, get_type_hints
 
+from sphinx.application import Sphinx
 from sphinx.util import logging
 from sphinx.util.inspect import signature as sphinx_signature
 from sphinx.util.inspect import stringify_signature
@@ -425,7 +426,17 @@ def split_type_comment_args(comment):
     return result
 
 
-def process_docstring(app, what, name, obj, options, lines):  # noqa: U100
+def format_default(app: Sphinx, default: Any) -> Optional[str]:
+    if default is inspect.Parameter.empty:
+        return None
+    formatted = repr(default).replace("\\", "\\\\")
+    if app.config.typehints_defaults.startswith("braces"):
+        return f" (default: ``{formatted}``)"
+    else:
+        return f", default: ``{formatted}``"
+
+
+def process_docstring(app: Sphinx, what, name, obj, options, lines):  # noqa: U100
     original_obj = obj
     if isinstance(obj, property):
         obj = obj.fget
@@ -435,11 +446,13 @@ def process_docstring(app, what, name, obj, options, lines):  # noqa: U100
             obj = obj.__init__
 
         obj = inspect.unwrap(obj)
+        signature = sphinx_signature(obj)
         type_hints = get_all_type_hints(obj, name)
 
         for arg_name, annotation in type_hints.items():
             if arg_name == "return":
                 continue  # this is handled separately later
+            default = signature.parameters[arg_name].default
             if arg_name.endswith("_"):
                 arg_name = f"{arg_name[:-1]}\\_"
 
@@ -462,7 +475,15 @@ def process_docstring(app, what, name, obj, options, lines):  # noqa: U100
                 insert_index = len(lines)
 
             if insert_index is not None:
-                lines.insert(insert_index, f":type {arg_name}: {formatted_annotation}")
+                type_annotation = f":type {arg_name}: {formatted_annotation}"
+                if app.config.typehints_defaults:
+                    formatted_default = format_default(app, default)
+                    if formatted_default:
+                        if app.config.typehints_defaults.endswith("after"):
+                            lines[insert_index] += formatted_default
+                        else:  # add to last param doc line
+                            type_annotation += formatted_default
+                lines.insert(insert_index, type_annotation)
 
         if "return" in type_hints and not inspect.isclass(original_obj):
             # This avoids adding a return type for data class __init__ methods
@@ -493,18 +514,26 @@ def process_docstring(app, what, name, obj, options, lines):  # noqa: U100
                 lines.insert(insert_index, f":rtype: {formatted_annotation}")
 
 
-def builder_ready(app):
+def builder_ready(app: Sphinx) -> None:
     if app.config.set_type_checking_flag:
         typing.TYPE_CHECKING = True
 
 
-def setup(app):
+def validate_config(app: Sphinx, *args) -> None:  # noqa: U100
+    valid = {None, "comma", "braces", "braces-after"}
+    if app.config.typehints_defaults not in valid | {False}:
+        raise ValueError(f"typehints_defaults needs to be one of {valid!r}, not {app.config.typehints_defaults!r}")
+
+
+def setup(app: Sphinx) -> Dict[str, bool]:
     app.add_config_value("set_type_checking_flag", False, "html")
     app.add_config_value("always_document_param_types", False, "html")
     app.add_config_value("typehints_fully_qualified", False, "env")
     app.add_config_value("typehints_document_rtype", True, "env")
+    app.add_config_value("typehints_defaults", None, "env")
     app.add_config_value("simplify_optional_unions", True, "env")
     app.connect("builder-inited", builder_ready)
+    app.connect("env-before-read-docs", validate_config)  # config may be changed after “config-inited” event
     app.connect("autodoc-process-signature", process_signature)
     app.connect("autodoc-process-docstring", process_docstring)
     return {"parallel_read_safe": True}
diff --git a/whitelist.txt b/whitelist.txt
index 7ff14be..2421e3a 100644
--- a/whitelist.txt
+++ b/whitelist.txt
@@ -16,6 +16,7 @@ func
 getmodule
 getsource
 idx
+inited
 inv
 isfunction
 iterdir


Test patch:
diff --git a/tests/roots/test-dummy/dummy_module_simple.py b/tests/roots/test-dummy/dummy_module_simple.py
new file mode 100644
index 0000000..400c748
--- /dev/null
+++ b/tests/roots/test-dummy/dummy_module_simple.py
@@ -0,0 +1,7 @@
+def function(x: bool, y: int = 1) -> str:  # noqa: U100
+    """
+    Function docstring.
+
+    :param x: foo
+    :param y: bar
+    """
diff --git a/tests/roots/test-dummy/simple.rst b/tests/roots/test-dummy/simple.rst
new file mode 100644
index 0000000..3634707
--- /dev/null
+++ b/tests/roots/test-dummy/simple.rst
@@ -0,0 +1,4 @@
+Simple Module
+=============
+
+.. autofunction:: dummy_module_simple.function
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 80283d9..dba00a0 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -263,11 +263,11 @@ def set_python_path():
 
 
 def maybe_fix_py310(expected_contents):
-    if sys.version_info[:2] >= (3, 10):
+    if PY310_PLUS:
         for old, new in [
             ("*str** | **None*", '"Optional"["str"]'),
             ("(*bool*)", '("bool")'),
-            ("(*int*)", '("int")'),
+            ("(*int*", '("int"'),
             ("   str", '   "str"'),
             ('"Optional"["str"]', '"Optional"["str"]'),
             ('"Optional"["Callable"[["int", "bytes"], "int"]]', '"Optional"["Callable"[["int", "bytes"], "int"]]'),
@@ -613,6 +613,55 @@ def test_sphinx_output_future_annotations(app, status):
         assert text_contents == maybe_fix_py310(expected_contents)
 
 
+@pytest.mark.parametrize(
+    ("defaults_config_val", "expected"),
+    [
+        (None, '("int") -- bar'),
+        ("comma", '("int", default: "1") -- bar'),
+        ("braces", '("int" (default: "1")) -- bar'),
+        ("braces-after", '("int") -- bar (default: "1")'),
+        ("comma-after", Exception("needs to be one of")),
+    ],
+)
+@pytest.mark.sphinx("text", testroot="dummy")
+@patch("sphinx.writers.text.MAXWIDTH", 2000)
+def test_sphinx_output_defaults(app, status, defaults_config_val, expected):
+    set_python_path()
+
+    app.config.master_doc = "simple"
+    app.config.typehints_defaults = defaults_config_val
+    try:
+        app.build()
+    except Exception as e:
+        if not isinstance(expected, Exception):
+            raise
+        assert str(expected) in str(e)
+        return
+    assert "build succeeded" in status.getvalue()
+
+    text_path = pathlib.Path(app.srcdir) / "_build" / "text" / "simple.txt"
+    text_contents = text_path.read_text().replace("–", "--")
+    expected_contents = textwrap.dedent(
+        f"""\
+    Simple Module
+    *************
+
+    dummy_module_simple.function(x, y=1)
+
+       Function docstring.
+
+       Parameters:
+          * **x** ("bool") -- foo
+
+          * **y** {expected}
+
+       Return type:
+          "str"
+    """
+    )
+    assert text_contents == expected_contents
+
+
 def test_normalize_source_lines_async_def():
     source = textwrap.dedent(
         """