Fixed #1786: Add configurable type hints.

This adds the option of giving, in addition to the type of the default
value, hints about permissible types for configuration values.
This commit is contained in:
Robert Lehmann
2015-09-11 09:35:46 +02:00
parent a22fb0d45f
commit 8b00f57f4d
5 changed files with 116 additions and 83 deletions

View File

@@ -535,13 +535,14 @@ class Sphinx(object):
builder.name, self.builderclasses[builder.name].__module__))
self.builderclasses[builder.name] = builder
def add_config_value(self, name, default, rebuild):
self.debug('[app] adding config value: %r', (name, default, rebuild))
def add_config_value(self, name, default, rebuild, types=()):
self.debug('[app] adding config value: %r',
(name, default, rebuild) + ((types,) if types else ()))
if name in self.config.values:
raise ExtensionError('Config value %r already present' % name)
if rebuild in (False, True):
rebuild = rebuild and 'env' or ''
self.config.values[name] = (default, rebuild)
self.config.values[name] = (default, rebuild, types)
def add_event(self, name):
self.debug('[app] adding event: %r', name)

View File

@@ -27,10 +27,8 @@ if PY3:
CONFIG_SYNTAX_ERROR += "\nDid you change the syntax from 2.x to 3.x?"
CONFIG_EXIT_ERROR = "The configuration file (or one of the modules it imports) " \
"called sys.exit()"
IGNORE_CONFIG_TYPE_CHECKS = (
'html_domain_indices', 'latex_domain_indices', 'texinfo_domain_indices'
)
CONFIG_TYPE_WARNING = "The config value `{name}' has type `{current.__name__}', " \
"defaults to `{default.__name__}.'"
class Config(object):
@@ -50,9 +48,10 @@ class Config(object):
version = ('', 'env'),
release = ('', 'env'),
today = ('', 'env'),
today_fmt = (None, 'env'), # the real default is locale-dependent
# the real default is locale-dependent
today_fmt = (None, 'env', [str]),
language = (None, 'env'),
language = (None, 'env', [str]),
locale_dirs = ([], 'env'),
master_doc = ('contents', 'env'),
@@ -60,23 +59,23 @@ class Config(object):
source_encoding = ('utf-8-sig', 'env'),
source_parsers = ({}, 'env'),
exclude_patterns = ([], 'env'),
default_role = (None, 'env'),
default_role = (None, 'env', [str]),
add_function_parentheses = (True, 'env'),
add_module_names = (True, 'env'),
trim_footnote_reference_space = (False, 'env'),
show_authors = (False, 'env'),
pygments_style = (None, 'html'),
pygments_style = (None, 'html', [str]),
highlight_language = ('python', 'env'),
highlight_options = ({}, 'env'),
templates_path = ([], 'html'),
template_bridge = (None, 'html'),
template_bridge = (None, 'html', [str]),
keep_warnings = (False, 'env'),
modindex_common_prefix = ([], 'html'),
rst_epilog = (None, 'env'),
rst_prolog = (None, 'env'),
rst_epilog = (None, 'env', [str]),
rst_prolog = (None, 'env', [str]),
trim_doctest_flags = (True, 'env'),
primary_domain = ('py', 'env'),
needs_sphinx = (None, None),
needs_sphinx = (None, None, [str]),
needs_extensions = ({}, None),
nitpicky = (False, 'env'),
nitpick_ignore = ([], 'html'),
@@ -95,34 +94,34 @@ class Config(object):
(self.project, self.release),
'html'),
html_short_title = (lambda self: self.html_title, 'html'),
html_style = (None, 'html'),
html_logo = (None, 'html'),
html_favicon = (None, 'html'),
html_style = (None, 'html', [str]),
html_logo = (None, 'html', [str]),
html_favicon = (None, 'html', [str]),
html_static_path = ([], 'html'),
html_extra_path = ([], 'html'),
# the real default is locale-dependent
html_last_updated_fmt = (None, 'html'),
html_last_updated_fmt = (None, 'html', [str]),
html_use_smartypants = (True, 'html'),
html_translator_class = (None, 'html'),
html_translator_class = (None, 'html', [str]),
html_sidebars = ({}, 'html'),
html_additional_pages = ({}, 'html'),
html_use_modindex = (True, 'html'), # deprecated
html_domain_indices = (True, 'html'),
html_domain_indices = (True, 'html', [list]),
html_add_permalinks = (u'\u00B6', 'html'),
html_use_index = (True, 'html'),
html_split_index = (False, 'html'),
html_copy_source = (True, 'html'),
html_show_sourcelink = (True, 'html'),
html_use_opensearch = ('', 'html'),
html_file_suffix = (None, 'html'),
html_link_suffix = (None, 'html'),
html_file_suffix = (None, 'html', [str]),
html_link_suffix = (None, 'html', [str]),
html_show_copyright = (True, 'html'),
html_show_sphinx = (True, 'html'),
html_context = ({}, 'html'),
html_output_encoding = ('utf-8', 'html'),
html_compact_lists = (True, 'html'),
html_secnumber_suffix = ('. ', 'html'),
html_search_language = (None, 'html'),
html_search_language = (None, 'html', [str]),
html_search_options = ({}, 'html'),
html_search_scorer = ('', None),
html_scaled_image_link = (True, 'html'),
@@ -139,17 +138,17 @@ class Config(object):
# Apple help options
applehelp_bundle_name = (lambda self: make_filename(self.project),
'applehelp'),
applehelp_bundle_id = (None, 'applehelp'),
applehelp_bundle_id = (None, 'applehelp', [str]),
applehelp_dev_region = ('en-us', 'applehelp'),
applehelp_bundle_version = ('1', 'applehelp'),
applehelp_icon = (None, 'applehelp'),
applehelp_icon = (None, 'applehelp', [str]),
applehelp_kb_product = (lambda self: '%s-%s' %
(make_filename(self.project), self.release),
'applehelp'),
applehelp_kb_url = (None, 'applehelp'),
applehelp_remote_url = (None, 'applehelp'),
applehelp_index_anchors = (False, 'applehelp'),
applehelp_min_term_length = (None, 'applehelp'),
applehelp_kb_url = (None, 'applehelp', [str]),
applehelp_remote_url = (None, 'applehelp', [str]),
applehelp_index_anchors = (False, 'applehelp', [str]),
applehelp_min_term_length = (None, 'applehelp', [str]),
applehelp_stopwords = (lambda self: self.language or 'en', 'applehelp'),
applehelp_locale = (lambda self: self.language or 'en', 'applehelp'),
applehelp_title = (lambda self: self.project + ' Help', 'applehelp'),
@@ -196,11 +195,11 @@ class Config(object):
self.project,
'', 'manual')],
None),
latex_logo = (None, None),
latex_logo = (None, None, [str]),
latex_appendices = ([], None),
latex_use_parts = (False, None),
latex_use_modindex = (True, None), # deprecated
latex_domain_indices = (True, None),
latex_domain_indices = (True, None, [list]),
latex_show_urls = ('no', None),
latex_show_pagerefs = (False, None),
# paper_size and font_size are still separate values
@@ -236,13 +235,13 @@ class Config(object):
None),
texinfo_appendices = ([], None),
texinfo_elements = ({}, None),
texinfo_domain_indices = (True, None),
texinfo_domain_indices = (True, None, [list]),
texinfo_show_urls = ('footnote', None),
texinfo_no_detailmenu = (False, None),
# linkcheck options
linkcheck_ignore = ([], None),
linkcheck_timeout = (None, None),
linkcheck_timeout = (None, None, [int]),
linkcheck_workers = (5, None),
linkcheck_anchors = (True, None),
@@ -292,25 +291,30 @@ class Config(object):
# NB. since config values might use l_() we have to wait with calling
# this method until i18n is initialized
for name in self._raw_config:
if name in IGNORE_CONFIG_TYPE_CHECKS:
continue # for a while, ignore multiple types config value. see #1781
if name not in Config.config_values:
if name not in self.values:
continue # we don't know a default value
default, dummy_rebuild = Config.config_values[name]
settings = self.values[name]
default, dummy_rebuild = settings[:2]
permitted = settings[2] if len(settings) == 3 else ()
if hasattr(default, '__call__'):
default = default(self) # could invoke l_()
if default is None:
continue
if default is None and not permitted:
continue # neither inferrable nor expliclitly permitted types
current = self[name]
if type(current) is type(default):
continue
if type(current) in permitted:
continue
common_bases = (set(type(current).__bases__ + (type(current),)) &
set(type(default).__bases__))
common_bases.discard(object)
if common_bases:
continue # at least we share a non-trivial base class
warn("the config value %r has type `%s', defaults to `%s.'" %
(name, type(current).__name__, type(default).__name__))
warn(CONFIG_TYPE_WARNING.format(
name=name, current=type(current), default=type(default)))
def check_unicode(self, warn):
# check all string values for non-ASCII characters in bytestrings,

