Repository: sphinx-doc/sphinx

Issue:
Module index (py-modindex.html) has duplicate titles, one of which is a hyperlink and the other is openable/closable
After running `sphinx-apidoc` on my project, then using the default `make html` created by `sphinx-quickstart`, I ended up with a Module Index that had duplicate titles for some packages. One of the titles is a clickable hyperlink, and the other one is an "openable" directory. I believe these two titles should be merged into one clickable hyperlink that's also an openable directory.

There's an easy way to show the bug, it happens in Sphinx's official documentations too: https://www.sphinx-doc.org/en/master/py-modindex.html

At the time of this bug report, this is what the `sphinx.builders` section looks like:

![sphinx.builders](https://user-images.githubusercontent.com/11020883/57912316-b2b0cb80-7892-11e9-8be9-106989103b84.png)

The same also happens with the `sphinx.environment`. Clicking the `[-]` icon next to the hyperlink title doesn't do anything (it just turns into a `[+]` icon), while the icon next to the non-hyperlink one works fine.

Discussion:

Patch:
diff --git a/CHANGES b/CHANGES
index cedbb7b61..024ff876c 100644
--- a/CHANGES
+++ b/CHANGES
@@ -110,6 +110,7 @@ Bugs fixed
 * #6213: ifconfig: contents after headings are not shown
 * commented term in glossary directive is wrongly recognized
 * #6299: rst domain: rst:directive directive generates waste space
+* #6379: py domain: Module index (py-modindex.html) has duplicate titles
 * #6331: man: invalid output when doctest follows rubric
 * #6351: "Hyperlink target is not referenced" message is shown even if
   referenced
diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py
index c4971ba60..9d3dc7565 100644
--- a/sphinx/domains/python.py
+++ b/sphinx/domains/python.py
@@ -858,7 +858,6 @@ def generate(self, docnames=None):
                         last = entries[-1]
                         entries[-1] = IndexEntry(last[0], 1, last[2], last[3],
                                                  last[4], last[5], last[6])
-                    entries.append(IndexEntry(stripped + package, 1, '', '', '', '', ''))
                 elif not prev_modname.startswith(package):
                     # submodule without parent in list, add dummy entry
                     entries.append(IndexEntry(stripped + package, 1, '', '', '', '', ''))


Test patch:
diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py
index fac8a838f..1c33ad4ba 100644
--- a/tests/test_domain_py.py
+++ b/tests/test_domain_py.py
@@ -18,7 +18,10 @@
     desc, desc_addname, desc_annotation, desc_content, desc_name, desc_optional,
     desc_parameter, desc_parameterlist, desc_returns, desc_signature
 )
-from sphinx.domains.python import py_sig_re, _pseudo_parse_arglist, PythonDomain
+from sphinx.domains import IndexEntry
+from sphinx.domains.python import (
+    py_sig_re, _pseudo_parse_arglist, PythonDomain, PythonModuleIndex
+)
 from sphinx.testing import restructuredtext
 from sphinx.testing.util import assert_node
 
@@ -460,3 +463,49 @@ def test_pyattribute(app):
                                    [desc_content, ()]))
     assert 'Class.attr' in domain.objects
     assert domain.objects['Class.attr'] == ('index', 'attribute')
+
+
+@pytest.mark.sphinx(freshenv=True)
+def test_module_index(app):
+    text = (".. py:module:: docutils\n"
+            ".. py:module:: sphinx\n"
+            ".. py:module:: sphinx.config\n"
+            ".. py:module:: sphinx.builders\n"
+            ".. py:module:: sphinx.builders.html\n"
+            ".. py:module:: sphinx_intl\n")
+    restructuredtext.parse(app, text)
+    index = PythonModuleIndex(app.env.get_domain('py'))
+    assert index.generate() == (
+        [('d', [IndexEntry('docutils', 0, 'index', 'module-docutils', '', '', '')]),
+         ('s', [IndexEntry('sphinx', 1, 'index', 'module-sphinx', '', '', ''),
+                IndexEntry('sphinx.builders', 2, 'index', 'module-sphinx.builders', '', '', ''),  # NOQA
+                IndexEntry('sphinx.builders.html', 2, 'index', 'module-sphinx.builders.html', '', '', ''),  # NOQA
+                IndexEntry('sphinx.config', 2, 'index', 'module-sphinx.config', '', '', ''),
+                IndexEntry('sphinx_intl', 0, 'index', 'module-sphinx_intl', '', '', '')])],
+        False
+    )
+
+
+@pytest.mark.sphinx(freshenv=True)
+def test_module_index_submodule(app):
+    text = ".. py:module:: sphinx.config\n"
+    restructuredtext.parse(app, text)
+    index = PythonModuleIndex(app.env.get_domain('py'))
+    assert index.generate() == (
+        [('s', [IndexEntry('sphinx', 1, '', '', '', '', ''),
+                IndexEntry('sphinx.config', 2, 'index', 'module-sphinx.config', '', '', '')])],
+        False
+    )
+
+
+@pytest.mark.sphinx(freshenv=True)
+def test_module_index_not_collapsed(app):
+    text = (".. py:module:: docutils\n"
+            ".. py:module:: sphinx\n")
+    restructuredtext.parse(app, text)
+    index = PythonModuleIndex(app.env.get_domain('py'))
+    assert index.generate() == (
+        [('d', [IndexEntry('docutils', 0, 'index', 'module-docutils', '', '', '')]),
+         ('s', [IndexEntry('sphinx', 0, 'index', 'module-sphinx', '', '', '')])],
+        True
+    )