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

Issue:
Intersphinx Mapping link not working with Plotly objects in signature
> The issue was originally posted on https://github.com/sphinx-doc/sphinx/issues/12360#issue-2283669001, and they suggested that I post my issue here.

Hello,

For some (unknown) reasons, it appears that Sphinx does not correctly link to the objects if they were defined in the signature. I would love to have all my external references actually work.

The following code

```python
def process_plotly_kwargs(
    kwargs: MutableMapping[str, Any],
) -> Figure:
    """
    Process keyword arguments passed to some Plotly plotting utility.

    Args:
        kwargs: A mutable mapping of keyword arguments passed to
            Plotly plotting.

            .. warning::

                The keys specified below will be removed from the mapping.

    Keyword Args:
        figure (:py:class:`Figure<plotly.graph_objects.Figure>`):
            The figure that draws contents of the scene.

    Return:
        The figure used to display contents.
    """
```

produces:

![image](https://github.com/user-attachments/assets/01b94f1d-bf45-458a-8d96-fd7c08b56427)


As you can see, the handwritten reference to `Figure` is clickable, but not the one from the signature.

This is also in other online documentation, see [plot_terminator_improvement](https://optuna.readthedocs.io/en/stable/_modules/optuna/visualization/_terminator_improvement.html#plot_terminator_improvement) from the Neptuna Python package:

![image](https://github.com/user-attachments/assets/52b083d6-4398-4197-9e4e-2508399aa87d)

In this function, I return one of three possible plot types, and we can clearly see that the only one failing is the Plotly one:

```python
@contextmanager
def reuse(**kwargs: Any) -> Iterator[SceneCanvas | MplFigure | Figure]:
    """Create a context manager that will automatically reuse the current canvas / figure.

    Args:
        args: Positional arguments passed to
            :py:func:`set_defaults`.
        kwargs: Keywords arguments passed to
            :py:func:`set_defaults`.

    Return:
        The canvas or figure that is reused for this context.
    """
    ...
```

![image](https://github.com/user-attachments/assets/4b6cb1f8-5bb7-4812-a20a-a05a71ca4ed4)

See original doc: https://differt.eertmans.be/v0.0.12/reference/differt.plotting.html#differt.plotting.process_plotly_kwargs

Pull request:
Add module name rewrite configuration option
This adds a configuration option that allows the user to rewrite module names if needed. Resolves #473. Note that we also rewrite the names of `_io` and `typing_extensions` internally. One benefit of this hook is that if other similar rewrites are needed, users will be able to immediately add them without having to patch sphinx_autodoc_typehints itself.

One disadvantage is that by default, having a function in the config prevents caching. I think this can be handled with the following slightly inelegant hack:

```py
class ModuleNameRewriteHook:
   version: int

   def __eq__(self, other):
      return type(self) == type(other) and self.version == other.version

   def __init__(self):
      self.version = 2

   def __call__(self, module):
      # logic here
      # Make sure to bump version if you edit this so that sphinx will rerun.
      return module

typehints_fixup_module_name = ModuleNameRewriteHook()
```

See https://github.com/sphinx-doc/sphinx/issues/12300.

Patch:
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index 824f458..fbd57ce 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -169,6 +169,18 @@ def format_internal_tuple(t: tuple[Any, ...], config: Config) -> str:
     return f"({', '.join(fmt)})"
 
 
+def fixup_module_name(config: Config, module: str) -> str:
+    if getattr(config, "typehints_fixup_module_name", None):
+        module = config.typehints_fixup_module_name(module)
+
+    if module == "typing_extensions":
+        module = "typing"
+
+    if module == "_io":
+        module = "io"
+    return module
+
+
 def format_annotation(annotation: Any, config: Config) -> str:  # noqa: C901, PLR0911, PLR0912, PLR0915, PLR0914
     """
     Format the annotation.
@@ -204,13 +216,7 @@ def format_annotation(annotation: Any, config: Config) -> str:  # noqa: C901, PL
     except ValueError:
         return str(annotation).strip("'")
 
-    # Redirect all typing_extensions types to the stdlib typing module
-    if module == "typing_extensions":
-        module = "typing"
-
-    if module == "_io":
-        module = "io"
-
+    module = fixup_module_name(config, module)
     full_name = f"{module}.{class_name}" if module != "builtins" else class_name
     fully_qualified: bool = getattr(config, "typehints_fully_qualified", False)
     prefix = "" if fully_qualified or full_name == class_name else "~"
@@ -967,6 +973,7 @@ def setup(app: Sphinx) -> dict[str, bool]:
     app.add_config_value("typehints_formatter", None, "env")
     app.add_config_value("typehints_use_signature", False, "env")  # noqa: FBT003
     app.add_config_value("typehints_use_signature_return", False, "env")  # noqa: FBT003
+    app.add_config_value("typehints_fixup_module_name", None, "env")
     app.add_role("sphinx_autodoc_typehints_type", sphinx_autodoc_typehints_type_role)
     app.connect("env-before-read-docs", validate_config)  # config may be changed after “config-inited” event
     app.connect("autodoc-process-signature", process_signature)


Test patch:
diff --git a/tests/roots/test-dummy/export_module.py b/tests/roots/test-dummy/export_module.py
new file mode 100644
index 0000000..0fa35a8
--- /dev/null
+++ b/tests/roots/test-dummy/export_module.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from wrong_module_path import A, f
+
+__all__ = ["A", "f"]
diff --git a/tests/roots/test-dummy/future_annotations.rst b/tests/roots/test-dummy/future_annotations.rst
index 3d774cb..97c2bce 100644
--- a/tests/roots/test-dummy/future_annotations.rst
+++ b/tests/roots/test-dummy/future_annotations.rst
@@ -1,3 +1,5 @@
+:orphan:
+
 Dummy Module
 ============
 
diff --git a/tests/roots/test-dummy/simple.rst b/tests/roots/test-dummy/simple.rst
index 3634707..c920b7d 100644
--- a/tests/roots/test-dummy/simple.rst
+++ b/tests/roots/test-dummy/simple.rst
@@ -1,3 +1,5 @@
+:orphan:
+
 Simple Module
 =============
 
diff --git a/tests/roots/test-dummy/simple_default_role.rst b/tests/roots/test-dummy/simple_default_role.rst
index c3148a7..186f1a8 100644
--- a/tests/roots/test-dummy/simple_default_role.rst
+++ b/tests/roots/test-dummy/simple_default_role.rst
@@ -1,3 +1,5 @@
+:orphan:
+
 Simple Module
 =============
 
diff --git a/tests/roots/test-dummy/simple_no_use_rtype.rst b/tests/roots/test-dummy/simple_no_use_rtype.rst
index 00b2d61..f7b3034 100644
--- a/tests/roots/test-dummy/simple_no_use_rtype.rst
+++ b/tests/roots/test-dummy/simple_no_use_rtype.rst
@@ -1,3 +1,5 @@
+:orphan:
+
 Simple Module
 =============
 
diff --git a/tests/roots/test-dummy/without_complete_typehints.rst b/tests/roots/test-dummy/without_complete_typehints.rst
index 2e7a204..4eb63ce 100644
--- a/tests/roots/test-dummy/without_complete_typehints.rst
+++ b/tests/roots/test-dummy/without_complete_typehints.rst
@@ -1,3 +1,5 @@
+:orphan:
+
 Simple Module
 =============
 
diff --git a/tests/roots/test-dummy/wrong_module_path.py b/tests/roots/test-dummy/wrong_module_path.py
new file mode 100644
index 0000000..e5542ed
--- /dev/null
+++ b/tests/roots/test-dummy/wrong_module_path.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+
+class A:
+    pass
+
+
+def f() -> A:
+    pass
diff --git a/tests/roots/test-dummy/wrong_module_path.rst b/tests/roots/test-dummy/wrong_module_path.rst
new file mode 100644
index 0000000..04b28b0
--- /dev/null
+++ b/tests/roots/test-dummy/wrong_module_path.rst
@@ -0,0 +1,5 @@
+:orphan:
+
+.. class:: export_module.A
+
+.. autofunction:: export_module.f
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 93df0fe..94c1c86 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -1123,3 +1123,26 @@ def test_default_annotation_without_typehints(app: SphinxTestApp, status: String
           "str"
     """
     assert text_contents == dedent(expected_contents)
+
+
+@pytest.mark.sphinx("text", testroot="dummy")
+@patch("sphinx.writers.text.MAXWIDTH", 2000)
+def test_wrong_module_path(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
+    set_python_path()
+
+    app.config.master_doc = "wrong_module_path"  # create flag
+    app.config.default_role = "literal"
+    app.config.nitpicky = True
+    app.config.nitpick_ignore = {("py:data", "typing.Optional")}
+
+    def fixup_module_name(mod: str) -> str:
+        if not mod.startswith("wrong_module_path"):
+            return mod
+        return "export_module" + mod.removeprefix("wrong_module_path")
+
+    app.config.suppress_warnings = ["config.cache"]
+    app.config.typehints_fixup_module_name = fixup_module_name
+    app.build()
+
+    assert "build succeeded" in status.getvalue()  # Build succeeded
+    assert not warning.getvalue().strip()