Sphinx

# Source code for sphinx.config

    """Build configuration file handling."""

    from __future__ import annotations

    import time
    import traceback
    import types
    from contextlib import chdir
    from os import getenv
    from pathlib import Path
    from typing import TYPE_CHECKING, Any, Literal, NamedTuple

    from sphinx.errors import ConfigError, ExtensionError
    from sphinx.locale import _, __
    from sphinx.util import logging

    if TYPE_CHECKING:
        import os
        from collections.abc import Collection, Iterable, Iterator, Sequence, Set

        from sphinx.application import Sphinx
        from sphinx.environment import BuildEnvironment
        from sphinx.util.tags import Tags
        from sphinx.util.typing import ExtensionMetadata, _ExtensionSetupFunc

    logger = logging.getLogger(__name__)

    type _ConfigRebuild = Literal[
        '',
        'env',
        'epub',
        'gettext',
        'html',
        # sphinxcontrib-applehelp
        'applehelp',
        # sphinxcontrib-devhelp
        'devhelp',
    ]

    CONFIG_FILENAME = 'conf.py'
    UNSERIALIZABLE_TYPES = (type, types.ModuleType, types.FunctionType)

    class ConfigValue(NamedTuple):
        name: str
        value: Any
        rebuild: _ConfigRebuild

    def is_serializable(obj: object, *, _seen: frozenset[int] = frozenset()) -> bool:
        """Check if an object is serializable or not."""
        if isinstance(obj, UNSERIALIZABLE_TYPES):
            return False

        # use id() to handle un-hashable objects
        if id(obj) in _seen:
            return True

        if isinstance(obj, dict):
            seen = _seen | {id(obj)}
            return all(
                is_serializable(key, _seen=seen) and is_serializable(value, _seen=seen)
                for key, value in obj.items()
            )
        elif isinstance(obj, (list, tuple, set, frozenset)):
            seen = _seen | {id(obj)}
            return all(is_serializable(item, _seen=seen) for item in obj)

        # if an issue occurs for a non-serializable type, pickle will complain
        # since the object is likely coming from a third-party extension
        # (we natively expect 'simple' types and not weird ones)
        return True

    class ENUM:
        """Represents the candidates which a config value should be one of.

        Example:
            app.add_config_value('latex_show_urls', 'no', None, ENUM('no', 'footnote', 'inline'))
        """

        def __init__(self, *candidates: str | bool | None) -> None:
            self._candidates = frozenset(candidates)

        def __repr__(self) -> str:
            return f'ENUM({", ".join(sorted(map(repr, self._candidates)))})'

        def match(self, value: str | bool | None | Sequence[str | bool | None]) -> bool:  # NoQA: RUF036
            if isinstance(value, (str, bool, types.NoneType)):
                return value in self._candidates
            return all(item in self._candidates for item in value)

    type _OptValidTypes = frozenset[type] | ENUM

    class _Opt:
        __slots__ = 'default', 'rebuild', 'valid_types', 'description'

        default: Any
        rebuild: _ConfigRebuild
        valid_types: _OptValidTypes
        description: str

        def __init__(
            self,
            default: Any,
            rebuild: _ConfigRebuild,
            valid_types: _OptValidTypes,
            description: str = '',
        ) -> None:
            """Configuration option type for Sphinx.

            The type is intended to be immutable; changing the field values
            is an unsupported action.
            No validation is performed on the values, though consumers will
            likely expect them to be of the types advertised.
            The old tuple-based interface will be removed in Sphinx 9.
            """
            super().__setattr__('default', default)
            super().__setattr__('rebuild', rebuild)
            super().__setattr__('valid_types', valid_types)
            super().__setattr__('description', description)

        def __repr__(self) -> str:
            return (
                f'{self.__class__.__qualname__}('
                f'default={self.default!r}, '
                f'rebuild={self.rebuild!r}, '
                f'valid_types={self.rebuild!r}, '
                f'description={self.description!r})'
            )

        def __eq__(self, other: object) -> bool:
            if isinstance(other, _Opt):
                self_tpl = (
                    self.default,
                    self.rebuild,
                    self.valid_types,
                    self.description,
                )
                other_tpl = (
                    other.default,
                    other.rebuild,
                    other.valid_types,
                    other.description,
                )
                return self_tpl == other_tpl
            return NotImplemented

        def __lt__(self, other: _Opt) -> bool:
            if self.__class__ is other.__class__:
                self_tpl = (
                    self.default,
                    self.rebuild,
                    self.valid_types,
                    self.description,
                )
                other_tpl = (
                    other.default,
                    other.rebuild,
                    other.valid_types,
                    other.description,
                )
                return self_tpl > other_tpl  # ty: ignore[unsupported-operator]
            return NotImplemented

        def __hash__(self) -> int:
            return hash((self.default, self.rebuild, self.valid_types, self.description))

        def __setattr__(self, key: str, value: Any) -> None:
            if key in {'default', 'rebuild', 'valid_types', 'description'}:
                msg = f'{self.__class__.__name__!r} object does not support assignment to {key!r}'
                raise TypeError(msg)
            super().__setattr__(key, value)

        def __delattr__(self, key: str) -> None:
            if key in {'default', 'rebuild', 'valid_types', 'description'}:
                msg = f'{self.__class__.__name__!r} object does not support deletion of {key!r}'
                raise TypeError(msg)
            super().__delattr__(key)

        def __getstate__(self) -> tuple[Any, _ConfigRebuild, _OptValidTypes, str]:
            return self.default, self.rebuild, self.valid_types, self.description

        def __setstate__(
            self, state: tuple[Any, _ConfigRebuild, _OptValidTypes, str]
        ) -> None:
            default, rebuild, valid_types, description = state
            super().__setattr__('default', default)
            super().__setattr__('rebuild', rebuild)
            super().__setattr__('valid_types', valid_types)
            super().__setattr__('description', description)

    class Config:
        r"""Configuration file abstraction.

        The Config object makes the values of all config options available as
        attributes.

        It is exposed via the :py:class:`~sphinx.application.Sphinx`\ ``.config``
        and :py:class:`sphinx.environment.BuildEnvironment`\ ``.config`` attributes.
        For example, to get the value of :confval:`language`, use either
        ``app.config.language`` or ``env.config.language``.
        """

        # The values are:
        # 1. Default
        # 2. What needs to be rebuilt if changed
        # 3. Valid types

        # If you add a value here, remember to include it in the docs!

        config_values: dict[str, _Opt] = {
            # general options
            'project': _Opt('Project name not set', 'env', frozenset((str,))),
            'author': _Opt('Author name not set', 'env', frozenset((str,))),
            'project_copyright': _Opt('', 'html', frozenset((str, tuple, list))),
            'copyright': _Opt(
                lambda config: config.project_copyright,
                'html',
                frozenset((str, tuple, list)),
            ),
            'version': _Opt('', 'env', frozenset((str,))),
            'release': _Opt('', 'env', frozenset((str,))),
            'today': _Opt('', 'env', frozenset((str,))),
            # the real default is locale-dependent
            'today_fmt': _Opt(None, 'env', frozenset((str,))),
            'language': _Opt('en', 'env', frozenset((str,))),
            'locale_dirs': _Opt(['locales'], 'env', frozenset((list, tuple))),
            'figure_language_filename': _Opt(
                '{root}.{language}{ext}', 'env', frozenset((str,))
            ),
            'gettext_allow_fuzzy_translations': _Opt(False, 'gettext', frozenset((bool,))),
            'translation_progress_classes': _Opt(
                False, 'env', ENUM(True, False, 'translated', 'untranslated')
            ),
            'master_doc': _Opt('index', 'env', frozenset((str,))),
            'root_doc': _Opt(lambda config: config.master_doc, 'env', frozenset((str,))),
            # ``source_suffix`` type is actually ``dict[str, str | None]``:
            # see ``convert_source_suffix()`` below.
            'source_suffix': _Opt({'.rst': 'restructuredtext'}, 'env', Any),  # type: ignore[arg-type]
            'source_encoding': _Opt('utf-8-sig', 'env', frozenset((str,))),
            'exclude_patterns': _Opt([], 'env', frozenset((str,))),
            'include_patterns': _Opt(['**'], 'env', frozenset((str,))),
            'default_role': _Opt(None, 'env', frozenset((str,))),
            'add_function_parentheses': _Opt(True, 'env', frozenset((bool,))),
            'add_module_names': _Opt(True, 'env', frozenset((bool,))),
            'toc_object_entries': _Opt(True, 'env', frozenset((bool,))),
            'toc_object_entries_show_parents': _Opt(
                'domain', 'env', ENUM('domain', 'all', 'hide')
            ),
            'trim_footnote_reference_space': _Opt(False, 'env', frozenset((bool,))),
            'show_authors': _Opt(False, 'env', frozenset((bool,))),
            'pygments_style': _Opt(None, 'html', frozenset((str,))),
            'highlight_language': _Opt('default', 'env', frozenset((str,))),
            'highlight_options': _Opt({}, 'env', frozenset((dict,))),
            'templates_path': _Opt([], 'html', frozenset((list,))),
            'template_bridge': _Opt(None, 'html', frozenset((str,))),
            'keep_warnings': _Opt(False, 'env', frozenset((bool,))),
            'suppress_warnings': _Opt([], 'env', frozenset((list, tuple))),
            'show_warning_types': _Opt(True, 'env', frozenset((bool,))),
            'modindex_common_prefix': _Opt([], 'html', frozenset((list, tuple))),
            'rst_epilog': _Opt(None, 'env', frozenset((str,))),
            'rst_prolog': _Opt(None, 'env', frozenset((str,))),
            'trim_doctest_flags': _Opt(True, 'env', frozenset((bool,))),
            'primary_domain': _Opt('py', 'env', frozenset((types.NoneType,))),
            'needs_sphinx': _Opt(None, '', frozenset((str,))),
            'needs_extensions': _Opt({}, '', frozenset((dict,))),
            'manpages_url': _Opt(None, 'env', frozenset((str, types.NoneType))),
            'nitpicky': _Opt(False, '', frozenset((bool,))),
            'nitpick_ignore': _Opt([], '', frozenset((set, list, tuple))),
            'nitpick_ignore_regex': _Opt([], '', frozenset((set, list, tuple))),
            'numfig': _Opt(False, 'env', frozenset((bool,))),
            'numfig_secnum_depth': _Opt(1, 'env', frozenset((int, types.NoneType))),
            # numfig_format will be initialized in init_numfig_format()
            'numfig_format': _Opt({}, 'env', frozenset((dict,))),
            'maximum_signature_line_length': _Opt(
                None, 'env', frozenset((int, types.NoneType))
            ),
            'math_number_all': _Opt(False, 'env', frozenset((bool,))),
            'math_eqref_format': _Opt(None, 'env', frozenset((str,))),
            'math_numfig': _Opt(True, 'env', frozenset((bool,))),
            'math_numsep': _Opt('.', 'env', frozenset((str,))),
            'tls_verify': _Opt(True, 'env', frozenset((bool,))),
            'tls_cacerts': _Opt(None, 'env', frozenset((str, dict, types.NoneType))),
            'user_agent': _Opt(None, 'env', frozenset((str,))),
            'smartquotes': _Opt(True, 'env', frozenset((bool,))),
            'smartquotes_action': _Opt('qDe', 'env', frozenset((str,))),
            'smartquotes_excludes': _Opt(
                {'languages': ['ja', 'zh_CN', 'zh_TW'], 'builders': ['man', 'text']},
                'env',
                frozenset((dict,)),
            ),
            'option_emphasise_placeholders': _Opt(False, 'env', frozenset((bool,))),
        }

        def __init__(
            self,
            config: dict[str, Any] | None = None,
            overrides: dict[str, Any] | None = None,
        ) -> None:
            raw_config: dict[str, Any] = config or {}
            self._overrides = dict(overrides) if overrides is not None else {}
            self._options = Config.config_values.copy()
            self._raw_config = raw_config

            for name in list(self._overrides.keys()):
                if '.' in name:
                    real_name, _, key = name.partition('.')
                    raw_config.setdefault(real_name, {})[key] = self._overrides.pop(name)

            self.setup: _ExtensionSetupFunc | None = raw_config.get('setup')

            if 'extensions' in self._overrides:
                extensions = self._overrides.pop('extensions')
                if isinstance(extensions, str):
                    raw_config['extensions'] = extensions.split(',')
                else:
                    raw_config['extensions'] = extensions
            self.extensions: list[str] = raw_config.get('extensions', [])

            self._verbosity: int = 0  # updated in Sphinx.__init__()

        @property
        def values(self) -> dict[str, _Opt]:
            return self._options

        @property
        def overrides(self) -> dict[str, Any]:
            return self._overrides

        @property
        def verbosity(self) -> int:
            return self._verbosity

        @classmethod
        def read(
            cls: type[Config],
            confdir: str | os.PathLike[str],
            *,
            overrides: dict[str, Any],
            tags: Tags,
        ) -> Config:
            """Create a Config object from configuration file."""
            filename = Path(confdir, CONFIG_FILENAME)
            if not filename.is_file():
                raise ConfigError(
                    __("config directory doesn't contain a conf.py file (%s)") % confdir
                )
            return _read_conf_py(filename, overrides=overrides, tags=tags)

        def convert_overrides(self, name: str, value: str) -> Any:
            opt = self._options[name]
            default = opt.default
            valid_types = opt.valid_types
            if valid_types == Any:
                return value
            if isinstance(valid_types, ENUM):
                if False in valid_types._candidates and value == '0':
                    return False
                if True in valid_types._candidates and value == '1':
                    return True
                return value
            elif type(default) is bool or (bool in valid_types):
                if value == '0':
                    return False
                if value == '1':
                    return True
                if len(valid_types) > 1:
                    return value
                msg = __("'%s' must be '0' or '1', got '%s'") % (name, value)
                raise ConfigError(msg)
            if isinstance(default, dict):
                raise ValueError(  # NoQA: TRY004
                    __(
                        'cannot override dictionary config setting %r, '
                        'ignoring (use %r to set individual elements)'
                    )
                    % (name, f'{name}.key=value')
                )
            if isinstance(default, list):
                return value.split(',')
            if isinstance(default, int):
                try:
                    return int(value)
                except ValueError as exc:
                    raise ValueError(
                        __('invalid number %r for config value %r, ignoring')
                        % (value, name)
                    ) from exc
            if callable(default):
                return value
            if isinstance(default, str) or default is None:
                return value
            raise ValueError(
                __('cannot override config setting %r with unsupported type, ignoring')
                % name
            )

        @staticmethod
        def pre_init_values() -> None:
            # method only retained for compatibility
            pass
            # warnings.warn(
            #     'Config.pre_init_values() will be removed in Sphinx 9.0 or later',
            #     RemovedInSphinx90Warning, stacklevel=2)

        def init_values(self) -> None:
            # method only retained for compatibility
            self._report_override_warnings()
            # warnings.warn(
            #     'Config.init_values() will be removed in Sphinx 9.0 or later',
            #     RemovedInSphinx90Warning, stacklevel=2)

        def _report_override_warnings(self) -> None:
            for name in self._overrides:
                if name not in self._options:
                    logger.warning(
                        __('unknown config value %r in override, ignoring'), name
                    )

        def __repr__(self) -> str:
            values = []
            for opt_name in self._options:
                try:
                    opt_value = getattr(self, opt_name)
                except Exception:
                    opt_value = '<error!>'
                values.append(f'{opt_name}={opt_value!r}')
            return self.__class__.__qualname__ + '(' + ', '.join(values) + ')'

        def __setattr__(self, key: str, value: object) -> None:
            # Ensure aliases update their counterpart.
            if key == 'master_doc':
                super().__setattr__('root_doc', value)
            elif key == 'root_doc':
                super().__setattr__('master_doc', value)
            elif key == 'copyright':
                super().__setattr__('project_copyright', value)
            elif key == 'project_copyright':
                super().__setattr__('copyright', value)
            super().__setattr__(key, value)

        def __getattr__(self, name: str) -> Any:
            if name in self._options:
                # first check command-line overrides
                if name in self._overrides:
                    value = self._overrides[name]
                    if not isinstance(value, str):
                        self.__dict__[name] = value
                        return value
                    try:
                        value = self.convert_overrides(name, value)
                    except ValueError as exc:
                        logger.warning('%s', exc)
                    else:
                        self.__setattr__(name, value)
                        return value
                # then check values from 'conf.py'
                if name in self._raw_config:
                    value = self._raw_config[name]
                    self.__setattr__(name, value)
                    return value
                # finally, fall back to the default value
                default = self._options[name].default
                if callable(default):
                    return default(self)
                self.__dict__[name] = default
                return default
            if name.startswith('_'):
                msg = f'{self.__class__.__name__!r} object has no attribute {name!r}'
                raise AttributeError(msg)
            msg = __('No such config value: %r') % name
            raise AttributeError(msg)

        def __getitem__(self, name: str) -> Any:
            return getattr(self, name)

        def __setitem__(self, name: str, value: Any) -> None:
            setattr(self, name, value)

        def __delitem__(self, name: str) -> None:
            delattr(self, name)

        def __contains__(self, name: str) -> bool:
            return name in self._options

        def __iter__(self) -> Iterator[ConfigValue]:
            for name, opt in self._options.items():
                yield ConfigValue(name, getattr(self, name), opt.rebuild)

        def add(
            self,
            name: str,
            default: Any,
            rebuild: _ConfigRebuild,
            types: type | Collection[type] | ENUM,
            description: str = '',
        ) -> None:
            if name in self._options:
                raise ExtensionError(__('Config value %r already present') % name)

            # standardise rebuild
            if isinstance(rebuild, bool):
                rebuild = 'env' if rebuild else ''

            # standardise valid_types
            valid_types = _validate_valid_types(types)
            self._options[name] = _Opt(default, rebuild, valid_types, description)

        def filter(self, rebuild: Set[_ConfigRebuild]) -> Iterator[ConfigValue]:
            if isinstance(rebuild, str):
                return (value for value in self if value.rebuild == rebuild)
            return (value for value in self if value.rebuild in rebuild)

        def __getstate__(self) -> dict[str, Any]:
            """Obtains serializable data for pickling."""
            # remove potentially pickling-problematic values from config
            __dict__ = {
                key: value
                for key, value in self.__dict__.items()
                if not key.startswith('_') and is_serializable(value)
            }
            # create a pickleable copy of ``self._options``
            __dict__['_options'] = _options = {}
            for name, opt in self._options.items():
                if not isinstance(opt, _Opt) and isinstance(opt, tuple) and len(opt) <= 3:
                    # Fix for Furo's ``_update_default``.
                    self._options[name] = opt = _Opt(*opt)
                real_value = getattr(self, name)
                if not is_serializable(real_value):
                    if opt.rebuild:
                        # if the value is not cached, then any build that utilises this cache
                        # will always mark the config value as changed,
                        # and thus always invalidate the cache and perform a rebuild.
                        logger.warning(
                            __(
                                'cannot cache unpickleable configuration value: %r '
                                '(because it contains a function, class, or module object)'
                            ),
                            name,
                            type='config',
                            subtype='cache',
                            once=True,
                        )
                    # omit unserializable value
                    real_value = None
                # valid_types is also omitted
                _options[name] = real_value, opt.rebuild

            return __dict__

        def __setstate__(self, state: dict[str, Any]) -> None:
            self._overrides = {}
            self._options = {
                name: _Opt(real_value, rebuild, frozenset())
                for name, (real_value, rebuild) in state.pop('_options').items()
            }
            self._raw_config = {}
            self.__dict__.update(state)

    def _read_conf_py(conf_path: Path, *, overrides: dict[str, Any], tags: Tags) -> Config:
        """Create a Config object from a conf.py file."""
        namespace = eval_config_file(conf_path, tags)

        # Note: Old sphinx projects have been configured as "language = None" because
        #       sphinx-quickstart previously generated this by default.
        #       To keep compatibility, they should be fallback to 'en' for a while
        #       (This conversion should not be removed before 2025-01-01).
        if namespace.get('language', ...) is None:
            logger.warning(
                __(
                    "Invalid configuration value found: 'language = None'. "
                    'Update your configuration to a valid language code. '
                    "Falling back to 'en' (English)."
                )
            )
            namespace['language'] = 'en'
        return Config(namespace, overrides)

    def eval_config_file(filename: Path, tags: Tags) -> dict[str, Any]:
        """Evaluate a config file."""
        namespace: dict[str, Any] = {
            '__file__': str(filename),
            'tags': tags,
        }

        with chdir(filename.parent):
            # during executing config file, current dir is changed to ``confdir``.
            try:
                code = compile(filename.read_bytes(), filename, 'exec')
                exec(code, namespace)  # NoQA: S102
            except SyntaxError as err:
                msg = __('There is a syntax error in your configuration file: %s\n')
                raise ConfigError(msg % err) from err
            except SystemExit as exc:
                msg = __(
                    'The configuration file (or one of the modules it imports) '
                    'called sys.exit()'
                )
                raise ConfigError(msg) from exc
            except ConfigError:
                # pass through ConfigError from conf.py as is.  It will be shown in console.
                raise
            except Exception as exc:
                msg = __('There is a programmable error in your configuration file:\n\n%s')
                raise ConfigError(msg % traceback.format_exc()) from exc

        return namespace

    def _validate_valid_types(
        valid_types: type | Collection[type] | ENUM, /
    ) -> frozenset[type] | ENUM:
        if not valid_types:
            return frozenset()
        if isinstance(valid_types, (frozenset, ENUM)):
            return valid_types  # ty: ignore[invalid-return-type]
        if isinstance(valid_types, type):
            return frozenset((valid_types,))
        if valid_types is Any:
            return frozenset({Any})
        if isinstance(valid_types, set):
            return frozenset(valid_types)
        try:
            return frozenset(valid_types)
        except TypeError:
            logger.warning(__('Failed to convert %r to a frozenset'), valid_types)
            return frozenset()

    def convert_source_suffix(app: Sphinx, config: Config) -> None:
        """Convert old styled source_suffix to new styled one.

        * old style: str or list
        * new style: a dict which maps from fileext to filetype
        """
        source_suffix = config.source_suffix
        if isinstance(source_suffix, str):
            # if str, considers as default filetype (None)
            #
            # The default filetype is determined on later step.
            # By default, it is considered as restructuredtext.
            config.source_suffix = {source_suffix: 'restructuredtext'}
            logger.info(
                __('Converting `source_suffix = %r` to `source_suffix = %r`.'),
                source_suffix,
                config.source_suffix,
            )
        elif isinstance(source_suffix, (list, tuple)):
            # if list, considers as all of them are default filetype
            config.source_suffix = dict.fromkeys(source_suffix, 'restructuredtext')
            logger.info(
                __('Converting `source_suffix = %r` to `source_suffix = %r`.'),
                source_suffix,
                config.source_suffix,
            )
        elif not isinstance(source_suffix, dict):
            msg = __(
                "The config value `source_suffix' expects a dictionary, "
                "a string, or a list of strings. Got `%r' instead (type %s)."
            )
            raise ConfigError(msg % (source_suffix, type(source_suffix)))

    def convert_highlight_options(app: Sphinx, config: Config) -> None:
        """Convert old styled highlight_options to new styled one.

        * old style: options
        * new style: a dict which maps from language name to options
        """
        options = config.highlight_options
        if options and not all(isinstance(v, dict) for v in options.values()):
            # old styled option detected because all values are not dictionary.
            config.highlight_options = {config.highlight_language: options}

    def init_numfig_format(app: Sphinx, config: Config) -> None:
        """Initialize :confval:`numfig_format`."""
        numfig_format = {
            'section': _('Section %s'),
            'figure': _('Fig. %s'),
            'table': _('Table %s'),
            'code-block': _('Listing %s'),
        }

        # override default labels by configuration
        numfig_format.update(config.numfig_format)
        config.numfig_format = numfig_format

    def evaluate_copyright_placeholders(_app: Sphinx, config: Config) -> None:
        """Replace copyright year placeholders (%Y) with the current year."""
        replace_yr = str(time.localtime().tm_year)
        for k in ('copyright', 'epub_copyright'):
            if k in config:
                value: str | Sequence[str] = config[k]
                if isinstance(value, str):
                    if '%Y' in value:
                        config[k] = value.replace('%Y', replace_yr)
                else:
                    if any('%Y' in line for line in value):
                        items = (line.replace('%Y', replace_yr) for line in value)
                        config[k] = type(value)(items)  # type: ignore[call-arg]

    def correct_copyright_year(_app: Sphinx, config: Config) -> None:
        """Correct values of copyright year that are not coherent with
        the SOURCE_DATE_EPOCH environment variable (if set)

        See https://reproducible-builds.org/specs/source-date-epoch/
        """
        if source_date_epoch := int(getenv('SOURCE_DATE_EPOCH', '0')):
            source_date_epoch_year = time.gmtime(source_date_epoch).tm_year
        else:
            return

        # If the current year is the replacement year, there's no work to do.
        # We also skip replacement years that are in the future.
        current_year = time.localtime().tm_year
        if current_year <= source_date_epoch_year:
            return

        current_yr = str(current_year)
        replace_yr = str(source_date_epoch_year)
        for k in ('copyright', 'epub_copyright'):
            if k in config:
                value: str | Sequence[str] = config[k]
                if isinstance(value, str):
                    config[k] = _substitute_copyright_year(value, current_yr, replace_yr)
                else:
                    items = (
                        _substitute_copyright_year(x, current_yr, replace_yr) for x in value
                    )
                    config[k] = type(value)(items)  # type: ignore[call-arg]

    def _substitute_copyright_year(
        copyright_line: str, current_year: str, replace_year: str
    ) -> str:
        """Replace the year in a single copyright line.

        Legal formats are:

        * ``YYYY``
        * ``YYYY,``
        * ``YYYY ``
        * ``YYYY-YYYY``
        * ``YYYY-YYYY,``
        * ``YYYY-YYYY ``

        The final year in the string is replaced with ``replace_year``.
        """
        if len(copyright_line) < 4 or not copyright_line[:4].isdigit():
            return copyright_line

        if copyright_line[:4] == current_year and copyright_line[4:5] in {'', ' ', ','}:
            return replace_year + copyright_line[4:]

        if copyright_line[4:5] != '-':
            return copyright_line

        if (
            copyright_line[5:9].isdigit()
            and copyright_line[5:9] == current_year
            and copyright_line[9:10] in {'', ' ', ','}
        ):
            return copyright_line[:5] + replace_year + copyright_line[9:]

        return copyright_line

    def check_confval_types(app: Sphinx | None, config: Config) -> None:
        """Check all values for deviation from the default value's type, since
        that can result in TypeErrors all over the place NB.
        """
        for name, opt in config._options.items():
            default = opt.default
            valid_types = opt.valid_types
            value = getattr(config, name)

            if callable(default):
                default = default(config)  # evaluate default value
            if default is None and not valid_types:
                continue  # neither inferable nor explicitly annotated types

            if valid_types == frozenset({Any}):  # any type of value is accepted
                continue

            if isinstance(valid_types, ENUM):
                if not valid_types.match(value):
                    msg = __(
                        'The config value `{name}` has to be a one of {candidates}, '
                        'but `{current}` is given.'
                    )
                    logger.warning(
                        msg.format(
                            name=name, current=value, candidates=valid_types._candidates
                        ),
                        once=True,
                    )
                continue

            type_value = type(value)
            type_default = type(default)

            if type_value is type_default:  # attempt to infer the type
                continue

            if type_value in valid_types:  # check explicitly listed types
                if frozenset in valid_types and type_value in {list, tuple, set}:
                    setattr(config, name, frozenset(value))
                elif tuple in valid_types and type_value is list:
                    setattr(config, name, tuple(value))
                continue

            common_bases = {*type_value.__bases__, type_value} & set(type_default.__bases__)
            common_bases.discard(object)
            if common_bases:
                continue  # at least we share a non-trivial base class

            if valid_types:
                msg = __(
                    "The config value `{name}' has type `{current.__name__}'; "
                    'expected {permitted}.'
                )
                wrapped_valid_types = sorted(f"`{c.__name__}'" for c in valid_types)
                if len(wrapped_valid_types) > 2:
                    permitted = (
                        ', '.join(wrapped_valid_types[:-1])
                        + f', or {wrapped_valid_types[-1]}'
                    )
                else:
                    permitted = ' or '.join(wrapped_valid_types)
                logger.warning(
                    msg.format(name=name, current=type_value, permitted=permitted),
                    once=True,
                )
            else:
                msg = __(
                    "The config value `{name}' has type `{current.__name__}', "
                    "defaults to `{default.__name__}'."
                )
                logger.warning(
                    msg.format(name=name, current=type_value, default=type_default),
                    once=True,
                )

    def check_primary_domain(app: Sphinx, config: Config) -> None:
        primary_domain = config.primary_domain
        if primary_domain and not app.registry.has_domain(primary_domain):
            logger.warning(__('primary_domain %r not found, ignored.'), primary_domain)
            config.primary_domain = None

    def check_master_doc(
        app: Sphinx,
        env: BuildEnvironment,
        added: Set[str],
        changed: Set[str],
        removed: Set[str],
    ) -> Iterable[str]:
        """Sphinx 2.0 changed the default from 'contents' to 'index'."""
        docnames = app.project.docnames
        if (
            app.config.master_doc == 'index'
            and 'index' not in docnames
            and 'contents' in docnames
        ):
            logger.warning(
                __(
                    'Sphinx now uses "index" as the master document by default. '
                    'To keep pre-2.0 behaviour, set "master_doc = \'contents\'".'
                )
            )
            app.config.master_doc = 'contents'

        return changed

    def deprecate_source_encoding(_app: Sphinx, config: Config) -> None:
        """Warn on non-UTF 8 source_encoding."""
        # RemovedInSphinx10Warning
        if config.source_encoding.lower() not in {'utf-8', 'utf-8-sig', 'utf8'}:
            msg = _(
                'Support for source encodings other than UTF-8 '
                'is deprecated and will be removed in Sphinx 10. '
                'Please comment at https://github.com/sphinx-doc/sphinx/issues/13665 '
                'if this causes a problem.'
            )
            logger.warning(msg)

    def setup(app: Sphinx) -> ExtensionMetadata:
        app.connect('config-inited', deprecate_source_encoding, priority=790)
        app.connect('config-inited', convert_source_suffix, priority=800)
        app.connect('config-inited', convert_highlight_options, priority=800)
        app.connect('config-inited', init_numfig_format, priority=800)
        app.connect('config-inited', evaluate_copyright_placeholders, priority=795)
        app.connect('config-inited', correct_copyright_year, priority=800)
        app.connect('config-inited', check_confval_types, priority=800)
        app.connect('config-inited', check_primary_domain, priority=800)
        app.connect('env-get-outdated', check_master_doc)

        return {
            'version': 'builtin',
            'parallel_read_safe': True,
            'parallel_write_safe': True,
        }