Repository: sphinx-doc/sphinx

Issue:
Remove KeyboardTransform, convert `:kbd:` to a real role
**Is your feature request related to a problem? Please describe.**

`KeyboardTransform.run` takes 1.2% of the runtime of a build. It was added in #7530 (#7593) to support sequences of keys, but shouldn't be needed if `:kbd:` is converted to a proper role.

**Describe the solution you'd like**

* Convert `:kbd:` to a `SphinxRole`
* Remove `KeyboardTransform`

**Additional context**

* #7530
* #7593
* #8714
* #8719

Discussion:

Solution patch:
diff --git a/.ruff.toml b/.ruff.toml
index ce194986213..71f1a6c4a0e 100644
--- a/.ruff.toml
+++ b/.ruff.toml
@@ -347,9 +347,9 @@ select = [
 ]
 
 # these tests need old ``typing`` generic aliases
-"tests/roots/test-ext-autodoc/target/genericalias.py" = ["UP006", "UP007", "UP035"]
-"tests/test_util/test_util_typing.py" = ["RUF036", "UP006", "UP007", "UP035"]
-"tests/test_util/typing_test_data.py" = ["FA100", "I002", "PYI030", "UP006", "UP007", "UP035"]
+"tests/roots/test-ext-autodoc/target/genericalias.py" = ["UP006", "UP007", "UP035", "UP045"]
+"tests/test_util/test_util_typing.py" = ["RUF036", "UP006", "UP007", "UP035", "UP045"]
+"tests/test_util/typing_test_data.py" = ["FA100", "I002", "PYI030", "UP006", "UP007", "UP035", "UP045"]
 
 "utils/*" = [
     "T201",  # whitelist ``print`` for stdout messages
diff --git a/CHANGES.rst b/CHANGES.rst
index f9a587f3181..3e05fb99f61 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -15,6 +15,12 @@ Incompatible changes
   now unconditionally returns ``True``.
   These are replaced by the ``has_maths_elements`` key of the page context dict.
   Patch by Adam Turner.
+* #13227: HTML output for sequences of keys in the :rst:role:`kbd` role
+  no longer uses a ``<kbd class="kbd compound">`` element to wrap
+  the keys and separators, but places them directly in the relevant parent node.
+  This means that CSS rulesets targeting ``kbd.compound`` or ``.kbd.compound``
+  will no longer have any effect.
+  Patch by Adam Turner.
 
 Deprecated
 ----------
@@ -36,6 +42,8 @@ Features added
 * #13146: Napoleon: Unify the type preprocessing logic to allow
   Google-style docstrings to use the optional and default keywords.
   Patch by Chris Barrick.
+* #13227: Implement the :rst:role:`kbd` role as a ``SphinxRole``.
+  Patch by Adam Turner.
 
 Bugs fixed
 ----------
@@ -58,3 +66,6 @@ Bugs fixed
 
 Testing
 -------
+
+* #13224: Correctness fixup for ``test_html_multi_line_copyright``.
+  Patch by Colin Watson, applied by James Addison.
diff --git a/doc/extdev/index.rst b/doc/extdev/index.rst
index 17618992726..2eff8f0738d 100644
--- a/doc/extdev/index.rst
+++ b/doc/extdev/index.rst
@@ -159,40 +159,61 @@ Extension metadata
 
 .. versionadded:: 1.3
 
-The ``setup()`` function can return a dictionary.  This is treated by Sphinx
-as metadata of the extension.  Metadata keys currently recognized are:
-
-* ``'version'``: a string that identifies the extension version.  It is used for
-  extension version requirement checking (see :confval:`needs_extensions`) and
-  informational purposes.  If not given, ``"unknown version"`` is substituted.
-* ``'env_version'``: an integer that identifies the version of env data
-  structure if the extension stores any data to environment.  It is used to
-  detect the data structure has been changed from last build.  The extensions
-  have to increment the version when data structure has changed.  If not given,
-  Sphinx considers the extension does not stores any data to environment.
-* ``'parallel_read_safe'``: a boolean that specifies if parallel reading of
-  source files can be used when the extension is loaded.  It defaults to
-  ``False``, i.e. you have to explicitly specify your extension to be
-  parallel-read-safe after checking that it is.
-
-  .. note:: The *parallel-read-safe* extension must satisfy the following
-            conditions:
-
-            * The core logic of the extension is parallelly executable during
-              the reading phase.
-            * It has event handlers for :event:`env-merge-info` and
-              :event:`env-purge-doc` events if it stores data to the build
-              environment object (env) during the reading phase.
-
-* ``'parallel_write_safe'``: a boolean that specifies if parallel writing of
-  output files can be used when the extension is loaded.  Since extensions
-  usually don't negatively influence the process, this defaults to ``True``.
-
-  .. note:: The *parallel-write-safe* extension must satisfy the following
-            conditions:
-
-            * The core logic of the extension is parallelly executable during
-              the writing phase.
+The ``setup()`` function should return a dictionary.
+This is treated by Sphinx as metadata of the extension.
+Metadata keys currently recognized are:
+
+``'version'``
+  A string that identifies the extension version.
+  It is used for extension version requirement checking
+  (see :confval:`needs_extensions`) and informational purposes.
+  If no version string is returned, ``'unknown version'`` is used by default.
+
+``'env_version'``
+  A non-zero positive integer integer that records
+  the version of data stored in the environment by the extension.
+
+  .. attention::
+     If ``'env_version'`` is not set, the extension **must not**
+     store any data or state directly on the environment object  (``env``).
+
+  This key must be defined if the extension uses the ``env`` object to store data.
+  The version number must be incremented whenever the type, structure,  or meaning
+  of the stored data change, to ensure Sphinx does not try and load invalid data
+  from a cached environment.
+
+  .. versionadded:: 1.8
+
+``'parallel_read_safe'``
+  A boolean that specifies if parallel reading of source files
+  can be used when the extension is loaded.
+  It defaults to ``False``, meaning that you have to explicitly specify
+  your extension to be safe for parallel reading after checking that it is.
+
+  .. important::
+
+     When *parallel-read-safe* is ``True``,
+     the extension must satisfy the following conditions:
+
+     * The core logic of the extension is parallelly executable during
+       the reading phase.
+     * It has event handlers for :event:`env-merge-info` and
+       :event:`env-purge-doc` events if it stores data to the build
+       environment object (``env``) during the reading phase.
+
+``'parallel_write_safe'``
+  A boolean that specifies if parallel writing of output files
+  can be used when the extension is loaded.
+  Since extensions usually don't negatively influence the process,
+  this defaults to ``True``.
+
+  .. important::
+
+     When *parallel-write-safe* is ``True``,
+     the extension must satisfy the following conditions:
+
+     * The core logic of the extension is parallelly executable during
+       the writing phase.
 
 
 APIs used for writing extensions
diff --git a/doc/usage/restructuredtext/field-lists.rst b/doc/usage/restructuredtext/field-lists.rst
index d352633f46b..b85d94520d1 100644
--- a/doc/usage/restructuredtext/field-lists.rst
+++ b/doc/usage/restructuredtext/field-lists.rst
@@ -68,11 +68,24 @@ At the moment, these metadata fields are recognized:
 
    .. versionadded:: 1.0
 
-``nosearch``
-   If set, full text search for this file is disabled. ::
+``no-search``
+   Disable full text search for this document.
 
-       :nosearch:
+   .. code-block:: rst
 
-   .. note:: object search is still available even if ``nosearch`` option is set.
+      :no-search:
+
+   .. note::
+      The ``:no-search:`` option only disables full text search,
+      meaning that object search will still be available even if
+      ``:no-search:`` is set.
 
    .. versionadded:: 3.0
+
+   .. versionchanged:: 7.3
+
+      The file-wide metadata option ``:nosearch:`` was renamed
+      to ``:no-search:``.
+      The previous name has been retained as an alias,
+      but will be deprecated and removed
+      in a future version of Sphinx.
diff --git a/pyproject.toml b/pyproject.toml
index 131a0a08316..2ae3a8d600c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -81,7 +81,7 @@ docs = [
     "sphinxcontrib-websupport",
 ]
 lint = [
-    "ruff==0.8.6",
+    "ruff==0.9.0",
     "mypy==1.14.1",
     "sphinx-lint>=0.9",
     "types-colorama==0.4.15.20240311",
diff --git a/sphinx/_cli/util/errors.py b/sphinx/_cli/util/errors.py
index 8bf2ac91d54..df7d1ad7cef 100644
--- a/sphinx/_cli/util/errors.py
+++ b/sphinx/_cli/util/errors.py
@@ -2,15 +2,18 @@
 
 import re
 import sys
-import tempfile
-from typing import TYPE_CHECKING, TextIO
+from typing import TYPE_CHECKING
 
-from sphinx.errors import SphinxParallelError
+from sphinx.errors import SphinxError, SphinxParallelError
 
 if TYPE_CHECKING:
-    from typing import Final
+    from collections.abc import Collection
+    from typing import Final, Protocol
 
-    from sphinx.application import Sphinx
+    from sphinx.extension import Extension
+
+    class SupportsWrite(Protocol):
+        def write(self, text: str, /) -> int | None: ...
 
 
 _CSI: Final[str] = re.escape('\x1b[')  # 'ESC [': Control Sequence Introducer
@@ -50,6 +53,34 @@ def strip_escape_sequences(text: str, /) -> str:
     return _ANSI_CODES.sub('', text)
 
 
+def full_exception_context(
+    exception: BaseException,
+    *,
+    message_log: Collection[str] = (),
+    extensions: Collection[Extension] = (),
+) -> str:
+    """Return a formatted message containing useful debugging context."""
+    last_msgs = '\n'.join(f'* {strip_escape_sequences(s)}' for s in message_log)
+    exts_list = '\n'.join(
+        f'* {ext.name} ({ext.version})'
+        for ext in extensions
+        if ext.version != 'builtin'
+    )
+    exc_format = format_traceback(exception)
+    return error_info(last_msgs or 'None.', exts_list or 'None.', exc_format)
+
+
+def format_traceback(exception: BaseException, /) -> str:
+    """Format the given exception's traceback."""
+    if isinstance(exception, SphinxParallelError):
+        return f'(Error in parallel process)\n{exception.traceback}'
+    else:
+        from traceback import format_exception
+
+        exc_format = ''.join(format_exception(exception))
+    return exc_format
+
+
 def error_info(messages: str, extensions: str, traceback: str) -> str:
     """Format the traceback and extensions list with environment information."""
     import platform
@@ -88,37 +119,26 @@ def error_info(messages: str, extensions: str, traceback: str) -> str:
 """
 
 
-def format_traceback(app: Sphinx | None, exc: BaseException) -> str:
-    """Format the given exception's traceback with environment information."""
-    if isinstance(exc, SphinxParallelError):
-        exc_format = '(Error in parallel process)\n' + exc.traceback
-    else:
-        import traceback
-
-        exc_format = traceback.format_exc()
-
-    last_msgs = exts_list = ''
-    if app is not None:
-        extensions = app.extensions.values()
-        last_msgs = '\n'.join(f'* {strip_escape_sequences(s)}' for s in app.messagelog)
-        exts_list = '\n'.join(
-            f'* {ext.name} ({ext.version})'
-            for ext in extensions
-            if ext.version != 'builtin'
-        )
-
-    return error_info(last_msgs, exts_list, exc_format)
-
-
-def save_traceback(app: Sphinx | None, exc: BaseException) -> str:
+def save_traceback(
+    exception: BaseException,
+    *,
+    message_log: Collection[str] = (),
+    extensions: Collection[Extension] = (),
+) -> str:
     """Save the given exception's traceback in a temporary file."""
-    output = format_traceback(app=app, exc=exc)
+    output = full_exception_context(
+        exception=exception,
+        message_log=message_log,
+        extensions=extensions,
+    )
     filename = write_temporary_file(output)
     return filename
 
 
 def write_temporary_file(content: str) -> str:
     """Write content to a temporary file and return the filename."""
+    import tempfile
+
     with tempfile.NamedTemporaryFile(
         'w', encoding='utf-8', suffix='.log', prefix='sphinx-err-', delete=False
     ) as f:
@@ -131,18 +151,18 @@ def handle_exception(
     exception: BaseException,
     /,
     *,
-    stderr: TextIO = sys.stderr,
+    stderr: SupportsWrite = sys.stderr,
     use_pdb: bool = False,
     print_traceback: bool = False,
-    app: Sphinx | None = None,
+    message_log: Collection[str] = (),
+    extensions: Collection[Extension] = (),
 ) -> None:
     from bdb import BdbQuit
-    from traceback import TracebackException, print_exc
+    from traceback import TracebackException
 
     from docutils.utils import SystemMessage
 
     from sphinx._cli.util.colour import red
-    from sphinx.errors import SphinxError
     from sphinx.locale import __
 
     if isinstance(exception, BdbQuit):
@@ -156,14 +176,14 @@ def print_red(*values: str) -> None:
 
     print_err()
     if print_traceback or use_pdb:
-        print_exc(file=stderr)
+        print_err(format_traceback(exception))
         print_err()
 
     if use_pdb:
         from pdb import post_mortem
 
         print_red(__('Exception occurred, starting debugger:'))
-        post_mortem()
+        post_mortem(exception.__traceback__)
         return
 
     if isinstance(exception, KeyboardInterrupt):
@@ -193,7 +213,7 @@ def print_red(*values: str) -> None:
             __(
                 'This can happen with very large or deeply nested source '
                 'files. You can carefully increase the default Python '
-                'recursion limit of 1000 in conf.py with e.g.:'
+                'recursion limit of 1,000 in conf.py with e.g.:'
             )
         )
         print_err('\n    import sys\n    sys.setrecursionlimit(1_500)\n')
@@ -205,7 +225,9 @@ def print_red(*values: str) -> None:
 
     print_red(__('Exception occurred:'))
     print_err(formatted_tb)
-    traceback_info_path = save_traceback(app, exception)
+    traceback_info_path = save_traceback(
+        exception, message_log=message_log, extensions=extensions
+    )
     print_err(__('The full traceback has been saved in:'))
     print_err(traceback_info_path)
     print_err()
diff --git a/sphinx/builders/gettext.py b/sphinx/builders/gettext.py
index 62e2959a37f..710665a0ca3 100644
--- a/sphinx/builders/gettext.py
+++ b/sphinx/builders/gettext.py
@@ -45,6 +45,12 @@
 class Message:
     """An entry of translatable message."""
 
+    __slots__ = 'text', 'locations', 'uuids'
+
+    text: str
+    locations: list[tuple[str, int]]
+    uuids: list[str]
+
     def __init__(
         self, text: str, locations: list[tuple[str, int]], uuids: list[str]
     ) -> None:
@@ -52,36 +58,41 @@ def __init__(
         self.locations = locations
         self.uuids = uuids
 
+    def __repr__(self) -> str:
+        return (
+            'Message('
+            f'text={self.text!r}, locations={self.locations!r}, uuids={self.uuids!r}'
+            ')'
+        )
+
 
 class Catalog:
     """Catalog of translatable messages."""
 
-    def __init__(self) -> None:
-        self.messages: list[str] = []  # retain insertion order
+    __slots__ = ('metadata',)
 
+    def __init__(self) -> None:
         # msgid -> file, line, uid
         self.metadata: dict[str, list[tuple[str, int, str]]] = {}
 
     def add(self, msg: str, origin: Element | MsgOrigin) -> None:
         if not hasattr(origin, 'uid'):
             # Nodes that are replicated like todo don't have a uid,
-            # however i18n is also unnecessary.
+            # however translation is also unnecessary.
             return
-        if msg not in self.metadata:  # faster lookup in hash
-            self.messages.append(msg)
-            self.metadata[msg] = []
-        line = origin.line
-        if line is None:
-            line = -1
-        self.metadata[msg].append((origin.source, line, origin.uid))  # type: ignore[arg-type]
+        msg_metadata = self.metadata.setdefault(msg, [])
+        line = line if (line := origin.line) is not None else -1
+        msg_metadata.append((origin.source or '', line, origin.uid))
 
     def __iter__(self) -> Iterator[Message]:
-        for message in self.messages:
-            positions = sorted({
-                (source, line) for source, line, uuid in self.metadata[message]
-            })
-            uuids = [uuid for source, line, uuid in self.metadata[message]]
-            yield Message(message, positions, uuids)
+        for message, msg_metadata in self.metadata.items():
+            positions = sorted(set(map(operator.itemgetter(0, 1), msg_metadata)))
+            uuids = list(map(operator.itemgetter(2), msg_metadata))
+            yield Message(text=message, locations=positions, uuids=uuids)
+
+    @property
+    def messages(self) -> list[str]:
+        return list(self.metadata)
 
 
 class MsgOrigin:
@@ -89,11 +100,20 @@ class MsgOrigin:
     Origin holder for Catalog message origin.
     """
 
+    __slots__ = 'source', 'line', 'uid'
+
+    source: str
+    line: int
+    uid: str
+
     def __init__(self, source: str, line: int) -> None:
         self.source = source
         self.line = line
         self.uid = uuid4().hex
 
+    def __repr__(self) -> str:
+        return f'<MsgOrigin {self.source}:{self.line}; uid={self.uid!r}>'
+
 
 class GettextRenderer(SphinxRenderer):
     def __init__(
@@ -258,7 +278,7 @@ def _extract_from_template(self) -> None:
                 with open(template, encoding='utf-8') as f:
                     context = f.read()
                 for line, _meth, msg in extract_translations(context):
-                    origin = MsgOrigin(template, line)
+                    origin = MsgOrigin(source=template, line=line)
                     self.catalogs['sphinx'].add(msg, origin)
             except Exception as exc:
                 msg = f'{template}: {exc!r}'
@@ -285,6 +305,7 @@ def finish(self) -> None:
             'display_location': self.config.gettext_location,
             'display_uuid': self.config.gettext_uuid,
         }
+        catalog: Catalog
         for textdomain, catalog in status_iterator(
             self.catalogs.items(),
             __('writing message catalogs... '),
diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py
index 1b89d40ecee..713e3afa8de 100644
--- a/sphinx/builders/html/__init__.py
+++ b/sphinx/builders/html/__init__.py
@@ -1510,9 +1510,6 @@ def setup(app: Sphinx) -> ExtensionMetadata:
     # load default math renderer
     app.setup_extension('sphinx.ext.mathjax')
 
-    # load transforms for HTML builder
-    app.setup_extension('sphinx.builders.html.transforms')
-
     return {
         'version': 'builtin',
         'parallel_read_safe': True,
diff --git a/sphinx/builders/html/transforms.py b/sphinx/builders/html/transforms.py
deleted file mode 100644
index 3f2be39f128..00000000000
--- a/sphinx/builders/html/transforms.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Transforms for HTML builder."""
-
-from __future__ import annotations
-
-import re
-from typing import TYPE_CHECKING, Any
-
-from docutils import nodes
-
-from sphinx.transforms.post_transforms import SphinxPostTransform
-from sphinx.util.nodes import NodeMatcher
-
-if TYPE_CHECKING:
-    from sphinx.application import Sphinx
-    from sphinx.util.typing import ExtensionMetadata
-
-
-class KeyboardTransform(SphinxPostTransform):
-    """Transform :kbd: role to more detailed form.
-
-    Before::
-
-        <literal class="kbd">
-            Control-x
-
-    After::
-
-        <literal class="kbd compound">
-            <literal class="kbd">
-                Control
-            -
-            <literal class="kbd">
-                x
-    """
-
-    default_priority = 400
-    formats = ('html',)
-    pattern = re.compile(r'(?<=.)(-|\+|\^|\s+)(?=.)')
-    multiwords_keys = (
-        ('caps', 'lock'),
-        ('page', 'down'),
-        ('page', 'up'),
-        ('scroll', 'lock'),
-        ('num', 'lock'),
-        ('sys', 'rq'),
-        ('back', 'space'),
-    )
-
-    def run(self, **kwargs: Any) -> None:
-        matcher = NodeMatcher(nodes.literal, classes=['kbd'])
-        # this list must be pre-created as during iteration new nodes
-        # are added which match the condition in the NodeMatcher.
-        for node in list(matcher.findall(self.document)):
-            parts = self.pattern.split(node[-1].astext())
-            if len(parts) == 1 or self.is_multiwords_key(parts):
-                continue
-
-            node['classes'].append('compound')
-            node.pop()
-            while parts:
-                if self.is_multiwords_key(parts):
-                    key = ''.join(parts[:3])
-                    parts[:3] = []
-                else:
-                    key = parts.pop(0)
-                node += nodes.literal('', key, classes=['kbd'])
-
-                try:
-                    # key separator (ex. -, +, ^)
-                    sep = parts.pop(0)
-                    node += nodes.Text(sep)
-                except IndexError:
-                    pass
-
-    def is_multiwords_key(self, parts: list[str]) -> bool:
-        if len(parts) >= 3 and not parts[1].strip():
-            name = parts[0].lower(), parts[2].lower()
-            return name in self.multiwords_keys
-        else:
-            return False
-
-
-def setup(app: Sphinx) -> ExtensionMetadata:
-    app.add_post_transform(KeyboardTransform)
-
-    return {
-        'version': 'builtin',
-        'parallel_read_safe': True,
-        'parallel_write_safe': True,
-    }
diff --git a/sphinx/cmd/build.py b/sphinx/cmd/build.py
index 21f741b6053..fe9ec3e8680 100644
--- a/sphinx/cmd/build.py
+++ b/sphinx/cmd/build.py
@@ -3,36 +3,28 @@
 from __future__ import annotations
 
 import argparse
-import bdb
 import contextlib
 import locale
 import multiprocessing
-import pdb  # NoQA: T100
 import sys
-import traceback
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, TextIO
 
-from docutils.utils import SystemMessage
-
+import sphinx._cli.util.errors
 import sphinx.locale
 from sphinx import __display_version__
 from sphinx.application import Sphinx
-from sphinx.errors import SphinxError, SphinxParallelError
 from sphinx.locale import __
 from sphinx.util._io import TeeStripANSI
 from sphinx.util._pathlib import _StrPath
-from sphinx.util.console import color_terminal, nocolor, red, terminal_safe
+from sphinx.util.console import color_terminal, nocolor
 from sphinx.util.docutils import docutils_namespace, patch_docutils
-from sphinx.util.exceptions import format_exception_cut_frames, save_traceback
 from sphinx.util.osutil import ensuredir
 
 if TYPE_CHECKING:
-    from collections.abc import Sequence
-    from typing import Protocol
+    from collections.abc import Collection, Sequence
 
-    class SupportsWrite(Protocol):
-        def write(self, text: str, /) -> int | None: ...
+    from sphinx.extension import Extension
 
 
 def handle_exception(
@@ -41,92 +33,19 @@ def handle_exception(
     exception: BaseException,
     stderr: TextIO = sys.stderr,
 ) -> None:
-    if isinstance(exception, bdb.BdbQuit):
-        return
-
-    if args.pdb:
-        print(
-            red(__('Exception occurred while building, starting debugger:')),
-            file=stderr,
-        )
-        traceback.print_exc()
-        pdb.post_mortem(sys.exc_info()[2])
+    if app is not None:
+        message_log: Sequence[str] = app.messagelog
+        extensions: Collection[Extension] = app.extensions.values()
     else:
-        print(file=stderr)
-        if args.verbosity or args.traceback:
-            exc = sys.exc_info()[1]
-            if isinstance(exc, SphinxParallelError):
-                exc_format = '(Error in parallel process)\n' + exc.traceback
-                print(exc_format, file=stderr)
-            else:
-                traceback.print_exc(None, stderr)
-                print(file=stderr)
-        if isinstance(exception, KeyboardInterrupt):
-            print(__('Interrupted!'), file=stderr)
-        elif isinstance(exception, SystemMessage):
-            print(red(__('reST markup error:')), file=stderr)
-            print(terminal_safe(exception.args[0]), file=stderr)
-        elif isinstance(exception, SphinxError):
-            print(red('%s:' % exception.category), file=stderr)
-            print(str(exception), file=stderr)
-        elif isinstance(exception, UnicodeError):
-            print(red(__('Encoding error:')), file=stderr)
-            print(terminal_safe(str(exception)), file=stderr)
-            tbpath = save_traceback(app, exception)
-            print(
-                red(
-                    __(
-                        'The full traceback has been saved in %s, if you want '
-                        'to report the issue to the developers.'
-                    )
-                    % tbpath
-                ),
-                file=stderr,
-            )
-        elif (
-            isinstance(exception, RuntimeError)
-            and 'recursion depth' in str(exception)
-        ):  # fmt: skip
-            print(red(__('Recursion error:')), file=stderr)
-            print(terminal_safe(str(exception)), file=stderr)
-            print(file=stderr)
-            print(
-                __(
-                    'This can happen with very large or deeply nested source '
-                    'files. You can carefully increase the default Python '
-                    'recursion limit of 1000 in conf.py with e.g.:'
-                ),
-                file=stderr,
-            )
-            print('    import sys; sys.setrecursionlimit(1500)', file=stderr)
-        else:
-            print(red(__('Exception occurred:')), file=stderr)
-            print(format_exception_cut_frames().rstrip(), file=stderr)
-            tbpath = save_traceback(app, exception)
-            print(
-                red(
-                    __(
-                        'The full traceback has been saved in %s, if you '
-                        'want to report the issue to the developers.'
-                    )
-                    % tbpath
-                ),
-                file=stderr,
-            )
-            print(
-                __(
-                    'Please also report this if it was a user error, so '
-                    'that a better error message can be provided next time.'
-                ),
-                file=stderr,
-            )
-            print(
-                __(
-                    'A bug report can be filed in the tracker at '
-                    '<https://github.com/sphinx-doc/sphinx/issues>. Thanks!'
-                ),
-                file=stderr,
-            )
+        message_log = extensions = ()
+    return sphinx._cli.util.errors.handle_exception(
+        exception,
+        stderr=stderr,
+        use_pdb=args.pdb,
+        print_traceback=args.verbosity or args.traceback,
+        message_log=message_log,
+        extensions=extensions,
+    )
 
 
 def jobs_argument(value: str) -> int:
@@ -512,7 +431,19 @@ def build_main(argv: Sequence[str]) -> int:
             app.build(args.force_all, args.filenames)
             return app.statuscode
     except (Exception, KeyboardInterrupt) as exc:
-        handle_exception(app, args, exc, args.error)
+        if app is not None:
+            message_log: Sequence[str] = app.messagelog
+            extensions: Collection[Extension] = app.extensions.values()
+        else:
+            message_log = extensions = ()
+        sphinx._cli.util.errors.handle_exception(
+            exc,
+            stderr=args.error,
+            use_pdb=args.pdb,
+            print_traceback=args.verbosity or args.traceback,
+            message_log=message_log,
+            extensions=extensions,
+        )
         return 2
     finally:
         if warnfp is not None:
diff --git a/sphinx/domains/c/__init__.py b/sphinx/domains/c/__init__.py
index b122da164b7..a2735705376 100644
--- a/sphinx/domains/c/__init__.py
+++ b/sphinx/domains/c/__init__.py
@@ -865,7 +865,7 @@ def _resolve_xref_inner(
                 'Unparseable C cross-reference: %r\n%s', target, e, location=node
             )
             return None, None
-        parent_key: LookupKey = node.get('c:parent_key', None)
+        parent_key: LookupKey | None = node.get('c:parent_key', None)
         root_symbol = self.data['root_symbol']
         if parent_key:
             parent_symbol: Symbol = root_symbol.direct_lookup(parent_key)
diff --git a/sphinx/domains/c/_symbol.py b/sphinx/domains/c/_symbol.py
index d4662b07fc0..2cccd4148a4 100644
--- a/sphinx/domains/c/_symbol.py
+++ b/sphinx/domains/c/_symbol.py
@@ -31,18 +31,35 @@ def __str__(self) -> str:
 
 
 class SymbolLookupResult:
+    __slots__ = 'symbols', 'parent_symbol', 'ident'
+
+    symbols: Iterable[Symbol]
+    parent_symbol: Symbol
+    ident_or_op: ASTIdentifier
+
     def __init__(
-        self, symbols: Sequence[Symbol], parentSymbol: Symbol, ident: ASTIdentifier
+        self, symbols: Iterable[Symbol], parent_symbol: Symbol, ident: ASTIdentifier
     ) -> None:
         self.symbols = symbols
-        self.parentSymbol = parentSymbol
+        self.parent_symbol = parent_symbol
         self.ident = ident
 
+    @property
+    def parentSymbol(self) -> Symbol:
+        return self.parent_symbol
+
 
 class LookupKey:
-    def __init__(self, data: list[tuple[ASTIdentifier, str]]) -> None:
+    __slots__ = ('data',)
+
+    data: Sequence[tuple[ASTIdentifier, str]]
+
+    def __init__(self, data: Sequence[tuple[ASTIdentifier, str]], /) -> None:
         self.data = data
 
+    def __repr__(self) -> str:
+        return f'LookupKey({self.data!r})'
+
     def __str__(self) -> str:
         inner = ', '.join(f'({ident}, {id_})' for ident, id_ in self.data)
         return f'[{inner}]'
@@ -226,14 +243,11 @@ def get_lookup_key(self) -> LookupKey:
         while s.parent:
             symbols.append(s)
             s = s.parent
-        symbols.reverse()
-        key = []
-        for s in symbols:
-            if s.declaration is not None:
-                # TODO: do we need the ID?
-                key.append((s.ident, s.declaration.get_newest_id()))
-            else:
-                key.append((s.ident, None))
+        key = [
+            # TODO: do we need the ID?
+            (s.ident, None if s.declaration is None else s.declaration.get_newest_id())
+            for s in reversed(symbols)
+        ]
         return LookupKey(key)
 
     def get_full_nested_name(self) -> ASTNestedName:
@@ -382,7 +396,7 @@ def on_missing_qualified_symbol(
                 Symbol.debug_print(f'location:    {docname}:{line}')
                 Symbol.debug_indent -= 1
             symbol = Symbol(
-                parent=lookup_result.parentSymbol,
+                parent=lookup_result.parent_symbol,
                 ident=lookup_result.ident,
                 declaration=declaration,
                 docname=docname,
@@ -433,7 +447,7 @@ def make_cand_symbol() -> Symbol:
             if Symbol.debug_lookup:
                 Symbol.debug_print('begin: creating candidate symbol')
             symbol = Symbol(
-                parent=lookup_result.parentSymbol,
+                parent=lookup_result.parent_symbol,
                 ident=lookup_result.ident,
                 declaration=declaration,
                 docname=docname,
diff --git a/sphinx/domains/cpp/__init__.py b/sphinx/domains/cpp/__init__.py
index 08c596b0374..1048a4bc051 100644
--- a/sphinx/domains/cpp/__init__.py
+++ b/sphinx/domains/cpp/__init__.py
@@ -628,6 +628,7 @@ def __init__(
         super().__init__()
         self.sig = sig
         self.aliasOptions = aliasOptions
+        self.parentKey: LookupKey
         if env is not None:
             if env.current_document.cpp_parent_symbol is None:
                 root = env.domaindata['cpp']['root_symbol']
@@ -1092,7 +1093,7 @@ def _resolve_xref_inner(
                 'Unparseable C++ cross-reference: %r\n%s', target, ex, location=node
             )
             return None, None
-        parent_key: LookupKey = node.get('cpp:parent_key', None)
+        parent_key: LookupKey | None = node.get('cpp:parent_key', None)
         root_symbol = self.data['root_symbol']
         if parent_key:
             parent_symbol: Symbol = root_symbol.direct_lookup(parent_key)
@@ -1288,7 +1289,7 @@ def get_full_qualified_name(self, node: Element) -> str | None:
         target = node.get('reftarget', None)
         if target is None:
             return None
-        parent_key: LookupKey = node.get('cpp:parent_key', None)
+        parent_key: LookupKey | None = node.get('cpp:parent_key', None)
         if parent_key is None or len(parent_key.data) <= 0:
             return None
 
diff --git a/sphinx/domains/cpp/_symbol.py b/sphinx/domains/cpp/_symbol.py
index 87505ca675c..718467420c4 100644
--- a/sphinx/domains/cpp/_symbol.py
+++ b/sphinx/domains/cpp/_symbol.py
@@ -17,7 +17,7 @@
 from sphinx.util import logging
 
 if TYPE_CHECKING:
-    from collections.abc import Callable, Iterable, Iterator
+    from collections.abc import Callable, Iterable, Iterator, Sequence
 
     from sphinx.environment import BuildEnvironment
 
@@ -36,32 +36,82 @@ def __str__(self) -> str:
 
 
 class SymbolLookupResult:
+    __slots__ = (
+        'symbols',
+        'parent_symbol',
+        'ident_or_op',
+        'template_params',
+        'template_args',
+    )
+
+    symbols: Iterable[Symbol]
+    parent_symbol: Symbol
+    ident_or_op: ASTIdentifier | ASTOperator
+    template_params: Any
+    template_args: ASTTemplateArgs
+
     def __init__(
         self,
-        symbols: Iterator[Symbol],
-        parentSymbol: Symbol,
-        identOrOp: ASTIdentifier | ASTOperator,
-        templateParams: Any,
-        templateArgs: ASTTemplateArgs,
+        symbols: Iterable[Symbol],
+        parent_symbol: Symbol,
+        ident_or_op: ASTIdentifier | ASTOperator,
+        template_params: Any,
+        template_args: ASTTemplateArgs,
     ) -> None:
         self.symbols = symbols
-        self.parentSymbol = parentSymbol
-        self.identOrOp = identOrOp
-        self.templateParams = templateParams
-        self.templateArgs = templateArgs
+        self.parent_symbol = parent_symbol
+        self.ident_or_op = ident_or_op
+        self.template_params = template_params
+        self.template_args = template_args
+
+    @property
+    def parentSymbol(self) -> Symbol:
+        return self.parent_symbol
+
+    @property
+    def identOrOp(self) -> ASTIdentifier | ASTOperator:
+        return self.ident_or_op
+
+    @property
+    def templateParams(self) -> Any:
+        return self.template_params
+
+    @property
+    def templateArgs(self) -> ASTTemplateArgs:
+        return self.template_args
 
 
 class LookupKey:
+    __slots__ = ('data',)
+
+    data: Sequence[
+        tuple[
+            ASTNestedNameElement,
+            ASTTemplateParams | ASTTemplateIntroduction,
+            str | None,
+        ]
+    ]
+
     def __init__(
         self,
-        data: list[
+        data: Sequence[
             tuple[
-                ASTNestedNameElement, ASTTemplateParams | ASTTemplateIntroduction, str
+                ASTNestedNameElement,
+                ASTTemplateParams | ASTTemplateIntroduction,
+                str | None,
             ]
         ],
+        /,
     ) -> None:
         self.data = data
 
+    def __repr__(self) -> str:
+        return f'LookupKey({self.data!r})'
+
+    def __str__(self) -> str:
+        inner = ', '.join(f'({ident}, {id_})' for ident, _, id_ in self.data)
+        return f'[{inner}]'
+
 
 def _is_specialization(
     template_params: ASTTemplateParams | ASTTemplateIntroduction,
@@ -279,14 +329,14 @@ def get_lookup_key(self) -> LookupKey:
         while s.parent:
             symbols.append(s)
             s = s.parent
-        symbols.reverse()
-        key = []
-        for s in symbols:
-            nne = ASTNestedNameElement(s.identOrOp, s.templateArgs)
-            if s.declaration is not None:
-                key.append((nne, s.templateParams, s.declaration.get_newest_id()))
-            else:
-                key.append((nne, s.templateParams, None))
+        key = [
+            (
+                ASTNestedNameElement(s.identOrOp, s.templateArgs),
+                s.templateParams,
+                None if s.declaration is None else s.declaration.get_newest_id(),
+            )
+            for s in reversed(symbols)
+        ]
         return LookupKey(key)
 
     def get_full_nested_name(self) -> ASTNestedName:
@@ -438,7 +488,7 @@ def _symbol_lookup(
         recurse_in_anon: bool,
         correct_primary_template_args: bool,
         search_in_siblings: bool,
-    ) -> SymbolLookupResult:
+    ) -> SymbolLookupResult | None:
         # ancestor_lookup_type: if not None, specifies the target type of the lookup
         if Symbol.debug_lookup:
             Symbol.debug_indent += 1
@@ -650,17 +700,17 @@ def on_missing_qualified_symbol(
             if Symbol.debug_lookup:
                 Symbol.debug_print('_add_symbols, result, no symbol:')
                 Symbol.debug_indent += 1
-                Symbol.debug_print('template_params:', lookup_result.templateParams)
-                Symbol.debug_print('ident_or_op:    ', lookup_result.identOrOp)
-                Symbol.debug_print('template_args:  ', lookup_result.templateArgs)
+                Symbol.debug_print('template_params:', lookup_result.template_params)
+                Symbol.debug_print('ident_or_op:    ', lookup_result.ident_or_op)
+                Symbol.debug_print('template_args:  ', lookup_result.template_args)
                 Symbol.debug_print('declaration:    ', declaration)
                 Symbol.debug_print(f'location:      {docname}:{line}')
                 Symbol.debug_indent -= 1
             symbol = Symbol(
-                parent=lookup_result.parentSymbol,
-                identOrOp=lookup_result.identOrOp,
-                templateParams=lookup_result.templateParams,
-                templateArgs=lookup_result.templateArgs,
+                parent=lookup_result.parent_symbol,
+                identOrOp=lookup_result.ident_or_op,
+                templateParams=lookup_result.template_params,
+                templateArgs=lookup_result.template_args,
                 declaration=declaration,
                 docname=docname,
                 line=line,
@@ -709,10 +759,10 @@ def make_cand_symbol() -> Symbol:
             if Symbol.debug_lookup:
                 Symbol.debug_print('begin: creating candidate symbol')
             symbol = Symbol(
-                parent=lookup_result.parentSymbol,
-                identOrOp=lookup_result.identOrOp,
-                templateParams=lookup_result.templateParams,
-                templateArgs=lookup_result.templateArgs,
+                parent=lookup_result.parent_symbol,
+                identOrOp=lookup_result.ident_or_op,
+                templateParams=lookup_result.template_params,
+                templateArgs=lookup_result.template_args,
                 declaration=declaration,
                 docname=docname,
                 line=line,
@@ -1117,13 +1167,13 @@ def on_missing_qualified_symbol(
                 Symbol.debug_indent -= 2
             return res, None
 
-        if lookup_result.parentSymbol.declaration is not None:
-            if lookup_result.parentSymbol.declaration.objectType == 'templateParam':
+        if lookup_result.parent_symbol.declaration is not None:
+            if lookup_result.parent_symbol.declaration.objectType == 'templateParam':
                 return None, 'templateParamInQualified'
 
         # try without template params and args
-        symbol = lookup_result.parentSymbol._find_first_named_symbol(
-            lookup_result.identOrOp,
+        symbol = lookup_result.parent_symbol._find_first_named_symbol(
+            lookup_result.ident_or_op,
             None,
             None,
             template_shorthand=templateShorthand,
@@ -1186,10 +1236,10 @@ def on_missing_qualified_symbol(
             return None
 
         query_symbol = Symbol(
-            parent=lookup_result.parentSymbol,
-            identOrOp=lookup_result.identOrOp,
-            templateParams=lookup_result.templateParams,
-            templateArgs=lookup_result.templateArgs,
+            parent=lookup_result.parent_symbol,
+            identOrOp=lookup_result.ident_or_op,
+            templateParams=lookup_result.template_params,
+            templateArgs=lookup_result.template_args,
             declaration=declaration,
             docname='fakeDocnameForQuery',
             line=42,
diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py
index f8774e46a99..8708b068319 100644
--- a/sphinx/environment/__init__.py
+++ b/sphinx/environment/__init__.py
@@ -31,7 +31,7 @@
 from sphinx.util.osutil import _last_modified_time, _relative_path, canon_path
 
 if TYPE_CHECKING:
-    from collections.abc import Callable, Iterable, Iterator
+    from collections.abc import Callable, Iterable, Iterator, Mapping
     from typing import Any, Literal
 
     from docutils import nodes
@@ -45,6 +45,7 @@
     from sphinx.domains.c._symbol import Symbol as CSymbol
     from sphinx.domains.cpp._symbol import Symbol as CPPSymbol
     from sphinx.events import EventManager
+    from sphinx.extension import Extension
     from sphinx.project import Project
     from sphinx.util._pathlib import _StrPath
 
@@ -113,7 +114,7 @@ def __init__(self, app: Sphinx) -> None:
         self.config_status_extra: str = ''
         self.events: EventManager = app.events
         self.project: Project = app.project
-        self.version: dict[str, int] = app.registry.get_envversion(app)
+        self.version: Mapping[str, int] = _get_env_version(app.extensions)
 
         # the method of doctree versioning; see set_versioning_method
         self.versioning_condition: Literal[False] | Callable[[Node], bool] | None = None
@@ -247,7 +248,7 @@ def __setstate__(self, state: dict[str, Any]) -> None:
 
     def setup(self, app: Sphinx) -> None:
         """Set up BuildEnvironment object."""
-        if self.version and self.version != app.registry.get_envversion(app):
+        if self.version and self.version != _get_env_version(app.extensions):
             raise BuildEnvironmentError(__('build environment version not current'))
         if self.srcdir and self.srcdir != app.srcdir:
             raise BuildEnvironmentError(__('source directory has changed'))
@@ -260,7 +261,7 @@ def setup(self, app: Sphinx) -> None:
         self.events = app.events
         self.srcdir = app.srcdir
         self.project = app.project
-        self.version = app.registry.get_envversion(app)
+        self.version = _get_env_version(app.extensions)
 
         # initialise domains
         if self.domains is None:
@@ -808,6 +809,16 @@ def check_consistency(self) -> None:
         self.events.emit('env-check-consistency', self)
 
 
+def _get_env_version(extensions: Mapping[str, Extension]) -> Mapping[str, int]:
+    env_version = {
+        ext.name: ext_env_version
+        for ext in extensions.values()
+        if (ext_env_version := ext.metadata.get('env_version'))
+    }
+    env_version['sphinx'] = ENV_VERSION
+    return env_version
+
+
 def _differing_config_keys(old: Config, new: Config) -> frozenset[str]:
     """Return a set of keys that differ between two config objects."""
     old_vals = {c.name: c.value for c in old}
diff --git a/sphinx/errors.py b/sphinx/errors.py
index c0339b4e998..c1ae15446f5 100644
--- a/sphinx/errors.py
+++ b/sphinx/errors.py
@@ -2,8 +2,6 @@
 
 from __future__ import annotations
 
-from typing import Any
-
 
 class SphinxError(Exception):
     """Base class for Sphinx errors.
@@ -109,7 +107,7 @@ class SphinxParallelError(SphinxError):
 
     category = 'Sphinx parallel build error'
 
-    def __init__(self, message: str, traceback: Any) -> None:
+    def __init__(self, message: str, traceback: str) -> None:
         self.message = message
         self.traceback = traceback
 
diff --git a/sphinx/ext/apidoc/_cli.py b/sphinx/ext/apidoc/_cli.py
index e13b915a17b..09645b3db4e 100644
--- a/sphinx/ext/apidoc/_cli.py
+++ b/sphinx/ext/apidoc/_cli.py
@@ -13,12 +13,8 @@
 import sphinx.locale
 from sphinx import __display_version__
 from sphinx.cmd.quickstart import EXTENSIONS
-from sphinx.ext.apidoc._generate import (
-    ApidocOptions,
-    create_modules_toc_file,
-    recurse_tree,
-)
-from sphinx.ext.apidoc._shared import LOGGER, _remove_old_files
+from sphinx.ext.apidoc._generate import create_modules_toc_file, recurse_tree
+from sphinx.ext.apidoc._shared import LOGGER, ApidocOptions, _remove_old_files
 from sphinx.locale import __
 from sphinx.util.osutil import ensuredir
 
diff --git a/sphinx/ext/apidoc/_generate.py b/sphinx/ext/apidoc/_generate.py
index e43b698eed5..00701cee3a1 100644
--- a/sphinx/ext/apidoc/_generate.py
+++ b/sphinx/ext/apidoc/_generate.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import dataclasses
 import glob
 import os
 import os.path
@@ -18,6 +17,8 @@
     import re
     from collections.abc import Iterator, Sequence
 
+    from sphinx.ext.apidoc._shared import ApidocOptions
+
 
 # automodule options
 if 'SPHINX_APIDOC_OPTIONS' in os.environ:
@@ -349,37 +350,3 @@ def is_excluded(root: str | Path, excludes: Sequence[re.Pattern[str]]) -> bool:
     """
     root_str = str(root)
     return any(exclude.match(root_str) for exclude in excludes)
-
-
-@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
-class ApidocOptions:
-    """Options for apidoc."""
-
-    module_path: Path
-    exclude_pattern: list[str]
-    destdir: Path
-    quiet: bool = False
-    maxdepth: int = 4
-    force: bool = False
-    followlinks: bool = False
-    dryrun: bool = False
-    separatemodules: bool = False
-    includeprivate: bool = False
-    tocfile: str = 'modules'
-    noheadings: bool = False
-    modulefirst: bool = False
-    implicit_namespaces: bool = False
-    automodule_options: set[str] = dataclasses.field(default_factory=set)
-    suffix: str = 'rst'
-
-    remove_old: bool = False
-
-    # --full only
-    full: bool = False
-    append_syspath: bool = False
-    header: str = ''
-    author: str | None = None
-    version: str | None = None
-    release: str | None = None
-    extensions: list[str] | None = None
-    templatedir: str | None = None
diff --git a/sphinx/ext/apidoc/_shared.py b/sphinx/ext/apidoc/_shared.py
index 7489f9d3e6e..2473e65d32d 100644
--- a/sphinx/ext/apidoc/_shared.py
+++ b/sphinx/ext/apidoc/_shared.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import dataclasses
 from typing import TYPE_CHECKING
 
 from sphinx.locale import __
@@ -28,3 +29,38 @@ def _remove_old_files(
                     exc.strerror,
                     type='autodoc',
                 )
+
+
+@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
+class ApidocOptions:
+    """Options for apidoc."""
+
+    module_path: Path
+    destdir: Path
+
+    exclude_pattern: Sequence[str] = ()
+    quiet: bool = False
+    maxdepth: int = 4
+    force: bool = False
+    followlinks: bool = False
+    dryrun: bool = False
+    separatemodules: bool = False
+    includeprivate: bool = False
+    tocfile: str = 'modules'
+    noheadings: bool = False
+    modulefirst: bool = False
+    implicit_namespaces: bool = False
+    automodule_options: set[str] = dataclasses.field(default_factory=set)
+    suffix: str = 'rst'
+
+    remove_old: bool = False
+
+    # --full only
+    full: bool = False
+    append_syspath: bool = False
+    header: str = ''
+    author: str | None = None
+    version: str | None = None
+    release: str | None = None
+    extensions: Sequence[str] | None = None
+    templatedir: str | None = None
diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py
index 684ccec9081..379f68910f3 100644
--- a/sphinx/ext/autodoc/__init__.py
+++ b/sphinx/ext/autodoc/__init__.py
@@ -304,6 +304,14 @@ class ObjectMember:
     represent each member of the object.
     """
 
+    __slots__ = '__name__', 'object', 'docstring', 'class_', 'skipped'
+
+    __name__: str
+    object: Any
+    docstring: str | None
+    class_: Any
+    skipped: bool
+
     def __init__(
         self,
         name: str,
@@ -316,8 +324,19 @@ def __init__(
         self.__name__ = name
         self.object = obj
         self.docstring = docstring
-        self.skipped = skipped
         self.class_ = class_
+        self.skipped = skipped
+
+    def __repr__(self) -> str:
+        return (
+            f'ObjectMember('
+            f'name={self.__name__!r}, '
+            f'obj={self.object!r}, '
+            f'docstring={self.docstring!r}, '
+            f'class_={self.class_!r}, '
+            f'skipped={self.skipped!r}'
+            f')'
+        )
 
 
 class Documenter:
diff --git a/sphinx/registry.py b/sphinx/registry.py
index 49b121f826a..0fe19faa4b1 100644
--- a/sphinx/registry.py
+++ b/sphinx/registry.py
@@ -22,7 +22,7 @@
 
 if TYPE_CHECKING:
     import os
-    from collections.abc import Callable, Iterator, Sequence
+    from collections.abc import Callable, Iterator, Mapping, Sequence
 
     from docutils import nodes
     from docutils.core import Publisher
@@ -565,16 +565,10 @@ def load_extension(self, app: Sphinx, extname: str) -> None:
 
             app.extensions[extname] = Extension(extname, mod, **metadata)
 
-    def get_envversion(self, app: Sphinx) -> dict[str, int]:
-        from sphinx.environment import ENV_VERSION
+    def get_envversion(self, app: Sphinx) -> Mapping[str, int]:
+        from sphinx.environment import _get_env_version
 
-        envversion = {
-            ext.name: ext.metadata['env_version']
-            for ext in app.extensions.values()
-            if ext.metadata.get('env_version')
-        }
-        envversion['sphinx'] = ENV_VERSION
-        return envversion
+        return _get_env_version(app.extensions)
 
     def get_publisher(self, app: Sphinx, filetype: str) -> Publisher:
         try:
diff --git a/sphinx/roles.py b/sphinx/roles.py
index aed317177c5..318ce0ed74a 100644
--- a/sphinx/roles.py
+++ b/sphinx/roles.py
@@ -29,7 +29,6 @@
 generic_docroles = {
     'command': addnodes.literal_strong,
     'dfn': nodes.emphasis,
-    'kbd': nodes.literal,
     'mailheader': addnodes.literal_emphasis,
     'makevar': addnodes.literal_strong,
     'mimetype': addnodes.literal_emphasis,
@@ -479,6 +478,59 @@ def run(self) -> tuple[list[Node], list[system_message]]:
         return [nodes.abbreviation(self.rawtext, text, **options)], []
 
 
+class Keyboard(SphinxRole):
+    """Implement the :kbd: role.
+
+    Split words in the text by separator or whitespace,
+    but keep multi-word keys together.
+    """
+
+    # capture ('-', '+', '^', or whitespace) in between any two characters
+    _pattern: Final = re.compile(r'(?<=.)([\-+^]| +)(?=.)')
+
+    def run(self) -> tuple[list[Node], list[system_message]]:
+        classes = ['kbd']
+        if 'classes' in self.options:
+            classes.extend(self.options['classes'])
+
+        parts = self._pattern.split(self.text)
+        if len(parts) == 1 or self._is_multi_word_key(parts):
+            return [nodes.literal(self.rawtext, self.text, classes=classes)], []
+
+        compound: list[Node] = []
+        while parts:
+            if self._is_multi_word_key(parts):
+                key = ''.join(parts[:3])
+                parts[:3] = []
+            else:
+                key = parts.pop(0)
+            compound.append(nodes.literal(key, key, classes=classes))
+
+            try:
+                sep = parts.pop(0)  # key separator ('-', '+', '^', etc)
+            except IndexError:
+                break
+            else:
+                compound.append(nodes.Text(sep))
+
+        return compound, []
+
+    @staticmethod
+    def _is_multi_word_key(parts: list[str]) -> bool:
+        if len(parts) <= 2 or not parts[1].isspace():
+            return False
+        name = parts[0].lower(), parts[2].lower()
+        return name in frozenset({
+            ('back', 'space'),
+            ('caps', 'lock'),
+            ('num', 'lock'),
+            ('page', 'down'),
+            ('page', 'up'),
+            ('scroll', 'lock'),
+            ('sys', 'rq'),
+        })
+
+
 class Manpage(ReferenceRole):
     _manpage_re = re.compile(r'^(?P<path>(?P<page>.+)[(.](?P<section>[1-9]\w*)?\)?)$')
 
@@ -576,6 +628,7 @@ def code_role(
     'samp': EmphasizedLiteral(),
     # other
     'abbr': Abbreviation(),
+    'kbd': Keyboard(),
     'manpage': Manpage(),
 }
 
diff --git a/sphinx/util/exceptions.py b/sphinx/util/exceptions.py
deleted file mode 100644
index c25a9ac7fdd..00000000000
--- a/sphinx/util/exceptions.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from __future__ import annotations
-
-import sys
-import traceback
-
-from sphinx._cli.util.errors import save_traceback
-
-__all__ = 'save_traceback', 'format_exception_cut_frames'
-
-
-def format_exception_cut_frames(x: int = 1) -> str:
-    """Format an exception with traceback, but only the last x frames."""
-    typ, val, tb = sys.exc_info()
-    # res = ['Traceback (most recent call last):\n']
-    res: list[str] = []
-    tbres = traceback.format_tb(tb)
-    res += tbres[-x:]
-    res += traceback.format_exception_only(typ, val)
-    return ''.join(res)
diff --git a/sphinx/util/i18n.py b/sphinx/util/i18n.py
index b4953032dc4..ae7cf2fd70d 100644
--- a/sphinx/util/i18n.py
+++ b/sphinx/util/i18n.py
@@ -65,7 +65,7 @@ def __call__(
 
 
 class CatalogInfo:
-    __slots__ = ('base_dir', 'domain', 'charset')
+    __slots__ = 'base_dir', 'domain', 'charset'
 
     def __init__(
         self, base_dir: str | os.PathLike[str], domain: str, charset: str


Test patch:
diff --git a/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py b/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py
index 4ba37706799..3a1500207ba 100644
--- a/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py
+++ b/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py
@@ -12,7 +12,7 @@
 variable2 = None  # type: myint
 
 #: docstring
-variable3: Optional[myint]  # NoQA: UP007
+variable3: Optional[myint]  # NoQA: UP045
 
 
 def read(r: io.BytesIO) -> io.StringIO:
diff --git a/tests/test_builders/test_build_html_copyright.py b/tests/test_builders/test_build_html_copyright.py
index 7a18bceb603..69ddd2d1f8a 100644
--- a/tests/test_builders/test_build_html_copyright.py
+++ b/tests/test_builders/test_build_html_copyright.py
@@ -9,6 +9,18 @@
 LOCALTIME_2009 = type(LT)(LT_NEW)
 
 
+@pytest.fixture
+def no_source_date_year(monkeypatch):
+    """
+    Explicitly clear SOURCE_DATE_EPOCH from the environment; this
+    fixture can be used to ensure that copyright substitution logic
+    does not occur during selected test cases.
+    """
+    with monkeypatch.context() as m:
+        m.delenv('SOURCE_DATE_EPOCH', raising=False)
+        yield
+
+
 @pytest.fixture(
     params=[
         1199145600,  # 2008-01-01 00:00:00
@@ -24,7 +36,7 @@ def source_date_year(request, monkeypatch):
 
 
 @pytest.mark.sphinx('html', testroot='copyright-multiline')
-def test_html_multi_line_copyright(app):
+def test_html_multi_line_copyright(no_source_date_year, app):
     app.build(force_all=True)
 
     content = (app.outdir / 'index.html').read_text(encoding='utf-8')
diff --git a/tests/test_events.py b/tests/test_events.py
index 349b3dde572..d601298918f 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+from types import SimpleNamespace
+
 import pytest
 
 from sphinx.errors import ExtensionError
@@ -24,16 +26,11 @@ def test_event_priority():
     assert result == [3, 1, 2, 5, 4]
 
 
-class FakeApp:
-    def __init__(self, pdb: bool = False):
-        self.pdb = pdb
-
-
 def test_event_allowed_exceptions():
     def raise_error(app):
         raise RuntimeError
 
-    app = FakeApp()  # pass a dummy object as an app
+    app = SimpleNamespace(pdb=False)  # pass a dummy object as an app
     events = EventManager(app)  # type: ignore[arg-type]
     events.connect('builder-inited', raise_error, priority=500)
 
@@ -50,7 +47,7 @@ def test_event_pdb():
     def raise_error(app):
         raise RuntimeError
 
-    app = FakeApp(pdb=True)  # pass a dummy object as an app
+    app = SimpleNamespace(pdb=True)  # pass a dummy object as an app
     events = EventManager(app)  # type: ignore[arg-type]
     events.connect('builder-inited', raise_error, priority=500)
 
diff --git a/tests/test_markup/test_markup.py b/tests/test_markup/test_markup.py
index 09deb90ff0f..d6dcbe384d9 100644
--- a/tests/test_markup/test_markup.py
+++ b/tests/test_markup/test_markup.py
@@ -11,7 +11,6 @@
 from docutils.parsers.rst import Parser as RstParser
 
 from sphinx import addnodes
-from sphinx.builders.html.transforms import KeyboardTransform
 from sphinx.builders.latex import LaTeXBuilder
 from sphinx.environment import default_settings
 from sphinx.roles import XRefRole
@@ -100,7 +99,6 @@ class ForgivingLaTeXTranslator(LaTeXTranslator, ForgivingTranslator):
 def verify_re_html(app, parse):
     def verify(rst, html_expected):
         document = parse(rst)
-        KeyboardTransform(document).apply()
         html_translator = ForgivingHTMLTranslator(document, app.builder)
         document.walkabout(html_translator)
         html_translated = ''.join(html_translator.fragment).strip()
@@ -357,28 +355,35 @@ def get(name):
             'verify',
             ':kbd:`Control+X`',
             (
-                '<p><kbd class="kbd compound docutils literal notranslate">'
+                '<p>'
                 '<kbd class="kbd docutils literal notranslate">Control</kbd>'
                 '+'
                 '<kbd class="kbd docutils literal notranslate">X</kbd>'
-                '</kbd></p>'
+                '</p>'
+            ),
+            (
+                '\\sphinxAtStartPar\n'
+                '\\sphinxkeyboard{\\sphinxupquote{Control}}'
+                '+'
+                '\\sphinxkeyboard{\\sphinxupquote{X}}'
             ),
-            '\\sphinxAtStartPar\n\\sphinxkeyboard{\\sphinxupquote{Control+X}}',
         ),
         (
             # kbd role
             'verify',
             ':kbd:`Alt+^`',
             (
-                '<p><kbd class="kbd compound docutils literal notranslate">'
+                '<p>'
                 '<kbd class="kbd docutils literal notranslate">Alt</kbd>'
                 '+'
                 '<kbd class="kbd docutils literal notranslate">^</kbd>'
-                '</kbd></p>'
+                '</p>'
             ),
             (
                 '\\sphinxAtStartPar\n'
-                '\\sphinxkeyboard{\\sphinxupquote{Alt+\\textasciicircum{}}}'
+                '\\sphinxkeyboard{\\sphinxupquote{Alt}}'
+                '+'
+                '\\sphinxkeyboard{\\sphinxupquote{\\textasciicircum{}}}'
             ),
         ),
         (
@@ -386,7 +391,7 @@ def get(name):
             'verify',
             ':kbd:`M-x  M-s`',
             (
-                '<p><kbd class="kbd compound docutils literal notranslate">'
+                '<p>'
                 '<kbd class="kbd docutils literal notranslate">M</kbd>'
                 '-'
                 '<kbd class="kbd docutils literal notranslate">x</kbd>'
@@ -394,11 +399,17 @@ def get(name):
                 '<kbd class="kbd docutils literal notranslate">M</kbd>'
                 '-'
                 '<kbd class="kbd docutils literal notranslate">s</kbd>'
-                '</kbd></p>'
+                '</p>'
             ),
             (
                 '\\sphinxAtStartPar\n'
-                '\\sphinxkeyboard{\\sphinxupquote{M\\sphinxhyphen{}x  M\\sphinxhyphen{}s}}'
+                '\\sphinxkeyboard{\\sphinxupquote{M}}'
+                '\\sphinxhyphen{}'
+                '\\sphinxkeyboard{\\sphinxupquote{x}}'
+                '  '
+                '\\sphinxkeyboard{\\sphinxupquote{M}}'
+                '\\sphinxhyphen{}'
+                '\\sphinxkeyboard{\\sphinxupquote{s}}'
             ),
         ),
         (
@@ -422,6 +433,28 @@ def get(name):
             '<p><kbd class="kbd docutils literal notranslate">sys   rq</kbd></p>',
             '\\sphinxAtStartPar\n\\sphinxkeyboard{\\sphinxupquote{sys   rq}}',
         ),
+        (
+            # kbd role
+            'verify',
+            ':kbd:`⌘+⇧+M`',
+            (
+                '<p>'
+                '<kbd class="kbd docutils literal notranslate">⌘</kbd>'
+                '+'
+                '<kbd class="kbd docutils literal notranslate">⇧</kbd>'
+                '+'
+                '<kbd class="kbd docutils literal notranslate">M</kbd>'
+                '</p>'
+            ),
+            (
+                '\\sphinxAtStartPar\n'
+                '\\sphinxkeyboard{\\sphinxupquote{⌘}}'
+                '+'
+                '\\sphinxkeyboard{\\sphinxupquote{⇧}}'
+                '+'
+                '\\sphinxkeyboard{\\sphinxupquote{M}}'
+            ),
+        ),
         (
             # non-interpolation of dashes in option role
             'verify_re',
diff --git a/tests/test_util/test_util_inspect.py b/tests/test_util/test_util_inspect.py
index 68e40ec7882..6cea49ba308 100644
--- a/tests/test_util/test_util_inspect.py
+++ b/tests/test_util/test_util_inspect.py
@@ -96,7 +96,7 @@ def test_TypeAliasForwardRef():
     sig_str = stringify_annotation(alias, 'fully-qualified-except-typing')
     assert sig_str == "TypeAliasForwardRef('example')"
 
-    alias = Optional[alias]  # NoQA: UP007
+    alias = Optional[alias]  # NoQA: UP045
     sig_str = stringify_annotation(alias, 'fully-qualified-except-typing')
     assert sig_str == "TypeAliasForwardRef('example') | None"