diff --git a/CHANGES b/CHANGES index c6c234d1d..27d3d4ab9 100644 --- a/CHANGES +++ b/CHANGES @@ -9,6 +9,7 @@ Incompatible changes * #5282: html theme: refer ``pygments_style`` settings of HTML themes preferentially +* The URL of download files are changed Deprecated ---------- @@ -35,6 +36,8 @@ Bugs fixed * #4379: toctree shows confusible warning when document is excluded * #2401: autodoc: ``:members:`` causes ``:special-members:`` not to be shown * autodoc: ImportError is replaced by AttributeError for deeper module +* #2720, #4034: Incorrect links with ``:download:``, duplicate names, and + parallel builds Testing -------- diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py index a0550728e..830c76ff2 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html.py @@ -864,10 +864,10 @@ class StandaloneHTMLBuilder(Builder): for src in status_iterator(self.env.dlfiles, __('copying downloadable files... '), "brown", len(self.env.dlfiles), self.app.verbosity, stringify_func=to_relpath): - dest = self.env.dlfiles[src][1] try: - copyfile(path.join(self.srcdir, src), - path.join(self.outdir, '_downloads', dest)) + dest = path.join(self.outdir, '_downloads', self.env.dlfiles[src][1]) + ensuredir(path.dirname(dest)) + copyfile(path.join(self.srcdir, src), dest) except EnvironmentError as err: logger.warning(__('cannot copy downloadable file %r: %s'), path.join(self.srcdir, src), err) diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 100531be8..4a79db3db 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -28,7 +28,7 @@ from sphinx.environment.adapters.toctree import TocTree from sphinx.errors import SphinxError, BuildEnvironmentError, DocumentError, ExtensionError from sphinx.locale import __ from sphinx.transforms import SphinxTransformer -from sphinx.util import get_matching_docs, FilenameUniqDict +from sphinx.util import get_matching_docs, DownloadFiles, FilenameUniqDict from sphinx.util import logging from sphinx.util.docutils import LoggingReporter from sphinx.util.i18n import find_catalog_files @@ -190,7 +190,8 @@ class BuildEnvironment(object): # these map absolute path -> (docnames, unique filename) self.images = FilenameUniqDict() # type: FilenameUniqDict - self.dlfiles = FilenameUniqDict() # type: FilenameUniqDict + self.dlfiles = DownloadFiles() # type: DownloadFiles + # filename -> (set of docnames, destination) # the original URI for images self.original_image_uri = {} # type: Dict[unicode, unicode] diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index 6a28432e3..29038978a 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -22,6 +22,7 @@ import warnings from codecs import BOM_UTF8 from collections import deque from datetime import datetime +from hashlib import md5 from os import path from time import mktime, strptime @@ -167,6 +168,37 @@ class FilenameUniqDict(dict): self._existing = state +class DownloadFiles(dict): + """A special dictionary for download files. + + .. important:: This class would be refactored in nearly future. + Hence don't hack this directly. + """ + + def add_file(self, docname, filename): + # type: (unicode, unicode) -> None + if filename not in self: + digest = md5(filename.encode('utf-8')).hexdigest() + dest = '%s/%s' % (digest, os.path.basename(filename)) + self[filename] = (set(), dest) + + self[filename][0].add(docname) + return self[filename][1] + + def purge_doc(self, docname): + # type: (unicode) -> None + for filename, (docs, dest) in list(self.items()): + docs.discard(docname) + if not docs: + del self[filename] + + def merge_other(self, docnames, other): + # type: (Set[unicode], Dict[unicode, Tuple[Set[unicode], Any]]) -> None + for filename, (docs, dest) in other.items(): + for docname in docs & set(docnames): + self.add_file(docname, filename) + + def copy_static_entry(source, targetdir, builder, context={}, exclude_matchers=(), level=0): # type: (unicode, unicode, Any, Dict, Tuple[Callable, ...], int) -> None diff --git a/tests/test_build_html.py b/tests/test_build_html.py index 51732435e..aae53615b 100644 --- a/tests/test_build_html.py +++ b/tests/test_build_html.py @@ -151,7 +151,7 @@ def test_html_warnings(app, warning): (".//img[@src='../_images/rimg.png']", ''), ], 'subdir/includes.html': [ - (".//a[@href='../_downloads/img.png']", ''), + (".//a[@class='reference download internal']", ''), (".//img[@src='../_images/img.png']", ''), (".//p", 'This is an include file.'), (".//pre/span", 'line 1'), @@ -159,8 +159,7 @@ def test_html_warnings(app, warning): ], 'includes.html': [ (".//pre", u'Max Strauß'), - (".//a[@href='_downloads/img.png']", ''), - (".//a[@href='_downloads/img1.png']", ''), + (".//a[@class='reference download internal']", ''), (".//pre/span", u'"quotes"'), (".//pre/span", u"'included'"), (".//pre/span[@class='s2']", u'üöä'), @@ -421,6 +420,31 @@ def test_html_output(app, cached_etree_parse, fname, expect): check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) +@pytest.mark.sphinx('html', tags=['testtag'], confoverrides={ + 'html_context.hckey_co': 'hcval_co'}) +@pytest.mark.test_params(shared_result='test_build_html_output') +def test_html_download(app): + app.build() + + # subdir/includes.html + result = (app.outdir / 'subdir' / 'includes.html').text() + pattern = ('') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1)).exists() + filename = matched.group(1) + + # includes.html + result = (app.outdir / 'includes.html').text() + pattern = ('') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1)).exists() + assert matched.group(1) == filename + + @pytest.mark.sphinx('html', testroot='build-html-translator') def test_html_translator(app): app.build() diff --git a/tests/test_build_html5.py b/tests/test_build_html5.py index 21da21224..e4c51eaea 100644 --- a/tests/test_build_html5.py +++ b/tests/test_build_html5.py @@ -14,7 +14,9 @@ :license: BSD, see LICENSE for details. """ +import re import xml.etree.cElementTree as ElementTree +from hashlib import md5 import pytest from html5lib import getTreeBuilder, HTMLParser @@ -58,7 +60,7 @@ def cached_etree_parse(): (".//img[@src='../_images/rimg.png']", ''), ], 'subdir/includes.html': [ - (".//a[@href='../_downloads/img.png']", ''), + (".//a[@class='reference download internal']", ''), (".//img[@src='../_images/img.png']", ''), (".//p", 'This is an include file.'), (".//pre/span", 'line 1'), @@ -66,8 +68,7 @@ def cached_etree_parse(): ], 'includes.html': [ (".//pre", u'Max Strauß'), - (".//a[@href='_downloads/img.png']", ''), - (".//a[@href='_downloads/img1.png']", ''), + (".//a[@class='reference download internal']", ''), (".//pre/span", u'"quotes"'), (".//pre/span", u"'included'"), (".//pre/span[@class='s2']", u'üöä'), @@ -323,17 +324,45 @@ def test_html5_output(app, cached_etree_parse, fname, expect): check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) +@pytest.mark.sphinx('html', tags=['testtag'], confoverrides={ + 'html_context.hckey_co': 'hcval_co', + 'html_experimental_html5_writer': True}) +@pytest.mark.test_params(shared_result='test_build_html_output') +def test_html_download(app): + app.build() + + # subdir/includes.html + result = (app.outdir / 'subdir' / 'includes.html').text() + pattern = ('') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1)).exists() + filename = matched.group(1) + + # includes.html + result = (app.outdir / 'includes.html').text() + pattern = ('') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1)).exists() + assert matched.group(1) == filename + + @pytest.mark.sphinx('html', testroot='roles-download', confoverrides={'html_experimental_html5_writer': True}) def test_html_download_role(app, status, warning): app.build() - assert (app.outdir / '_downloads' / 'dummy.dat').exists() + digest = md5((app.srcdir / 'dummy.dat').encode('utf-8')).hexdigest() + assert (app.outdir / '_downloads' / digest / 'dummy.dat').exists() content = (app.outdir / 'index.html').text() - assert ('
  • ' - '' - 'dummy.dat

  • ' in content) + assert (('
  • ' + '' + 'dummy.dat

  • ' % digest) + in content) assert ('
  • ' 'not_found.dat

  • ' in content) assert ('