diff --git a/doc/users/next_whats_new/sphinx_plot_preserve.rst b/doc/users/next_whats_new/sphinx_plot_preserve.rst new file mode 100644 index 000000000000..88d70c86a02c --- /dev/null +++ b/doc/users/next_whats_new/sphinx_plot_preserve.rst @@ -0,0 +1,32 @@ +Caching sphinx directive figure outputs +--------------------------------------- + +The new ``:outname:`` property for the Sphinx plot directive can +be used to cache generated images. It is used like: + +.. code-block:: rst + + .. plot:: + :outname: stinkbug_plot + + import matplotlib.pyplot as plt + import matplotlib.image as mpimg + import numpy as np + img = mpimg.imread('_static/stinkbug.png') + imgplot = plt.imshow(img) + +Without ``:outname:``, the figure generated above would normally be called, +e.g. :file:`docfile3-4-01.png` or something equally mysterious. With +``:outname:`` the figure generated will instead be named +:file:`stinkbug_plot-01.png` or even :file:`stinkbug_plot.png`. This makes it +easy to understand which output image is which and, more importantly, uniquely +keys output images to the code snippets that generated them. + +Configuring the cache directory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The directory that images are cached to can be configured using the +``plot_cache_dir`` configuration value in the Sphinx configuration file. + +If an image is already in ``plot_cache_dir`` when documentation is being +generated, this image is copied to the build directory thereby pre-empting +generation and reducing computation time in low-resource environments. diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index c154baeaf361..a3a9e1bd1fcd 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -77,6 +77,11 @@ If specified, the option's argument will be used as a caption for the figure. This overwrites the caption given in the content, when the plot is generated from a file. + ``:outname:`` : str + If specified, the names of the generated plots will start with the + value of ``:outname:``. This is handy for preserving output results if + code is reordered between runs. The value of ``:outname:`` must be + unique across the generated documentation. Additionally, this directive supports all the options of the `image directive `_, @@ -139,6 +144,12 @@ plot_template Provide a customized template for preparing restructured text. + + plot_cache_dir + Files with outnames are copied to this directory and files in this + directory are copied back into the build directory prior to the build + beginning. + """ import contextlib @@ -146,12 +157,14 @@ from io import StringIO import itertools import os -from os.path import relpath -from pathlib import Path -import re -import shutil import sys +import shutil +import re import textwrap +import glob +import logging +from os.path import relpath +from pathlib import Path import traceback from docutils.parsers.rst import directives, Directive @@ -167,6 +180,12 @@ __version__ = 2 +_log = logging.getLogger(__name__) + +# Outnames must be unique. This variable stores the outnames that +# have been seen so we can guarantee this and warn the user if a +# duplicate is encountered. +_outname_list = set() # ----------------------------------------------------------------------------- # Registration hook @@ -245,6 +264,7 @@ class PlotDirective(Directive): 'context': _option_context, 'nofigs': directives.flag, 'caption': directives.unchanged, + 'outname': str } def run(self): @@ -280,6 +300,7 @@ def setup(app): app.add_config_value('plot_apply_rcparams', False, True) app.add_config_value('plot_working_directory', None, True) app.add_config_value('plot_template', None, True) + app.add_config_value('plot_cache_dir', '', True) app.connect('doctree-read', mark_plot_labels) app.add_css_file('plot_directive.css') app.connect('build-finished', _copy_css_file) @@ -517,7 +538,8 @@ def get_plot_formats(config): def render_figures(code, code_path, output_dir, output_base, context, function_name, config, context_reset=False, close_figs=False, - code_includes=None): + code_includes=None, + outname=None): """ Run a pyplot script and save the images in *output_dir*. @@ -613,6 +635,12 @@ def render_figures(code, code_path, output_dir, output_base, context, for fmt, dpi in formats: try: figman.canvas.figure.savefig(img.filename(fmt), dpi=dpi) + if config.plot_cache_dir and outname is not None: + _log.info( + "Preserving '{0}' into '{1}'".format( + img.filename(fmt), config.plot_cache_dir)) + shutil.copy2(img.filename(fmt), + config.plot_cache_dir) except Exception as err: raise PlotError(traceback.format_exc()) from err img.formats.append(fmt) @@ -648,6 +676,21 @@ def run(arguments, content, options, state_machine, state, lineno): rst_file = document.attributes['source'] rst_dir = os.path.dirname(rst_file) + # Get output name of the images, if the option was provided + outname = options.get('outname', '') + + # Ensure that the outname is unique, otherwise copied images will + # not be what user expects + if outname and outname in _outname_list: + raise Exception("The outname '{0}' is not unique!".format(outname)) + else: + _outname_list.add(outname) + + if config.plot_cache_dir: + # Ensure `preserve_dir` ends with a slash, otherwise `copy2` + # will misbehave + config.plot_cache_dir = os.path.join(config.plot_cache_dir, '') + if len(arguments): if not config.plot_basedir: source_file_name = os.path.join(setup.app.builder.srcdir, @@ -693,6 +736,11 @@ def run(arguments, content, options, state_machine, state, lineno): else: source_ext = '' + # outname, if present, overrides output_base, but preserve + # numbering of multi-figure code snippets + if outname: + output_base = re.sub('^[^-]*', outname, output_base) + # ensure that LaTeX includegraphics doesn't choke in foo.bar.pdf filenames output_base = output_base.replace('.', '-') @@ -753,6 +801,16 @@ def run(arguments, content, options, state_machine, state, lineno): else code, encoding='utf-8') + # If we previously preserved copies of the generated figures this copies + # them into the build directory so that they will not be remade. + if config.plot_cache_dir and outname: + outfiles = glob.glob( + os.path.join(config.plot_cache_dir, outname) + '*') + for of in outfiles: + _log.info("Copying preserved copy of '{0}' into '{1}'".format( + of, build_dir)) + shutil.copy2(of, build_dir) + # make figures try: results = render_figures(code=code, @@ -764,7 +822,8 @@ def run(arguments, content, options, state_machine, state, lineno): config=config, context_reset=context_opt == 'reset', close_figs=context_opt == 'close-figs', - code_includes=source_file_includes) + code_includes=source_file_includes, + outname=outname) errors = [] except PlotError as err: reporter = state.memo.reporter diff --git a/lib/matplotlib/tests/tinypages/some_plots.rst b/lib/matplotlib/tests/tinypages/some_plots.rst index dd1f79892b0e..d707fb004b84 100644 --- a/lib/matplotlib/tests/tinypages/some_plots.rst +++ b/lib/matplotlib/tests/tinypages/some_plots.rst @@ -174,3 +174,12 @@ Plot 21 is generated via an include directive: Plot 22 uses a different specific function in a file with plot commands: .. plot:: range6.py range10 + +Plot 23 has an outname + +.. plot:: + :context: close-figs + :outname: plot23out + + plt.figure() + plt.plot(range(4))