Repository: sphinx-doc/sphinx

Issue:
LaTeX: the ``py:class`` directive renders badly in PDF when used for generics with typing
### Describe the bug

With 7.4.0 and current Sphinx I obtain:

![Capture d’écran 2024-08-05 à 14 22 22](https://github.com/user-attachments/assets/1d0d2295-af5c-481d-bd62-b111474523ef)

while with 7.0.1 I obtain

![Capture d’écran 2024-08-05 à 14 19 53](https://github.com/user-attachments/assets/4394e261-17f1-424a-bff8-7adbf788f86a)

(intermediate 7.1.0, 7.2.0, and 7.3.0 raise an error when building the minimal example).

Perhaps my mark-up is not legit? But this copied over from tests/test_builders/test_build_latex.py::test_one_parameter_per_line.

The 7.4.0 HTML build is ok (like PDF from 7.0.1).

### How to Reproduce

index.rst

```rest
domain-py-maximum_signature_line_length
=======================================

text

.. py:class:: MyGenericClass[X]

   A very nice class.

.. py:class:: MyList[T](list[T])

   Another very nice one.

```


### Environment Information

```text
Python version:        3.12.3 (v3.12.3:f6650f9ad7, Apr  9 2024, 08:18:48) [Clang 13.0.0 (clang-1300.0.29.30)])
Python implementation: CPython
Sphinx version:        7.4.0
Docutils version:      0.21.2
Jinja2 version:        3.1.4
Pygments version:      2.18.0
```


### Sphinx extensions

```python
none
```


### Additional context

I tested also with DocUtils 0.19 and DocUtils current master same output.

Same output with current Sphinx also.

I am not one hundred percent sure this is a bug because I never used directly the `py:class` directive always via autodoc. Ping @picnixz for advice.

Discussion:
picnixz: I'll need a laptop for that PR just to confirm that the comments are also correct. The logic should be correct though. I think I first wanted a three arg macro parameter but then I observed that I could just have an empty parameter instead and then I forget to update one case. Thanks JF! I also forgot about the CR for cosmetic purposes. By the way do you think we should have some CRI which also indents the arguments like 


```tex
\macro
  {}
  {}
  {}
  {}
````

Instead of

```tex
\macro
{}
{}
{}
{}
```
jfbu: @picnixz +1 for indenting by two spaces as you display, it is helpful for those who like me like to browse the latex files...
picnixz: Let's do it in a separate PR when I return. 
jfbu: > Let's do it in a separate PR when I return.

ok.

@AA-Turner Can we merge this now? (test of ``tests/test_builders/test_build_latex.py::test_one_parameter_per_line`` will still be failing on DocUtils HEAD, but passes on current DocUtils).
jfbu: This is a bugfix but there was no bug report... I asked for merge because if we do a 8.0.x bugfix release it appeared to belong to it.  I am +0 on adding a CHANGES but documentation is always good, so I can do it if you prefer.
picnixz: Huh, I forgot about this one. Tomorrow afternoon, I'll have a last look and we can merge it I think. Sorry for that JF.
jfbu: > Ok, three is _one_ single case where I'm wondering whether we should actually handle it or not.
> 
> ```
> .. py:function:: generic_foo[R] -> R
> ```
> 

Yes good find this looks a bit anomalous:
- latex code will have no associated `\index` command, and function does not show in the PDF index indeed
- but the latex code itself, using `\pysigline` with one argument, at least does not work only per accident, it does fetch one legit argument and renders it,
- everything is bold including brackets in output, contrasting with output for correct input.

It seems, afaict, that this not correct source anyhow. It does not cause a build crash, it produces in PDF a reasonable output, main deficiency is lacking an index entry (which is actually a help perhaps to locate such problems).

I would say it is a separate issue!

I need to go for some outdoor activity before upcoming bad weather, so I will not do anything yet until a few more hours to give you a chance to respond... +1 for merge-as-is on my side.
picnixz: > I would say it is a separate issue!

I'll file one after this. Otherwise, I'm good to go. You can merge whenever you want!
jfbu: Here we go, thanks @picnixz!

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 5785d62ef..707f2627e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -83,6 +83,8 @@ Bugs fixed
   e.g., ``index.html#foo`` becomes ``#foo``.
   (note: continuation of a partial fix added in Sphinx 7.3.0)
   Patch by James Addison (with reference to prior work by Eric Norige)
+* #12735: Fix :pep:`695` generic classes LaTeX output formatting.
+  Patch by Jean-François B. and Bénédikt Tran.
 * #12782: intersphinx: fix double forward slashes when generating the inventory
   file URL (user-defined base URL of an intersphinx project are left untouched
   even if they end with double forward slashes).
diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py
index 0f2ffc29f..00e8db942 100644
--- a/sphinx/writers/latex.py
+++ b/sphinx/writers/latex.py
@@ -758,26 +758,30 @@ def has_multi_line(e: Element) -> bool:
 
                 if multi_tp_list:
                     if multi_arglist:
-                        self.body.append(CR + r'\pysigwithonelineperargwithonelinepertparg{')
+                        self.body.append(CR + r'\pysigwithonelineperargwithonelinepertparg'
+                                         + CR + '{')
                     else:
-                        self.body.append(CR + r'\pysiglinewithargsretwithonelinepertparg{')
+                        self.body.append(CR + r'\pysiglinewithargsretwithonelinepertparg'
+                                         + CR + '{')
                 else:
                     if multi_arglist:
-                        self.body.append(CR + r'\pysigwithonelineperargwithtypelist{')
+                        self.body.append(CR + r'\pysigwithonelineperargwithtypelist'
+                                         + CR + '{')
                     else:
-                        self.body.append(CR + r'\pysiglinewithargsretwithtypelist{')
+                        self.body.append(CR + r'\pysiglinewithargsretwithtypelist'
+                                         + CR + '{')
                 break
 
             if isinstance(child, addnodes.desc_parameterlist):
                 # arglist only: \macro{name}{arglist}{retann}
                 if has_multi_line(child):
-                    self.body.append(CR + r'\pysigwithonelineperarg{')
+                    self.body.append(CR + r'\pysigwithonelineperarg' + CR + '{')
                 else:
-                    self.body.append(CR + r'\pysiglinewithargsret{')
+                    self.body.append(CR + r'\pysiglinewithargsret' + CR + '{')
                 break
         else:
             # no tp_list, no arglist: \macro{name}
-            self.body.append(CR + r'\pysigline{')
+            self.body.append(CR + r'\pysigline' + CR + '{')
 
     def _depart_signature_line(self, node: Element) -> None:
         self.body.append('}')
@@ -878,26 +882,32 @@ def visit_desc_parameterlist(self, node: Element) -> None:
         if self.has_tp_list:
             if self.orphan_tp_list:
                 # close type parameters list (#2)
-                self.body.append('}{')
+                self.body.append('}' + CR + '{')
                 # empty parameters list argument (#3)
                 return
         else:
             # close name argument (#1), open parameters list argument (#2)
-            self.body.append('}{')
+            self.body.append('}' + CR + '{')
         self._visit_sig_parameter_list(node, addnodes.desc_parameter)
 
     def depart_desc_parameterlist(self, node: Element) -> None:
         # close parameterlist, open return annotation
-        self.body.append('}{')
+        assert not self.orphan_tp_list
+        self.body.append('}' + CR + '{')
 
     def visit_desc_type_parameter_list(self, node: Element) -> None:
         # close name argument (#1), open type parameters list argument (#2)
-        self.body.append('}{')
+        self.body.append('}' + CR + '{')
         self._visit_sig_parameter_list(node, addnodes.desc_type_parameter)
 
     def depart_desc_type_parameter_list(self, node: Element) -> None:
-        # close type parameters list, open parameters list argument (#3)
-        self.body.append('}{')
+        if self.orphan_tp_list:
+            # this node next sibling isn't a desc_parameterlist, there are no parameters:
+            # close the type list, output an empty parameter list, open return annotation.
+            self.body.append('}' + CR + '{}' + CR + '{')
+        else:
+            # close type parameters list, open parameters list argument (#3)
+            self.body.append('}' + CR + '{')
 
     def _visit_sig_parameter(self, node: Element, parameter_macro: str) -> None:
         if self.is_first_param:


Test patch:
diff --git a/tests/test_builders/test_build_latex.py b/tests/test_builders/test_build_latex.py
index 2aa4e0ea5..92fef44df 100644
--- a/tests/test_builders/test_build_latex.py
+++ b/tests/test_builders/test_build_latex.py
@@ -2230,43 +2230,71 @@ def test_one_parameter_per_line(app):
 
     # TODO: should these asserts check presence or absence of a final \sphinxparamcomma?
     # signature of 23 characters is too short to trigger one-param-per-line mark-up
-    assert '\\pysiglinewithargsret{\\sphinxbfcode{\\sphinxupquote{hello}}}' in result
+    assert (
+        '\\pysiglinewithargsret\n'
+        '{\\sphinxbfcode{\\sphinxupquote{hello}}}\n'
+        '{\\sphinxparam{' in result
+    )
 
-    assert '\\pysigwithonelineperarg{\\sphinxbfcode{\\sphinxupquote{foo}}}' in result
+    assert (
+        '\\pysigwithonelineperarg\n'
+        '{\\sphinxbfcode{\\sphinxupquote{foo}}}\n'
+        '{\\sphinxoptional{\\sphinxparam{' in result
+    )
 
     # generic_arg[T]
     assert (
-        '\\pysiglinewithargsretwithtypelist{\\sphinxbfcode{\\sphinxupquote{generic\\_arg}}}'
-        '{\\sphinxtypeparam{\\DUrole{n}{T}}}{}{}'
-    ) in result
+        '\\pysiglinewithargsretwithtypelist\n'
+        '{\\sphinxbfcode{\\sphinxupquote{generic\\_arg}}}\n'
+        '{\\sphinxtypeparam{\\DUrole{n}{T}}}\n'
+        '{}\n'
+        '{}\n' in result
+    )
 
     # generic_foo[T]()
     assert (
-        '\\pysiglinewithargsretwithtypelist{\\sphinxbfcode{\\sphinxupquote{generic\\_foo}}}'
-    ) in result
+        '\\pysiglinewithargsretwithtypelist\n'
+        '{\\sphinxbfcode{\\sphinxupquote{generic\\_foo}}}\n'
+        '{\\sphinxtypeparam{\\DUrole{n}{T}}}\n'
+        '{}\n'
+        '{}\n' in result
+    )
 
     # generic_bar[T](x: list[T])
     assert (
-        '\\pysigwithonelineperargwithtypelist{\\sphinxbfcode{\\sphinxupquote{generic\\_bar}}}'
-    ) in result
+        '\\pysigwithonelineperargwithtypelist\n'
+        '{\\sphinxbfcode{\\sphinxupquote{generic\\_bar}}}\n'
+        '{\\sphinxtypeparam{' in result
+    )
 
     # generic_ret[R]() -> R
     assert (
-        '\\pysiglinewithargsretwithtypelist{\\sphinxbfcode{\\sphinxupquote{generic\\_ret}}}'
-        '{\\sphinxtypeparam{\\DUrole{n}{R}}}{}{{ $\\rightarrow$ R}}'
-    ) in result
+        '\\pysiglinewithargsretwithtypelist\n'
+        '{\\sphinxbfcode{\\sphinxupquote{generic\\_ret}}}\n'
+        '{\\sphinxtypeparam{\\DUrole{n}{R}}}\n'
+        '{}\n'
+        '{{ $\\rightarrow$ R}}\n' in result
+    )
 
     # MyGenericClass[X]
     assert (
-        '\\pysiglinewithargsretwithtypelist{\\sphinxbfcode{\\sphinxupquote{class\\DUrole{w}{ '
-        '}}}\\sphinxbfcode{\\sphinxupquote{MyGenericClass}}}'
-    ) in result
+        '\\pysiglinewithargsretwithtypelist\n'
+        '{\\sphinxbfcode{\\sphinxupquote{class\\DUrole{w}{ }}}'
+        '\\sphinxbfcode{\\sphinxupquote{MyGenericClass}}}\n'
+        '{\\sphinxtypeparam{\\DUrole{n}{X}}}\n'
+        '{}\n'
+        '{}\n' in result
+    )
 
     # MyList[T](list[T])
     assert (
-        '\\pysiglinewithargsretwithtypelist{\\sphinxbfcode{\\sphinxupquote{class\\DUrole{w}{ '
-        '}}}\\sphinxbfcode{\\sphinxupquote{MyList}}}'
-    ) in result
+        '\\pysiglinewithargsretwithtypelist\n'
+        '{\\sphinxbfcode{\\sphinxupquote{class\\DUrole{w}{ }}}'
+        '\\sphinxbfcode{\\sphinxupquote{MyList}}}\n'
+        '{\\sphinxtypeparam{\\DUrole{n}{T}}}\n'
+        '{\\sphinxparam{list{[}T{]}}}\n'
+        '{}\n' in result
+    )
 
 
 @pytest.mark.sphinx('latex', testroot='markup-rubric')