Repository: sphinx-doc/sphinx

Issue:
Support `linkcheck_ignore` in link redirection
**Is your feature request related to a problem? Please describe.**
Specifying a domain in `linkcheck_ignore` works well for links containing this domain but it doesnn't for links which redirect to a link to the domain to be ignored.
For example, the following configuration:
```python
linkcheck_ignore = [
    "https://onlinelibrary.wiley.com",  # 403 Client Error: Forbidden for url
]
```
works perfectly for links like `https://onlinelibrary.wiley.com/doi/10.1002/jemt.20597` but not for `https://doi.org/10.1002/jemt.20597`, which redirect to `https://onlinelibrary.wiley.com/doi/10.1002/jemt.20597`

**Describe the solution you'd like**
The `linkcheck_ignore` configuration parameters should also apply to redirect links.

**Additional context**
See for example https://github.com/hyperspy/hyperspy/pull/3108. This typically happen for DOI links, which are by design permanent url and redirect to urls which can changed. In this case, the DOI should be used in favour of the redirect url however, the `linkcheck_ignore` will not be effective on the redirect url.

Hints from issue comments:
@francoisfreitag: Sounds very reasonable to me. When a user expects all links to a domain (or path) to be ignored, linkcheck should also ignore the redirections pointing to that domain.@goekce: I tried to understand why Wiley URLs have this problem but was not successful, so I asked for help upstream:  https://github.com/psf/requests/issues/6471

Meanwhile having the option to ignore specific redirect sites of DOI links would a good idea.

<details><summary>Here is what I have tried here if someone is interested</summary>
If I for instance visit `https://doi.org/10.1002/jccs.200600142` with my browser, everything is fine. But both requests and Sphinx fail:

```sh
python -c "import requests; print(requests.head('https://doi.org/10.1002/jccs.200600142', allow_redirects=True)); import sphinx.util.requests; print(sphinx.util.requests.head('https://doi.org/10.1002/jccs.200600142', allow_redirects=True))"
<Response [403]>
<Response [403]>
```

I also tried accepting cookies and changing the user-agent, which also did not help:
```py
import requests
with requests.Session() as s:
    print(s.get('https://doi.org/10.1002/jccs.200600142', allow_redirects=True, headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/114.0'}))
```

I posted this upstream: https://github.com/psf/requests/issues/6471
</details>@francoisfreitag: Unsure what problem Wiley URLs have? They are always replying with a 403. The issue here is that developers can instruct linkcheck to ignore URLs matching a regexp pattern, but that URL is only ignored if it appears in the documentation, not if it comes as the result of a redirect.

So:
```python
# conf.py
linkcheck_ignore = [
    "https://onlinelibrary.wiley.com",  # 403 Client Error: Forbidden for url
]
```
```rst
.. doc.rst
.. this link is ignored by linkcheck, it matches the pattern from linkcheck_ignore
`direct link <https://onlinelibrary.wiley.com/doi/10.1002/jemt.20597>`_
.. doi.org redirects to Wiley, but linkcheck does not check the linkcheck_ignore during the redirection chain, so the following shows as a broken link for linkcheck
`indirect link <https://doi.org/10.1002/jemt.20597>`_@jayaddison: I'm interested in implementing this feature, but there's a detail about the existing implementation that I think is important to consider first:

The Sphinx `linkcheck` builder currently uses the `requests` HTTP client to handle following of redirects, and so the logic to determine whether one-or-more-redirects will be followed is encapsulated by that library, and a naive application of `linkcheck_ignore` rules would only apply after a final response URL is resolved (or a timeout occurs, or an error code is returned, etc).

My intuition for a feature like this is that we'd ideally want to ignore redirections as soon as they suggest navigating through an ignored path -- that is, we'd follow the initial hyperlink, and if it tells us to go to a known-ignorable URL, we'd stop immediately and return the `ignore` status code for that link.  I believe that'd be preferable because it'd imply that we'd generate less network traffic, we'd spend less time linkchecking, and we wouldn't attempt to initiate traffic to domains we've configured as ignored.

To do so, however, we'd probably want to adjust Sphinx's `linkchecker` so that `requests` doesn't follow redirects on its behalf.  That's OK... but it potentially has other consequences; we'd want to enforce a maximum-redirect limit (similar to the long-standing [`requests.sessions.DEFAULT_REDIRECT_LIMIT`](https://github.com/psf/requests/blob/22701d149ad9585cc01b3f9bda4e78cd77ffb996/requests/sessions.py#L34)), for example.  And `requests` itself will have had a wealth of experience dealing with unusual behaviours and bugreports related to redirects.  So... as a maintainer I'm initially a bit cautious (an understatement) about this.

We _could_ perhaps apply the naive solution, and ignore URLs after `requests` has followed all redirects.  Perhaps that's OK - I just don't love the idea that some traffic may in fact navigate through ignored URLs.  Hiding those by marking them as `ignored` -- even though they weren't -- feels potentially worse than doing nothing.@jayaddison: The concerns I had in my previous comment about achieving this using `requests` have been resolved after I learned that the `requests.Session` class -- that we use for all `linkcheck` HTTP requests -- provides a conveniently-overridable `get_redirect_target` method.

