Repository: sphinx-doc/sphinx

Issue:
Double slashes when generating the inventory file URL
### Describe the bug

This might be considered an edge case but I've noticed that if the value tuple of a `intersphinx_mapping` contains a base URI with a trailing slash and `None` as the inventory file, Sphinx will try to load a URL with double slashes.

### How to Reproduce

Using Sphinx 8.0.2 set the following in the `conf.py` file of a `sphinx-quickstart` project:

```python
extensions = ["sphinx.ext.intersphinx"]
intersphinx_mapping = {'python': ('https://docs.python.org/3/', None)}
```

Run `make html` and notice the double slashes in the output message:

```console
...
loading intersphinx inventory 'python' from https://docs.python.org/3//objects.inv ...
...
```

The double slashes URL could be problematic depending on the web server setup.

### Environment Information

```text
Platform:              linux; (Linux-6.5.0-45-generic-x86_64-with-glibc2.35)
Python version:        3.10.12 (main, Jul 29 2024, 16:56:48) [GCC 11.4.0])
Python implementation: CPython
Sphinx version:        8.0.2
Docutils version:      0.21.2
Jinja2 version:        3.1.4
Pygments version:      2.18.0
```


### Sphinx extensions

```python
["sphinx.ext.intersphinx"]
```


### Additional context

I think the problem could have been introduced in https://github.com/sphinx-doc/sphinx/pull/12087 where:

https://github.com/sphinx-doc/sphinx/blob/f90b506a9ff2b202fbec707be776cfa590519a14/sphinx/ext/intersphinx/_load.py#L185-L186

was changed into:

https://github.com/sphinx-doc/sphinx/blob/fadb6b10cb15d2a8ce336cee307dcb3ff64680bd/sphinx/ext/intersphinx/_load.py#L214

`posixpath.join` removes the trailing slash in the target URI, but the f-string doesn't.

Discussion:
AA-Turner: Can we add a test?

A
picnixz: Yes, we can. I'll prepare it now.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 3f0ce6e7b..5785d62ef 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -83,6 +83,10 @@ 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)
+* #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).
+  Patch by Bénédikt Tran.
 
 Testing
 -------
diff --git a/doc/man/sphinx-autogen.rst b/doc/man/sphinx-autogen.rst
index 43cc369b7..fed230d2c 100644
--- a/doc/man/sphinx-autogen.rst
+++ b/doc/man/sphinx-autogen.rst
@@ -14,7 +14,7 @@ that, using the :py:mod:`~sphinx.ext.autodoc` extension, document items included
 in :rst:dir:`autosummary` listing(s).
 
 *sourcefile* is the path to one or more reStructuredText documents containing
-:rst:dir:`autosummary` entries with the ``:toctree::`` option set. *sourcefile*
+:rst:dir:`autosummary` entries with the ``:toctree:`` option set. *sourcefile*
 can be an :py:mod:`fnmatch`-style pattern.
 
 Options
diff --git a/pyproject.toml b/pyproject.toml
index dcca55027..96aa09271 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -87,7 +87,7 @@ lint = [
     "types-colorama==0.4.15.20240311",
     "types-defusedxml==0.7.0.20240218",
     "types-docutils==0.21.0.20240724",
-    "types-Pillow==10.2.0.20240520",
+    "types-Pillow==10.2.0.20240822",
     "types-Pygments==2.18.0.20240506",
     "types-requests>=2.30.0",  # align with requests
     "tomli>=2",  # for mypy (Python<=3.10)
diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py
index 1973de379..358ae1f7c 100644
--- a/sphinx/ext/intersphinx/_load.py
+++ b/sphinx/ext/intersphinx/_load.py
@@ -211,7 +211,10 @@ def _fetch_inventory_group(
 
     for location in project.locations:
         # location is either None or a non-empty string
-        inv = f'{project.target_uri}/{INVENTORY_FILENAME}' if location is None else location
+        if location is None:
+            inv = posixpath.join(project.target_uri, INVENTORY_FILENAME)
+        else:
+            inv = location
 
         # decide whether the inventory must be read: always read local
         # files; remote ones only if the cache time is expired


Test patch:
diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py
index c41ac982c..99dde92a5 100644
--- a/tests/test_extensions/test_ext_intersphinx.py
+++ b/tests/test_extensions/test_ext_intersphinx.py
@@ -12,6 +12,7 @@
 
 from sphinx import addnodes
 from sphinx.builders.html import INVENTORY_FILENAME
+from sphinx.config import Config
 from sphinx.errors import ConfigError
 from sphinx.ext.intersphinx import (
     inspect_main,
@@ -768,3 +769,73 @@ def test_intersphinx_cache_limit(app):
             config=app.config,
             srcdir=app.srcdir,
         )
+
+
+def test_intersphinx_fetch_inventory_group_url():
+    class InventoryHandler(http.server.BaseHTTPRequestHandler):
+        def do_GET(self):
+            self.send_response(200, 'OK')
+            self.end_headers()
+            self.wfile.write(INVENTORY_V2)
+
+        def log_message(*args, **kwargs):
+            # Silenced.
+            pass
+
+    with http_server(InventoryHandler) as server:
+        url1 = f'http://localhost:{server.server_port}'
+        url2 = f'http://localhost:{server.server_port}/'
+
+        config = Config()
+        config.intersphinx_cache_limit = -1
+        config.intersphinx_mapping = {
+            '1': (url1, None),
+            '2': (url2, None),
+        }
+
+        now = int(time.time())
+        # we can use 'srcdir=None' since we are raising in _fetch_inventory
+        kwds = {'cache': {}, 'now': now, 'config': config, 'srcdir': None}
+        # We need an exception with its 'args' attribute set (see error
+        # handling in sphinx.ext.intersphinx._load._fetch_inventory_group).
+        side_effect = ValueError('')
+
+        project1 = _IntersphinxProject(
+            name='1', target_uri=url1, locations=(url1, None)
+        )
+        with mock.patch(
+            'sphinx.ext.intersphinx._load._fetch_inventory', side_effect=side_effect
+        ) as mockfn:
+            assert not _fetch_inventory_group(project=project1, **kwds)
+        mockfn.assert_any_call(
+            target_uri=url1,
+            inv_location=url1,
+            config=config,
+            srcdir=None,
+        )
+        mockfn.assert_any_call(
+            target_uri=url1,
+            inv_location=url1 + '/' + INVENTORY_FILENAME,
+            config=config,
+            srcdir=None,
+        )
+
+        project2 = _IntersphinxProject(
+            name='2', target_uri=url2, locations=(url2, None)
+        )
+        with mock.patch(
+            'sphinx.ext.intersphinx._load._fetch_inventory', side_effect=side_effect
+        ) as mockfn:
+            assert not _fetch_inventory_group(project=project2, **kwds)
+        mockfn.assert_any_call(
+            target_uri=url2,
+            inv_location=url2,
+            config=config,
+            srcdir=None,
+        )
+        mockfn.assert_any_call(
+            target_uri=url2,
+            inv_location=url2 + INVENTORY_FILENAME,
+            config=config,
+            srcdir=None,
+        )