"""
Sage multidocs extension
The goal of this extension is to manage a multi-documentation in Sphinx. To be
able to compile Sage's huge documentation in parallel, the documentation is cut
into a bunch of independent documentations called "sub-docs", which are
compiled separately. There is a master document which points to all the
sub-docs. The intersphinx extension ensures that the cross-link between the
sub-docs are correctly resolved. However some work is needed to build a global
index. This is the goal of the ``multidocs`` extension.
More precisely this extension ensures the correct merging of
- the todo list if this extension is activated
- the python indexes
- the list of python modules
- the javascript index
- the citations
"""
import os
import pickle
import shutil
import sphinx
from sphinx.application import Sphinx
from sphinx.util.console import bold
from sage.env import SAGE_DOC
from pathlib import Path
logger = sphinx.util.logging.getLogger(__name__)
CITE_FILENAME = 'citations.pickle'
def merge_environment(app, env):
"""
Merge the following attributes of the sub-docs environment into the main
environment:
- ``titles`` -- Titles
- ``todo_all_todos`` -- todo's
- ``indexentries`` -- global python index
- ``all_docs`` -- needed by the js index
- ``citations`` -- citations
- ``domaindata['py']['modules']`` -- list of python modules
"""
logger.info(bold('Merging environment/index files...'))
for curdoc in app.env.config.multidocs_subdoc_list:
logger.info(" %s:" % curdoc, nonl=1)
docenv = get_env(app, curdoc)
if docenv is not None:
fixpath = lambda path: os.path.join(curdoc, path)
todos = docenv.domaindata['todo'].get('todos', dict())
citations = docenv.domaindata['citation'].get('citations', dict())
indexentries = docenv.domaindata['index'].get('entries', dict())
logger.info(" %s todos, %s index, %s citations" % (
sum(len(t) for t in todos.values()),
len(indexentries),
len(citations)
), nonl=1)
for t in docenv.titles:
env.titles[fixpath(t)] = docenv.titles[t]
for dct in todos:
env.domaindata['todo']['todos'][fixpath(dct)] = todos[dct]
newindex = {}
for ind in indexentries:
if ind.startswith('sage/'):
newindex[fixpath(ind)] = indexentries[ind]
else:
newindex[ind] = indexentries[ind]
env.domaindata['index']['entries'].update(newindex)
newalldoc = {}
for ind in docenv.all_docs:
newalldoc[fixpath(ind)] = docenv.all_docs[ind]
env.all_docs.update(newalldoc)
for ind in newalldoc:
md = env.metadata.get(ind, dict())
md['orphan'] = 1
env.metadata[ind] = md
newcite = {}
for ind, (path, tag, lineno) in citations.items():
newcite[ind] = (fixpath(path), tag, lineno)
env.domaindata['citation']['citations'].update(newcite)
newmodules = {}
from sphinx.domains.python import ModuleEntry
for ind,mod in docenv.domaindata['py']['modules'].items():
newmodules[ind] = ModuleEntry(fixpath(mod.docname), mod.node_id, mod.synopsis, mod.platform, mod.deprecated)
env.domaindata['py']['modules'].update(newmodules)
logger.info(", %s modules" % (len(newmodules)))
logger.info('... done (%s todos, %s index, %s citations, %s modules)' % (
sum(len(t) for t in env.domaindata['todo']['todos'].values()),
len(env.domaindata['index']['entries']),
len(env.domaindata['citation']['citations']),
len(env.domaindata['py']['modules'])))
write_citations(app, env.domaindata['citation']['citations'])
def get_env(app, curdoc):
"""
Get the environment of a sub-doc from the pickle
"""
from sphinx.application import ENV_PICKLE_FILENAME
filename = os.path.join(
app.env.doctreedir, curdoc, ENV_PICKLE_FILENAME)
try:
f = open(filename, 'rb')
except OSError:
logger.debug(f"Unable to load pickled environment '{filename}'", exc_info=True)
return None
docenv = pickle.load(f)
f.close()
return docenv
def merge_js_index(app):
"""
Merge the JS indexes of the sub-docs into the main JS index
"""
logger.info('')
logger.info(bold('Merging js index files...'))
mapping = app.builder.indexer._mapping
for curdoc in app.env.config.multidocs_subdoc_list:
logger.info(" %s:" % curdoc, nonl=1)
fixpath = lambda path: os.path.join(curdoc, path)
index = get_js_index(app, curdoc)
if index is not None:
logger.info(" %s js index entries" % (len(index._mapping)))
for (ref, locs) in index._mapping.items():
newmapping = set(map(fixpath, locs))
if ref in mapping:
newmapping = mapping[ref] | newmapping
mapping[str(ref)] = newmapping
titles = app.builder.indexer._titles
for (res, title) in index._titles.items():
titles[fixpath(res)] = title
alltitles = app.builder.indexer._all_titles
for (res, alltitle) in index._all_titles.items():
alltitles[fixpath(res)] = alltitle
filenames = app.builder.indexer._filenames
for (res, filename) in index._filenames.items():
filenames[fixpath(res)] = fixpath(filename)
dest = os.path.join(app.outdir, "_sources", curdoc)
if not os.path.exists(dest):
os.symlink(os.path.join("..", curdoc, "_sources"), dest)
logger.info('... done (%s js index entries)' % (len(mapping)))
logger.info(bold('Writing js search indexes...'), nonl=1)
return []
def get_js_index(app, curdoc):
"""
Get the JS index of a sub-doc from the file
"""
from sphinx.search import IndexBuilder, languages
sphinx_version = __import__("sphinx").__version__
if (sphinx_version < '1.2'):
indexer = IndexBuilder(app.env, 'en',
app.config.html_search_options)
else:
indexer = IndexBuilder(app.env, 'en',
app.config.html_search_options, scoring=None)
indexfile = os.path.join(app.outdir, curdoc, 'searchindex.js')
try:
f = open(indexfile)
except OSError:
logger.info("")
logger.warning("Unable to fetch %s " % indexfile)
return None
indexer.load(f, sphinx.search.js_index)
f.close()
return indexer
mustbefixed = ['search', 'genindex', 'genindex-all',
'py-modindex', 'searchindex.js']
def fix_path_html(app, pagename, templatename, ctx, event_arg):
"""
Fix the context so that the files
- :file:`search.html`
- :file:`genindex.html`
- :file:`py-modindex.html`
point to the right place, that is in :file:`reference/` instead of
:file:`reference/subdocument`.
"""
old_pathto = ctx['pathto']
def sage_pathto(otheruri, *args, **opts):
if otheruri in mustbefixed:
otheruri = os.path.join("..", otheruri)
return old_pathto(otheruri, *args, **opts)
ctx['pathto'] = sage_pathto
def citation_dir(app: Sphinx) -> Path:
outdir = Path(app.outdir).resolve()
sage_doc = Path(SAGE_DOC).resolve()
if sage_doc in outdir.parents:
rel = outdir.relative_to(sage_doc)
dirs = list(rel.parts)
if dirs[0] == '/':
dirs.pop(0)
tail = dirs[1:]
citedir = (sage_doc / "inventory").joinpath(*tail)
else:
citedir = outdir / "inventory"
os.makedirs(citedir, exist_ok=True)
return citedir
def write_citations(app: Sphinx, citations):
"""
Pickle the citation in a file.
"""
from sage.misc.temporary_file import atomic_write
outdir = citation_dir(app)
with atomic_write(outdir / CITE_FILENAME, binary=True) as f:
pickle.dump(citations, f)
logger.info("Saved pickle file: %s" % CITE_FILENAME)
def fetch_citation(app: Sphinx, env):
"""
Fetch the global citation index from the refman to allow for cross
references.
"""
logger.info(bold('loading cross citations... '), nonl=1)
file = citation_dir(app).parent / CITE_FILENAME
if not file.is_file():
return
with open(file, 'rb') as f:
cache = pickle.load(f)
logger.info("done (%s citations)." % len(cache))
cite = env.domaindata['citation'].get('citations', dict())
for ind, (path, tag, lineno) in cache.items():
if ind not in cite:
cite[ind] = (os.path.join("..", path), tag, lineno)
def init_subdoc(app):
"""
Init the merger depending on if we are compiling a sub-doc or the master
doc itself.
"""
if app.config.multidocs_is_master:
logger.info(bold("Compiling the master document"))
app.connect('env-updated', merge_environment)
app.connect('html-collect-pages', merge_js_index)
if app.config.multidocs_subdoc_list:
def load_indexer(docnames):
logger.info(bold('skipping loading of indexes... '), nonl=1)
app.builder.load_indexer = load_indexer
else:
logger.info(bold("Compiling a sub-document"))
app.connect('html-page-context', fix_path_html)
if not app.config.multidoc_first_pass:
app.connect('env-updated', fetch_citation)
def link_static_files():
"""
Instead of copying static files, make a link to the master static file.
See sphinx/builder/html.py line 536::
class StandaloneHTMLBuilder(Builder):
[...]
def copy_static_files(self):
[...]
"""
logger.info(bold('linking _static directory.'))
static_dir = os.path.join(app.builder.outdir, '_static')
master_static_dir = os.path.join('..', '_static')
if os.path.lexists(static_dir):
try:
shutil.rmtree(static_dir)
except OSError:
os.unlink(static_dir)
os.makedirs(os.path.join(app.builder.outdir, master_static_dir),
exist_ok=True)
os.symlink(master_static_dir, static_dir)
app.builder.copy_static_files = link_static_files
if app.config.multidoc_first_pass == 1:
app.config.intersphinx_mapping = {}
else:
app.emit('env-check-consistency', app.env)
def setup(app: Sphinx):
app.add_config_value('multidocs_is_master', True, True)
app.add_config_value('multidocs_subdoc_list', [], True)
app.add_config_value('multidoc_first_pass', 0, False)
app.connect('builder-inited', init_subdoc)
return {'parallel_read_safe': True}