diff --git a/CHANGES b/CHANGES index e36262fc1..50b4751b2 100644 --- a/CHANGES +++ b/CHANGES @@ -21,6 +21,9 @@ New features added links to another document without the need of creating a label to which a ``:ref:`` could link to. + - #4: Added a ``:download:`` role that marks a non-document file + for inclusion into the HTML output and links to it. + - The ``toctree`` directive now supports a ``:hidden:`` flag, which will prevent links from being generated in place of the directive -- this allows you to define your document diff --git a/doc/markup/inline.rst b/doc/markup/inline.rst index 5776c35e1..f7f998639 100644 --- a/doc/markup/inline.rst +++ b/doc/markup/inline.rst @@ -12,7 +12,7 @@ For all other roles, you have to write ``:rolename:`content```. .. note:: The default role (```content```) has no special meaning by default. You are - free to use it for anything you like. + free to use it for anything you like. .. _xref-syntax: @@ -244,6 +244,30 @@ There is also a way to directly link to documents: ```), the link caption will be the title of the given document. +Referencing downloadable files +------------------------------ + +.. versionadded:: 0.6 + +.. role:: download + + This role lets you link to files within your source tree that are not reST + documents that can be viewed, but files that can be downloaded. + + When you use this role, the referenced file is automatically marked for + inclusion in the output when building (obviously, for HTML output only). + All downloadable files are put into the ``_downloads`` subdirectory of the + output directory; duplicate filenames are handled. + + An example:: + + See :download:`this example script <../example.py>`. + + The given filename is relative to the directory the current source file is + contained in. The ``../example.py`` file will be copied to the output + directory, and a suitable link generated to it. + + Other semantic markup --------------------- @@ -348,7 +372,7 @@ in a different style: curly braces to indicate a "variable" part, as in ``:file:``. If you don't need the "variable part" indication, use the standard - ````code```` instead. + ````code```` instead. The following roles generate external links: @@ -369,6 +393,7 @@ The following roles generate external links: Note that there are no special roles for including hyperlinks as you can use the standard reST markup for that purpose. + .. _default-substitutions: Substitutions diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index a9135b0ab..1d719d88f 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -68,6 +68,9 @@ class pending_xref(nodes.Element): pass # compact paragraph -- never makes a

