Repository: tox-dev/sphinx-autodoc-typehints
Pull request #525 (2025-02-19 11:13:29)

Support Sphinx 8.2.0 - drop 3.10 support because Sphinx does
Closes https://github.com/tox-dev/sphinx-autodoc-typehints/issues/523.

b-kamphorst: This PR fixes some incompatibilities with sphinx 8.2.0. Most notable changes:
- mock `_stringify_annotation` now accepts arbitrary `*args` and `**kwargs`, which may or may not be desired
- adds logic for dealing with `python_display_short_literal_types` (which is the reason the mock broke)

Please take additional care in those changes as they are opinionated / change behaviour. Especially the second one: revisit the tests and ensure that the short syntax produces valid and expected rst.



Patch:
diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index 0800434..c028c36 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -22,7 +22,6 @@ jobs:
           - "3.13"
           - "3.12"
           - "3.11"
-          - "3.10"
           - type
           - dev
           - pkg_meta
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index aaf240a..fb3e099 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -34,7 +34,7 @@ repos:
     hooks:
       - id: prettier
         additional_dependencies:
-          - prettier@3.4.2
+          - prettier@3.5.1
           - "@prettier/plugin-xml@3.4.1"
   - repo: meta
     hooks:
diff --git a/pyproject.toml b/pyproject.toml
index d60d302..1fbdbdb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,7 +23,7 @@ maintainers = [
 authors = [
   { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
 ]
-requires-python = ">=3.10"
+requires-python = ">=3.11"
 classifiers = [
   "Development Status :: 5 - Production/Stable",
   "Framework :: Sphinx :: Extension",
@@ -31,7 +31,6 @@ classifiers = [
   "License :: OSI Approved :: MIT License",
   "Programming Language :: Python",
   "Programming Language :: Python :: 3 :: Only",
-  "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
   "Programming Language :: Python :: 3.13",
@@ -41,16 +40,16 @@ dynamic = [
   "version",
 ]
 dependencies = [
-  "sphinx>=8.1.3",
+  "sphinx>=8.2",
 ]
 optional-dependencies.docs = [
   "furo>=2024.8.6",
 ]
 optional-dependencies.testing = [
   "covdefaults>=2.3",
-  "coverage>=7.6.10",
+  "coverage>=7.6.12",
   "defusedxml>=0.7.1",         # required by sphinx.testing
-  "diff-cover>=9.2.1",
+  "diff-cover>=9.2.3",
   "pytest>=8.3.4",
   "pytest-cov>=6",
   "sphobjinv>=2.3.1.2",
@@ -142,7 +141,7 @@ run.plugins = [
 ]
 
 [tool.mypy]
-python_version = "3.10"
+python_version = "3.11"
 strict = true
 exclude = "^(.*/roots/.*)|(tests/test_integration.*.py)$"
 overrides = [
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index e621592..432fdf4 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -172,11 +172,11 @@ def get_annotation_args(annotation: Any, module: str, class_name: str) -> tuple[
     return () if len(result) == 1 and result[0] == () else result  # type: ignore[misc]
 
 
-def format_internal_tuple(t: tuple[Any, ...], config: Config) -> str:
+def format_internal_tuple(t: tuple[Any, ...], config: Config, *, short_literals: bool = False) -> str:
     # An annotation can be a tuple, e.g., for numpy.typing:
     # In this case, format_annotation receives:
     # This solution should hopefully be general for *any* type that allows tuples in annotations
-    fmt = [format_annotation(a, config) for a in t]
+    fmt = [format_annotation(a, config, short_literals=short_literals) for a in t]
     if len(fmt) == 0:
         return "()"
     if len(fmt) == 1:
@@ -196,12 +196,13 @@ def fixup_module_name(config: Config, module: str) -> str:
     return module
 
 
-def format_annotation(annotation: Any, config: Config) -> str:  # noqa: C901, PLR0911, PLR0912, PLR0915, PLR0914
+def format_annotation(annotation: Any, config: Config, *, short_literals: bool = False) -> str:  # noqa: C901, PLR0911, PLR0912, PLR0915, PLR0914
     """
     Format the annotation.
 
     :param annotation:
     :param config:
+    :param short_literals: Render :py:class:`Literals` in PEP 604 style (``|``).
     :return:
     """
     typehints_formatter: Callable[..., str] | None = getattr(config, "typehints_formatter", None)
@@ -222,7 +223,7 @@ def format_annotation(annotation: Any, config: Config) -> str:  # noqa: C901, PL
         return format_internal_tuple(annotation, config)
 
     if isinstance(annotation, TypeAliasForwardRef):
-        return str(annotation)
+        return annotation.name
 
     try:
         module = get_annotation_module(annotation)
@@ -254,7 +255,7 @@ def format_annotation(annotation: Any, config: Config) -> str:  # noqa: C901, PL
         params = {k: getattr(annotation, f"__{k}__") for k in ("bound", "covariant", "contravariant")}
         params = {k: v for k, v in params.items() if v}
         if "bound" in params:
-            params["bound"] = f" {format_annotation(params['bound'], config)}"
+            params["bound"] = f" {format_annotation(params['bound'], config, short_literals=short_literals)}"
         args_format = f"\\(``{annotation.__name__}``{', {}' if args else ''}"
         if params:
             args_format += "".join(f", {k}={v}" for k, v in params.items())
@@ -275,20 +276,22 @@ def format_annotation(annotation: Any, config: Config) -> str:  # noqa: C901, PL
                 args_format = f"\\[:py:data:`{prefix}typing.Union`\\[{{}}]]"
                 args = tuple(x for x in args if x is not type(None))
     elif full_name in {"typing.Callable", "collections.abc.Callable"} and args and args[0] is not ...:
-        fmt = [format_annotation(arg, config) for arg in args]
+        fmt = [format_annotation(arg, config, short_literals=short_literals) for arg in args]
         formatted_args = f"\\[\\[{', '.join(fmt[:-1])}], {fmt[-1]}]"
     elif full_name == "typing.Literal":
+        if short_literals:
+            return f"\\{' | '.join(f'``{arg!r}``' for arg in args)}"
         formatted_args = f"\\[{', '.join(f'``{arg!r}``' for arg in args)}]"
     elif is_bars_union:
-        return " | ".join([format_annotation(arg, config) for arg in args])
+        return " | ".join([format_annotation(arg, config, short_literals=short_literals) for arg in args])
 
     if args and not formatted_args:
         try:
             iter(args)
         except TypeError:
-            fmt = [format_annotation(args, config)]
+            fmt = [format_annotation(args, config, short_literals=short_literals)]
         else:
-            fmt = [format_annotation(arg, config) for arg in args]
+            fmt = [format_annotation(arg, config, short_literals=short_literals) for arg in args]
         formatted_args = args_format.format(", ".join(fmt))
 
     escape = "\\ " if formatted_args else ""
@@ -783,7 +786,10 @@ def _inject_signature(
             if annotation is None:
                 type_annotation = f":type {arg_name}: "
             else:
-                formatted_annotation = add_type_css_class(format_annotation(annotation, app.config))
+                short_literals = app.config.python_display_short_literal_types
+                formatted_annotation = add_type_css_class(
+                    format_annotation(annotation, app.config, short_literals=short_literals)
+                )
                 type_annotation = f":type {arg_name}: {formatted_annotation}"
 
             if app.config.typehints_defaults:
@@ -923,7 +929,10 @@ def _inject_rtype(  # noqa: PLR0913, PLR0917
     if not app.config.typehints_use_rtype and r.found_return and " -- " in lines[insert_index]:
         return
 
-    formatted_annotation = add_type_css_class(format_annotation(type_hints["return"], app.config))
+    short_literals = app.config.python_display_short_literal_types
+    formatted_annotation = add_type_css_class(
+        format_annotation(type_hints["return"], app.config, short_literals=short_literals)
+    )
 
     if r.found_param and insert_index < len(lines) and lines[insert_index].strip():
         insert_index -= 1
diff --git a/src/sphinx_autodoc_typehints/attributes_patch.py b/src/sphinx_autodoc_typehints/attributes_patch.py
index d038eae..276dbd0 100644
--- a/src/sphinx_autodoc_typehints/attributes_patch.py
+++ b/src/sphinx_autodoc_typehints/attributes_patch.py
@@ -42,12 +42,12 @@ orig_add_directive_header = AttributeDocumenter.add_directive_header
 orig_handle_signature = PyAttribute.handle_signature
 
 
-def _stringify_annotation(app: Sphinx, annotation: Any, mode: str = "") -> str:  # noqa: ARG001
+def _stringify_annotation(app: Sphinx, annotation: Any, *args: Any, short_literals: bool = False, **kwargs: Any) -> str:  # noqa: ARG001
     # Format the annotation with sphinx-autodoc-typehints and inject our magic prefix to tell our patched
     # PyAttribute.handle_signature to treat it as rst.
     from . import format_annotation  # noqa: PLC0415
 
-    return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config)
+    return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config, short_literals=short_literals)
 
 
 def patch_attribute_documenter(app: Sphinx) -> None:
diff --git a/tox.ini b/tox.ini
index eef0f28..07cd05a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,13 +1,12 @@
 [tox]
 requires =
-    tox>=4.23.2
-    tox-uv>=1.16.2
+    tox>=4.24.1
+    tox-uv>=1.24
 env_list =
     fix
     3.13
     3.12
     3.11
-    3.10
     type
     pkg_meta
 skip_missing_interpreters = true
@@ -45,7 +44,7 @@ commands =
 [testenv:type]
 description = run type check on code base
 deps =
-    mypy==1.14
+    mypy==1.15
     types-docutils>=0.21.0.20241128
 commands =
     mypy src
@@ -56,8 +55,8 @@ description = check that the long description is valid
 skip_install = true
 deps =
     check-wheel-contents>=0.6.1
-    twine>=6.0.1
-    uv>=0.5.11
+    twine>=6.1
+    uv>=0.6.1
 commands =
     uv build --sdist --wheel --out-dir {env_tmp_dir} .
     twine check {env_tmp_dir}{/}*


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 91874ad..19b09f3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import re
 import shutil
 import sys
+from contextlib import suppress
 from pathlib import Path
 from typing import TYPE_CHECKING
 
@@ -36,11 +37,9 @@ def _remove_sphinx_projects(sphinx_test_tempdir: Path) -> None:
     # the temporary directory area.
     # See https://github.com/sphinx-doc/sphinx/issues/4040
     for entry in sphinx_test_tempdir.iterdir():
-        try:
+        with suppress(PermissionError):
             if entry.is_dir() and Path(entry, "_build").exists():
                 shutil.rmtree(str(entry))
-        except PermissionError:  # noqa: PERF203
-            pass
 
 
 @pytest.fixture
diff --git a/tests/test_integration.py b/tests/test_integration.py
index c0d218b..900d585 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -9,6 +9,7 @@ from textwrap import dedent, indent
 from typing import (  # no type comments
     TYPE_CHECKING,
     Any,
+    Literal,
     NewType,
     Optional,
     TypeVar,
@@ -661,6 +662,59 @@ def func_with_overload(a: Union[int, str], b: Union[int, str]) -> None:
     """
 
 
+@expected(
+    """\
+mod.func_literals_long_format(a, b)
+
+   A docstring.
+
+   Parameters:
+      * **a** ("Literal"["'arg1'", "'arg2'"]) -- Argument that can
+        take either of two literal values.
+
+      * **b** ("Literal"["'arg1'", "'arg2'"]) -- Argument that can
+        take either of two literal values.
+
+   Return type:
+      "None"
+""",
+)
+def func_literals_long_format(a: Literal["arg1", "arg2"], b: Literal["arg1", "arg2"]) -> None:
+    """
+    A docstring.
+
+    :param a: Argument that can take either of two literal values.
+    :param b: Argument that can take either of two literal values.
+    """
+
+
+@expected(
+    """\
+mod.func_literals_short_format(a, b)
+
+   A docstring.
+
+   Parameters:
+      * **a** ("'arg1'" | "'arg2'") -- Argument that can take either
+        of two literal values.
+
+      * **b** ("'arg1'" | "'arg2'") -- Argument that can take either
+        of two literal values.
+
+   Return type:
+      "None"
+""",
+    python_display_short_literal_types=True,
+)
+def func_literals_short_format(a: Literal["arg1", "arg2"], b: Literal["arg1", "arg2"]) -> None:
+    """
+    A docstring.
+
+    :param a: Argument that can take either of two literal values.
+    :param b: Argument that can take either of two literal values.
+    """
+
+
 @expected(
     """\
 class mod.TestClassAttributeDocs
@@ -1386,7 +1440,7 @@ def has_doctest1() -> None:
 Unformatted = TypeVar("Unformatted")
 
 
-@warns("cannot cache unpickable configuration value: 'typehints_formatter'")
+@warns("cannot cache unpickleable configuration value: 'typehints_formatter'")
 @expected(
     """
     mod.typehints_formatter_applied_to_signature(param: Formatted) -> Formatted
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 31e077e..c0963c2 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -554,17 +554,6 @@ def test_always_document_param_types(
     assert contents == expected_contents
 
 
-def maybe_fix_py310(expected_contents: str) -> str:
-    if sys.version_info >= (3, 11):
-        return expected_contents
-
-    for old, new in [
-        ('"str" | "None"', '"Optional"["str"]'),
-    ]:
-        expected_contents = expected_contents.replace(old, new)
-    return expected_contents
-
-
 @pytest.mark.sphinx("text", testroot="dummy")
 @patch("sphinx.writers.text.MAXWIDTH", 2000)
 def test_sphinx_output_future_annotations(app: SphinxTestApp, status: StringIO) -> None:
@@ -595,7 +584,7 @@ def test_sphinx_output_future_annotations(app: SphinxTestApp, status: StringIO)
           "str"
     """
     expected_contents = dedent(expected_contents)
-    expected_contents = maybe_fix_py310(dedent(expected_contents))
+    expected_contents = dedent(expected_contents)
     assert contents == expected_contents