diff --git a/CHANGES b/CHANGES index f730a8d93..0b2baed46 100644 --- a/CHANGES +++ b/CHANGES @@ -129,6 +129,9 @@ Bugs fixed * #2775: Fix failing linkcheck with servers not supporting identidy encoding * #2833: Fix formatting instance annotations in ext.autodoc. * #1911: ``-D`` option of ``sphinx-build`` does not override the ``extensions`` variable +* #2789: `sphinx.ext.intersphinx` generates wrong hyperlinks if the inventory is given +* parsing errors for caption of code-blocks are displayed in document (ref: #2845) +* #2846: ``singlehtml`` builder does not include figure numbers Release 1.4.5 (released Jul 13, 2016) ===================================== diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py index 2cb12609b..defee9bed 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html.py @@ -978,6 +978,26 @@ class SingleFileHTMLBuilder(StandaloneHTMLBuilder): return {self.config.master_doc: new_secnumbers} + def assemble_toc_fignumbers(self): + # Assemble toc_fignumbers to resolve figure numbers on SingleHTML. + # Merge all fignumbers to single fignumber. + # + # Note: current Sphinx has refid confliction in singlehtml mode. + # To avoid the problem, it replaces key of secnumbers to + # tuple of docname and refid. + # + # There are related codes in inline_all_toctres() and + # HTMLTranslter#add_fignumber(). + new_fignumbers = {} + # {u'foo': {'figure': {'id2': (2,), 'id1': (1,)}}, u'bar': {'figure': {'id1': (3,)}}} + for docname, fignumlist in iteritems(self.env.toc_fignumbers): + for figtype, fignums in iteritems(fignumlist): + new_fignumbers.setdefault((docname, figtype), {}) + for id, fignum in iteritems(fignums): + new_fignumbers[(docname, figtype)][id] = fignum + + return {self.config.master_doc: new_fignumbers} + def get_doc_context(self, docname, body, metatags): # no relation links... toc = self.env.get_toctree_for(self.config.master_doc, self, False) @@ -1014,6 +1034,7 @@ class SingleFileHTMLBuilder(StandaloneHTMLBuilder): self.info(bold('assembling single document... '), nonl=True) doctree = self.assemble_doctree() self.env.toc_secnumbers = self.assemble_toc_secnumbers() + self.env.toc_fignumbers = self.assemble_toc_fignumbers() self.info() self.info(bold('writing... '), nonl=True) self.write_doc_serialized(self.config.master_doc, doctree) @@ -1066,6 +1087,7 @@ class SerializingHTMLBuilder(StandaloneHTMLBuilder): self.config_hash = '' self.tags_hash = '' self.imagedir = '_images' + self.current_docname = None self.theme = None # no theme necessary self.templates = None # no template bridge necessary self.init_translator_class() diff --git a/sphinx/directives/code.py b/sphinx/directives/code.py index f88b30987..a2738a30d 100644 --- a/sphinx/directives/code.py +++ b/sphinx/directives/code.py @@ -18,6 +18,7 @@ from docutils.statemachine import ViewList from six import string_types from sphinx import addnodes +from sphinx.locale import _ from sphinx.util import parselinenos from sphinx.util.nodes import set_source_info @@ -68,6 +69,8 @@ def container_wrapper(directive, literal_node, caption): parsed = nodes.Element() directive.state.nested_parse(ViewList([caption], source=''), directive.content_offset, parsed) + if isinstance(parsed[0], nodes.system_message): + raise ValueError(parsed[0]) caption_node = nodes.caption(parsed[0].rawsource, '', *parsed[0].children) caption_node.source = parsed[0].source @@ -131,7 +134,12 @@ class CodeBlock(Directive): caption = self.options.get('caption') if caption: self.options.setdefault('name', nodes.fully_normalize_name(caption)) - literal = container_wrapper(self, literal, caption) + try: + literal = container_wrapper(self, literal, caption) + except ValueError as exc: + document = self.state.document + errmsg = _('Invalid caption: %s' % exc[0][0].astext()) + return [document.reporter.warning(errmsg, line=self.lineno)] # literal will be note_implicit_target that is linked from caption and numref. # when options['name'] is provided, it should be primary ID. @@ -333,7 +341,12 @@ class LiteralInclude(Directive): if not caption: caption = self.arguments[0] self.options.setdefault('name', nodes.fully_normalize_name(caption)) - retnode = container_wrapper(self, retnode, caption) + try: + retnode = container_wrapper(self, retnode, caption) + except ValueError as exc: + document = self.state.document + errmsg = _('Invalid caption: %s' % exc[0][0].astext()) + return [document.reporter.warning(errmsg, line=self.lineno)] # retnode will be note_implicit_target that is linked from caption and numref. # when options['name'] is provided, it should be primary ID. diff --git a/sphinx/ext/intersphinx.py b/sphinx/ext/intersphinx.py index c43b8ae6a..16a487ef8 100644 --- a/sphinx/ext/intersphinx.py +++ b/sphinx/ext/intersphinx.py @@ -239,12 +239,12 @@ def fetch_inventory(app, uri, inv): return try: if hasattr(f, 'geturl'): - newuri = f.geturl() - if newuri.endswith("/" + INVENTORY_FILENAME): - newuri = newuri[:-len(INVENTORY_FILENAME) - 1] - if uri != newuri and uri != newuri + "/": - app.info('intersphinx inventory has moved: %s -> %s' % (uri, newuri)) - uri = newuri + newinv = f.geturl() + if inv != newinv: + app.info('intersphinx inventory has moved: %s -> %s' % (inv, newinv)) + + if uri in (inv, path.dirname(inv), path.dirname(inv) + '/'): + uri = path.dirname(newinv) line = f.readline().rstrip().decode('utf-8') try: if line == '# Sphinx inventory version 1': diff --git a/sphinx/writers/html.py b/sphinx/writers/html.py index be3e3a18c..324f8132a 100644 --- a/sphinx/writers/html.py +++ b/sphinx/writers/html.py @@ -69,6 +69,7 @@ class HTMLTranslator(BaseTranslator): builder.config.highlight_language self.highlightopts = builder.config.highlight_options self.highlightlinenothreshold = sys.maxsize + self.docnames = [builder.current_docname] # for singlehtml builder self.protect_literal_text = 0 self.permalink_text = builder.config.html_add_permalinks # support backwards-compatible setting to a bool @@ -82,10 +83,11 @@ class HTMLTranslator(BaseTranslator): def visit_start_of_file(self, node): # only occurs in the single-file builder + self.docnames.append(node['docname']) self.body.append('' % node['docname']) def depart_start_of_file(self, node): - pass + self.docnames.pop() def visit_desc(self, node): self.body.append(self.starttag(node, 'dl', CLASS=node['objtype'])) @@ -247,7 +249,7 @@ class HTMLTranslator(BaseTranslator): self.secnumber_suffix) elif isinstance(node.parent, nodes.section): if self.builder.name == 'singlehtml': - docname = node.parent.get('docname') + docname = self.docnames[-1] anchorname = '#' + node.parent['ids'][0] if (docname, anchorname) not in self.builder.secnumbers: anchorname = (docname, '') # try first heading which has no anchor @@ -264,14 +266,19 @@ class HTMLTranslator(BaseTranslator): def add_fignumber(self, node): def append_fignumber(figtype, figure_id): - if figure_id in self.builder.fignumbers.get(figtype, {}): + if self.builder.name == 'singlehtml': + key = (self.docnames[-1], figtype) + else: + key = figtype + + if figure_id in self.builder.fignumbers.get(key, {}): self.body.append('') prefix = self.builder.config.numfig_format.get(figtype) if prefix is None: msg = 'numfig_format is not defined for %s' % figtype self.builder.warn(msg) else: - numbers = self.builder.fignumbers[figtype][figure_id] + numbers = self.builder.fignumbers[key][figure_id] self.body.append(prefix % '.'.join(map(str, numbers)) + ' ') self.body.append('') diff --git a/tests/test_build_html.py b/tests/test_build_html.py index 085ba5067..95343921c 100644 --- a/tests/test_build_html.py +++ b/tests/test_build_html.py @@ -548,7 +548,7 @@ def test_numfig_disabled(app, status, warning): yield check_xpath, etree, fname, xpath, check, be_found -@gen_with_app(buildername='html', testroot='numfig', +@gen_with_app(buildername='html', testroot='numfig', freshenv=True, confoverrides={'numfig': True}) def test_numfig_without_numbered_toctree(app, status, warning): # remove :numbered: option @@ -935,6 +935,92 @@ def test_numfig_with_secnum_depth(app, status, warning): yield check_xpath, etree, fname, xpath, check, be_found +@gen_with_app(buildername='singlehtml', testroot='numfig', + confoverrides={'numfig': True}) +def test_numfig_with_singlehtml(app, status, warning): + app.builder.build_all() + + expects = { + 'index.html': [ + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 1 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 2 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2 $', True), + (".//li/a/span", '^Fig. 1$', True), + (".//li/a/span", '^Figure2.2$', True), + (".//li/a/span", '^Table 1$', True), + (".//li/a/span", '^Table:2.2$', True), + (".//li/a/span", '^Listing 1$', True), + (".//li/a/span", '^Code-2.2$', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 1.1 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 1.2 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 1.3 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 1.4 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.1 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.2 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.3 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.4 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.1 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.2 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.3 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.4 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 2.1 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 2.3 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 2.4 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.1 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.3 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.4 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.1 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.3 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.4 $', True), + (".//div[@class='figure']/p[@class='caption']/" + "span[@class='caption-number']", '^Fig. 2.2 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.2 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.2 $', True), + ], + } + + for fname, paths in iteritems(expects): + parser = NslessParser() + parser.entity.update(html_entities.entitydefs) + with (app.outdir / fname).open('rb') as fp: + etree = ET.parse(fp, parser) + + for xpath, check, be_found in paths: + yield check_xpath, etree, fname, xpath, check, be_found + + @gen_with_app(buildername='html', testroot='add_enumerable_node') def test_enumerable_node(app, status, warning): app.builder.build_all() diff --git a/tests/test_ext_intersphinx.py b/tests/test_ext_intersphinx.py index 90987a3ef..334d53971 100644 --- a/tests/test_ext_intersphinx.py +++ b/tests/test_ext_intersphinx.py @@ -19,7 +19,7 @@ from docutils import nodes from sphinx import addnodes from sphinx.ext.intersphinx import read_inventory_v1, read_inventory_v2, \ load_mappings, missing_reference, _strip_basic_auth, _read_from_url, \ - _get_safe_url + _get_safe_url, fetch_inventory, INVENTORY_FILENAME from util import with_app, with_tempdir, mock @@ -83,6 +83,50 @@ def test_read_inventory_v2(): '/util/glossary.html#term-a-term-including-colon' +@with_app() +@mock.patch('sphinx.ext.intersphinx.read_inventory_v2') +@mock.patch('sphinx.ext.intersphinx._read_from_url') +def test_fetch_inventory_redirection(app, status, warning, _read_from_url, read_inventory_v2): + _read_from_url().readline.return_value = '# Sphinx inventory version 2'.encode('utf-8') + + # same uri and inv, not redirected + _read_from_url().geturl.return_value = 'http://hostname/' + INVENTORY_FILENAME + fetch_inventory(app, 'http://hostname/', 'http://hostname/' + INVENTORY_FILENAME) + assert 'intersphinx inventory has moved' not in status.getvalue() + assert read_inventory_v2.call_args[0][1] == 'http://hostname/' + + # same uri and inv, redirected + status.seek(0) + status.truncate(0) + _read_from_url().geturl.return_value = 'http://hostname/new/' + INVENTORY_FILENAME + + fetch_inventory(app, 'http://hostname/', 'http://hostname/' + INVENTORY_FILENAME) + assert status.getvalue() == ('intersphinx inventory has moved: ' + 'http://hostname/%s -> http://hostname/new/%s\n' % + (INVENTORY_FILENAME, INVENTORY_FILENAME)) + assert read_inventory_v2.call_args[0][1] == 'http://hostname/new' + + # different uri and inv, not redirected + status.seek(0) + status.truncate(0) + _read_from_url().geturl.return_value = 'http://hostname/new/' + INVENTORY_FILENAME + + fetch_inventory(app, 'http://hostname/', 'http://hostname/new/' + INVENTORY_FILENAME) + assert 'intersphinx inventory has moved' not in status.getvalue() + assert read_inventory_v2.call_args[0][1] == 'http://hostname/' + + # different uri and inv, redirected + status.seek(0) + status.truncate(0) + _read_from_url().geturl.return_value = 'http://hostname/other/' + INVENTORY_FILENAME + + fetch_inventory(app, 'http://hostname/', 'http://hostname/new/' + INVENTORY_FILENAME) + assert status.getvalue() == ('intersphinx inventory has moved: ' + 'http://hostname/new/%s -> http://hostname/other/%s\n' % + (INVENTORY_FILENAME, INVENTORY_FILENAME)) + assert read_inventory_v2.call_args[0][1] == 'http://hostname/' + + @with_app() @with_tempdir def test_missing_reference(tempdir, app, status, warning):