class compact_paragraph(nodes.paragraph): pass +# reference to a file to download +class download_reference(nodes.reference): pass + # for the ACKS list class acks(nodes.Element): pass @@ -95,8 +98,8 @@ class meta(nodes.Special, nodes.PreBibliographic, nodes.Element): pass # make them known to docutils. this is needed, because the HTML writer # will choke at some point if these are not added nodes._add_node_class_names("""index desc desc_content desc_signature - desc_type desc_returns - desc_addname desc_name desc_parameterlist desc_parameter desc_optional + desc_type desc_returns desc_addname desc_name desc_parameterlist + desc_parameter desc_optional download_reference centered versionmodified seealso productionlist production toctree pending_xref compact_paragraph highlightlang literal_emphasis glossary acks module start_of_file tabular_col_spec meta""".split()) diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py index a86eaed86..86d3f0b77 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html.py @@ -226,6 +226,7 @@ class StandaloneHTMLBuilder(Builder): doctree.settings = self.docsettings self.imgpath = relative_uri(self.get_target_uri(docname), '_images') + self.dlpath = relative_uri(self.get_target_uri(docname), '_downloads') self.docwriter.write(doctree, destination) self.docwriter.assemble_parts() body = self.docwriter.parts['fragment'] @@ -353,6 +354,16 @@ class StandaloneHTMLBuilder(Builder): path.join(self.outdir, '_images', dest)) self.info() + # copy downloadable files + if self.env.dlfiles: + self.info(bold('copying downloadable files...'), nonl=True) + ensuredir(path.join(self.outdir, '_downloads')) + for src, (_, dest) in self.env.dlfiles.iteritems(): + self.info(' '+src, nonl=1) + shutil.copyfile(path.join(self.srcdir, src), + path.join(self.outdir, '_downloads', dest)) + self.info() + # copy static files self.info(bold('copying static files... '), nonl=True) ensuredir(path.join(self.outdir, '_static')) diff --git a/sphinx/environment.py b/sphinx/environment.py index d709d6c2d..f77eb32e1 100644 --- a/sphinx/environment.py +++ b/sphinx/environment.py @@ -42,7 +42,8 @@ from docutils.transforms import Transform from docutils.transforms.parts import ContentsFilter from sphinx import addnodes -from sphinx.util import get_matching_docs, SEP, ustrftime, docname_join +from sphinx.util import get_matching_docs, SEP, ustrftime, docname_join, \ + FilenameUniqDict from sphinx.directives import additional_xref_types default_settings = { @@ -57,7 +58,7 @@ default_settings = { # This is increased every time an environment attribute is added # or changed to properly invalidate pickle files. -ENV_VERSION = 26 +ENV_VERSION = 27 default_substitutions = set([ @@ -276,7 +277,8 @@ class BuildEnvironment: # (type, string, target, aliasname) self.versionchanges = {} # version -> list of # (type, docname, lineno, module, descname, content) - self.images = {} # absolute path -> (docnames, unique filename) + self.images = FilenameUniqDict() # absolute path -> (docnames, unique filename) + self.dlfiles = FilenameUniqDict() # absolute path -> (docnames, unique filename) # These are set while parsing a file self.docname = None # current document name @@ -317,6 +319,8 @@ class BuildEnvironment: self.filemodules.pop(docname, None) self.indexentries.pop(docname, None) self.glob_toctrees.discard(docname) + self.images.purge_doc(docname) + self.dlfiles.purge_doc(docname) for subfn, fnset in self.files_to_rebuild.items(): fnset.discard(docname) @@ -340,10 +344,6 @@ class BuildEnvironment: for version, changes in self.versionchanges.items(): new = [change for change in changes if change[1] != docname] changes[:] = new - for fullpath, (docs, _) in self.images.items(): - docs.discard(docname) - if not docs: - del self.images[fullpath] def doc2path(self, docname, base=True, suffix=None): """ @@ -480,12 +480,6 @@ class BuildEnvironment: self.doc2path(config.master_doc)) self.app = None - - # remove all non-existing images from inventory - for imgsrc in self.images.keys(): - if not os.access(path.join(self.srcdir, imgsrc), os.R_OK): - del self.images[imgsrc] - if app: app.emit('env-updated', self) @@ -544,6 +538,7 @@ class BuildEnvironment: self.filter_messages(doctree) 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) self.note_labels_from(docname, doctree) @@ -608,11 +603,25 @@ class BuildEnvironment: dep = path.join(docdir, dep) self.dependencies.setdefault(docname, set()).add(dep) + def process_downloads(self, docname, doctree): + """ + Process downloadable file paths. + """ + docdir = path.dirname(self.doc2path(docname, base=None)) + for node in doctree.traverse(addnodes.download_reference): + filepath = path.normpath(path.join(docdir, node['reftarget'])) + self.dependencies.setdefault(docname, set()).add(filepath) + if not os.access(path.join(self.srcdir, filepath), os.R_OK): + self.warn(docname, 'Download file not readable: %s' % filepath, + getattr(node, 'line', None)) + continue + uniquename = self.dlfiles.add_file(docname, filepath) + node['filename'] = uniquename + def process_images(self, docname, doctree): """ Process and rewrite image URIs. """ - existing_names = set(v[1] for v in self.images.itervalues()) docdir = path.dirname(self.doc2path(docname, base=None)) for node in doctree.traverse(nodes.image): # Map the mimetype to the corresponding image. The writer may @@ -656,17 +665,8 @@ class BuildEnvironment: if not os.access(path.join(self.srcdir, imgpath), os.R_OK): self.warn(docname, 'Image file not readable: %s' % imgpath, node.line) - if imgpath in self.images: - self.images[imgpath][0].add(docname) continue - uniquename = path.basename(imgpath) - base, ext = path.splitext(uniquename) - i = 0 - while uniquename in existing_names: - i += 1 - uniquename = '%s%s%s' % (base, i, ext) - self.images[imgpath] = (set([docname]), uniquename) - existing_names.add(uniquename) + self.images.add_file(docname, imgpath) def process_metadata(self, docname, doctree): """ diff --git a/sphinx/roles.py b/sphinx/roles.py index 70fca848c..d942ddc93 100644 --- a/sphinx/roles.py +++ b/sphinx/roles.py @@ -96,6 +96,7 @@ innernodetypes = { 'term': nodes.emphasis, 'token': nodes.strong, 'envvar': nodes.strong, + 'download': nodes.strong, 'option': addnodes.literal_emphasis, } @@ -122,8 +123,10 @@ def xfileref_role(typ, rawtext, text, lineno, inliner, options={}, content=[]): return [innernodetypes.get(typ, nodes.literal)( rawtext, text, classes=['xref'])], [] # we want a cross-reference, create the reference node - pnode = addnodes.pending_xref(rawtext, reftype=typ, refcaption=False, - modname=env.currmodule, classname=env.currclass) + nodeclass = (typ == 'download') and addnodes.download_reference or \ + addnodes.pending_xref + pnode = nodeclass(rawtext, reftype=typ, refcaption=False, + modname=env.currmodule, classname=env.currclass) # we may need the line number for warnings pnode.line = lineno # the link title may differ from the target, but by default they are the same @@ -236,6 +239,7 @@ specific_docroles = { 'term': xfileref_role, 'option': xfileref_role, 'doc': xfileref_role, + 'download': xfileref_role, 'menuselection': menusel_role, 'file': emph_literal_role, diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index 1d654134a..e25bc5a1b 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -288,3 +288,39 @@ def nested_parse_with_titles(state, content, node): def ustrftime(format, *args): # strftime for unicode strings return time.strftime(unicode(format).encode('utf-8'), *args).decode('utf-8') + + +class FilenameUniqDict(dict): + """ + A dictionary that automatically generates unique names for its keys, + interpreted as filenames, and keeps track of a set of docnames they + appear in. Used for images and downloadable files in the environment. + """ + def __init__(self): + self._existing = set() + + def add_file(self, docname, newfile): + if newfile in self: + self[newfile][0].add(docname) + return + uniquename = path.basename(newfile) + base, ext = path.splitext(uniquename) + i = 0 + while uniquename in self._existing: + i += 1 + uniquename = '%s%s%s' % (base, i, ext) + self[newfile] = (set([docname]), uniquename) + self._existing.add(uniquename) + return uniquename + + def purge_doc(self, docname): + for filename, (docs, _) in self.items(): + docs.discard(docname) + if not docs: + del self[filename] + + def __getstate__(self): + return self._existing + + def __setstate__(self, state): + self._existing = state diff --git a/sphinx/writers/html.py b/sphinx/writers/html.py index b9db98db8..69c1de722 100644 --- a/sphinx/writers/html.py +++ b/sphinx/writers/html.py @@ -259,6 +259,16 @@ class HTMLTranslator(BaseTranslator): def depart_highlightlang(self, node): pass + def visit_download_reference(self, node): + if node.hasattr('filename'): + self.body.append('' % posixpath.join( + self.builder.dlpath, node['filename'])) + self.context.append('') + else: + self.context.append('') + def depart_download_reference(self, node): + self.body.append(self.context.pop()) + # overwritten def visit_image(self, node): olduri = node['uri'] diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 226c98cfc..7695d6c5a 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -953,6 +953,11 @@ class LaTeXTranslator(nodes.NodeVisitor): def depart_reference(self, node): self.body.append(self.context.pop()) + def visit_download_reference(self, node): + pass + def depart_download_reference(self, node): + pass + def visit_pending_xref(self, node): pass def depart_pending_xref(self, node): diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index cd8a464a3..f54a6386a 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -614,6 +614,11 @@ class TextTranslator(nodes.NodeVisitor): def depart_reference(self, node): pass + def visit_download_reference(self, node): + pass + def depart_download_reference(self, node): + pass + def visit_emphasis(self, node): self.add_text('*') def depart_emphasis(self, node): diff --git a/tests/root/includes.txt b/tests/root/includes.txt index ad507fc68..d2964d3f0 100644 --- a/tests/root/includes.txt +++ b/tests/root/includes.txt @@ -14,3 +14,11 @@ Test file and literal inclusion :encoding: latin-1 .. include:: wrongenc.inc :encoding: latin-1 + + +Testing downloadable files +========================== + +Download :download:`img.png` here. +Download :download:`this ` there. +Don't download :download:`this `. diff --git a/tests/test_build.py b/tests/test_build.py index d307818a1..91506dad3 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -32,6 +32,7 @@ WARNING: %(root)s/images.txt:9: Image file not readable: foo.png WARNING: %(root)s/images.txt:23: Nonlocal image URI found: http://www.python.org/logo.png WARNING: %(root)s/includes.txt:: (WARNING/2) Encoding 'utf-8' used for reading included \ file u'wrongenc.inc' seems to be wrong, try giving an :encoding: option +WARNING: %(root)s/includes.txt:34: Download file not readable: nonexisting.png """ HTML_WARNINGS = ENV_WARNINGS + """\ @@ -58,6 +59,8 @@ HTML_XPATH = { 'includes.html': { ".//pre/span[@class='s']": u'üöä', ".//pre": u'Max Strauß', + ".//a[@href='_downloads/img.png']": '', + ".//a[@href='_downloads/img1.png']": '', }, 'autodoc.html': { ".//dt[@id='test_autodoc.Class']": '', diff --git a/tests/test_env.py b/tests/test_env.py index c8a8364b6..1040b88a2 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -77,11 +77,13 @@ def test_second_update(): (root / 'new.txt').write_text('New file\n========\n') it = env.update(app.config, app.srcdir, app.doctreedir, app) msg = it.next() - assert '1 added, 1 changed, 1 removed' in msg + assert '1 added, 2 changed, 1 removed' in msg docnames = set() for docname in it: docnames.add(docname) - assert docnames == set(['contents', 'new']) + # "includes" is in there because it contains a reference to a nonexisting + # downloadable file, which is given another chance to exist + assert docnames == set(['contents', 'new', 'includes']) assert 'images' not in env.all_docs assert 'images' not in env.found_docs