Introduce a configuration option type

* Normalise rebuild values
* Ensure that the tuple interface continues to work
This commit is contained in:
Adam Turner
2024-01-03 18:03:07 +00:00
parent c3deb1746a
commit fd23cf0256
3 changed files with 198 additions and 84 deletions

View File

@@ -6,9 +6,11 @@ import sys
import time import time
import traceback import traceback
import types import types
import warnings
from os import getenv, path from os import getenv, path
from typing import TYPE_CHECKING, Any, Literal, NamedTuple from typing import TYPE_CHECKING, Any, Literal, NamedTuple
from sphinx.deprecation import RemovedInSphinx90Warning
from sphinx.errors import ConfigError, ExtensionError from sphinx.errors import ConfigError, ExtensionError
from sphinx.locale import _, __ from sphinx.locale import _, __
from sphinx.util import logging from sphinx.util import logging
@@ -73,6 +75,85 @@ class ENUM:
return value in self.candidates return value in self.candidates
class _Opt:
__slots__ = 'default', 'rebuild', 'valid_types'
default: Any
rebuild: _ConfigRebuild
valid_types: Sequence[type] | ENUM | Any
def __init__(
self,
default: Any,
rebuild: _ConfigRebuild,
valid_types: Sequence[type] | ENUM | Any,
) -> 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)
def __repr__(self):
return (
f'{self.__class__.__qualname__}('
f'default={self.default!r}, '
f'rebuild={self.rebuild!r}, '
f'valid_types={self.valid_types!r})'
)
def __eq__(self, other):
if self.__class__ is other.__class__:
self_tpl = (self.default, self.rebuild, self.valid_types)
other_tpl = (other.default, other.rebuild, other.valid_types)
return self_tpl == other_tpl
return NotImplemented
def __lt__(self, other):
if self.__class__ is other.__class__:
self_tpl = (self.default, self.rebuild, self.valid_types)
other_tpl = (other.default, other.rebuild, other.valid_types)
return self_tpl > other_tpl
return NotImplemented
def __hash__(self):
return hash((self.default, self.rebuild, self.valid_types))
def __setattr__(self, key, value):
if key in {'default', 'rebuild', 'valid_types'}:
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):
if key in {'default', 'rebuild', 'valid_types'}:
msg = f'{self.__class__.__name__!r} object does not support deletion of {key!r}'
raise TypeError(msg)
super().__delattr__(key)
def __getstate__(self):
return self.default, self.rebuild, self.valid_types
def __setstate__(self, state):
default, rebuild, valid_types = state
super().__setattr__('default', default)
super().__setattr__('rebuild', rebuild)
super().__setattr__('valid_types', valid_types)
def __getitem__(self, item):
warnings.warn(
f'The {self.__class__.__name__!r} object tuple interface is deprecated, '
"use attribute access instead for 'default', 'rebuild', and 'valid_types'.",
RemovedInSphinx90Warning, stacklevel=2)
return (self.default, self.rebuild, self.valid_types)[item]
class Config: class Config:
r"""Configuration file abstraction. r"""Configuration file abstraction.
@@ -92,73 +173,72 @@ class Config:
# If you add a value here, remember to include it in the docs! # If you add a value here, remember to include it in the docs!
config_values: dict[str, tuple[Any, _ConfigRebuild, Sequence[type] | ENUM | Any]] = { config_values: dict[str, _Opt] = {
# general options # general options
'project': ('Python', 'env', []), 'project': _Opt('Python', 'env', []),
'author': ('unknown', 'env', []), 'author': _Opt('unknown', 'env', []),
'project_copyright': ('', 'html', [str, tuple, list]), 'project_copyright': _Opt('', 'html', [str, tuple, list]),
'copyright': (lambda c: c.project_copyright, 'html', [str, tuple, list]), 'copyright': _Opt(lambda c: c.project_copyright, 'html', [str, tuple, list]),
'version': ('', 'env', []), 'version': _Opt('', 'env', []),
'release': ('', 'env', []), 'release': _Opt('', 'env', []),
'today': ('', 'env', []), 'today': _Opt('', 'env', []),
# the real default is locale-dependent # the real default is locale-dependent
'today_fmt': (None, 'env', [str]), 'today_fmt': _Opt(None, 'env', [str]),
'language': ('en', 'env', [str]), 'language': _Opt('en', 'env', [str]),
'locale_dirs': (['locales'], 'env', []), 'locale_dirs': _Opt(['locales'], 'env', []),
'figure_language_filename': ('{root}.{language}{ext}', 'env', [str]), 'figure_language_filename': _Opt('{root}.{language}{ext}', 'env', [str]),
'gettext_allow_fuzzy_translations': (False, 'gettext', []), 'gettext_allow_fuzzy_translations': _Opt(False, 'gettext', []),
'translation_progress_classes': (False, 'env', 'translation_progress_classes': _Opt(
ENUM(True, False, 'translated', 'untranslated')), False, 'env', ENUM(True, False, 'translated', 'untranslated')),
'master_doc': ('index', 'env', []), 'master_doc': _Opt('index', 'env', []),
'root_doc': (lambda config: config.master_doc, 'env', []), 'root_doc': _Opt(lambda config: config.master_doc, 'env', []),
'source_suffix': ({'.rst': 'restructuredtext'}, 'env', Any), 'source_suffix': _Opt({'.rst': 'restructuredtext'}, 'env', Any),
'source_encoding': ('utf-8-sig', 'env', []), 'source_encoding': _Opt('utf-8-sig', 'env', []),
'exclude_patterns': ([], 'env', [str]), 'exclude_patterns': _Opt([], 'env', [str]),
'include_patterns': (["**"], 'env', [str]), 'include_patterns': _Opt(["**"], 'env', [str]),
'default_role': (None, 'env', [str]), 'default_role': _Opt(None, 'env', [str]),
'add_function_parentheses': (True, 'env', []), 'add_function_parentheses': _Opt(True, 'env', []),
'add_module_names': (True, 'env', []), 'add_module_names': _Opt(True, 'env', []),
'toc_object_entries': (True, 'env', [bool]), 'toc_object_entries': _Opt(True, 'env', [bool]),
'toc_object_entries_show_parents': ('domain', 'env', 'toc_object_entries_show_parents': _Opt(
ENUM('domain', 'all', 'hide')), 'domain', 'env', ENUM('domain', 'all', 'hide')),
'trim_footnote_reference_space': (False, 'env', []), 'trim_footnote_reference_space': _Opt(False, 'env', []),
'show_authors': (False, 'env', []), 'show_authors': _Opt(False, 'env', []),
'pygments_style': (None, 'html', [str]), 'pygments_style': _Opt(None, 'html', [str]),
'highlight_language': ('default', 'env', []), 'highlight_language': _Opt('default', 'env', []),
'highlight_options': ({}, 'env', []), 'highlight_options': _Opt({}, 'env', []),
'templates_path': ([], 'html', []), 'templates_path': _Opt([], 'html', []),
'template_bridge': (None, 'html', [str]), 'template_bridge': _Opt(None, 'html', [str]),
'keep_warnings': (False, 'env', []), 'keep_warnings': _Opt(False, 'env', []),
'suppress_warnings': ([], 'env', []), 'suppress_warnings': _Opt([], 'env', []),
'modindex_common_prefix': ([], 'html', []), 'modindex_common_prefix': _Opt([], 'html', []),
'rst_epilog': (None, 'env', [str]), 'rst_epilog': _Opt(None, 'env', [str]),
'rst_prolog': (None, 'env', [str]), 'rst_prolog': _Opt(None, 'env', [str]),
'trim_doctest_flags': (True, 'env', []), 'trim_doctest_flags': _Opt(True, 'env', []),
'primary_domain': ('py', 'env', [NoneType]), 'primary_domain': _Opt('py', 'env', [NoneType]),
'needs_sphinx': (None, '', [str]), 'needs_sphinx': _Opt(None, '', [str]),
'needs_extensions': ({}, '', []), 'needs_extensions': _Opt({}, '', []),
'manpages_url': (None, 'env', []), 'manpages_url': _Opt(None, 'env', []),
'nitpicky': (False, '', []), 'nitpicky': _Opt(False, '', []),
'nitpick_ignore': ([], '', [set, list, tuple]), 'nitpick_ignore': _Opt([], '', [set, list, tuple]),
'nitpick_ignore_regex': ([], '', [set, list, tuple]), 'nitpick_ignore_regex': _Opt([], '', [set, list, tuple]),
'numfig': (False, 'env', []), 'numfig': _Opt(False, 'env', []),
'numfig_secnum_depth': (1, 'env', []), 'numfig_secnum_depth': _Opt(1, 'env', []),
'numfig_format': ({}, 'env', []), # will be initialized in init_numfig_format() 'numfig_format': _Opt({}, 'env', []), # will be initialized in init_numfig_format()
'maximum_signature_line_length': (None, 'env', {int, None}), 'maximum_signature_line_length': _Opt(None, 'env', {int, None}),
'math_number_all': (False, 'env', []), 'math_number_all': _Opt(False, 'env', []),
'math_eqref_format': (None, 'env', [str]), 'math_eqref_format': _Opt(None, 'env', [str]),
'math_numfig': (True, 'env', []), 'math_numfig': _Opt(True, 'env', []),
'tls_verify': (True, 'env', []), 'tls_verify': _Opt(True, 'env', []),
'tls_cacerts': (None, 'env', []), 'tls_cacerts': _Opt(None, 'env', []),
'user_agent': (None, 'env', [str]), 'user_agent': _Opt(None, 'env', [str]),
'smartquotes': (True, 'env', []), 'smartquotes': _Opt(True, 'env', []),
'smartquotes_action': ('qDe', 'env', []), 'smartquotes_action': _Opt('qDe', 'env', []),
'smartquotes_excludes': ({'languages': ['ja'], 'smartquotes_excludes': _Opt(
'builders': ['man', 'text']}, {'languages': ['ja'], 'builders': ['man', 'text']}, 'env', []),
'env', []), 'option_emphasise_placeholders': _Opt(False, 'env', []),
'option_emphasise_placeholders': (False, 'env', []),
} }
def __init__(self, config: dict[str, Any] | None = None, def __init__(self, config: dict[str, Any] | None = None,
@@ -203,7 +283,9 @@ class Config:
if not isinstance(value, str): if not isinstance(value, str):
return value return value
else: else:
default, _rebuild, valid_types = self.values[name] opt = self.values[name]
default = opt.default
valid_types = opt.valid_types
if valid_types == Any: if valid_types == Any:
return value return value
elif valid_types == {bool, str}: elif valid_types == {bool, str}:
@@ -289,7 +371,7 @@ class Config:
def __repr__(self): def __repr__(self):
values = [] values = []
for opt_name in self._options: for opt_name in self.values:
try: try:
opt_value = getattr(self, opt_name) opt_value = getattr(self, opt_name)
except Exception: except Exception:
@@ -302,7 +384,7 @@ class Config:
raise AttributeError(name) raise AttributeError(name)
if name not in self.values: if name not in self.values:
raise AttributeError(__('No such config value: %s') % name) raise AttributeError(__('No such config value: %s') % name)
default = self.values[name][0] default = self.values[name].default
if callable(default): if callable(default):
return default(self) return default(self)
return default return default
@@ -320,15 +402,20 @@ class Config:
return name in self.values return name in self.values
def __iter__(self) -> Iterator[ConfigValue]: def __iter__(self) -> Iterator[ConfigValue]:
for name, (_default, rebuild, _valid_types) in self.values.items(): for name, opt in self.values.items():
yield ConfigValue(name, getattr(self, name), rebuild) yield ConfigValue(name, getattr(self, name), opt.rebuild)
def add(self, name: str, default: Any, rebuild: _ConfigRebuild, def add(self, name: str, default: Any, rebuild: _ConfigRebuild,
types: Sequence[type] | ENUM | Any) -> None: types: Sequence[type] | ENUM | Any) -> None:
valid_types = types valid_types = types
if name in self.values: if name in self.values:
raise ExtensionError(__('Config value %r already present') % name) raise ExtensionError(__('Config value %r already present') % name)
self.values[name] = (default, rebuild, valid_types)
# standardise rebuild
if isinstance(rebuild, bool):
rebuild = 'env' if rebuild else ''
self.values[name] = _Opt(default, rebuild, valid_types)
def filter(self, rebuild: str | Sequence[str]) -> Iterator[ConfigValue]: def filter(self, rebuild: str | Sequence[str]) -> Iterator[ConfigValue]:
if isinstance(rebuild, str): if isinstance(rebuild, str):
@@ -338,27 +425,28 @@ class Config:
def __getstate__(self) -> dict: def __getstate__(self) -> dict:
"""Obtains serializable data for pickling.""" """Obtains serializable data for pickling."""
# remove potentially pickling-problematic values from config # remove potentially pickling-problematic values from config
__dict__ = {} __dict__ = {
for key, value in self.__dict__.items(): key: value
if key.startswith('_') or not is_serializable(value): for key, value in self.__dict__.items()
pass if not key.startswith('_') and is_serializable(value)
else: }
__dict__[key] = value
# create a picklable copy of values list # create a picklable copy of values list
__dict__['values'] = values = {} __dict__['values'] = values = {}
for name, (_default, rebuild, _valid_types) in self.values.items(): for name, opt in self.values.items():
real_value = getattr(self, name) real_value = getattr(self, name)
if not is_serializable(real_value): if not is_serializable(real_value):
# omit unserializable value # omit unserializable value
real_value = None real_value = None
# valid_types is also omitted
# The valid_types column is also omitted values[name] = real_value, opt.rebuild
values[name] = (real_value, rebuild, None)
return __dict__ return __dict__
def __setstate__(self, state: dict) -> None: def __setstate__(self, state: dict) -> None:
self.values = {
name: _Opt(real_value, rebuild, ())
for name, (real_value, rebuild) in state.pop('values').items()
}
self.__dict__.update(state) self.__dict__.update(state)
@@ -491,7 +579,9 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None:
"""Check all values for deviation from the default value's type, since """Check all values for deviation from the default value's type, since
that can result in TypeErrors all over the place NB. that can result in TypeErrors all over the place NB.
""" """
for name, (default, _rebuild, valid_types) in config.values.items(): for name, opt in config.values.items():
default = opt.default
valid_types = opt.valid_types
value = getattr(config, name) value = getattr(config, name)
if callable(default): if callable(default):

View File

@@ -58,7 +58,7 @@ default_settings: dict[str, Any] = {
# This is increased every time an environment attribute is added # This is increased every time an environment attribute is added
# or changed to properly invalidate pickle files. # or changed to properly invalidate pickle files.
ENV_VERSION = 60 ENV_VERSION = 61
# config status # config status
CONFIG_UNSET = -1 CONFIG_UNSET = -1

View File

@@ -1,5 +1,5 @@
"""Test the sphinx.config.Config class.""" """Test the sphinx.config.Config class."""
import pickle
import time import time
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@@ -7,10 +7,24 @@ from unittest import mock
import pytest import pytest
import sphinx import sphinx
from sphinx.config import ENUM, Config, check_confval_types from sphinx.config import ENUM, Config, _Opt, check_confval_types
from sphinx.deprecation import RemovedInSphinx90Warning
from sphinx.errors import ConfigError, ExtensionError, VersionRequirementError from sphinx.errors import ConfigError, ExtensionError, VersionRequirementError
def test_config_opt_deprecated(recwarn):
opt = _Opt('default', '', ())
with pytest.warns(RemovedInSphinx90Warning):
default, rebuild, valid_types = opt
with pytest.warns(RemovedInSphinx90Warning):
_ = opt[0]
with pytest.warns(RemovedInSphinx90Warning):
_ = list(opt)
@pytest.mark.sphinx(testroot='config', confoverrides={ @pytest.mark.sphinx(testroot='config', confoverrides={
'root_doc': 'root', 'root_doc': 'root',
'nonexisting_value': 'True', 'nonexisting_value': 'True',
@@ -71,6 +85,16 @@ def test_config_not_found(tmp_path):
Config.read(tmp_path) Config.read(tmp_path)
@pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL)))
def test_config_pickle_protocol(tmp_path, protocol: int):
config = Config()
pickled_config = pickle.loads(pickle.dumps(config, protocol))
assert list(config.values) == list(pickled_config.values)
assert repr(config) == repr(pickled_config)
def test_extension_values(): def test_extension_values():
config = Config() config = Config()