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

Issue:
s-a-t breaks default_role in parameter descriptions
When using sphinx-autodoc-typehints, text between single backticks in parameter descriptions gets rendered as `<cite/>` nodes, i.e. using docutils’ default [`title-reference`](https://docutils.sourceforge.io/docs/ref/rst/roles.html#title-reference) role instead of the role we configured.

I don’t know exactly why, but removing s-a-t from the plugin list fixes this, and you’re e.g. using a plain `RstParser()` instance in several places which has that behavior. Maybe that parser replaces the role and doesn’t clean up properly after itself?

https://github.com/tox-dev/sphinx-autodoc-typehints/blob/0bec1bc2def5c56150fbeccf655d3fdf93c7bf12/src/sphinx_autodoc_typehints/__init__.py#L799

https://github.com/tox-dev/sphinx-autodoc-typehints/blob/0bec1bc2def5c56150fbeccf655d3fdf93c7bf12/src/sphinx_autodoc_typehints/attributes_patch.py#L66

Check out [`sphinx.testing.restructuredtext:parse`](https://github.com/sphinx-doc/sphinx/blob/master/sphinx/testing/restructuredtext.py) for a parser setup that sets up all the Sphinx stuff.

Pull request description:
Stop parsing from overwriting Sphinx configuration
Fixes #421, fixes https://github.com/scverse/scanpy/issues/2829

This PR introduces a `parse` function that abstracts away the instantiation and use of a `RSTParser` instance.

It uses the `sphinx.util.docutils:sphinx_domains` context manager to make sure that the `docutils` directive and role registries are managed properly.

Patch:
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index a4f735a..69e1489 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -13,14 +13,13 @@ from typing import TYPE_CHECKING, Any, AnyStr, Callable, ForwardRef, NewType, Ty
 
 from docutils import nodes
 from docutils.frontend import OptionParser
-from docutils.parsers.rst import Parser as RstParser
-from docutils.parsers.rst import states
-from docutils.utils import new_document
 from sphinx.ext.autodoc.mock import mock
+from sphinx.parsers import RSTParser
 from sphinx.util import logging, rst
 from sphinx.util.inspect import signature as sphinx_signature
 from sphinx.util.inspect import stringify_signature
 
+from .parser import parse
 from .patches import install_patches
 from .version import __version__
 
@@ -28,6 +27,7 @@ if TYPE_CHECKING:
     from ast import FunctionDef, Module, stmt
 
     from docutils.nodes import Node
+    from docutils.parsers.rst import states
     from sphinx.application import Sphinx
     from sphinx.config import Config
     from sphinx.environment import BuildEnvironment
@@ -793,10 +793,9 @@ def get_insert_index(app: Sphinx, lines: list[str]) -> InsertIndexInfo | None:
 
     # 3. Insert after the parameters.
     # To find the parameters, parse as a docutils tree.
-    settings = OptionParser(components=(RstParser,)).get_default_values()
+    settings = OptionParser(components=(RSTParser,)).get_default_values()
     settings.env = app.env
-    doc = new_document("", settings=settings)
-    RstParser().parse("\n".join(lines), doc)
+    doc = parse("\n".join(lines), settings)
 
     # Find a top level child which is a field_list that contains a field whose
     # name starts with one of the PARAM_SYNONYMS. This is the parameter list. We
@@ -915,8 +914,7 @@ def sphinx_autodoc_typehints_type_role(
     """
     unescaped = unescape(text)
     # the typestubs for docutils don't have any info about Inliner
-    doc = new_document("", inliner.document.settings)  # type: ignore[attr-defined]
-    RstParser().parse(unescaped, doc)
+    doc = parse(unescaped, inliner.document.settings)  # type: ignore[attr-defined]
     n = nodes.inline(text)
     n["classes"].append("sphinx_autodoc_typehints-type")
     n += doc.children[0].children
diff --git a/src/sphinx_autodoc_typehints/attributes_patch.py b/src/sphinx_autodoc_typehints/attributes_patch.py
index 807fab3..e091955 100644
--- a/src/sphinx_autodoc_typehints/attributes_patch.py
+++ b/src/sphinx_autodoc_typehints/attributes_patch.py
@@ -7,14 +7,13 @@ from unittest.mock import patch
 
 import sphinx.domains.python
 import sphinx.ext.autodoc
-from docutils.parsers.rst import Parser as RstParser
-from docutils.utils import new_document
 from sphinx.domains.python import PyAttribute
 from sphinx.ext.autodoc import AttributeDocumenter
 
-if TYPE_CHECKING:
-    from optparse import Values
+from .parser import parse
 
+if TYPE_CHECKING:
+    from docutils.frontend import Values
     from sphinx.addnodes import desc_signature
     from sphinx.application import Sphinx
 
@@ -62,8 +61,7 @@ def patch_attribute_documenter(app: Sphinx) -> None:
 
 def rst_to_docutils(settings: Values, rst: str) -> Any:
     """Convert rst to a sequence of docutils nodes."""
-    doc = new_document("", settings)
-    RstParser().parse(rst, doc)
+    doc = parse(rst, settings)
     # Remove top level paragraph node so that there is no line break.
     return doc.children[0].children
 
diff --git a/src/sphinx_autodoc_typehints/parser.py b/src/sphinx_autodoc_typehints/parser.py
new file mode 100644
index 0000000..ea38444
--- /dev/null
+++ b/src/sphinx_autodoc_typehints/parser.py
@@ -0,0 +1,25 @@
+"""Utilities for side-effect-free rST parsing."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from docutils.utils import new_document
+from sphinx.parsers import RSTParser
+from sphinx.util.docutils import sphinx_domains
+
+if TYPE_CHECKING:
+    import optparse
+
+    from docutils import nodes
+    from docutils.frontend import Values
+
+
+def parse(inputstr: str, settings: Values | optparse.Values) -> nodes.document:
+    """Parse inputstr and return a docutils document."""
+    doc = new_document("", settings=settings)
+    with sphinx_domains(settings.env):
+        parser = RSTParser()
+        parser.set_application(settings.env.app)
+        parser.parse(inputstr, doc)
+    return doc


Test patch:
diff --git a/tests/roots/test-dummy/dummy_module_simple_default_role.py b/tests/roots/test-dummy/dummy_module_simple_default_role.py
new file mode 100644
index 0000000..758196f
--- /dev/null
+++ b/tests/roots/test-dummy/dummy_module_simple_default_role.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+
+def function(x: bool, y: int) -> str:  # noqa: ARG001
+    """
+    Function docstring.
+
+    :param x: `foo`
+    :param y: ``bar``
+    """
diff --git a/tests/roots/test-dummy/simple_default_role.rst b/tests/roots/test-dummy/simple_default_role.rst
new file mode 100644
index 0000000..c3148a7
--- /dev/null
+++ b/tests/roots/test-dummy/simple_default_role.rst
@@ -0,0 +1,4 @@
+Simple Module
+=============
+
+.. autofunction:: dummy_module_simple_default_role.function
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 10b22d3..2c05c99 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -567,6 +567,38 @@ def test_sphinx_output_future_annotations(app: SphinxTestApp, status: StringIO)
     assert contents == expected_contents
 
 
+@pytest.mark.sphinx("pseudoxml", testroot="dummy")
+@patch("sphinx.writers.text.MAXWIDTH", 2000)
+def test_sphinx_output_default_role(app: SphinxTestApp, status: StringIO) -> None:
+    set_python_path()
+
+    app.config.master_doc = "simple_default_role"  # type: ignore[attr-defined] # create flag
+    app.config.default_role = "literal"  # type: ignore[attr-defined]
+    app.build()
+
+    assert "build succeeded" in status.getvalue()  # Build succeeded
+
+    contents_lines = (Path(app.srcdir) / "_build/pseudoxml/simple_default_role.pseudoxml").read_text().splitlines()
+    list_item_idxs = [i for i, line in enumerate(contents_lines) if line.strip() == "<list_item>"]
+    foo_param = dedent("\n".join(contents_lines[list_item_idxs[0] : list_item_idxs[1]]))
+    expected_foo_param = """\
+    <list_item>
+        <paragraph>
+            <literal_strong>
+                x
+             (
+            <inline classes="sphinx_autodoc_typehints-type">
+                <literal classes="xref py py-class">
+                    bool
+            )
+             \N{EN DASH}\N{SPACE}
+            <literal>
+                foo
+    """.rstrip()
+    expected_foo_param = dedent(expected_foo_param)
+    assert foo_param == expected_foo_param
+
+
 @pytest.mark.parametrize(
     ("defaults_config_val", "expected"),
     [