"""Build configuration file handling.""" import re import traceback import types from collections import OrderedDict from os import getenv, path from typing import (TYPE_CHECKING, Any, Callable, Dict, Generator, Iterator, List, NamedTuple, Optional, Set, Tuple, Union) from sphinx.errors import ConfigError, ExtensionError from sphinx.locale import _, __ from sphinx.util import logging from sphinx.util.i18n import format_date from sphinx.util.osutil import cd, fs_encoding from sphinx.util.tags import Tags from sphinx.util.typing import NoneType if TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment logger = logging.getLogger(__name__) CONFIG_FILENAME = 'conf.py' UNSERIALIZABLE_TYPES = (type, types.ModuleType, types.FunctionType) copyright_year_re = re.compile(r'^((\d{4}-)?)(\d{4})(?=[ ,])') class ConfigValue(NamedTuple): name: str value: Any rebuild: Union[bool, str] def is_serializable(obj: Any) -> bool: """Check if object is serializable or not.""" if isinstance(obj, UNSERIALIZABLE_TYPES): return False elif isinstance(obj, dict): for key, value in obj.items(): if not is_serializable(key) or not is_serializable(value): return False elif isinstance(obj, (list, tuple, set)): return all(is_serializable(i) for i in obj) 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) -> None: self.candidates = candidates def match(self, value: Union[str, List, Tuple]) -> bool: if isinstance(value, (list, tuple)): return all(item in self.candidates for item in value) else: return value in self.candidates class Config: """Configuration file abstraction. The config object makes the values of all config values available as attributes. It is exposed via the :py:attr:`sphinx.application.Application.config` and :py:attr:`sphinx.environment.Environment.config` attributes. For example, to get the value of :confval:`language`, use either ``app.config.language`` or ``env.config.language``. """ # the values are: (default, what needs to be rebuilt if changed) # If you add a value here, don't forget to include it in the # quickstart.py file template as well as in the docs! config_values: Dict[str, Tuple] = { # general options 'project': ('Python', 'env', []), 'author': ('unknown', 'env', []), 'project_copyright': ('', 'html', [str]), 'copyright': (lambda c: c.project_copyright, 'html', [str]), 'version': ('', 'env', []), 'release': ('', 'env', []), 'today': ('', 'env', []), # the real default is locale-dependent 'today_fmt': (None, 'env', [str]), 'language': (None, 'env', [str]), 'locale_dirs': (['locales'], 'env', []), 'figure_language_filename': ('{root}.{language}{ext}', 'env', [str]), 'gettext_allow_fuzzy_translations': (False, 'gettext', []), 'master_doc': ('index', 'env', []), 'root_doc': (lambda config: config.master_doc, 'env', []), 'source_suffix': ({'.rst': 'restructuredtext'}, 'env', Any), 'source_encoding': ('utf-8-sig', 'env', []), 'exclude_patterns': ([], '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', [str]), 'highlight_language': ('default', 'env', []), 'highlight_options': ({}, 'env', []), 'templates_path': ([], 'html', []), 'template_bridge': (None, 'html', [str]), 'keep_warnings': (False, 'env', []), 'suppress_warnings': ([], 'env', []), 'modindex_common_prefix': ([], 'html', []), 'rst_epilog': (None, 'env', [str]), 'rst_prolog': (None, 'env', [str]), 'trim_doctest_flags': (True, 'env', []), 'primary_domain': ('py', 'env', [NoneType]), 'needs_sphinx': (None, None, [str]), 'needs_extensions': ({}, None, []), 'manpages_url': (None, 'env', []), 'nitpicky': (False, None, []), 'nitpick_ignore': ([], None, []), 'nitpick_ignore_regex': ([], None, []), 'numfig': (False, 'env', []), 'numfig_secnum_depth': (1, 'env', []), 'numfig_format': ({}, 'env', []), # will be initialized in init_numfig_format() 'math_number_all': (False, 'env', []), 'math_eqref_format': (None, 'env', [str]), 'math_numfig': (True, 'env', []), 'tls_verify': (True, 'env', []), 'tls_cacerts': (None, 'env', []), 'user_agent': (None, 'env', [str]), 'smartquotes': (True, 'env', []), 'smartquotes_action': ('qDe', 'env', []), 'smartquotes_excludes': ({'languages': ['ja'], 'builders': ['man', 'text']}, 'env', []), } def __init__(self, config: Dict[str, Any] = {}, overrides: Dict[str, Any] = {}) -> None: self.overrides = dict(overrides) self.values = Config.config_values.copy() self._raw_config = config self.setup: Optional[Callable] = config.get('setup', None) if 'extensions' in self.overrides: if isinstance(self.overrides['extensions'], str): config['extensions'] = self.overrides.pop('extensions').split(',') else: config['extensions'] = self.overrides.pop('extensions') self.extensions: List[str] = config.get('extensions', []) @classmethod def read(cls, confdir: str, overrides: Dict = None, tags: Tags = None) -> "Config": """Create a Config object from configuration file.""" filename = path.join(confdir, CONFIG_FILENAME) if not path.isfile(filename): raise ConfigError(__("config directory doesn't contain a conf.py file (%s)") % confdir) namespace = eval_config_file(filename, tags) return cls(namespace, overrides or {}) def convert_overrides(self, name: str, value: Any) -> Any: if not isinstance(value, str): return value else: defvalue = self.values[name][0] if self.values[name][2] == Any: return value elif self.values[name][2] == {bool, str}: if value == '0': # given falsy string from command line option return False elif value == '1': return True else: return value elif type(defvalue) is bool or self.values[name][2] == [bool]: if value == '0': # given falsy string from command line option return False else: return bool(value) elif isinstance(defvalue, dict): raise ValueError(__('cannot override dictionary config setting %r, ' 'ignoring (use %r to set individual elements)') % (name, name + '.key=value')) elif isinstance(defvalue, list): return value.split(',') elif isinstance(defvalue, int): try: return int(value) except ValueError as exc: raise ValueError(__('invalid number %r for config value %r, ignoring') % (value, name)) from exc elif callable(defvalue): return value elif defvalue is not None and not isinstance(defvalue, str): raise ValueError(__('cannot override config setting %r with unsupported ' 'type, ignoring') % name) else: return value def pre_init_values(self) -> None: """ Initialize some limited config variables before initializing i18n and loading extensions. """ variables = ['needs_sphinx', 'suppress_warnings', 'language', 'locale_dirs'] for name in variables: try: if name in self.overrides: self.__dict__[name] = self.convert_overrides(name, self.overrides[name]) elif name in self._raw_config: self.__dict__[name] = self._raw_config[name] except ValueError as exc: logger.warning("%s", exc) def init_values(self) -> None: config = self._raw_config for valname, value in self.overrides.items(): try: if '.' in valname: realvalname, key = valname.split('.', 1) config.setdefault(realvalname, {})[key] = value continue elif valname not in self.values: logger.warning(__('unknown config value %r in override, ignoring'), valname) continue if isinstance(value, str): config[valname] = self.convert_overrides(valname, value) else: config[valname] = value except ValueError as exc: logger.warning("%s", exc) for name in config: if name in self.values: self.__dict__[name] = config[name] def post_init_values(self) -> None: """ Initialize additional config variables that are added after init_values() called. """ config = self._raw_config for name in config: if name not in self.__dict__ and name in self.values: self.__dict__[name] = config[name] check_confval_types(None, self) def __getattr__(self, name: str) -> Any: if name.startswith('_'): raise AttributeError(name) if name not in self.values: raise AttributeError(__('No such config value: %s') % name) default = self.values[name][0] if callable(default): return default(self) return default 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.values def __iter__(self) -> Generator[ConfigValue, None, None]: for name, value in self.values.items(): yield ConfigValue(name, getattr(self, name), value[1]) def add(self, name: str, default: Any, rebuild: Union[bool, str], types: Any) -> None: if name in self.values: raise ExtensionError(__('Config value %r already present') % name) else: self.values[name] = (default, rebuild, types) def filter(self, rebuild: Union[str, List[str]]) -> Iterator[ConfigValue]: if isinstance(rebuild, str): rebuild = [rebuild] return (value for value in self if value.rebuild in rebuild) def __getstate__(self) -> Dict: """Obtains serializable data for pickling.""" # remove potentially pickling-problematic values from config __dict__ = {} for key, value in self.__dict__.items(): if key.startswith('_') or not is_serializable(value): pass else: __dict__[key] = value # create a picklable copy of values list __dict__['values'] = {} for key, value in self.values.items(): real_value = getattr(self, key) if not is_serializable(real_value): # omit unserializable value real_value = None # types column is also omitted __dict__['values'][key] = (real_value, value[1], None) return __dict__ def __setstate__(self, state: Dict) -> None: self.__dict__.update(state) def eval_config_file(filename: str, tags: Optional[Tags]) -> Dict[str, Any]: """Evaluate a config file.""" namespace: Dict[str, Any] = {} namespace['__file__'] = filename namespace['tags'] = tags with cd(path.dirname(filename)): # during executing config file, current dir is changed to ``confdir``. try: with open(filename, 'rb') as f: code = compile(f.read(), filename.encode(fs_encoding), 'exec') exec(code, namespace) 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 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 = OrderedDict({source_suffix: None}) # type: ignore elif isinstance(source_suffix, (list, tuple)): # if list, considers as all of them are default filetype config.source_suffix = OrderedDict([(s, None) for s in source_suffix]) # type: ignore # NOQA elif isinstance(source_suffix, dict): # if dict, convert it to OrderedDict config.source_suffix = OrderedDict(config.source_suffix) # type: ignore else: logger.warning(__("The config value `source_suffix' expects " "a string, list of strings, or dictionary. " "But `%r' is given." % 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} # type: ignore 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 # type: ignore 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 getenv('SOURCE_DATE_EPOCH') is not None: for k in ('copyright', 'epub_copyright'): if k in config: replace = r'\g<1>%s' % format_date('%Y') config[k] = copyright_year_re.sub(replace, config[k]) def check_confval_types(app: "Sphinx", 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 confval in config: default, rebuild, annotations = config.values[confval.name] if callable(default): default = default(config) # evaluate default value if default is None and not annotations: continue # neither inferable nor expliclitly annotated types if annotations is Any: # any type of value is accepted pass elif isinstance(annotations, ENUM): if not annotations.match(confval.value): msg = __("The config value `{name}` has to be a one of {candidates}, " "but `{current}` is given.") logger.warning(msg.format(name=confval.name, current=confval.value, candidates=annotations.candidates), once=True) else: if type(confval.value) is type(default): continue if type(confval.value) in annotations: continue common_bases = (set(type(confval.value).__bases__ + (type(confval.value),)) & set(type(default).__bases__)) common_bases.discard(object) if common_bases: continue # at least we share a non-trivial base class if annotations: msg = __("The config value `{name}' has type `{current.__name__}'; " "expected {permitted}.") wrapped_annotations = ["`{}'".format(c.__name__) for c in annotations] if len(wrapped_annotations) > 2: permitted = "{}, or {}".format( ", ".join(wrapped_annotations[:-1]), wrapped_annotations[-1]) else: permitted = " or ".join(wrapped_annotations) logger.warning(msg.format(name=confval.name, current=type(confval.value), permitted=permitted), once=True) else: msg = __("The config value `{name}' has type `{current.__name__}', " "defaults to `{default.__name__}'.") logger.warning(msg.format(name=confval.name, current=type(confval.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 # type: ignore def check_root_doc(app: "Sphinx", env: "BuildEnvironment", added: Set[str], changed: Set[str], removed: Set[str]) -> Set[str]: """Adjust root_doc to 'contents' to support an old project which does not have any root_doc setting. """ if (app.config.root_doc == 'index' and 'index' not in app.project.docnames and 'contents' in app.project.docnames): logger.warning(__('Since v2.0, Sphinx uses "index" as root_doc by default. ' 'Please add "root_doc = \'contents\'" to your conf.py.')) app.config.root_doc = "contents" # type: ignore return changed def setup(app: "Sphinx") -> Dict[str, Any]: 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', 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_root_doc) return { 'version': 'builtin', 'parallel_read_safe': True, 'parallel_write_safe': True, }