# -*- coding: utf-8 -*-
"""
sphinx.builder
~~~~~~~~~~~~~~
Builder classes for different output formats.
:copyright: 2007 by Georg Brandl.
:license: Python license.
"""
from __future__ import with_statement
import os
import sys
import time
import types
import codecs
import shutil
import cPickle as pickle
import cStringIO as StringIO
from os import path
from docutils.io import StringOutput, DocTreeInput
from docutils.core import publish_parts
from docutils.utils import new_document
from docutils.readers import doctree
from docutils.frontend import OptionParser
from .util import (get_matching_files, attrdict, status_iterator,
ensuredir, get_category, relative_uri)
from .writer import HTMLWriter
from .console import bold, purple, green
from .htmlhelp import build_hhx
from .environment import BuildEnvironment
from .highlighting import pygments, get_stylesheet
# side effect: registers roles and directives
from . import roles
from . import directives
ENV_PICKLE_FILENAME = 'environment.pickle'
LAST_BUILD_FILENAME = 'last_build'
# Helper objects
class relpath_to(object):
def __init__(self, builder, filename):
self.baseuri = builder.get_target_uri(filename)
self.builder = builder
def __call__(self, otheruri, resource=False):
if not resource:
otheruri = self.builder.get_target_uri(otheruri)
return relative_uri(self.baseuri, otheruri)
class collect_env_warnings(object):
def __init__(self, builder):
self.builder = builder
def __enter__(self):
self.stream = StringIO.StringIO()
self.builder.env.set_warning_stream(self.stream)
def __exit__(self, *args):
self.builder.env.set_warning_stream(self.builder.warning_stream)
warnings = self.stream.getvalue()
if warnings:
print >>self.builder.warning_stream, warnings
class Builder(object):
"""
Builds target formats from the reST sources.
"""
option_spec = {
'freshenv': 'Don\'t use a pickled environment',
}
def __init__(self, srcdirname, outdirname, options, env=None,
status_stream=None, warning_stream=None,
confoverrides=None):
self.srcdir = srcdirname
self.outdir = outdirname
if not path.isdir(path.join(outdirname, '.doctrees')):
os.mkdir(path.join(outdirname, '.doctrees'))
self.options = attrdict(options)
self.validate_options()
# probably set in load_env()
self.env = env
self.config = {}
execfile(path.join(srcdirname, 'conf.py'), self.config)
# remove potentially pickling-problematic values
del self.config['__builtins__']
for key, val in self.config.items():
if isinstance(val, types.ModuleType):
del self.config[key]
if confoverrides:
self.config.update(confoverrides)
self.status_stream = status_stream or sys.stdout
self.warning_stream = warning_stream or sys.stderr
self.init()
# helper methods
def validate_options(self):
for option in self.options:
if option not in self.option_spec:
raise ValueError('Got unexpected option %s' % option)
for option in self.option_spec:
if option not in self.options:
self.options[option] = False
def msg(self, message='', nonl=False, nobold=False):
if not nobold: message = bold(message)
if nonl:
print >>self.status_stream, message,
else:
print >>self.status_stream, message
self.status_stream.flush()
def init(self):
"""Load necessary templates and perform initialization."""
raise NotImplementedError
def get_target_uri(self, source_filename):
"""Return the target URI for a source filename."""
raise NotImplementedError
def get_relative_uri(self, from_, to):
"""Return a relative URI between two source filenames."""
return relative_uri(self.get_target_uri(from_),
self.get_target_uri(to))
def get_outdated_files(self):
"""Return a list of output files that are outdated."""
raise NotImplementedError
# build methods
def load_env(self):
"""Set up the build environment. Return True if a pickled file could be
successfully loaded, False if a new environment had to be created."""
if self.env:
return
if not self.options.freshenv:
try:
self.msg('trying to load pickled env...', nonl=True)
self.env = BuildEnvironment.frompickle(
path.join(self.outdir, ENV_PICKLE_FILENAME))
self.msg('done', nobold=True)
except Exception, err:
self.msg('failed: %s' % err, nobold=True)
self.env = BuildEnvironment(self.srcdir,
path.join(self.outdir, '.doctrees'))
else:
self.env = BuildEnvironment(self.srcdir,
path.join(self.outdir, '.doctrees'))
def build_all(self):
"""Build all source files."""
self.load_env()
self.build(None, summary='all source files')
def build_specific(self, source_filenames):
"""Only rebuild as much as needed for changes in the source_filenames."""
# bring the filenames to the canonical format, that is,
# relative to the source directory.
dirlen = len(self.srcdir) + 1
to_write = [path.abspath(filename)[dirlen:] for filename in source_filenames]
self.load_env()
self.build(to_write,
summary='%d source files given on command line' % len(to_write))
def build_update(self):
"""Only rebuild files changed or added since last build."""
self.load_env()
to_build = list(self.get_outdated_files())
if not to_build:
self.msg('no files are out of date, exiting.')
return
self.build(to_build,
summary='%d source files that are out of date' % len(to_build))
def build(self, filenames, summary=None):
if summary:
self.msg('building [%s]:' % self.name, nonl=1)
self.msg(summary, nobold=1)
# while reading, collect all warnings from docutils
with collect_env_warnings(self):
self.msg('reading, updating environment:', nonl=1)
iterator = self.env.update(self.config)
self.msg(iterator.next(), nobold=1)
for filename in iterator:
self.msg(purple(filename), nonl=1, nobold=1)
self.msg()
# save the environment
self.msg('pickling the env...', nonl=True)
self.env.topickle(path.join(self.outdir, ENV_PICKLE_FILENAME))
self.msg('done', nobold=True)
# global actions
self.msg('checking consistency...')
self.env.check_consistency()
self.msg('creating index...')
self.env.create_index(self)
self.prepare_writing()
if filenames:
# add all TOC files that may have changed
filenames_set = set(filenames)
for filename in filenames:
for tocfilename in self.env.files_to_rebuild.get(filename, []):
filenames_set.add(tocfilename)
filenames_set.add('contents.rst')
else:
# build all
filenames_set = set(self.env.all_files)
# write target files
with collect_env_warnings(self):
self.msg('writing output...')
for filename in status_iterator(sorted(filenames_set), green,
stream=self.status_stream):
doctree = self.env.get_and_resolve_doctree(filename, self)
self.write_file(filename, doctree)
# finish (write style files etc.)
self.msg('finishing...')
self.finish()
self.msg('done!')
def prepare_writing(self):
raise NotImplementedError
def write_file(self, filename, doctree):
raise NotImplementedError
def finish(self):
raise NotImplementedError
class StandaloneHTMLBuilder(Builder):
"""
Builds standalone HTML docs.
"""
name = 'html'
option_spec = Builder.option_spec
option_spec.update({
'nostyle': 'Don\'t copy style and script files',
'nosearchindex': 'Don\'t create a JSON search index for offline search',
})
copysource = True
def init(self):
"""Load templates."""
# lazily import this, maybe other builders won't need it
from ._jinja import Environment, FileSystemLoader
# load templates
self.templates = {}
templates_path = path.join(path.dirname(__file__), 'templates')
jinja_env = Environment(loader=FileSystemLoader(templates_path),
# disable traceback, more likely that something in the
# application is broken than in the templates
friendly_traceback=False)
for fname in os.listdir(templates_path):
if fname.endswith('.html'):
self.templates[fname[:-5]] = jinja_env.get_template(fname)
def render_partial(self, node):
"""Utility: Render a lone doctree node."""
doc = new_document('foo')
doc.append(node)
return publish_parts(
doc,
source_class=DocTreeInput,
reader=doctree.Reader(),
writer=HTMLWriter(self.config),
settings_overrides={'output_encoding': 'unicode'}
)
def prepare_writing(self):
if not self.options.nosearchindex:
from .search import IndexBuilder
self.indexer = IndexBuilder()
else:
self.indexer = None
self.docwriter = HTMLWriter(self.config)
self.docsettings = OptionParser(
defaults=self.env.settings,
components=(self.docwriter,)).get_default_values()
# format the "last updated on" string, only once is enough since it
# typically doesn't include the time of day
lufmt = self.config.get('last_updated_format')
if lufmt:
self.last_updated = time.strftime(lufmt)
else:
self.last_updated = None
self.globalcontext = dict(
last_updated = self.last_updated,
builder = self.name,
release = self.config['release'],
parents = [],
len = len,
titles = {},
)
def write_file(self, filename, doctree):
destination = StringOutput(encoding='utf-8')
doctree.settings = self.docsettings
output = self.docwriter.write(doctree, destination)
self.docwriter.assemble_parts()
prev = next = None
parents = []
related = self.env.toctree_relations.get(filename)
if related:
prev = {'link': self.get_relative_uri(filename, related[1]),
'title': self.render_partial(self.env.titles[related[1]])['title']}
next = {'link': self.get_relative_uri(filename, related[2]),
'title': self.render_partial(self.env.titles[related[2]])['title']}
while related:
parents.append(
{'link': self.get_relative_uri(filename, related[0]),
'title': self.render_partial(self.env.titles[related[0]])['title']})
related = self.env.toctree_relations.get(related[0])
if parents:
parents.pop() # remove link to "contents.rst"; we have a generic
# "back to index" link already
parents.reverse()
title = self.env.titles.get(filename)
if title:
title = self.render_partial(title)['title']
else:
title = ''
self.globalcontext['titles'][filename] = title
sourcename = filename[:-4] + '.txt'
context = dict(
title = title,
sourcename = sourcename,
pathto = relpath_to(self, self.get_target_uri(filename)),
body = self.docwriter.parts['fragment'],
toc = self.render_partial(self.env.get_toc_for(filename))['fragment'],
# only display a TOC if there's more than one item to show
display_toc = (self.env.toc_num_entries[filename] > 1),
parents = parents,
prev = prev,
next = next,
)
self.index_file(filename, doctree, title)
self.handle_file(filename, context)
def finish(self):
self.msg('writing additional files...')
# the global general index
# the total count of lines for each index letter, used to distribute
# the entries into two columns
indexcounts = []
for key, entries in self.env.index:
indexcounts.append(sum(1 + len(subitems) for _, (_, subitems) in entries))
genindexcontext = dict(
genindexentries = self.env.index,
genindexcounts = indexcounts,
current_page_name = 'genindex',
pathto = relpath_to(self, self.get_target_uri('genindex.rst')),
)
self.handle_file('genindex.rst', genindexcontext, 'genindex')
# the global module index
# the sorted list of all modules, for the global module index
modules = sorted(((mn, (self.get_relative_uri('modindex.rst', fn) +
'#module-' + mn, sy, pl))
for (mn, (fn, sy, pl)) in self.env.modules.iteritems()),
key=lambda x: x[0].lower())
# collect all platforms
platforms = set()
# sort out collapsable modules
modindexentries = []
pmn = ''
cg = 0 # collapse group
fl = '' # first letter
for mn, (fn, sy, pl) in modules:
pl = pl.split(', ') if pl else []
platforms.update(pl)
if fl != mn[0].lower() and mn[0] != '_':
modindexentries.append(['', False, 0, False, mn[0].upper(), '', []])
tn = mn.partition('.')[0]
if tn != mn:
# submodule
if pmn == tn:
# first submodule - make parent collapsable
modindexentries[-1][1] = True
elif not pmn.startswith(tn):
# submodule without parent in list, add dummy entry
cg += 1
modindexentries.append([tn, True, cg, False, '', '', []])
else:
cg += 1
modindexentries.append([mn, False, cg, (tn != mn), fn, sy, pl])
pmn = mn
fl = mn[0].lower()
platforms = sorted(platforms)
modindexcontext = dict(
modindexentries = modindexentries,
platforms = platforms,
current_page_name = 'modindex',
pathto = relpath_to(self, self.get_target_uri('modindex.rst')),
)
self.handle_file('modindex.rst', modindexcontext, 'modindex')
# the index page
indexcontext = dict(
pathto = relpath_to(self, self.get_target_uri('index.rst')),
current_page_name = 'index',
)
self.handle_file('index.rst', indexcontext, 'index')
# the search page
searchcontext = dict(
pathto = relpath_to(self, self.get_target_uri('search.rst')),
current_page_name = 'search',
)
self.handle_file('search.rst', searchcontext, 'search')
if not self.options.nostyle:
self.msg('copying style files...')
# copy style files
styledirname = path.join(path.dirname(__file__), 'style')
ensuredir(path.join(self.outdir, 'style'))
for filename in os.listdir(styledirname):
if not filename.startswith('.'):
shutil.copyfile(path.join(styledirname, filename),
path.join(self.outdir, 'style', filename))
# add pygments style file
f = open(path.join(self.outdir, 'style', 'pygments.css'), 'w')
if pygments:
f.write(get_stylesheet())
f.close()
# dump the search index
self.handle_finish()
# --------- these are overwritten by the Web builder
def get_target_uri(self, source_filename):
return source_filename[:-4] + '.html'
def get_outdated_files(self):
for filename in get_matching_files(
self.srcdir, '*.rst', exclude=set(self.config.get('unused_files', ()))):
try:
targetmtime = path.getmtime(path.join(self.outdir,
filename[:-4] + '.html'))
except:
targetmtime = 0
if path.getmtime(path.join(self.srcdir, filename)) > targetmtime:
yield filename
def index_file(self, filename, doctree, title):
# only index pages with title
if self.indexer is not None and title:
category = get_category(filename)
if category is not None:
self.indexer.feed(self.get_target_uri(filename)[:-5], # strip '.html'
category, title, doctree)
def handle_file(self, filename, context, templatename='page'):
ctx = self.globalcontext.copy()
ctx.update(context)
output = self.templates[templatename].render(ctx)
outfilename = path.join(self.outdir, filename[:-4] + '.html')
ensuredir(path.dirname(outfilename)) # normally different from self.outdir
try:
with codecs.open(outfilename, 'w', 'utf-8') as fp:
fp.write(output)
except (IOError, OSError), err:
print >>self.warning_stream, "Error writing file %s: %s" % (outfilename, err)
if self.copysource and context.get('sourcename'):
# copy the source file for the "show source" link
shutil.copyfile(path.join(self.srcdir, filename),
path.join(self.outdir, context['sourcename']))
def handle_finish(self):
if self.indexer is not None:
self.msg('dumping search index...')
f = open(path.join(self.outdir, 'searchindex.json'), 'w')
self.indexer.dump(f, 'json')
f.close()
class WebHTMLBuilder(StandaloneHTMLBuilder):
"""
Builds HTML docs usable with the web-based doc server.
"""
name = 'web'
# doesn't use the standalone specific options
option_spec = Builder.option_spec.copy()
option_spec.update({
'nostyle': 'Don\'t copy style and script files',
'nosearchindex': 'Don\'t create a search index for the online search',
})
def init(self):
# Nothing to do here.
pass
def get_outdated_files(self):
for filename in get_matching_files(
self.srcdir, '*.rst', exclude=set(self.config.get('unused_files', ()))):
try:
targetmtime = path.getmtime(path.join(self.outdir,
filename[:-4] + '.fpickle'))
except:
targetmtime = 0
if path.getmtime(path.join(self.srcdir, filename)) > targetmtime:
yield filename
def get_target_uri(self, source_filename):
if source_filename == 'index.rst':
return ''
if source_filename.endswith('/index.rst'):
return source_filename[:-9] # up to /
return source_filename[:-4] + '/'
def index_file(self, filename, doctree, title):
# only index pages with title and category
if self.indexer is not None and title:
category = get_category(filename)
if category is not None:
self.indexer.feed(filename, category, title, doctree)
def handle_file(self, filename, context, templatename='page'):
outfilename = path.join(self.outdir, filename[:-4] + '.fpickle')
ensuredir(path.dirname(outfilename))
context.pop('pathto', None) # can't be pickled
with file(outfilename, 'wb') as fp:
pickle.dump(context, fp, 2)
# if there is a source file, copy the source file for the "show source" link
if context.get('sourcename'):
source_name = path.join(self.outdir, 'sources', context['sourcename'])
ensuredir(path.dirname(source_name))
shutil.copyfile(path.join(self.srcdir, filename), source_name)
def handle_finish(self):
# dump the global context
outfilename = path.join(self.outdir, 'globalcontext.pickle')
with file(outfilename, 'wb') as fp:
pickle.dump(self.globalcontext, fp, 2)
if self.indexer is not None:
self.msg('dumping search index...')
f = open(path.join(self.outdir, 'searchindex.pickle'), 'w')
self.indexer.dump(f, 'pickle')
f.close()
# touch 'last build' file, used by the web application to determine
# when to reload its environment and clear the cache
open(path.join(self.outdir, LAST_BUILD_FILENAME), 'w').close()
# copy configuration file if not present
if not path.isfile(path.join(self.outdir, 'webconf.py')):
shutil.copyfile(path.join(path.dirname(__file__), 'web', 'webconf.py'),
path.join(self.outdir, 'webconf.py'))
class HTMLHelpBuilder(StandaloneHTMLBuilder):
"""
Builder that also outputs Windows HTML help project, contents and index files.
Adapted from the original Doc/tools/prechm.py.
"""
name = 'htmlhelp'
option_spec = Builder.option_spec.copy()
option_spec.update({
'outname': 'Output file base name (default "pydoc")'
})
# don't copy the reST source
copysource = False
def handle_finish(self):
build_hhx(self, self.outdir, self.options.get('outname') or 'pydoc')
builders = {
'html': StandaloneHTMLBuilder,
'web': WebHTMLBuilder,
'htmlhelp': HTMLHelpBuilder,
}