View File

@@ -0,0 +1,32 @@
value1 = 123 # wrong type
value2 = 123 # lambda with wrong type
value3 = [] # lambda with correct type
value4 = True # child type
value5 = 3 # parent type
value6 = () # other sequence type, also raises
value7 = ['foo'] # explicitly permitted
class A(object):
pass
class B(A):
pass
class C(A):
pass
value8 = C() # sibling type
# both have no default or permissible types
value9 = 'foo'
value10 = 123
def setup(app):
app.add_config_value('value1', 'string', False)
app.add_config_value('value2', lambda conf: [], False)
app.add_config_value('value3', [], False)
app.add_config_value('value4', 100, False)
app.add_config_value('value5', False, False)
app.add_config_value('value6', [], False)
app.add_config_value('value7', 'string', False, [list])
app.add_config_value('value8', B(), False)
app.add_config_value('value9', None, False)
app.add_config_value('value10', None, False)

View File

@@ -9,9 +9,10 @@
:copyright: Copyright 2007-2015 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from six import PY2, PY3, StringIO
from six import PY2, PY3, StringIO, iteritems
from util import TestApp, with_app, with_tempdir, raises, raises_msg
from util import TestApp, with_app, gen_with_app, with_tempdir, \
raises, raises_msg, assert_in, assert_not_in
from sphinx.config import Config
from sphinx.errors import ExtensionError, ConfigError, VersionRequirementError
@@ -135,36 +136,30 @@ def test_config_eol(tmpdir):
assert cfg.project == u'spam'
TYPECHECK_OVERRIDES = [
# configuration key, override value, should warn, default type
('master_doc', 123, True, str),
('man_pages', 123, True, list), # lambda
('man_pages', [], False, list),
('epub_tocdepth', True, True, int), # child type
('nitpicky', 3, False, bool), # parent type
('templates_path', (), True, list), # other sequence, also raises
]
if PY2:
# Run a check for proper sibling detection in Python 2. Under py3k, the
# default types do not have any siblings.
TYPECHECK_OVERRIDES.append(
('html_add_permalinks', 'bar', False, unicode))
@with_app(confoverrides={'master_doc': 123, 'language': 'foo'})
def test_builtin_conf(app, status, warning):
assert_in('master_doc', warning.getvalue(),
'override on builtin "master_doc" should raise a type warning')
assert_not_in('language', warning.getvalue(), 'explicitly permitted '
'override on builtin "language" should NOT raise a type warning')
def test_gen_check_types():
for key, value, should, deftype in TYPECHECK_OVERRIDES:
warning = StringIO()
app = TestApp(confoverrides={key: value}, warning=warning)
app.cleanup()
real = type(value).__name__
msg = ("WARNING: the config value %r has type `%s',"
" defaults to `%s.'\n" % (key, real, deftype.__name__))
def test():
warning_list = warning.getvalue()
assert (msg in warning_list) == should, \
"Setting %s to %r should%s raise: %s" % \
(key, value, " not" if should else "", msg)
test.description = "test_check_type_%s_on_%s" % \
(real, type(Config.config_values[key][0]).__name__)
yield test
# See roots/test-config/conf.py.
TYPECHECK_WARNINGS = {
'value1': True,
'value2': True,
'value3': False,
'value4': True,
'value5': False,
'value6': True,
'value7': False,
'value8': False,
'value9': False,
'value10': False,
}
@gen_with_app(testroot='config')
def test_gen_check_types(app, status, warning):
for key, should in iteritems(TYPECHECK_WARNINGS):
yield assert_in if should else assert_not_in, key, warning.getvalue(), \
'override on "%s" should%s raise a type warning' % \
(key, '' if should else ' NOT')

View File

@@ -94,14 +94,15 @@ def assert_startswith(thing, prefix):
assert False, '%r does not start with %r' % (thing, prefix)
def assert_in(x, thing):
if x not in thing:
assert False, '%r is not in %r' % (x, thing)
def assert_not_in(x, thing):
if x in thing:
assert False, '%r is in %r' % (x, thing)
try:
from nose.tools import assert_in, assert_not_in
except ImportError:
def assert_in(x, thing, msg=''):
if x not in thing:
assert False, msg or '%r is not in %r%r' % (x, thing)
def assert_not_in(x, thing, msg=''):
if x in thing:
assert False, msg or '%r is in %r%r' % (x, thing)
def skip_if(condition, msg=None):