By overriding that method in #13127 in a Sphinx-internal subclass of `requests.Session`, I'm suggesting a change that inspects each HTTP redirect as-it-occurs, and compares each against the configured `linkcheck_ignore` patterns.

Solution patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 29759d1df..b4d416a55 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -35,6 +35,7 @@ Contributors
 * Bruce Mitchener -- Minor epub improvement
 * Buck Evan -- dummy builder
 * Charles Duffy -- original graphviz extension
+* Chris Barrick -- Napoleon type preprocessing logic
 * Chris Holdgraf -- improved documentation structure
 * Chris Lamb -- reproducibility fixes
 * Christopher Perkins -- autosummary integration
diff --git a/CHANGES.rst b/CHANGES.rst
index 1d6132034..9a69515b2 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -26,6 +26,9 @@ Features added
   Patch by Nicolas Peugnet.
 * #13144: Add a ``class`` option to the :rst:dir:`autosummary` directive.
   Patch by Tim Hoffmann.
+* #13146: Napoleon: Unify the type preprocessing logic to allow
+  Google-style docstrings to use the optional and default keywords.
+  Patch by Chris Barrick.
 
 Bugs fixed
 ----------
@@ -40,6 +43,8 @@ Bugs fixed
   Patch by Jean-François B.
 * #13096: HTML Search: check that query terms exist as properties in
   term indices before accessing them.
+* #11233: linkcheck: match redirect URIs against :confval:`linkcheck_ignore` by
+  overriding session-level ``requests.get_redirect_target``.
 
 Testing
 -------
diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst
index 69c9ccaa1..6b23825a3 100644
--- a/doc/usage/configuration.rst
+++ b/doc/usage/configuration.rst
@@ -3709,6 +3709,9 @@ and which failures and redirects it ignores.
    A list of regular expressions that match URIs that should not be checked
    when doing a ``linkcheck`` build.
 
+   Server-issued redirects that match :confval:`ignored URIs <linkcheck_ignore>`
+   will not be followed.
+
    Example:
 
    .. code-block:: python
diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py
index 7e4ac98ec..d1ad8692d 100644
--- a/sphinx/builders/linkcheck.py
+++ b/sphinx/builders/linkcheck.py
@@ -398,7 +398,9 @@ def __init__(
         self.tls_verify = config.tls_verify
         self.tls_cacerts = config.tls_cacerts
 
-        self._session = requests._Session()
+        self._session = requests._Session(
+            _ignored_redirects=tuple(map(re.compile, config.linkcheck_ignore))
+        )
 
         super().__init__(daemon=True)
 
@@ -570,6 +572,14 @@ def _check_uri(self, uri: str, hyperlink: Hyperlink) -> _URIProperties:
                 error_message = str(err)
                 continue
 
+            except requests._IgnoredRedirection as err:
+                # A redirection to an ignored URI was attempted; report it appropriately
+                return (
+                    _Status.IGNORED,
+                    f'ignored redirect: {err.destination}',
+                    err.status_code,
+                )
+
             except HTTPError as err:
                 error_message = str(err)
 
diff --git a/sphinx/ext/intersphinx/_cli.py b/sphinx/ext/intersphinx/_cli.py
index 04ac28762..552b537ef 100644
--- a/sphinx/ext/intersphinx/_cli.py
+++ b/sphinx/ext/intersphinx/_cli.py
@@ -3,8 +3,9 @@
 from __future__ import annotations
 
 import sys
+from pathlib import Path
 
-from sphinx.ext.intersphinx._load import _fetch_inventory
+from sphinx.ext.intersphinx._load import _fetch_inventory, _InvConfig
 
 
 def inspect_main(argv: list[str], /) -> int:
@@ -17,19 +18,21 @@ def inspect_main(argv: list[str], /) -> int:
         )
         return 1
 
-    class MockConfig:
-        intersphinx_timeout: int | None = None
-        tls_verify = False
-        tls_cacerts: str | dict[str, str] | None = None
-        user_agent: str = ''
+    filename = argv[0]
+    config = _InvConfig(
+        intersphinx_cache_limit=5,
+        intersphinx_timeout=None,
+        tls_verify=False,
+        tls_cacerts=None,
+        user_agent='',
+    )
 
     try:
-        filename = argv[0]
         inv_data = _fetch_inventory(
             target_uri='',
             inv_location=filename,
-            config=MockConfig(),  # type: ignore[arg-type]
-            srcdir='',  # type: ignore[arg-type]
+            config=config,
+            srcdir=Path(''),
         )
         for key in sorted(inv_data or {}):
             print(key)
diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py
index eb8b46be8..b8257b916 100644
--- a/sphinx/ext/intersphinx/_load.py
+++ b/sphinx/ext/intersphinx/_load.py
@@ -3,6 +3,8 @@
 from __future__ import annotations
 
 import concurrent.futures
+import dataclasses
+import io
 import os.path
 import posixpath
 import time
@@ -20,8 +22,6 @@
 if TYPE_CHECKING:
     from pathlib import Path
 
-    from urllib3.response import HTTPResponse
-
     from sphinx.application import Sphinx
     from sphinx.config import Config
     from sphinx.ext.intersphinx._shared import (
@@ -31,7 +31,7 @@
         InventoryName,
         InventoryURI,
     )
