summaryrefslogtreecommitdiff
path: root/sphinx/environment
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/environment')
-rw-r--r--sphinx/environment/__init__.py1202
-rw-r--r--sphinx/environment/managers/__init__.py37
-rw-r--r--sphinx/environment/managers/indexentries.py172
-rw-r--r--sphinx/environment/managers/toctree.py561
4 files changed, 1972 insertions, 0 deletions
diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py
new file mode 100644
index 000000000..d750b0284
--- /dev/null
+++ b/sphinx/environment/__init__.py
@@ -0,0 +1,1202 @@
+# -*- coding: utf-8 -*-
+"""
+ sphinx.environment
+ ~~~~~~~~~~~~~~~~~~
+
+ Global creation environment.
+
+ :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+
+import re
+import os
+import sys
+import time
+import types
+import codecs
+import fnmatch
+from os import path
+from glob import glob
+
+from six import iteritems, itervalues, class_types, next
+from six.moves import cPickle as pickle
+from docutils import nodes
+from docutils.io import NullOutput
+from docutils.core import Publisher
+from docutils.utils import Reporter, relative_path, get_source_line
+from docutils.parsers.rst import roles
+from docutils.parsers.rst.languages import en as english
+from docutils.frontend import OptionParser
+
+from sphinx import addnodes
+from sphinx.io import SphinxStandaloneReader, SphinxDummyWriter, SphinxFileInput
+from sphinx.util import get_matching_docs, docname_join, FilenameUniqDict
+from sphinx.util.nodes import clean_astext, WarningStream, is_translatable, \
+ process_only_nodes
+from sphinx.util.osutil import SEP, getcwd, fs_encoding, ensuredir
+from sphinx.util.images import guess_mimetype
+from sphinx.util.i18n import find_catalog_files, get_image_filename_for_language, \
+ search_image_for_language
+from sphinx.util.console import bold, purple
+from sphinx.util.docutils import sphinx_domains
+from sphinx.util.matching import compile_matchers
+from sphinx.util.parallel import ParallelTasks, parallel_available, make_chunks
+from sphinx.util.websupport import is_commentable
+from sphinx.errors import SphinxError, ExtensionError
+from sphinx.versioning import add_uids, merge_doctrees
+from sphinx.transforms import SphinxContentsFilter
+from sphinx.environment.managers.indexentries import IndexEntries
+from sphinx.environment.managers.toctree import Toctree
+
+
+default_settings = {
+ 'embed_stylesheet': False,
+ 'cloak_email_addresses': True,
+ 'pep_base_url': 'https://www.python.org/dev/peps/',
+ 'rfc_base_url': 'https://tools.ietf.org/html/',
+ 'input_encoding': 'utf-8-sig',
+ 'doctitle_xform': False,
+ 'sectsubtitle_xform': False,
+ 'halt_level': 5,
+ 'file_insertion_enabled': True,
+}
+
+# This is increased every time an environment attribute is added
+# or changed to properly invalidate pickle files.
+#
+# NOTE: increase base version by 2 to have distinct numbers for Py2 and 3
+ENV_VERSION = 50 + (sys.version_info[0] - 2)
+
+
+dummy_reporter = Reporter('', 4, 4)
+
+versioning_conditions = {
+ 'none': False,
+ 'text': is_translatable,
+ 'commentable': is_commentable,
+}
+
+
+class NoUri(Exception):
+ """Raised by get_relative_uri if there is no URI available."""
+ pass
+
+
+class BuildEnvironment(object):
+ """
+ The environment in which the ReST files are translated.
+ Stores an inventory of cross-file targets and provides doctree
+ transformations to resolve links to them.
+ """
+
+ # --------- ENVIRONMENT PERSISTENCE ----------------------------------------
+
+ @staticmethod
+ def frompickle(srcdir, config, filename):
+ with open(filename, 'rb') as picklefile:
+ env = pickle.load(picklefile)
+ if env.version != ENV_VERSION:
+ raise IOError('build environment version not current')
+ if env.srcdir != srcdir:
+ raise IOError('source directory has changed')
+ env.config.values = config.values
+ return env
+
+ def topickle(self, filename):
+ # remove unpicklable attributes
+ warnfunc = self._warnfunc
+ self.set_warnfunc(None)
+ values = self.config.values
+ del self.config.values
+ domains = self.domains
+ del self.domains
+ managers = self.detach_managers()
+ # remove potentially pickling-problematic values from config
+ for key, val in list(vars(self.config).items()):
+ if key.startswith('_') or \
+ isinstance(val, types.ModuleType) or \
+ isinstance(val, types.FunctionType) or \
+ isinstance(val, class_types):
+ del self.config[key]
+ with open(filename, 'wb') as picklefile:
+ pickle.dump(self, picklefile, pickle.HIGHEST_PROTOCOL)
+ # reset attributes
+ self.attach_managers(managers)
+ self.domains = domains
+ self.config.values = values
+ self.set_warnfunc(warnfunc)
+
+ # --------- ENVIRONMENT INITIALIZATION -------------------------------------
+
+ def __init__(self, srcdir, doctreedir, config):
+ self.doctreedir = doctreedir
+ self.srcdir = srcdir
+ self.config = config
+
+ # the method of doctree versioning; see set_versioning_method
+ self.versioning_condition = None
+ self.versioning_compare = None
+
+ # the application object; only set while update() runs
+ self.app = None
+
+ # all the registered domains, set by the application
+ self.domains = {}
+
+ # the docutils settings for building
+ self.settings = default_settings.copy()
+ self.settings['env'] = self
+
+ # the function to write warning messages with
+ self._warnfunc = None
+
+ # this is to invalidate old pickles
+ self.version = ENV_VERSION
+
+ # All "docnames" here are /-separated and relative and exclude
+ # the source suffix.
+
+ self.found_docs = set() # contains all existing docnames
+ self.all_docs = {} # docname -> mtime at the time of reading
+ # contains all read docnames
+ self.dependencies = {} # docname -> set of dependent file
+ # names, relative to documentation root
+ self.included = set() # docnames included from other documents
+ self.reread_always = set() # docnames to re-read unconditionally on
+ # next build
+
+ # File metadata
+ self.metadata = {} # docname -> dict of metadata items
+
+ # TOC inventory
+ self.titles = {} # docname -> title node
+ self.longtitles = {} # docname -> title node; only different if
+ # set differently with title directive
+ self.tocs = {} # docname -> table of contents nodetree
+ self.toc_num_entries = {} # docname -> number of real entries
+ # used to determine when to show the TOC
+ # in a sidebar (don't show if it's only one item)
+ self.toc_secnumbers = {} # docname -> dict of sectionid -> number
+ self.toc_fignumbers = {} # docname -> dict of figtype ->
+ # dict of figureid -> number
+
+ self.toctree_includes = {} # docname -> list of toctree includefiles
+ self.files_to_rebuild = {} # docname -> set of files
+ # (containing its TOCs) to rebuild too
+ self.glob_toctrees = set() # docnames that have :glob: toctrees
+ self.numbered_toctrees = set() # docnames that have :numbered: toctrees
+
+ # domain-specific inventories, here to be pickled
+ self.domaindata = {} # domainname -> domain-specific dict
+
+ # Other inventories
+ self.indexentries = {} # docname -> list of
+ # (type, string, target, aliasname)
+ self.versionchanges = {} # version -> list of (type, docname,
+ # lineno, module, descname, content)
+
+ # these map absolute path -> (docnames, unique filename)
+ self.images = FilenameUniqDict()
+ self.dlfiles = FilenameUniqDict()
+
+ # temporary data storage while reading a document
+ self.temp_data = {}
+ # context for cross-references (e.g. current module or class)
+ # this is similar to temp_data, but will for example be copied to
+ # attributes of "any" cross references
+ self.ref_context = {}
+
+ self.managers = {}
+ self.init_managers()
+
+ def init_managers(self):
+ managers = {}
+ for manager_class in [IndexEntries, Toctree]:
+ managers[manager_class.name] = manager_class(self)
+ self.attach_managers(managers)
+
+ def attach_managers(self, managers):
+ for name, manager in iteritems(managers):
+ self.managers[name] = manager
+ manager.attach(self)
+
+ def detach_managers(self):
+ managers = self.managers
+ self.managers = {}
+ for _, manager in iteritems(managers):
+ manager.detach(self)
+ return managers
+
+ def set_warnfunc(self, func):
+ self._warnfunc = func
+ self.settings['warning_stream'] = WarningStream(func)
+
+ def set_versioning_method(self, method, compare):
+ """This sets the doctree versioning method for this environment.
+
+ Versioning methods are a builder property; only builders with the same
+ versioning method can share the same doctree directory. Therefore, we
+ raise an exception if the user tries to use an environment with an
+ incompatible versioning method.
+ """
+ if method not in versioning_conditions:
+ raise ValueError('invalid versioning method: %r' % method)
+ condition = versioning_conditions[method]
+ if self.versioning_condition not in (None, condition):
+ raise SphinxError('This environment is incompatible with the '
+ 'selected builder, please choose another '
+ 'doctree directory.')
+ self.versioning_condition = condition
+ self.versioning_compare = compare
+
+ def warn(self, docname, msg, lineno=None, **kwargs):
+ """Emit a warning.
+
+ This differs from using ``app.warn()`` in that the warning may not
+ be emitted instantly, but collected for emitting all warnings after
+ the update of the environment.
+ """
+ # strange argument order is due to backwards compatibility
+ self._warnfunc(msg, (docname, lineno), **kwargs)
+
+ def warn_node(self, msg, node, **kwargs):
+ """Like :meth:`warn`, but with source information taken from *node*."""
+ self._warnfunc(msg, '%s:%s' % get_source_line(node), **kwargs)
+
+ def clear_doc(self, docname):
+ """Remove all traces of a source file in the inventory."""
+ if docname in self.all_docs:
+ self.all_docs.pop(docname, None)
+ self.reread_always.discard(docname)
+ self.metadata.pop(docname, None)
+ self.dependencies.pop(docname, None)
+ self.titles.pop(docname, None)
+ self.longtitles.pop(docname, None)
+ self.images.purge_doc(docname)
+ self.dlfiles.purge_doc(docname)
+
+ for version, changes in self.versionchanges.items():
+ new = [change for change in changes if change[1] != docname]
+ changes[:] = new
+
+ for manager in itervalues(self.managers):
+ manager.clear_doc(docname)
+
+ for domain in self.domains.values():
+ domain.clear_doc(docname)
+
+ def merge_info_from(self, docnames, other, app):
+ """Merge global information gathered about *docnames* while reading them
+ from the *other* environment.
+
+ This possibly comes from a parallel build process.
+ """
+ docnames = set(docnames)
+ for docname in docnames:
+ self.all_docs[docname] = other.all_docs[docname]
+ if docname in other.reread_always:
+ self.reread_always.add(docname)
+ self.metadata[docname] = other.metadata[docname]
+ if docname in other.dependencies:
+ self.dependencies[docname] = other.dependencies[docname]
+ self.titles[docname] = other.titles[docname]
+ self.longtitles[docname] = other.longtitles[docname]
+
+ self.images.merge_other(docnames, other.images)
+ self.dlfiles.merge_other(docnames, other.dlfiles)
+
+ for version, changes in other.versionchanges.items():
+ self.versionchanges.setdefault(version, []).extend(
+ change for change in changes if change[1] in docnames)
+
+ for manager in itervalues(self.managers):
+ manager.merge_other(docnames, other)
+ for domainname, domain in self.domains.items():
+ domain.merge_domaindata(docnames, other.domaindata[domainname])
+ app.emit('env-merge-info', self, docnames, other)
+
+ def path2doc(self, filename):
+ """Return the docname for the filename if the file is document.
+
+ *filename* should be absolute or relative to the source directory.
+ """
+ if filename.startswith(self.srcdir):
+ filename = filename[len(self.srcdir) + 1:]
+ for suffix in self.config.source_suffix:
+ if fnmatch.fnmatch(filename, '*' + suffix):
+ return filename[:-len(suffix)]
+ else:
+ # the file does not have docname
+ return None
+
+ def doc2path(self, docname, base=True, suffix=None):
+ """Return the filename for the document name.
+
+ If *base* is True, return absolute path under self.srcdir.
+ If *base* is None, return relative path to self.srcdir.
+ If *base* is a path string, return absolute path under that.
+ If *suffix* is not None, add it instead of config.source_suffix.
+ """
+ docname = docname.replace(SEP, path.sep)
+ if suffix is None:
+ for candidate_suffix in self.config.source_suffix:
+ if path.isfile(path.join(self.srcdir, docname) +
+ candidate_suffix):
+ suffix = candidate_suffix
+ break
+ else:
+ # document does not exist
+ suffix = self.config.source_suffix[0]
+ if base is True:
+ return path.join(self.srcdir, docname) + suffix
+ elif base is None:
+ return docname + suffix
+ else:
+ return path.join(base, docname) + suffix
+
+ def relfn2path(self, filename, docname=None):
+ """Return paths to a file referenced from a document, relative to
+ documentation root and absolute.
+
+ In the input "filename", absolute filenames are taken as relative to the
+ source dir, while relative filenames are relative to the dir of the
+ containing document.
+ """
+ if filename.startswith('/') or filename.startswith(os.sep):
+ rel_fn = filename[1:]
+ else:
+ docdir = path.dirname(self.doc2path(docname or self.docname,
+ base=None))
+ rel_fn = path.join(docdir, filename)
+ try:
+ # the path.abspath() might seem redundant, but otherwise artifacts
+ # such as ".." will remain in the path
+ return rel_fn, path.abspath(path.join(self.srcdir, rel_fn))
+ except UnicodeDecodeError:
+ # the source directory is a bytestring with non-ASCII characters;
+ # let's try to encode the rel_fn in the file system encoding
+ enc_rel_fn = rel_fn.encode(sys.getfilesystemencoding())
+ return rel_fn, path.abspath(path.join(self.srcdir, enc_rel_fn))
+
+ def find_files(self, config):
+ """Find all source files in the source dir and put them in
+ self.found_docs.
+ """
+ matchers = compile_matchers(
+ config.exclude_patterns[:] +
+ config.templates_path +
+ config.html_extra_path +
+ ['**/_sources', '.#*', '**/.#*', '*.lproj/**']
+ )
+ self.found_docs = set()
+ for docname in get_matching_docs(self.srcdir, config.source_suffix,
+ exclude_matchers=matchers):
+ if os.access(self.doc2path(docname), os.R_OK):
+ self.found_docs.add(docname)
+ else:
+ self.warn(docname, "document not readable. Ignored.")
+
+ # add catalog mo file dependency
+ for docname in self.found_docs:
+ catalog_files = find_catalog_files(
+ docname,
+ self.srcdir,
+ self.config.locale_dirs,
+ self.config.language,
+ self.config.gettext_compact)
+ for filename in catalog_files:
+ self.dependencies.setdefault(docname, set()).add(filename)
+
+ def get_outdated_files(self, config_changed):
+ """Return (added, changed, removed) sets."""
+ # clear all files no longer present
+ removed = set(self.all_docs) - self.found_docs
+
+ added = set()
+ changed = set()
+
+ if config_changed:
+ # config values affect e.g. substitutions
+ added = self.found_docs
+ else:
+ for docname in self.found_docs:
+ if docname not in self.all_docs:
+ added.add(docname)
+ continue
+ # if the doctree file is not there, rebuild
+ if not path.isfile(self.doc2path(docname, self.doctreedir,
+ '.doctree')):
+ changed.add(docname)
+ continue
+ # check the "reread always" list
+ if docname in self.reread_always:
+ changed.add(docname)
+ continue
+ # check the mtime of the document
+ mtime = self.all_docs[docname]
+ newmtime = path.getmtime(self.doc2path(docname))
+ if newmtime > mtime:
+ changed.add(docname)
+ continue
+ # finally, check the mtime of dependencies
+ for dep in self.dependencies.get(docname, ()):
+ try:
+ # this will do the right thing when dep is absolute too
+ deppath = path.join(self.srcdir, dep)
+ if not path.isfile(deppath):
+ changed.add(docname)
+ break
+ depmtime = path.getmtime(deppath)
+ if depmtime > mtime:
+ changed.add(docname)
+ break
+ except EnvironmentError:
+ # give it another chance
+ changed.add(docname)
+ break
+
+ return added, changed, removed
+
+ def update(self, config, srcdir, doctreedir, app):
+ """(Re-)read all files new or changed since last update.
+
+ Store all environment docnames in the canonical format (ie using SEP as
+ a separator in place of os.path.sep).
+ """
+ config_changed = False
+ if self.config is None:
+ msg = '[new config] '
+ config_changed = True
+ else:
+ # check if a config value was changed that affects how
+ # doctrees are read
+ for key, descr in iteritems(config.values):
+ if descr[1] != 'env':
+ continue
+ if self.config[key] != config[key]:
+ msg = '[config changed] '
+ config_changed = True
+ break
+ else:
+ msg = ''
+ # this value is not covered by the above loop because it is handled
+ # specially by the config class
+ if self.config.extensions != config.extensions:
+ msg = '[extensions changed] '
+ config_changed = True
+ # the source and doctree directories may have been relocated
+ self.srcdir = srcdir
+ self.doctreedir = doctreedir
+ self.find_files(config)
+ self.config = config
+
+ # this cache also needs to be updated every time
+ self._nitpick_ignore = set(self.config.nitpick_ignore)
+
+ app.info(bold('updating environment: '), nonl=1)
+
+ added, changed, removed = self.get_outdated_files(config_changed)
+
+ # allow user intervention as well
+ for docs in app.emit('env-get-outdated', self, added, changed, removed):
+ changed.update(set(docs) & self.found_docs)
+
+ # if files were added or removed, all documents with globbed toctrees
+ # must be reread
+ if added or removed:
+ # ... but not those that already were removed
+ changed.update(self.glob_toctrees & self.found_docs)
+
+ msg += '%s added, %s changed, %s removed' % (len(added), len(changed),
+ len(removed))
+ app.info(msg)
+
+ self.app = app
+
+ # clear all files no longer present
+ for docname in removed:
+ app.emit('env-purge-doc', self, docname)
+ self.clear_doc(docname)
+
+ # read all new and changed files
+ docnames = sorted(added | changed)
+ # allow changing and reordering the list of docs to read
+ app.emit('env-before-read-docs', self, docnames)
+
+ # check if we should do parallel or serial read
+ par_ok = False
+ if parallel_available and len(docnames) > 5 and app.parallel > 1:
+ par_ok = True
+ for extname, md in app._extension_metadata.items():
+ ext_ok = md.get('parallel_read_safe')
+ if ext_ok:
+ continue
+ if ext_ok is None:
+ app.warn('the %s extension does not declare if it '
+ 'is safe for parallel reading, assuming it '
+ 'isn\'t - please ask the extension author to '
+ 'check and make it explicit' % extname)
+ app.warn('doing serial read')
+ else:
+ app.warn('the %s extension is not safe for parallel '
+ 'reading, doing serial read' % extname)
+ par_ok = False
+ break
+ if par_ok:
+ self._read_parallel(docnames, app, nproc=app.parallel)
+ else:
+ self._read_serial(docnames, app)
+
+ if config.master_doc not in self.all_docs:
+ raise SphinxError('master file %s not found' %
+ self.doc2path(config.master_doc))
+
+ self.app = None
+
+ for retval in app.emit('env-updated', self):
+ if retval is not None:
+ docnames.extend(retval)
+
+ return sorted(docnames)
+
+ def _read_serial(self, docnames, app):
+ for docname in app.status_iterator(docnames, 'reading sources... ',
+ purple, len(docnames)):
+ # remove all inventory entries for that file
+ app.emit('env-purge-doc', self, docname)
+ self.clear_doc(docname)
+ self.read_doc(docname, app)
+
+ def _read_parallel(self, docnames, app, nproc):
+ # clear all outdated docs at once
+ for docname in docnames:
+ app.emit('env-purge-doc', self, docname)
+ self.clear_doc(docname)
+
+ def read_process(docs):
+ self.app = app
+ self.warnings = []
+ self.set_warnfunc(lambda *args, **kwargs: self.warnings.append((args, kwargs)))
+ for docname in docs:
+ self.read_doc(docname, app)
+ # allow pickling self to send it back
+ self.set_warnfunc(None)
+ del self.app
+ del self.domains
+ del self.config.values
+ del self.config
+ return self
+
+ def merge(docs, otherenv):
+ warnings.extend(otherenv.warnings)
+ self.merge_info_from(docs, otherenv, app)
+
+ tasks = ParallelTasks(nproc)
+ chunks = make_chunks(docnames, nproc)
+
+ warnings = []
+ for chunk in app.status_iterator(
+ chunks, 'reading sources... ', purple, len(chunks)):
+ tasks.add_task(read_process, chunk, merge)
+
+ # make sure all threads have finished
+ app.info(bold('waiting for workers...'))
+ tasks.join()
+
+ for warning, kwargs in warnings:
+ self._warnfunc(*warning, **kwargs)
+
+ def check_dependents(self, already):
+ to_rewrite = (self.toctree.assign_section_numbers() +
+ self.toctree.assign_figure_numbers())
+ for docname in set(to_rewrite):
+ if docname not in already:
+ yield docname
+
+ # --------- SINGLE FILE READING --------------------------------------------
+
+ def warn_and_replace(self, error):
+ """Custom decoding error handler that warns and replaces."""
+ linestart = error.object.rfind(b'\n', 0, error.start)
+ lineend = error.object.find(b'\n', error.start)
+ if lineend == -1:
+ lineend = len(error.object)
+ lineno = error.object.count(b'\n', 0, error.start) + 1
+ self.warn(self.docname, 'undecodable source characters, '
+ 'replacing with "?": %r' %
+ (error.object[linestart+1:error.start] + b'>>>' +
+ error.object[error.start:error.end] + b'<<<' +
+ error.object[error.end:lineend]), lineno)
+ return (u'?', error.end)
+
+ def read_doc(self, docname, app=None):
+ """Parse a file and add/update inventory entries for the doctree."""
+
+ self.temp_data['docname'] = docname
+ # defaults to the global default, but can be re-set in a document
+ self.temp_data['default_domain'] = \
+ self.domains.get(self.config.primary_domain)
+
+ self.settings['input_encoding'] = self.config.source_encoding
+ self.settings['trim_footnote_reference_space'] = \
+ self.config.trim_footnote_reference_space
+ self.settings['gettext_compact'] = self.config.gettext_compact
+
+ docutilsconf = path.join(self.srcdir, 'docutils.conf')
+ # read docutils.conf from source dir, not from current dir
+ OptionParser.standard_config_files[1] = docutilsconf
+ if path.isfile(docutilsconf):
+ self.note_dependency(docutilsconf)
+
+ with sphinx_domains(self):
+ if self.config.default_role:
+ role_fn, messages = roles.role(self.config.default_role, english,
+ 0, dummy_reporter)
+ if role_fn:
+ roles._roles[''] = role_fn
+ else:
+ self.warn(docname, 'default role %s not found' %
+ self.config.default_role)
+
+ codecs.register_error('sphinx', self.warn_and_replace)
+
+ # publish manually
+ reader = SphinxStandaloneReader(self.app, parsers=self.config.source_parsers)
+ pub = Publisher(reader=reader,
+ writer=SphinxDummyWriter(),
+ destination_class=NullOutput)
+ pub.set_components(None, 'restructuredtext', None)
+ pub.process_programmatic_settings(None, self.settings, None)
+ src_path = self.doc2path(docname)
+ source = SphinxFileInput(app, self, source=None, source_path=src_path,
+ encoding=self.config.source_encoding)
+ pub.source = source
+ pub.settings._source = src_path
+ pub.set_destination(None, None)
+ pub.publish()
+ doctree = pub.document
+
+ # post-processing
+ self.process_dependencies(docname, doctree)
+ self.process_images(docname, doctree)
+ self.process_downloads(docname, doctree)
+ self.process_metadata(docname, doctree)
+ self.create_title_from(docname, doctree)
+ for manager in itervalues(self.managers):
+ manager.process_doc(docname, doctree)
+ for domain in itervalues(self.domains):
+ domain.process_doc(self, docname, doctree)
+
+ # allow extension-specific post-processing
+ if app:
+ app.emit('doctree-read', doctree)
+
+ # store time of reading, for outdated files detection
+ # (Some filesystems have coarse timestamp resolution;
+ # therefore time.time() can be older than filesystem's timestamp.
+ # For example, FAT32 has 2sec timestamp resolution.)
+ self.all_docs[docname] = max(
+ time.time(), path.getmtime(self.doc2path(docname)))
+
+ if self.versioning_condition:
+ old_doctree = None
+ if self.versioning_compare:
+ # get old doctree
+ try:
+ with open(self.doc2path(docname,
+ self.doctreedir, '.doctree'), 'rb') as f:
+ old_doctree = pickle.load(f)
+ except EnvironmentError:
+ pass
+
+ # add uids for versioning
+ if not self.versioning_compare or old_doctree is None:
+ list(add_uids(doctree, self.versioning_condition))
+ else:
+ list(merge_doctrees(
+ old_doctree, doctree, self.versioning_condition))
+
+ # make it picklable
+ doctree.reporter = None
+ doctree.transformer = None
+ doctree.settings.warning_stream = None
+ doctree.settings.env = None
+ doctree.settings.record_dependencies = None
+
+ # cleanup
+ self.temp_data.clear()
+ self.ref_context.clear()
+ roles._roles.pop('', None) # if a document has set a local default role
+
+ # save the parsed doctree
+ doctree_filename = self.doc2path(docname, self.doctreedir,
+ '.doctree')
+ ensuredir(path.dirname(doctree_filename))
+ with open(doctree_filename, 'wb') as f:
+ pickle.dump(doctree, f, pickle.HIGHEST_PROTOCOL)
+
+ # utilities to use while reading a document
+
+ @property
+ def docname(self):
+ """Returns the docname of the document currently being parsed."""
+ return self.temp_data['docname']
+
+ @property
+ def currmodule(self):
+ """Backwards compatible alias. Will be removed."""
+ self.warn(self.docname, 'env.currmodule is being referenced by an '
+ 'extension; this API will be removed in the future')
+ return self.ref_context.get('py:module')
+
+ @property
+ def currclass(self):
+ """Backwards compatible alias. Will be removed."""
+ self.warn(self.docname, 'env.currclass is being referenced by an '
+ 'extension; this API will be removed in the future')
+ return self.ref_context.get('py:class')
+
+ def new_serialno(self, category=''):
+ """Return a serial number, e.g. for index entry targets.
+
+ The number is guaranteed to be unique in the current document.
+ """
+ key = category + 'serialno'
+ cur = self.temp_data.get(key, 0)
+ self.temp_data[key] = cur + 1
+ return cur
+
+ def note_dependency(self, filename):
+ """Add *filename* as a dependency of the current document.
+
+ This means that the document will be rebuilt if this file changes.
+
+ *filename* should be absolute or relative to the source directory.
+ """
+ self.dependencies.setdefault(self.docname, set()).add(filename)
+
+ def note_included(self, filename):
+ """Add *filename* as a included from other document.
+
+ This means the document is not orphaned.
+
+ *filename* should be absolute or relative to the source directory.
+ """
+ self.included.add(self.path2doc(filename))
+
+ def note_reread(self):
+ """Add the current document to the list of documents that will
+ automatically be re-read at the next build.
+ """
+ self.reread_always.add(self.docname)
+
+ def note_versionchange(self, type, version, node, lineno):
+ self.versionchanges.setdefault(version, []).append(
+ (type, self.temp_data['docname'], lineno,
+ self.ref_context.get('py:module'),
+ self.temp_data.get('object'), node.astext()))
+
+ # post-processing of read doctrees
+
+ def process_dependencies(self, docname, doctree):
+ """Process docutils-generated dependency info."""
+ cwd = getcwd()
+ frompath = path.join(path.normpath(self.srcdir), 'dummy')
+ deps = doctree.settings.record_dependencies
+ if not deps:
+ return
+ for dep in deps.list:
+ # the dependency path is relative to the working dir, so get
+ # one relative to the srcdir
+ if isinstance(dep, bytes):
+ dep = dep.decode(fs_encoding)
+ relpath = relative_path(frompath,
+ path.normpath(path.join(cwd, dep)))
+ self.dependencies.setdefault(docname, set()).add(relpath)
+
+ def process_downloads(self, docname, doctree):
+ """Process downloadable file paths. """
+ for node in doctree.traverse(addnodes.download_reference):
+ targetname = node['reftarget']
+ rel_filename, filename = self.relfn2path(targetname, docname)
+ self.dependencies.setdefault(docname, set()).add(rel_filename)
+ if not os.access(filename, os.R_OK):
+ self.warn_node('download file not readable: %s' % filename,
+ node)
+ continue
+ uniquename = self.dlfiles.add_file(docname, filename)
+ node['filename'] = uniquename
+
+ def process_images(self, docname, doctree):
+ """Process and rewrite image URIs."""
+ def collect_candidates(imgpath, candidates):
+ globbed = {}
+ for filename in glob(imgpath):
+ new_imgpath = relative_path(path.join(self.srcdir, 'dummy'),
+ filename)
+ try:
+ mimetype = guess_mimetype(filename)
+ if mimetype not in candidates:
+ globbed.setdefault(mimetype, []).append(new_imgpath)
+ except (OSError, IOError) as err:
+ self.warn_node('image file %s not readable: %s' %
+ (filename, err), node)
+ for key, files in iteritems(globbed):
+ candidates[key] = sorted(files, key=len)[0] # select by similarity
+
+ for node in doctree.traverse(nodes.image):
+ # Map the mimetype to the corresponding image. The writer may
+ # choose the best image from these candidates. The special key * is
+ # set if there is only single candidate to be used by a writer.
+ # The special key ? is set for nonlocal URIs.
+ node['candidates'] = candidates = {}
+ imguri = node['uri']
+ if imguri.startswith('data:'):
+ self.warn_node('image data URI found. some builders might not support', node,
+ type='image', subtype='data_uri')
+ candidates['?'] = imguri
+ continue
+ elif imguri.find('://') != -1:
+ self.warn_node('nonlocal image URI found: %s' % imguri, node,
+ type='image', subtype='nonlocal_uri')
+ candidates['?'] = imguri
+ continue
+ rel_imgpath, full_imgpath = self.relfn2path(imguri, docname)
+ if self.config.language:
+ # substitute figures (ex. foo.png -> foo.en.png)
+ i18n_full_imgpath = search_image_for_language(full_imgpath, self)
+ if i18n_full_imgpath != full_imgpath:
+ full_imgpath = i18n_full_imgpath
+ rel_imgpath = relative_path(path.join(self.srcdir, 'dummy'),
+ i18n_full_imgpath)
+ # set imgpath as default URI
+ node['uri'] = rel_imgpath
+ if rel_imgpath.endswith(os.extsep + '*'):
+ if self.config.language:
+ # Search language-specific figures at first
+ i18n_imguri = get_image_filename_for_language(imguri, self)
+ _, full_i18n_imgpath = self.relfn2path(i18n_imguri, docname)
+ collect_candidates(full_i18n_imgpath, candidates)
+
+ collect_candidates(full_imgpath, candidates)
+ else:
+ candidates['*'] = rel_imgpath
+
+ # map image paths to unique image names (so that they can be put
+ # into a single directory)
+ for imgpath in itervalues(candidates):
+ self.dependencies.setdefault(docname, set()).add(imgpath)
+ if not os.access(path.join(self.srcdir, imgpath), os.R_OK):
+ self.warn_node('image file not readable: %s' % imgpath,
+ node)
+ continue
+ self.images.add_file(docname, imgpath)
+
+ def process_metadata(self, docname, doctree):
+ """Process the docinfo part of the doctree as metadata.
+
+ Keep processing minimal -- just return what docutils says.
+ """
+ self.metadata[docname] = md = {}
+ try:
+ docinfo = doctree[0]
+ except IndexError:
+ # probably an empty document
+ return
+ if docinfo.__class__ is not nodes.docinfo:
+ # nothing to see here
+ return
+ for node in docinfo:
+ # nodes are multiply inherited...
+ if isinstance(node, nodes.authors):
+ md['authors'] = [author.astext() for author in node]
+ elif isinstance(node, nodes.TextElement): # e.g. author
+ md[node.__class__.__name__] = node.astext()
+ else:
+ name, body = node
+ md[name.astext()] = body.astext()
+ for name, value in md.items():
+ if name in ('tocdepth',):
+ try:
+ value = int(value)
+ except ValueError:
+ value = 0
+ md[name] = value
+
+ del doctree[0]
+
+ def create_title_from(self, docname, document):
+ """Add a title node to the document (just copy the first section title),
+ and store that title in the environment.
+ """
+ titlenode = nodes.title()
+ longtitlenode = titlenode
+ # explicit title set with title directive; use this only for
+ # the <title> tag in HTML output
+ if 'title' in document:
+ longtitlenode = nodes.title()
+ longtitlenode += nodes.Text(document['title'])
+ # look for first section title and use that as the title
+ for node in document.traverse(nodes.section):
+ visitor = SphinxContentsFilter(document)
+ node[0].walkabout(visitor)
+ titlenode += visitor.get_entry_text()
+ break
+ else:
+ # document has no title
+ titlenode += nodes.Text('<no title>')
+ self.titles[docname] = titlenode
+ self.longtitles[docname] = longtitlenode
+
+ def note_toctree(self, docname, toctreenode):
+ """Note a TOC tree directive in a document and gather information about
+ file relations from it.
+ """
+ self.toctree.note_toctree(docname, toctreenode)
+
+ def get_toc_for(self, docname, builder):
+ """Return a TOC nodetree -- for use on the same page only!"""
+ return self.toctree.get_toc_for(docname, builder)
+
+ def get_toctree_for(self, docname, builder, collapse, **kwds):
+ """Return the global TOC nodetree."""
+ return self.toctree.get_toctree_for(docname, builder, collapse, **kwds)
+
+ def get_domain(self, domainname):
+ """Return the domain instance with the specified name.
+
+ Raises an ExtensionError if the domain is not registered.
+ """
+ try:
+ return self.domains[domainname]
+ except KeyError:
+ raise ExtensionError('Domain %r is not registered' % domainname)
+
+ # --------- RESOLVING REFERENCES AND TOCTREES ------------------------------
+
+ def get_doctree(self, docname):
+ """Read the doctree for a file from the pickle and return it."""
+ doctree_filename = self.doc2path(docname, self.doctreedir, '.doctree')
+ with open(doctree_filename, 'rb') as f:
+ doctree = pickle.load(f)
+ doctree.settings.env = self
+ doctree.reporter = Reporter(self.doc2path(docname), 2, 5,
+ stream=WarningStream(self._warnfunc))
+ return doctree
+
+ def get_and_resolve_doctree(self, docname, builder, doctree=None,
+ prune_toctrees=True, includehidden=False):
+ """Read the doctree from the pickle, resolve cross-references and
+ toctrees and return it.
+ """
+ if doctree is None:
+ doctree = self.get_doctree(docname)
+
+ # resolve all pending cross-references
+ self.resolve_references(doctree, docname, builder)
+
+ # now, resolve all toctree nodes
+ for toctreenode in doctree.traverse(addnodes.toctree):
+ result = self.resolve_toctree(docname, builder, toctreenode,
+ prune=prune_toctrees,
+ includehidden=includehidden)
+ if result is None:
+ toctreenode.replace_self([])
+ else:
+ toctreenode.replace_self(result)
+
+ return doctree
+
+ def resolve_toctree(self, docname, builder, toctree, prune=True, maxdepth=0,
+ titles_only=False, collapse=False, includehidden=False):
+ """Resolve a *toctree* node into individual bullet lists with titles
+ as items, returning None (if no containing titles are found) or
+ a new node.
+
+ If *prune* is True, the tree is pruned to *maxdepth*, or if that is 0,
+ to the value of the *maxdepth* option on the *toctree* node.
+ If *titles_only* is True, only toplevel document titles will be in the
+ resulting tree.
+ If *collapse* is True, all branches not containing docname will
+ be collapsed.
+ """
+ return self.toctree.resolve_toctree(docname, builder, toctree, prune,
+ maxdepth, titles_only, collapse,
+ includehidden)
+
+ def resolve_references(self, doctree, fromdocname, builder):
+ for node in doctree.traverse(addnodes.pending_xref):
+ contnode = node[0].deepcopy()
+ newnode = None
+
+ typ = node['reftype']
+ target = node['reftarget']
+ refdoc = node.get('refdoc', fromdocname)
+ domain = None
+
+ try:
+ if 'refdomain' in node and node['refdomain']:
+ # let the domain try to resolve the reference
+ try:
+ domain = self.domains[node['refdomain']]
+ except KeyError:
+ raise NoUri
+ newnode = domain.resolve_xref(self, refdoc, builder,
+ typ, target, node, contnode)
+ # really hardwired reference types
+ elif typ == 'any':
+ newnode = self._resolve_any_reference(builder, refdoc, node, contnode)
+ elif typ == 'doc':
+ newnode = self._resolve_doc_reference(builder, refdoc, node, contnode)
+ # no new node found? try the missing-reference event
+ if newnode is None:
+ newnode = builder.app.emit_firstresult(
+ 'missing-reference', self, node, contnode)
+ # still not found? warn if node wishes to be warned about or
+ # we are in nit-picky mode
+ if newnode is None:
+ self._warn_missing_reference(refdoc, typ, target, node, domain)
+ except NoUri:
+ newnode = contnode
+ node.replace_self(newnode or contnode)
+
+ # remove only-nodes that do not belong to our builder
+ process_only_nodes(doctree, builder.tags, warn_node=self.warn_node)
+
+ # allow custom references to be resolved
+ builder.app.emit('doctree-resolved', doctree, fromdocname)
+
+ def _warn_missing_reference(self, refdoc, typ, target, node, domain):
+ warn = node.get('refwarn')
+ if self.config.nitpicky:
+ warn = True
+ if self._nitpick_ignore:
+ dtype = domain and '%s:%s' % (domain.name, typ) or typ
+ if (dtype, target) in self._nitpick_ignore:
+ warn = False
+ # for "std" types also try without domain name
+ if (not domain or domain.name == 'std') and \
+ (typ, target) in self._nitpick_ignore:
+ warn = False
+ if not warn:
+ return
+ if domain and typ in domain.dangling_warnings:
+ msg = domain.dangling_warnings[typ]
+ elif typ == 'doc':
+ msg = 'unknown document: %(target)s'
+ elif node.get('refdomain', 'std') not in ('', 'std'):
+ msg = '%s:%s reference target not found: %%(target)s' % \
+ (node['refdomain'], typ)
+ else:
+ msg = '%r reference target not found: %%(target)s' % typ
+ self.warn_node(msg % {'target': target}, node, type='ref', subtype=typ)
+
+ def _resolve_doc_reference(self, builder, refdoc, node, contnode):
+ # directly reference to document by source name;
+ # can be absolute or relative
+ docname = docname_join(refdoc, node['reftarget'])
+ if docname in self.all_docs:
+ if node['refexplicit']:
+ # reference with explicit title
+ caption = node.astext()
+ else:
+ caption = clean_astext(self.titles[docname])
+ innernode = nodes.inline(caption, caption)
+ innernode['classes'].append('doc')
+ newnode = nodes.reference('', '', internal=True)
+ newnode['refuri'] = builder.get_relative_uri(refdoc, docname)
+ newnode.append(innernode)
+ return newnode
+
+ def _resolve_any_reference(self, builder, refdoc, node, contnode):
+ """Resolve reference generated by the "any" role."""
+ target = node['reftarget']
+ results = []
+ # first, try resolving as :doc:
+ doc_ref = self._resolve_doc_reference(builder, refdoc, node, contnode)
+ if doc_ref:
+ results.append(('doc', doc_ref))
+ # next, do the standard domain (makes this a priority)
+ results.extend(self.domains['std'].resolve_any_xref(
+ self, refdoc, builder, target, node, contnode))
+ for domain in self.domains.values():
+ if domain.name == 'std':
+ continue # we did this one already
+ try:
+ results.extend(domain.resolve_any_xref(self, refdoc, builder,
+ target, node, contnode))
+ except NotImplementedError:
+ # the domain doesn't yet support the new interface
+ # we have to manually collect possible references (SLOW)
+ for role in domain.roles:
+ res = domain.resolve_xref(self, refdoc, builder, role, target,
+ node, contnode)
+ if res and isinstance(res[0], nodes.Element):
+ results.append(('%s:%s' % (domain.name, role), res))
+ # now, see how many matches we got...
+ if not results:
+ return None
+ if len(results) > 1:
+ nice_results = ' or '.join(':%s:' % r[0] for r in results)
+ self.warn_node('more than one target found for \'any\' cross-'
+ 'reference %r: could be %s' % (target, nice_results),
+ node)
+ res_role, newnode = results[0]
+ # Override "any" class with the actual role type to get the styling
+ # approximately correct.
+ res_domain = res_role.split(':')[0]
+ if newnode and newnode[0].get('classes'):
+ newnode[0]['classes'].append(res_domain)
+ newnode[0]['classes'].append(res_role.replace(':', '-'))
+ return newnode
+
+ def create_index(self, builder, group_entries=True,
+ _fixre=re.compile(r'(.*) ([(][^()]*[)])')):
+ return self.indices.create_index(builder, group_entries=group_entries, _fixre=_fixre)
+
+ def collect_relations(self):
+ traversed = set()
+
+ def traverse_toctree(parent, docname):
+ if parent == docname:
+ self.warn(docname, 'self referenced toctree found. Ignored.')
+ return
+
+ # traverse toctree by pre-order
+ yield parent, docname
+ traversed.add(docname)
+
+ for child in (self.toctree_includes.get(docname) or []):
+ for subparent, subdocname in traverse_toctree(docname, child):
+ if subdocname not in traversed:
+ yield subparent, subdocname
+ traversed.add(subdocname)
+
+ relations = {}
+ docnames = traverse_toctree(None, self.config.master_doc)
+ prevdoc = None
+ parent, docname = next(docnames)
+ for nextparent, nextdoc in docnames:
+ relations[docname] = [parent, prevdoc, nextdoc]
+ prevdoc = docname
+ docname = nextdoc
+ parent = nextparent
+
+ relations[docname] = [parent, prevdoc, None]
+
+ return relations
+
+ def check_consistency(self):
+ """Do consistency checks."""
+ for docname in sorted(self.all_docs):
+ if docname not in self.files_to_rebuild:
+ if docname == self.config.master_doc:
+ # the master file is not included anywhere ;)
+ continue
+ if docname in self.included:
+ # the document is included from other documents
+ continue
+ if 'orphan' in self.metadata[docname]:
+ continue
+ self.warn(docname, 'document isn\'t included in any toctree')
diff --git a/sphinx/environment/managers/__init__.py b/sphinx/environment/managers/__init__.py
new file mode 100644
index 000000000..963ec54b8
--- /dev/null
+++ b/sphinx/environment/managers/__init__.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""
+ sphinx.environment.managers
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Manager components for sphinx.environment.
+
+ :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+
+
+class EnvironmentManager(object):
+ """Base class for sphinx.environment managers."""
+ name = None
+
+ def __init__(self, env):
+ self.env = env
+
+ def attach(self, env):
+ self.env = env
+ if self.name:
+ setattr(env, self.name, self)
+
+ def detach(self, env):
+ self.env = None
+ if self.name:
+ delattr(env, self.name)
+
+ def clear_doc(self, docname):
+ raise NotImplementedError
+
+ def merge_other(self, docnames, other):
+ raise NotImplementedError
+
+ def process_doc(self, docname, doctree):
+ raise NotImplementedError
diff --git a/sphinx/environment/managers/indexentries.py b/sphinx/environment/managers/indexentries.py
new file mode 100644
index 000000000..c35a161b4
--- /dev/null
+++ b/sphinx/environment/managers/indexentries.py
@@ -0,0 +1,172 @@
+# -*- coding: utf-8 -*-
+"""
+ sphinx.environment.managers.indexentries
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Index entries manager for sphinx.environment.
+
+ :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+import re
+import bisect
+import unicodedata
+import string
+from itertools import groupby
+
+from six import text_type
+
+from sphinx import addnodes
+from sphinx.util import iteritems, split_index_msg, split_into
+from sphinx.locale import _
+from sphinx.environment.managers import EnvironmentManager
+
+
+class IndexEntries(EnvironmentManager):
+ name = 'indices'
+
+ def __init__(self, env):
+ super(IndexEntries, self).__init__(env)
+ self.data = env.indexentries
+
+ def clear_doc(self, docname):
+ self.data.pop(docname, None)
+
+ def merge_other(self, docnames, other):
+ for docname in docnames:
+ self.data[docname] = other.indexentries[docname]
+
+ def process_doc(self, docname, doctree):
+ entries = self.data[docname] = []
+ for node in doctree.traverse(addnodes.index):
+ try:
+ for entry in node['entries']:
+ split_index_msg(entry[0], entry[1])
+ except ValueError as exc:
+ self.env.warn_node(exc, node)
+ node.parent.remove(node)
+ else:
+ for entry in node['entries']:
+ if len(entry) == 5:
+ # Since 1.4: new index structure including index_key (5th column)
+ entries.append(entry)
+ else:
+ entries.append(entry + (None,))
+
+ def create_index(self, builder, group_entries=True,
+ _fixre=re.compile(r'(.*) ([(][^()]*[)])')):
+ """Create the real index from the collected index entries."""
+ from sphinx.environment import NoUri
+
+ new = {}
+
+ def add_entry(word, subword, main, link=True, dic=new, key=None):
+ # Force the word to be unicode if it's a ASCII bytestring.
+ # This will solve problems with unicode normalization later.
+ # For instance the RFC role will add bytestrings at the moment
+ word = text_type(word)
+ entry = dic.get(word)
+ if not entry:
+ dic[word] = entry = [[], {}, key]
+ if subword:
+ add_entry(subword, '', main, link=link, dic=entry[1], key=key)
+ elif link:
+ try:
+ uri = builder.get_relative_uri('genindex', fn) + '#' + tid
+ except NoUri:
+ pass
+ else:
+ # maintain links in sorted/deterministic order
+ bisect.insort(entry[0], (main, uri))
+
+ for fn, entries in iteritems(self.data):
+ # new entry types must be listed in directives/other.py!
+ for type, value, tid, main, index_key in entries:
+ try:
+ if type == 'single':
+ try:
+ entry, subentry = split_into(2, 'single', value)
+ except ValueError:
+ entry, = split_into(1, 'single', value)
+ subentry = ''
+ add_entry(entry, subentry, main, key=index_key)
+ elif type == 'pair':
+ first, second = split_into(2, 'pair', value)
+ add_entry(first, second, main, key=index_key)
+ add_entry(second, first, main, key=index_key)
+ elif type == 'triple':
+ first, second, third = split_into(3, 'triple', value)
+ add_entry(first, second + ' ' + third, main, key=index_key)
+ add_entry(second, third + ', ' + first, main, key=index_key)
+ add_entry(third, first + ' ' + second, main, key=index_key)
+ elif type == 'see':
+ first, second = split_into(2, 'see', value)
+ add_entry(first, _('see %s') % second, None,
+ link=False, key=index_key)
+ elif type == 'seealso':
+ first, second = split_into(2, 'see', value)
+ add_entry(first, _('see also %s') % second, None,
+ link=False, key=index_key)
+ else:
+ self.env.warn(fn, 'unknown index entry type %r' % type)
+ except ValueError as err:
+ self.env.warn(fn, str(err))
+
+ # sort the index entries; put all symbols at the front, even those
+ # following the letters in ASCII, this is where the chr(127) comes from
+ def keyfunc(entry, lcletters=string.ascii_lowercase + '_'):
+ lckey = unicodedata.normalize('NFD', entry[0].lower())
+ if lckey[0:1] in lcletters:
+ lckey = chr(127) + lckey
+ # ensure a determinstic order *within* letters by also sorting on
+ # the entry itself
+ return (lckey, entry[0])
+ newlist = sorted(new.items(), key=keyfunc)
+
+ if group_entries:
+ # fixup entries: transform
+ # func() (in module foo)
+ # func() (in module bar)
+ # into
+ # func()
+ # (in module foo)
+ # (in module bar)
+ oldkey = ''
+ oldsubitems = None
+ i = 0
+ while i < len(newlist):
+ key, (targets, subitems, _key) = newlist[i]
+ # cannot move if it has subitems; structure gets too complex
+ if not subitems:
+ m = _fixre.match(key)
+ if m:
+ if oldkey == m.group(1):
+ # prefixes match: add entry as subitem of the
+ # previous entry
+ oldsubitems.setdefault(m.group(2), [[], {}, _key])[0].\
+ extend(targets)
+ del newlist[i]
+ continue
+ oldkey = m.group(1)
+ else:
+ oldkey = key
+ oldsubitems = subitems
+ i += 1
+
+ # group the entries by letter
+ def keyfunc2(item, letters=string.ascii_uppercase + '_'):
+ # hack: mutating the subitems dicts to a list in the keyfunc
+ k, v = item
+ v[1] = sorted((si, se) for (si, (se, void, void)) in iteritems(v[1]))
+ if v[2] is None:
+ # now calculate the key
+ letter = unicodedata.normalize('NFD', k[0])[0].upper()
+ if letter in letters:
+ return letter
+ else:
+ # get all other symbols under one heading
+ return _('Symbols')
+ else:
+ return v[2]
+ return [(key_, list(group))
+ for (key_, group) in groupby(newlist, keyfunc2)]
diff --git a/sphinx/environment/managers/toctree.py b/sphinx/environment/managers/toctree.py
new file mode 100644
index 000000000..d4848a72c
--- /dev/null
+++ b/sphinx/environment/managers/toctree.py
@@ -0,0 +1,561 @@
+# -*- coding: utf-8 -*-
+"""
+ sphinx.environment.managers.toctree
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Toctree manager for sphinx.environment.
+
+ :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+
+from six import iteritems
+from docutils import nodes
+
+from sphinx import addnodes
+from sphinx.util import url_re
+from sphinx.util.nodes import clean_astext, process_only_nodes
+from sphinx.transforms import SphinxContentsFilter
+from sphinx.environment.managers import EnvironmentManager
+
+
+class Toctree(EnvironmentManager):
+ name = 'toctree'
+
+ def __init__(self, env):
+ super(Toctree, self).__init__(env)
+
+ self.tocs = env.tocs
+ self.toc_num_entries = env.toc_num_entries
+ self.toc_secnumbers = env.toc_secnumbers
+ self.toc_fignumbers = env.toc_fignumbers
+ self.toctree_includes = env.toctree_includes
+ self.files_to_rebuild = env.files_to_rebuild
+ self.glob_toctrees = env.glob_toctrees
+ self.numbered_toctrees = env.numbered_toctrees
+
+ def clear_doc(self, docname):
+ self.tocs.pop(docname, None)
+ self.toc_secnumbers.pop(docname, None)
+ self.toc_fignumbers.pop(docname, None)
+ self.toc_num_entries.pop(docname, None)
+ self.toctree_includes.pop(docname, None)
+ self.glob_toctrees.discard(docname)
+ self.numbered_toctrees.discard(docname)
+
+ for subfn, fnset in list(self.files_to_rebuild.items()):
+ fnset.discard(docname)
+ if not fnset:
+ del self.files_to_rebuild[subfn]
+
+ def merge_other(self, docnames, other):
+ for docname in docnames:
+ self.tocs[docname] = other.tocs[docname]
+ self.toc_num_entries[docname] = other.toc_num_entries[docname]
+ if docname in other.toctree_includes:
+ self.toctree_includes[docname] = other.toctree_includes[docname]
+ if docname in other.glob_toctrees:
+ self.glob_toctrees.add(docname)
+ if docname in other.numbered_toctrees:
+ self.numbered_toctrees.add(docname)
+
+ for subfn, fnset in other.files_to_rebuild.items():
+ self.files_to_rebuild.setdefault(subfn, set()).update(fnset & docnames)
+
+ def process_doc(self, docname, doctree):
+ """Build a TOC from the doctree and store it in the inventory."""
+ numentries = [0] # nonlocal again...
+
+ def traverse_in_section(node, cls):
+ """Like traverse(), but stay within the same section."""
+ result = []
+ if isinstance(node, cls):
+ result.append(node)
+ for child in node.children:
+ if isinstance(child, nodes.section):
+ continue
+ result.extend(traverse_in_section(child, cls))
+ return result
+
+ def build_toc(node, depth=1):
+ entries = []
+ for sectionnode in node:
+ # find all toctree nodes in this section and add them
+ # to the toc (just copying the toctree node which is then
+ # resolved in self.get_and_resolve_doctree)
+ if isinstance(sectionnode, addnodes.only):
+ onlynode = addnodes.only(expr=sectionnode['expr'])
+ blist = build_toc(sectionnode, depth)
+ if blist:
+ onlynode += blist.children
+ entries.append(onlynode)
+ continue
+ if not isinstance(sectionnode, nodes.section):
+ for toctreenode in traverse_in_section(sectionnode,
+ addnodes.toctree):
+ item = toctreenode.copy()
+ entries.append(item)
+ # important: do the inventory stuff
+ self.note_toctree(docname, toctreenode)
+ continue
+ title = sectionnode[0]
+ # copy the contents of the section title, but without references
+ # and unnecessary stuff
+ visitor = SphinxContentsFilter(doctree)
+ title.walkabout(visitor)
+ nodetext = visitor.get_entry_text()
+ if not numentries[0]:
+ # for the very first toc entry, don't add an anchor
+ # as it is the file's title anyway
+ anchorname = ''
+ else:
+ anchorname = '#' + sectionnode['ids'][0]
+ numentries[0] += 1
+ # make these nodes:
+ # list_item -> compact_paragraph -> reference
+ reference = nodes.reference(
+ '', '', internal=True, refuri=docname,
+ anchorname=anchorname, *nodetext)
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ sub_item = build_toc(sectionnode, depth + 1)
+ item += sub_item
+ entries.append(item)
+ if entries:
+ return nodes.bullet_list('', *entries)
+ return []
+ toc = build_toc(doctree)
+ if toc:
+ self.tocs[docname] = toc
+ else:
+ self.tocs[docname] = nodes.bullet_list('')
+ self.toc_num_entries[docname] = numentries[0]
+
+ def note_toctree(self, docname, toctreenode):
+ """Note a TOC tree directive in a document and gather information about
+ file relations from it.
+ """
+ if toctreenode['glob']:
+ self.glob_toctrees.add(docname)
+ if toctreenode.get('numbered'):
+ self.numbered_toctrees.add(docname)
+ includefiles = toctreenode['includefiles']
+ for includefile in includefiles:
+ # note that if the included file is rebuilt, this one must be
+ # too (since the TOC of the included file could have changed)
+ self.files_to_rebuild.setdefault(includefile, set()).add(docname)
+ self.toctree_includes.setdefault(docname, []).extend(includefiles)
+
+ def get_toc_for(self, docname, builder):
+ """Return a TOC nodetree -- for use on the same page only!"""
+ tocdepth = self.env.metadata[docname].get('tocdepth', 0)
+ try:
+ toc = self.tocs[docname].deepcopy()
+ self._toctree_prune(toc, 2, tocdepth)
+ except KeyError:
+ # the document does not exist anymore: return a dummy node that
+ # renders to nothing
+ return nodes.paragraph()
+ process_only_nodes(toc, builder.tags, warn_node=self.env.warn_node)
+ for node in toc.traverse(nodes.reference):
+ node['refuri'] = node['anchorname'] or '#'
+ return toc
+
+ def get_toctree_for(self, docname, builder, collapse, **kwds):
+ """Return the global TOC nodetree."""
+ doctree = self.env.get_doctree(self.env.config.master_doc)
+ toctrees = []
+ if 'includehidden' not in kwds:
+ kwds['includehidden'] = True
+ if 'maxdepth' not in kwds:
+ kwds['maxdepth'] = 0
+ kwds['collapse'] = collapse
+ for toctreenode in doctree.traverse(addnodes.toctree):
+ toctree = self.env.resolve_toctree(docname, builder, toctreenode,
+ prune=True, **kwds)
+ if toctree:
+ toctrees.append(toctree)
+ if not toctrees:
+ return None
+ result = toctrees[0]
+ for toctree in toctrees[1:]:
+ result.extend(toctree.children)
+ return result
+
+ def resolve_toctree(self, docname, builder, toctree, prune=True, maxdepth=0,
+ titles_only=False, collapse=False, includehidden=False):
+ """Resolve a *toctree* node into individual bullet lists with titles
+ as items, returning None (if no containing titles are found) or
+ a new node.
+
+ If *prune* is True, the tree is pruned to *maxdepth*, or if that is 0,
+ to the value of the *maxdepth* option on the *toctree* node.
+ If *titles_only* is True, only toplevel document titles will be in the
+ resulting tree.
+ If *collapse* is True, all branches not containing docname will
+ be collapsed.
+ """
+ if toctree.get('hidden', False) and not includehidden:
+ return None
+
+ # For reading the following two helper function, it is useful to keep
+ # in mind the node structure of a toctree (using HTML-like node names
+ # for brevity):
+ #
+ # <ul>
+ # <li>
+ # <p><a></p>
+ # <p><a></p>
+ # ...
+ # <ul>
+ # ...
+ # </ul>
+ # </li>
+ # </ul>
+ #
+ # The transformation is made in two passes in order to avoid
+ # interactions between marking and pruning the tree (see bug #1046).
+
+ toctree_ancestors = self.get_toctree_ancestors(docname)
+
+ def _toctree_add_classes(node, depth):
+ """Add 'toctree-l%d' and 'current' classes to the toctree."""
+ for subnode in node.children:
+ if isinstance(subnode, (addnodes.compact_paragraph,
+ nodes.list_item)):
+ # for <p> and <li>, indicate the depth level and recurse
+ subnode['classes'].append('toctree-l%d' % (depth-1))
+ _toctree_add_classes(subnode, depth)
+ elif isinstance(subnode, nodes.bullet_list):
+ # for <ul>, just recurse
+ _toctree_add_classes(subnode, depth+1)
+ elif isinstance(subnode, nodes.reference):
+ # for <a>, identify which entries point to the current
+ # document and therefore may not be collapsed
+ if subnode['refuri'] == docname:
+ if not subnode['anchorname']:
+ # give the whole branch a 'current' class
+ # (useful for styling it differently)
+ branchnode = subnode
+ while branchnode:
+ branchnode['classes'].append('current')
+ branchnode = branchnode.parent
+ # mark the list_item as "on current page"
+ if subnode.parent.parent.get('iscurrent'):
+ # but only if it's not already done
+ return
+ while subnode:
+ subnode['iscurrent'] = True
+ subnode = subnode.parent
+
+ def _entries_from_toctree(toctreenode, parents,
+ separate=False, subtree=False):
+ """Return TOC entries for a toctree node."""
+ refs = [(e[0], e[1]) for e in toctreenode['entries']]
+ entries = []
+ for (title, ref) in refs:
+ try:
+ refdoc = None
+ if url_re.match(ref):
+ if title is None:
+ title = ref
+ reference = nodes.reference('', '', internal=False,
+ refuri=ref, anchorname='',
+ *[nodes.Text(title)])
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ toc = nodes.bullet_list('', item)
+ elif ref == 'self':
+ # 'self' refers to the document from which this
+ # toctree originates
+ ref = toctreenode['parent']
+ if not title:
+ title = clean_astext(self.titles[ref])
+ reference = nodes.reference('', '', internal=True,
+ refuri=ref,
+ anchorname='',
+ *[nodes.Text(title)])
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ # don't show subitems
+ toc = nodes.bullet_list('', item)
+ else:
+ if ref in parents:
+ self.env.warn(ref, 'circular toctree references '
+ 'detected, ignoring: %s <- %s' %
+ (ref, ' <- '.join(parents)))
+ continue
+ refdoc = ref
+ toc = self.tocs[ref].deepcopy()
+ maxdepth = self.env.metadata[ref].get('tocdepth', 0)
+ if ref not in toctree_ancestors or (prune and maxdepth > 0):
+ self._toctree_prune(toc, 2, maxdepth, collapse)
+ process_only_nodes(toc, builder.tags, warn_node=self.env.warn_node)
+ if title and toc.children and len(toc.children) == 1:
+ child = toc.children[0]
+ for refnode in child.traverse(nodes.reference):
+ if refnode['refuri'] == ref and \
+ not refnode['anchorname']:
+ refnode.children = [nodes.Text(title)]
+ if not toc.children:
+ # empty toc means: no titles will show up in the toctree
+ self.env.warn_node(
+ 'toctree contains reference to document %r that '
+ 'doesn\'t have a title: no link will be generated'
+ % ref, toctreenode)
+ except KeyError:
+ # this is raised if the included file does not exist
+ self.env.warn_node(
+ 'toctree contains reference to nonexisting document %r'
+ % ref, toctreenode)
+ else:
+ # if titles_only is given, only keep the main title and
+ # sub-toctrees
+ if titles_only:
+ # delete everything but the toplevel title(s)
+ # and toctrees
+ for toplevel in toc:
+ # nodes with length 1 don't have any children anyway
+ if len(toplevel) > 1:
+ subtrees = toplevel.traverse(addnodes.toctree)
+ if subtrees:
+ toplevel[1][:] = subtrees
+ else:
+ toplevel.pop(1)
+ # resolve all sub-toctrees
+ for subtocnode in toc.traverse(addnodes.toctree):
+ if not (subtocnode.get('hidden', False) and
+ not includehidden):
+ i = subtocnode.parent.index(subtocnode) + 1
+ for item in _entries_from_toctree(
+ subtocnode, [refdoc] + parents,
+ subtree=True):
+ subtocnode.parent.insert(i, item)
+ i += 1
+ subtocnode.parent.remove(subtocnode)
+ if separate:
+ entries.append(toc)
+ else:
+ entries.extend(toc.children)
+ if not subtree and not separate:
+ ret = nodes.bullet_list()
+ ret += entries
+ return [ret]
+ return entries
+
+ maxdepth = maxdepth or toctree.get('maxdepth', -1)
+ if not titles_only and toctree.get('titlesonly', False):
+ titles_only = True
+ if not includehidden and toctree.get('includehidden', False):
+ includehidden = True
+
+ # NOTE: previously, this was separate=True, but that leads to artificial
+ # separation when two or more toctree entries form a logical unit, so
+ # separating mode is no longer used -- it's kept here for history's sake
+ tocentries = _entries_from_toctree(toctree, [], separate=False)
+ if not tocentries:
+ return None
+
+ newnode = addnodes.compact_paragraph('', '')
+ caption = toctree.attributes.get('caption')
+ if caption:
+ caption_node = nodes.caption(caption, '', *[nodes.Text(caption)])
+ caption_node.line = toctree.line
+ caption_node.source = toctree.source
+ caption_node.rawsource = toctree['rawcaption']
+ if hasattr(toctree, 'uid'):
+ # move uid to caption_node to translate it
+ caption_node.uid = toctree.uid
+ del toctree.uid
+ newnode += caption_node
+ newnode.extend(tocentries)
+ newnode['toctree'] = True
+
+ # prune the tree to maxdepth, also set toc depth and current classes
+ _toctree_add_classes(newnode, 1)
+ self._toctree_prune(newnode, 1, prune and maxdepth or 0, collapse)
+
+ if len(newnode[-1]) == 0: # No titles found
+ return None
+
+ # set the target paths in the toctrees (they are not known at TOC
+ # generation time)
+ for refnode in newnode.traverse(nodes.reference):
+ if not url_re.match(refnode['refuri']):
+ refnode['refuri'] = builder.get_relative_uri(
+ docname, refnode['refuri']) + refnode['anchorname']
+ return newnode
+
+ def get_toctree_ancestors(self, docname):
+ parent = {}
+ for p, children in iteritems(self.toctree_includes):
+ for child in children:
+ parent[child] = p
+ ancestors = []
+ d = docname
+ while d in parent and d not in ancestors:
+ ancestors.append(d)
+ d = parent[d]
+ return ancestors
+
+ def _toctree_prune(self, node, depth, maxdepth, collapse=False):
+ """Utility: Cut a TOC at a specified depth."""
+ for subnode in node.children[:]:
+ if isinstance(subnode, (addnodes.compact_paragraph,
+ nodes.list_item)):
+ # for <p> and <li>, just recurse
+ self._toctree_prune(subnode, depth, maxdepth, collapse)
+ elif isinstance(subnode, nodes.bullet_list):
+ # for <ul>, determine if the depth is too large or if the
+ # entry is to be collapsed
+ if maxdepth > 0 and depth > maxdepth:
+ subnode.parent.replace(subnode, [])
+ else:
+ # cull sub-entries whose parents aren't 'current'
+ if (collapse and depth > 1 and
+ 'iscurrent' not in subnode.parent):
+ subnode.parent.remove(subnode)
+ else:
+ # recurse on visible children
+ self._toctree_prune(subnode, depth+1, maxdepth, collapse)
+
+ def assign_section_numbers(self):
+ """Assign a section number to each heading under a numbered toctree."""
+ # a list of all docnames whose section numbers changed
+ rewrite_needed = []
+
+ assigned = set()
+ old_secnumbers = self.toc_secnumbers
+ self.toc_secnumbers = self.env.toc_secnumbers = {}
+
+ def _walk_toc(node, secnums, depth, titlenode=None):
+ # titlenode is the title of the document, it will get assigned a
+ # secnumber too, so that it shows up in next/prev/parent rellinks
+ for subnode in node.children:
+ if isinstance(subnode, nodes.bullet_list):
+ numstack.append(0)
+ _walk_toc(subnode, secnums, depth-1, titlenode)
+ numstack.pop()
+ titlenode = None
+ elif isinstance(subnode, nodes.list_item):
+ _walk_toc(subnode, secnums, depth, titlenode)
+ titlenode = None
+ elif isinstance(subnode, addnodes.only):
+ # at this stage we don't know yet which sections are going
+ # to be included; just include all of them, even if it leads
+ # to gaps in the numbering
+ _walk_toc(subnode, secnums, depth, titlenode)
+ titlenode = None
+ elif isinstance(subnode, addnodes.compact_paragraph):
+ numstack[-1] += 1
+ if depth > 0:
+ number = tuple(numstack)
+ else:
+ number = None
+ secnums[subnode[0]['anchorname']] = \
+ subnode[0]['secnumber'] = number
+ if titlenode:
+ titlenode['secnumber'] = number
+ titlenode = None
+ elif isinstance(subnode, addnodes.toctree):
+ _walk_toctree(subnode, depth)
+
+ def _walk_toctree(toctreenode, depth):
+ if depth == 0:
+ return
+ for (title, ref) in toctreenode['entries']:
+ if url_re.match(ref) or ref == 'self' or ref in assigned:
+ # don't mess with those
+ continue
+ if ref in self.tocs:
+ secnums = self.toc_secnumbers[ref] = {}
+ assigned.add(ref)
+ _walk_toc(self.tocs[ref], secnums, depth,
+ self.env.titles.get(ref))
+ if secnums != old_secnumbers.get(ref):
+ rewrite_needed.append(ref)
+
+ for docname in self.numbered_toctrees:
+ assigned.add(docname)
+ doctree = self.env.get_doctree(docname)
+ for toctreenode in doctree.traverse(addnodes.toctree):
+ depth = toctreenode.get('numbered', 0)
+ if depth:
+ # every numbered toctree gets new numbering
+ numstack = [0]
+ _walk_toctree(toctreenode, depth)
+
+ return rewrite_needed
+
+ def assign_figure_numbers(self):
+ """Assign a figure number to each figure under a numbered toctree."""
+
+ rewrite_needed = []
+
+ assigned = set()
+ old_fignumbers = self.toc_fignumbers
+ self.toc_fignumbers = self.env.toc_fignumbers = {}
+ fignum_counter = {}
+
+ def get_section_number(docname, section):
+ anchorname = '#' + section['ids'][0]
+ secnumbers = self.toc_secnumbers.get(docname, {})
+ if anchorname in secnumbers:
+ secnum = secnumbers.get(anchorname)
+ else:
+ secnum = secnumbers.get('')
+
+ return secnum or tuple()
+
+ def get_next_fignumber(figtype, secnum):
+ counter = fignum_counter.setdefault(figtype, {})
+
+ secnum = secnum[:self.env.config.numfig_secnum_depth]
+ counter[secnum] = counter.get(secnum, 0) + 1
+ return secnum + (counter[secnum],)
+
+ def register_fignumber(docname, secnum, figtype, fignode):
+ self.toc_fignumbers.setdefault(docname, {})
+ fignumbers = self.toc_fignumbers[docname].setdefault(figtype, {})
+ figure_id = fignode['ids'][0]
+
+ fignumbers[figure_id] = get_next_fignumber(figtype, secnum)
+
+ def _walk_doctree(docname, doctree, secnum):
+ for subnode in doctree.children:
+ if isinstance(subnode, nodes.section):
+ next_secnum = get_section_number(docname, subnode)
+ if next_secnum:
+ _walk_doctree(docname, subnode, next_secnum)
+ else:
+ _walk_doctree(docname, subnode, secnum)
+ continue
+ elif isinstance(subnode, addnodes.toctree):
+ for title, subdocname in subnode['entries']:
+ if url_re.match(subdocname) or subdocname == 'self':
+ # don't mess with those
+ continue
+
+ _walk_doc(subdocname, secnum)
+
+ continue
+
+ figtype = self.env.domains['std'].get_figtype(subnode)
+ if figtype and subnode['ids']:
+ register_fignumber(docname, secnum, figtype, subnode)
+
+ _walk_doctree(docname, subnode, secnum)
+
+ def _walk_doc(docname, secnum):
+ if docname not in assigned:
+ assigned.add(docname)
+ doctree = self.env.get_doctree(docname)
+ _walk_doctree(docname, doctree, secnum)
+
+ if self.env.config.numfig:
+ _walk_doc(self.env.config.master_doc, tuple())
+ for docname, fignums in iteritems(self.toc_fignumbers):
+ if fignums != old_fignumbers.get(docname):
+ rewrite_needed.append(docname)
+
+ return rewrite_needed