-    from sphinx.util.typing import Inventory, _ReadableStream
+    from sphinx.util.typing import Inventory
 
 
 def validate_intersphinx_mapping(app: Sphinx, config: Config) -> None:
@@ -170,6 +170,7 @@ def load_mappings(app: Sphinx) -> None:
             # This happens when the URI in `intersphinx_mapping` is changed.
             del intersphinx_cache[uri]
 
+    inv_config = _InvConfig.from_config(app.config)
     with concurrent.futures.ThreadPoolExecutor() as pool:
         futures = [
             pool.submit(
@@ -177,7 +178,7 @@ def load_mappings(app: Sphinx) -> None:
                 project=project,
                 cache=intersphinx_cache,
                 now=now,
-                config=app.config,
+                config=inv_config,
                 srcdir=app.srcdir,
             )
             for project in projects
@@ -202,12 +203,31 @@ def load_mappings(app: Sphinx) -> None:
                 inventories.main_inventory.setdefault(objtype, {}).update(objects)
 
 
+@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
+class _InvConfig:
+    intersphinx_cache_limit: int
+    intersphinx_timeout: int | float | None
+    tls_verify: bool
+    tls_cacerts: str | dict[str, str] | None
+    user_agent: str
+
+    @classmethod
+    def from_config(cls, config: Config) -> _InvConfig:
+        return cls(
+            intersphinx_cache_limit=config.intersphinx_cache_limit,
+            intersphinx_timeout=config.intersphinx_timeout,
+            tls_verify=config.tls_verify,
+            tls_cacerts=config.tls_cacerts,
+            user_agent=config.user_agent,
+        )
+
+
 def _fetch_inventory_group(
     *,
     project: _IntersphinxProject,
     cache: dict[InventoryURI, InventoryCacheEntry],
     now: int,
-    config: Config,
+    config: _InvConfig,
     srcdir: Path,
 ) -> bool:
     if config.intersphinx_cache_limit >= 0:
@@ -284,26 +304,51 @@ def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory:
     return _fetch_inventory(
         target_uri=uri,
         inv_location=inv,
-        config=app.config,
+        config=_InvConfig.from_config(app.config),
         srcdir=app.srcdir,
     )
 
 
 def _fetch_inventory(
-    *, target_uri: InventoryURI, inv_location: str, config: Config, srcdir: Path
+    *, target_uri: InventoryURI, inv_location: str, config: _InvConfig, srcdir: Path
 ) -> Inventory:
     """Fetch, parse and return an intersphinx inventory file."""
     # both *target_uri* (base URI of the links to generate)
     # and *inv_location* (actual location of the inventory file)
     # can be local or remote URIs
     if '://' in target_uri:
-        # case: inv URI points to remote resource; strip any existing auth
+        # inv URI points to remote resource; strip any existing auth
         target_uri = _strip_basic_auth(target_uri)
+    if '://' in inv_location:
+        raw_data, target_uri = _fetch_inventory_url(
+            target_uri=target_uri, inv_location=inv_location, config=config
+        )
+    else:
+        raw_data = _fetch_inventory_file(inv_location=inv_location, srcdir=srcdir)
+
+    stream = io.BytesIO(raw_data)
     try:
-        if '://' in inv_location:
-            f: _ReadableStream[bytes] = _read_from_url(inv_location, config=config)
-        else:
-            f = open(os.path.join(srcdir, inv_location), 'rb')  # NoQA: SIM115
+        invdata = InventoryFile.load(stream, target_uri, posixpath.join)
+    except ValueError as exc:
+        msg = f'unknown or unsupported inventory version: {exc!r}'
+        raise ValueError(msg) from exc
+    return invdata
+
+
+def _fetch_inventory_url(
+    *, target_uri: InventoryURI, inv_location: str, config: _InvConfig
+) -> tuple[bytes, str]:
+    try:
+        with requests.get(
+            inv_location,
+            stream=True,
+            timeout=config.intersphinx_timeout,
+            _user_agent=config.user_agent,
+            _tls_info=(config.tls_verify, config.tls_cacerts),
+        ) as r:
+            r.raise_for_status()
+            raw_data = r.content
+            new_inv_location = r.url
     except Exception as err:
         err.args = (
             'intersphinx inventory %r not fetchable due to %s: %s',
@@ -312,25 +357,25 @@ def _fetch_inventory(
             str(err),
         )
         raise
+
+    if inv_location != new_inv_location:
+        msg = __('intersphinx inventory has moved: %s -> %s')
+        LOGGER.info(msg, inv_location, new_inv_location)
+
+        if target_uri in {
+            inv_location,
+            os.path.dirname(inv_location),
+            os.path.dirname(inv_location) + '/',
+        }:
+            target_uri = os.path.dirname(new_inv_location)
+
+    return raw_data, target_uri
+
+
+def _fetch_inventory_file(*, inv_location: str, srcdir: Path) -> bytes:
     try:
-        if hasattr(f, 'url'):
-            new_inv_location = f.url
-            if inv_location != new_inv_location:
-                msg = __('intersphinx inventory has moved: %s -> %s')
-                LOGGER.info(msg, inv_location, new_inv_location)
-
-                if target_uri in {
-                    inv_location,
-                    os.path.dirname(inv_location),
-                    os.path.dirname(inv_location) + '/',
-                }:
-                    target_uri = os.path.dirname(new_inv_location)
-        with f:
-            try:
-                invdata = InventoryFile.load(f, target_uri, posixpath.join)
-            except ValueError as exc:
-                msg = f'unknown or unsupported inventory version: {exc!r}'
-                raise ValueError(msg) from exc
+        with open(srcdir / inv_location, 'rb') as f:
+            raw_data = f.read()
     except Exception as err:
         err.args = (
             'intersphinx inventory %r not readable due to %s: %s',
@@ -339,8 +384,7 @@ def _fetch_inventory(
             str(err),
         )
         raise
-    else:
-        return invdata
+    return raw_data
 
 
 def _get_safe_url(url: str) -> str:
@@ -387,37 +431,3 @@ def _strip_basic_auth(url: str) -> str:
     if '@' in frags[1]:
         frags[1] = frags[1].split('@')[1]
     return urlunsplit(frags)
-
-
-def _read_from_url(url: str, *, config: Config) -> HTTPResponse:
-    """Reads data from *url* with an HTTP *GET*.
-
-    This function supports fetching from resources which use basic HTTP auth as
-    laid out by RFC1738 § 3.1. See § 5 for grammar definitions for URLs.
-
-    .. seealso:
-
-       https://www.ietf.org/rfc/rfc1738.txt
-
-    :param url: URL of an HTTP resource
-    :type url: ``str``
-
-    :return: data read from resource described by *url*
-    :rtype: ``file``-like object
-    """
-    r = requests.get(
-        url,
-        stream=True,
-        timeout=config.intersphinx_timeout,
-        _user_agent=config.user_agent,
-        _tls_info=(config.tls_verify, config.tls_cacerts),
-    )
-    r.raise_for_status()
-
-    # For inv_location / new_inv_location
-    r.raw.url = r.url  # type: ignore[union-attr]
-
-    # Decode content-body based on the header.
-    # xref: https://github.com/psf/requests/issues/2155
-    r.raw.decode_content = True
-    return r.raw
diff --git a/sphinx/ext/napoleon/docstring.py b/sphinx/ext/napoleon/docstring.py
index 9b987566e..2c6aaedea 100644
--- a/sphinx/ext/napoleon/docstring.py
+++ b/sphinx/ext/napoleon/docstring.py
@@ -77,13 +77,185 @@ def next(self) -> Any:
             raise StopIteration
 
 
-def _convert_type_spec(_type: str, translations: dict[str, str] | None = None) -> str:
-    """Convert type specification to reference in reST."""
-    if translations is not None and _type in translations:
-        return translations[_type]
-    if _type == 'None':
-        return ':py:obj:`None`'
-    return f':py:class:`{_type}`'
+def _recombine_set_tokens(tokens: list[str]) -> list[str]:
+    token_queue = collections.deque(tokens)
+    keywords = ('optional', 'default')
+
+    def takewhile_set(tokens: collections.deque[str]) -> Iterator[str]:
+        open_braces = 0
+        previous_token = None
+        while True:
+            try:
+                token = tokens.popleft()
+            except IndexError:
+                break
+
+            if token == ', ':
+                previous_token = token
+                continue
+
+            if not token.strip():
+                continue
+
+            if token in keywords:
+                tokens.appendleft(token)
+                if previous_token is not None:
+                    tokens.appendleft(previous_token)
+                break
+
+            if previous_token is not None:
+                yield previous_token
+                previous_token = None
+
+            if token == '{':
+                open_braces += 1
+            elif token == '}':
+                open_braces -= 1
+
+            yield token
+
+            if open_braces == 0:
+                break
+
+    def combine_set(tokens: collections.deque[str]) -> Iterator[str]:
+        while True:
+            try:
+                token = tokens.popleft()
+            except IndexError:
+                break
+
+            if token == '{':
+                tokens.appendleft('{')
+                yield ''.join(takewhile_set(tokens))
+            else:
+                yield token
+
+    return list(combine_set(token_queue))
+
+
+def _tokenize_type_spec(spec: str) -> list[str]:
+    def postprocess(item: str) -> list[str]:
+        if _default_regex.match(item):
+            default = item[:7]
+            # can't be separated by anything other than a single space
+            # for now
+            other = item[8:]
+
+            return [default, ' ', other]
+        else:
+            return [item]
+
+    tokens = [
+        item
+        for raw_token in _token_regex.split(spec)
+        for item in postprocess(raw_token)
+        if item
+    ]
+    return tokens
+
+
+def _token_type(token: str, debug_location: str | None = None) -> str:
+    def is_numeric(token: str) -> bool:
+        try:
+            # use complex to make sure every numeric value is detected as literal
+            complex(token)
+        except ValueError:
+            return False
+        else:
+            return True
+
+    if token.startswith(' ') or token.endswith(' '):
+        type_ = 'delimiter'
+    elif (
+        is_numeric(token)
+        or (token.startswith('{') and token.endswith('}'))
+        or (token.startswith('"') and token.endswith('"'))
+        or (token.startswith("'") and token.endswith("'"))
+    ):
+        type_ = 'literal'
+    elif token.startswith('{'):
+        logger.warning(
+            __('invalid value set (missing closing brace): %s'),
+            token,
+            location=debug_location,
+        )
+        type_ = 'literal'
+    elif token.endswith('}'):
+        logger.warning(
+            __('invalid value set (missing opening brace): %s'),
+            token,
+            location=debug_location,
+        )
+        type_ = 'literal'
+    elif token.startswith(("'", '"')):
+        logger.warning(
+            __('malformed string literal (missing closing quote): %s'),
+            token,
+            location=debug_location,
+        )
+        type_ = 'literal'
+    elif token.endswith(("'", '"')):
+        logger.warning(
+            __('malformed string literal (missing opening quote): %s'),
+            token,
+            location=debug_location,
+        )
+        type_ = 'literal'
+    elif token in {'optional', 'default'}:
+        # default is not a official keyword (yet) but supported by the
+        # reference implementation (numpydoc) and widely used
+        type_ = 'control'
+    elif _xref_regex.match(token):
+        type_ = 'reference'
+    else:
+        type_ = 'obj'
+
+    return type_
+
+
+def _convert_type_spec(
+    _type: str,
+    translations: dict[str, str] | None = None,
+    debug_location: str | None = None,
+) -> str:
+    if translations is None:
+        translations = {}
+
+    def convert_obj(
+        obj: str, translations: dict[str, str], default_translation: str
+    ) -> str:
+        translation = translations.get(obj, obj)
+
+        # use :class: (the default) only if obj is not a standard singleton
+        if translation in _SINGLETONS and default_translation == ':class:`%s`':
+            default_translation = ':obj:`%s`'
+        elif translation == '...' and default_translation == ':class:`%s`':
+            # allow referencing the builtin ...
+            default_translation = ':obj:`%s <Ellipsis>`'
+
+        if _xref_regex.match(translation) is None:
+            translation = default_translation % translation
+
+        return translation
+
+    tokens = _tokenize_type_spec(_type)
+    combined_tokens = _recombine_set_tokens(tokens)
+    types = [(token, _token_type(token, debug_location)) for token in combined_tokens]
+
+    converters = {
+        'literal': lambda x: '``%s``' % x,
+        'obj': lambda x: convert_obj(x, translations, ':class:`%s`'),
+        'control': lambda x: '*%s*' % x,
+        'delimiter': lambda x: x,
+        'reference': lambda x: x,
+    }
+
+    converted = ''.join(
+        converters.get(type_)(token)  # type: ignore[misc]
+        for token, type_ in types
+    )
+
+    return converted
 
 
 class GoogleDocstring:
@@ -252,6 +424,20 @@ def __str__(self) -> str:
         """
         return '\n'.join(self.lines())
 
+    def _get_location(self) -> str | None:
+        try:
+            filepath = inspect.getfile(self._obj) if self._obj is not None else None
+        except TypeError:
+            filepath = None
+        name = self._name
+
+        if filepath is None and name is None:
+            return None
+        elif filepath is None:
+            filepath = ''
+
+        return f'{filepath}:docstring of {name}'
+
     def lines(self) -> list[str]:
         """Return the parsed lines of the docstring in reStructuredText format.
 
@@ -309,7 +495,11 @@ def _consume_field(
             _type, _name = _name, _type
 
         if _type and self._config.napoleon_preprocess_types:
-            _type = _convert_type_spec(_type, self._config.napoleon_type_aliases or {})
+            _type = _convert_type_spec(
+                _type,
+                translations=self._config.napoleon_type_aliases or {},
+                debug_location=self._get_location(),
+            )
 
         indent = self._get_indent(line) + 1
         _descs = [_desc, *self._dedent(self._consume_indented_block(indent))]
@@ -357,7 +547,9 @@ def _consume_returns_section(
 
             if _type and preprocess_types and self._config.napoleon_preprocess_types:
                 _type = _convert_type_spec(
-                    _type, self._config.napoleon_type_aliases or {}
+                    _type,
+                    translations=self._config.napoleon_type_aliases or {},
+                    debug_location=self._get_location(),
                 )
 
             _desc = self.__class__(_desc, self._config).lines()
@@ -914,187 +1106,6 @@ def _lookup_annotation(self, _name: str) -> str:
         return ''
 
 
-def _recombine_set_tokens(tokens: list[str]) -> list[str]:
-    token_queue = collections.deque(tokens)
-    keywords = ('optional', 'default')
-
-    def takewhile_set(tokens: collections.deque[str]) -> Iterator[str]:
-        open_braces = 0
-        previous_token = None
-        while True:
-            try:
-                token = tokens.popleft()
-            except IndexError:
-                break
-
-            if token == ', ':
-                previous_token = token
-                continue
-
-            if not token.strip():
-                continue
-
-            if token in keywords:
-                tokens.appendleft(token)
-                if previous_token is not None:
-                    tokens.appendleft(previous_token)
-                break
-
-            if previous_token is not None:
-                yield previous_token
-                previous_token = None
-
-            if token == '{':
-                open_braces += 1
-            elif token == '}':
-                open_braces -= 1
-
-            yield token
-
-            if open_braces == 0:
-                break
-
-    def combine_set(tokens: collections.deque[str]) -> Iterator[str]:
-        while True:
-            try:
-                token = tokens.popleft()
-            except IndexError:
-                break
-
-            if token == '{':
-                tokens.appendleft('{')
-                yield ''.join(takewhile_set(tokens))
-            else:
-                yield token
-
-    return list(combine_set(token_queue))
-
-
-def _tokenize_type_spec(spec: str) -> list[str]:
-    def postprocess(item: str) -> list[str]:
-        if _default_regex.match(item):
-            default = item[:7]
-            # can't be separated by anything other than a single space
-            # for now
-            other = item[8:]
-
-            return [default, ' ', other]
-        else:
-            return [item]
-
-    tokens = [
-        item
-        for raw_token in _token_regex.split(spec)
-        for item in postprocess(raw_token)
-        if item
-    ]
-    return tokens
-
-
-def _token_type(token: str, location: str | None = None) -> str:
-    def is_numeric(token: str) -> bool:
-        try:
-            # use complex to make sure every numeric value is detected as literal
-            complex(token)
-        except ValueError:
-            return False
-        else:
-            return True
-
-    if token.startswith(' ') or token.endswith(' '):
-        type_ = 'delimiter'
-    elif (
-        is_numeric(token)
-        or (token.startswith('{') and token.endswith('}'))
-        or (token.startswith('"') and token.endswith('"'))
-        or (token.startswith("'") and token.endswith("'"))
-    ):
-        type_ = 'literal'
-    elif token.startswith('{'):
-        logger.warning(
-            __('invalid value set (missing closing brace): %s'),
-            token,
-            location=location,
-        )
-        type_ = 'literal'
-    elif token.endswith('}'):
-        logger.warning(
-            __('invalid value set (missing opening brace): %s'),
-            token,
-            location=location,
-        )
-        type_ = 'literal'
-    elif token.startswith(("'", '"')):
-        logger.warning(
-            __('malformed string literal (missing closing quote): %s'),
-            token,
-            location=location,
-        )
-        type_ = 'literal'
-    elif token.endswith(("'", '"')):
-        logger.warning(
-            __('malformed string literal (missing opening quote): %s'),
-            token,
-            location=location,
-        )
-        type_ = 'literal'
-    elif token in {'optional', 'default'}:
-        # default is not a official keyword (yet) but supported by the
-        # reference implementation (numpydoc) and widely used
-        type_ = 'control'
-    elif _xref_regex.match(token):
-        type_ = 'reference'
-    else:
-        type_ = 'obj'
-
-    return type_
-
-
-def _convert_numpy_type_spec(
-    _type: str,
-    location: str | None = None,
-    translations: dict[str, str] | None = None,
-) -> str:
-    if translations is None:
-        translations = {}
-
-    def convert_obj(
-        obj: str, translations: dict[str, str], default_translation: str
-    ) -> str:
-        translation = translations.get(obj, obj)
-
-        # use :class: (the default) only if obj is not a standard singleton
-        if translation in _SINGLETONS and default_translation == ':class:`%s`':
-            default_translation = ':obj:`%s`'
-        elif translation == '...' and default_translation == ':class:`%s`':
-            # allow referencing the builtin ...
-            default_translation = ':obj:`%s <Ellipsis>`'
-
-        if _xref_regex.match(translation) is None:
-            translation = default_translation % translation
-
-        return translation
-
-    tokens = _tokenize_type_spec(_type)
-    combined_tokens = _recombine_set_tokens(tokens)
-    types = [(token, _token_type(token, location)) for token in combined_tokens]
-
-    converters = {
-        'literal': lambda x: '``%s``' % x,
-        'obj': lambda x: convert_obj(x, translations, ':class:`%s`'),
-        'control': lambda x: '*%s*' % x,
-        'delimiter': lambda x: x,
-        'reference': lambda x: x,
-    }
-
-    converted = ''.join(
-        converters.get(type_)(token)  # type: ignore[misc]
-        for token, type_ in types
-    )
-
-    return converted
-
-
 class NumpyDocstring(GoogleDocstring):
     """Convert NumPy style docstrings to reStructuredText.
 
@@ -1202,20 +1213,6 @@ def __init__(
         self._directive_sections = ['.. index::']
         super().__init__(docstring, config, app, what, name, obj, options)
 
-    def _get_location(self) -> str | None:
-        try:
-            filepath = inspect.getfile(self._obj) if self._obj is not None else None
-        except TypeError:
-            filepath = None
-        name = self._name
-
-        if filepath is None and name is None:
-            return None
-        elif filepath is None:
-            filepath = ''
-
-        return f'{filepath}:docstring of {name}'
-
     def _escape_args_and_kwargs(self, name: str) -> str:
         func = super()._escape_args_and_kwargs
 
@@ -1242,10 +1239,10 @@ def _consume_field(
             _type, _name = _name, _type
 
         if self._config.napoleon_preprocess_types:
-            _type = _convert_numpy_type_spec(
+            _type = _convert_type_spec(
                 _type,
-                location=self._get_location(),
                 translations=self._config.napoleon_type_aliases or {},
+                debug_location=self._get_location(),
             )
 
         indent = self._get_indent(line) + 1
diff --git a/sphinx/util/requests.py b/sphinx/util/requests.py
index a647bf5ba..b439ce437 100644
--- a/sphinx/util/requests.py
+++ b/sphinx/util/requests.py
@@ -3,20 +3,34 @@
 from __future__ import annotations
 
 import warnings
-from typing import Any
-from urllib.parse import urlsplit
+from typing import TYPE_CHECKING
+from urllib.parse import urljoin, urlsplit
 
 import requests
 from urllib3.exceptions import InsecureRequestWarning
 
 import sphinx
 
+if TYPE_CHECKING:
+    import re
+    from collections.abc import Sequence
+    from typing import Any
+
+
 _USER_AGENT = (
     f'Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 '
     f'Sphinx/{sphinx.__version__}'
 )
 
 
+class _IgnoredRedirection(Exception):
+    """Sphinx-internal exception raised when an HTTP redirect is ignored"""
+
+    def __init__(self, destination: str, status_code: int) -> None:
+        self.destination = destination
+        self.status_code = status_code
+
+
 def _get_tls_cacert(url: str, certs: str | dict[str, str] | None) -> str | bool:
     """Get additional CA cert for a specific URL."""
     if not certs:
@@ -50,6 +64,23 @@ def head(url: str, **kwargs: Any) -> requests.Response:
 
 
 class _Session(requests.Session):
+    _ignored_redirects: Sequence[re.Pattern[str]]
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        self._ignored_redirects = kwargs.pop('_ignored_redirects', ())
+        super().__init__(*args, **kwargs)
+
+    def get_redirect_target(self, resp: requests.Response) -> str | None:
+        """Overrides the default requests.Session.get_redirect_target"""
+        # do not follow redirections that match ignored URI patterns
+        if resp.is_redirect:
+            destination = urljoin(resp.url, resp.headers['location'])
+            if any(pat.match(destination) for pat in self._ignored_redirects):
+                raise _IgnoredRedirection(
+                    destination=destination, status_code=resp.status_code
+                )
+        return super().get_redirect_target(resp)
+
     def request(  # type: ignore[override]
         self,
         method: str,


Test patch:
diff --git a/tests/test_builders/test_build_linkcheck.py b/tests/test_builders/test_build_linkcheck.py
index d35672025..f1cc1c88a 100644
--- a/tests/test_builders/test_build_linkcheck.py
+++ b/tests/test_builders/test_build_linkcheck.py
@@ -926,7 +926,7 @@ class InfiniteRedirectOnHeadHandler(BaseHTTPRequestHandler):
 
     def do_HEAD(self):
         self.send_response(302, 'Found')
-        self.send_header('Location', '/')
+        self.send_header('Location', '/redirected')
         self.send_header('Content-Length', '0')
         self.end_headers()
 
@@ -966,6 +966,55 @@ def test_TooManyRedirects_on_HEAD(app, monkeypatch):
     }
 
 
+@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver')
+def test_ignore_local_redirection(app):
+    with serve_application(app, InfiniteRedirectOnHeadHandler) as address:
+        app.config.linkcheck_ignore = [f'http://{address}/redirected']
+        app.build()
+
+    with open(app.outdir / 'output.json', encoding='utf-8') as fp:
+        content = json.load(fp)
+    assert content == {
+        'code': 302,
+        'status': 'ignored',
+        'filename': 'index.rst',
+        'lineno': 1,
+        'uri': f'http://{address}/',
+        'info': f'ignored redirect: http://{address}/redirected',
+    }
+
+
+class RemoteDomainRedirectHandler(InfiniteRedirectOnHeadHandler):
+    protocol_version = 'HTTP/1.1'
+
+    def do_GET(self):
+        self.send_response(301, 'Found')
+        if self.path == '/':
+            self.send_header('Location', '/local')
+        elif self.path == '/local':
+            self.send_header('Location', 'http://example.test/migrated')
+        self.send_header('Content-Length', '0')
+        self.end_headers()
+
+
+@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver')
+def test_ignore_remote_redirection(app):
+    with serve_application(app, RemoteDomainRedirectHandler) as address:
+        app.config.linkcheck_ignore = ['http://example.test']
+        app.build()
+
+    with open(app.outdir / 'output.json', encoding='utf-8') as fp:
+        content = json.load(fp)
+    assert content == {
+        'code': 301,
+        'status': 'ignored',
+        'filename': 'index.rst',
+        'lineno': 1,
+        'uri': f'http://{address}/',
+        'info': 'ignored redirect: http://example.test/migrated',
+    }
+
+
 def make_retry_after_handler(
     responses: list[tuple[int, str | None]],
 ) -> type[BaseHTTPRequestHandler]:
diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py
index 5aaa91d93..70262cdd0 100644
--- a/tests/test_extensions/test_ext_intersphinx.py
+++ b/tests/test_extensions/test_ext_intersphinx.py
@@ -25,6 +25,7 @@
     _fetch_inventory,
     _fetch_inventory_group,
     _get_safe_url,
+    _InvConfig,
     _strip_basic_auth,
 )
 from sphinx.ext.intersphinx._shared import _IntersphinxProject
@@ -67,21 +68,23 @@ def set_config(app, mapping):
     app.config.intersphinx_mapping = mapping.copy()
     app.config.intersphinx_cache_limit = 0
     app.config.intersphinx_disabled_reftypes = []
+    app.config.intersphinx_timeout = None
 
 
 @mock.patch('sphinx.ext.intersphinx._load.InventoryFile')
-@mock.patch('sphinx.ext.intersphinx._load._read_from_url')
+@mock.patch('sphinx.ext.intersphinx._load.requests.get')
 @pytest.mark.sphinx('html', testroot='root')
-def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app):  # NoQA: PT019
+def test_fetch_inventory_redirection(get_request, InventoryFile, app):
+    mocked_get = get_request.return_value.__enter__.return_value
     intersphinx_setup(app)
-    _read_from_url().readline.return_value = b'# Sphinx inventory version 2'
+    mocked_get.content = b'# Sphinx inventory version 2'
 
     # same uri and inv, not redirected
-    _read_from_url().url = 'https://hostname/' + INVENTORY_FILENAME
+    mocked_get.url = 'https://hostname/' + INVENTORY_FILENAME
     _fetch_inventory(
         target_uri='https://hostname/',
         inv_location='https://hostname/' + INVENTORY_FILENAME,
-        config=app.config,
+        config=_InvConfig.from_config(app.config),
         srcdir=app.srcdir,
     )
     assert 'intersphinx inventory has moved' not in app.status.getvalue()
@@ -90,12 +93,12 @@ def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app):  # NoQ
     # same uri and inv, redirected
     app.status.seek(0)
     app.status.truncate(0)
-    _read_from_url().url = 'https://hostname/new/' + INVENTORY_FILENAME
+    mocked_get.url = 'https://hostname/new/' + INVENTORY_FILENAME
 
     _fetch_inventory(
         target_uri='https://hostname/',
         inv_location='https://hostname/' + INVENTORY_FILENAME,
-        config=app.config,
+        config=_InvConfig.from_config(app.config),
         srcdir=app.srcdir,
     )
     assert app.status.getvalue() == (
@@ -108,12 +111,12 @@ def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app):  # NoQ
     # different uri and inv, not redirected
     app.status.seek(0)
     app.status.truncate(0)
-    _read_from_url().url = 'https://hostname/new/' + INVENTORY_FILENAME
+    mocked_get.url = 'https://hostname/new/' + INVENTORY_FILENAME
 
     _fetch_inventory(
         target_uri='https://hostname/',
         inv_location='https://hostname/new/' + INVENTORY_FILENAME,
-        config=app.config,
+        config=_InvConfig.from_config(app.config),
         srcdir=app.srcdir,
     )
     assert 'intersphinx inventory has moved' not in app.status.getvalue()
@@ -122,12 +125,12 @@ def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app):  # NoQ
     # different uri and inv, redirected
     app.status.seek(0)
     app.status.truncate(0)
-    _read_from_url().url = 'https://hostname/other/' + INVENTORY_FILENAME
+    mocked_get.url = 'https://hostname/other/' + INVENTORY_FILENAME
 
     _fetch_inventory(
         target_uri='https://hostname/',
         inv_location='https://hostname/new/' + INVENTORY_FILENAME,
-        config=app.config,
+        config=_InvConfig.from_config(app.config),
         srcdir=app.srcdir,
     )
     assert app.status.getvalue() == (
@@ -760,6 +763,7 @@ def test_intersphinx_cache_limit(app, monkeypatch, cache_limit, expected_expired
     app.config.intersphinx_mapping = {
         'inv': (url, None),
     }
+    app.config.intersphinx_timeout = None
     # load the inventory and check if it's done correctly
     intersphinx_cache: dict[str, InventoryCacheEntry] = {
         url: ('inv', 0, {}),  # Timestamp of last cache write is zero.
@@ -784,7 +788,7 @@ def test_intersphinx_cache_limit(app, monkeypatch, cache_limit, expected_expired
             project=project,
             cache=intersphinx_cache,
             now=now,
-            config=app.config,
+            config=_InvConfig.from_config(app.config),
             srcdir=app.srcdir,
         )
         # If we hadn't mocked `_fetch_inventory`, it would've made
diff --git a/tests/test_extensions/test_ext_napoleon_docstring.py b/tests/test_extensions/test_ext_napoleon_docstring.py
index 311816ce1..8bca9c5a5 100644
--- a/tests/test_extensions/test_ext_napoleon_docstring.py
+++ b/tests/test_extensions/test_ext_napoleon_docstring.py
@@ -17,7 +17,7 @@
 from sphinx.ext.napoleon.docstring import (
     GoogleDocstring,
     NumpyDocstring,
-    _convert_numpy_type_spec,
+    _convert_type_spec,
     _recombine_set_tokens,
     _token_type,
     _tokenize_type_spec,
@@ -1332,7 +1332,7 @@ def test_preprocess_types(self):
         expected = """\
 Do as you please
 
-:Yields: :py:class:`str` -- Extended
+:Yields: :class:`str` -- Extended
 """
         assert str(actual) == expected
 
@@ -2677,7 +2677,7 @@ def test_convert_numpy_type_spec(self):
         )
 
         for spec, expected in zip(specs, converted, strict=True):
-            actual = _convert_numpy_type_spec(spec, translations=translations)
+            actual = _convert_type_spec(spec, translations=translations)
             assert actual == expected
 
     def test_parameter_types(self):