From 4c9917eba69d293b0d40c94873fa44dad7dd77e4 Mon Sep 17 00:00:00 2001 From: Blair Bonnett Date: Thu, 19 Sep 2019 14:07:10 +0200 Subject: [PATCH] Sphinx extension: support captions in inline plots. This commit adds a :caption: option to the plot directive provided by the Sphinx extension (matplotlib.sphinxext.plot_directive). Without this option, there is no way to specify a caption for a plot generated from inline content. This is fully backwards-compatible. If a plot directive with a path to a source file has both a :caption: option and content provided, the content is used for the caption and the option is ignored. --- lib/matplotlib/sphinxext/plot_directive.py | 34 +++++ .../plot_directive_caption/conf.py | 1 + .../plot_directive_caption/index.rst | 47 ++++++ .../plot_directive_caption/test_plot.py | 6 + lib/matplotlib/tests/test_sphinxext.py | 143 +++++++++++++++++- 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/conf.py create mode 100644 lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/index.rst create mode 100644 lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/test_plot.py diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index 31dc1a6ff414..b7b4ee1401ca 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -18,6 +18,18 @@ This is the caption for the plot + Alternatively, the caption may be given using the :caption: option:: + + .. plot:: path/to/plot.py + :caption: This is the caption for the plot + + If content is given, then the :caption: option is ignored:: + + .. plot:: path/to/plot.py + :caption: This caption is not used + + This is the actual caption used for the plot + Additionally, one may specify the name of a function to call (with no arguments) immediately after importing the module:: @@ -33,6 +45,17 @@ img = mpimg.imread('_static/stinkbug.png') imgplot = plt.imshow(img) + To add a caption to an inline plot, the :caption: option must be used:: + + .. plot:: + :caption: This is the caption for the 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) + 3. Using **doctest** syntax:: .. plot:: @@ -70,6 +93,11 @@ If specified, the code block will be run, but no figures will be inserted. This is usually useful with the ``:context:`` option. + caption : str + If given, the caption to add to the plot. If the code to generate the + plot is specified by a external file and the directive has content, + then this option is ignored. + Additionally, this directive supports all of the options of the `image` directive, except for *target* (since plot will add its own target). These include `alt`, `height`, `width`, `scale`, `align` and `class`. @@ -252,6 +280,7 @@ class PlotDirective(Directive): 'context': _option_context, 'nofigs': directives.flag, 'encoding': directives.encoding, + 'caption': directives.unchanged, } def run(self): @@ -666,6 +695,11 @@ def run(arguments, content, options, state_machine, state, lineno): function_name = None caption = '' + # We didn't get a caption from the directive content. + # See if the options contains one. + if not caption: + caption = options.get('caption', '') + base, source_ext = os.path.splitext(output_base) if source_ext in ('.py', '.rst', '.txt'): output_base = base diff --git a/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/conf.py b/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/conf.py new file mode 100644 index 000000000000..9aef3329ab6e --- /dev/null +++ b/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/conf.py @@ -0,0 +1 @@ +extensions = ['matplotlib.sphinxext.plot_directive'] diff --git a/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/index.rst b/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/index.rst new file mode 100644 index 000000000000..36252f4d4ba5 --- /dev/null +++ b/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/index.rst @@ -0,0 +1,47 @@ +Plot directive caption tests +============================ + +Inline plot with no caption: + +.. plot:: + + import matplotlib.pyplot as plt + import numpy as np + f = 3 + t = np.linspace(0, 1, 100) + s = np.sin(2 * np.pi * f * t) + plt.plot(t, s) + +Inline plot with caption: + +.. plot:: + :caption: Caption for inline plot. + + import matplotlib.pyplot as plt + import numpy as np + f = 3 + t = np.linspace(0, 1, 100) + s = np.sin(2 * np.pi * f * t) + plt.plot(t, s) + +Included file with no caption: + +.. plot:: test_plot.py + +Included file with caption in the directive content: + +.. plot:: test_plot.py + + This is a caption in the content. + +Included file with caption option: + +.. plot:: test_plot.py + :caption: This is a caption in the options. + +If both content and options have a caption, the one in the content should prevail: + +.. plot:: test_plot.py + :caption: This should be ignored. + + The content caption should be used instead. diff --git a/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/test_plot.py b/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/test_plot.py new file mode 100644 index 000000000000..5467d138d298 --- /dev/null +++ b/lib/matplotlib/tests/sphinxext_sources/plot_directive_caption/test_plot.py @@ -0,0 +1,6 @@ +import matplotlib.pyplot as plt +import numpy as np +f = 3 +t = np.linspace(0, 1, 100) +s = np.sin(2 * np.pi * f * t) +plt.plot(t, s) diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index 6752d74afd9c..c1afb0825956 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -2,14 +2,155 @@ import filecmp from os.path import join as pjoin, dirname, isdir +import pathlib from subprocess import Popen, PIPE import sys +import warnings import pytest - +# Only run the tests if Sphinx is installed. pytest.importorskip('sphinx') +# Docutils is a dependency of Sphinx so it is safe to +# import after we know Sphinx is available. +from docutils.nodes import caption, figure # noqa: E402 + +# Sphinx has some deprecation warnings we don't want to turn into errors. +with warnings.catch_warnings(): + warnings.simplefilter('ignore') + from sphinx.application import Sphinx + + +#: Directory of sources for testing the Sphinx extension. +SRCDIR = pathlib.Path(__file__).parent / 'sphinxext_sources' + + +class NodeFilter: + """Test utility class to filter nodes from a Sphinx doctree. + + This is designed to be used with the walkabout() method of nodes. You + probably want to use the filter_children() class method. + + Parameters + ---------- + document : node + The document node. + classes : list of classes + The node classes to filter from the document. If None, all classes will + be accepted resulting in a flattened list of all nodes. + + """ + def __init__(self, document, classes=None): + self.document = document + self.nodes = [] + if classes: + self.classes = tuple(classes) + else: + self.classes = None + + def dispatch_visit(self, obj): + if not self.classes or isinstance(obj, self.classes): + self.nodes.append(obj) + + def dispatch_departure(self, obj): + pass + + @classmethod + def filter_children(cls, document, parent, classes=None): + """Filter child nodes from a parent node. + + Parameters + ---------- + document : node + The main document node. + parent : node + The parent node to work on. + classes : list of classes + The node classes to filter. + + Returns + ------- + children : list + A list of the nodes which are instances of the given classes or + their subclasses. + + """ + obj = cls(document, classes=classes) + parent.walkabout(obj) + return obj.nodes + + +def build_test_doc(src_dir, build_dir, builder='html'): + """Build a test document. + + Parameters + ---------- + src_dir : pathlib.Path + The location of the sources. + build_dir : pathlib.Path + The build directory to use. + builder : str + Which builder to use. + + Returns + ------- + app : sphinx.application.Sphinx + The Sphinx application that built the document. + + """ + doctree_dir = build_dir / "doctrees" + output_dir = build_dir / "html" + + # Avoid some internal Sphinx deprecation warnings being turned into errors. + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + app = Sphinx(src_dir, src_dir, output_dir, doctree_dir, builder) + app.build() + return app + + +def test_plot_directive_caption(tmpdir): + """Test the :caption: option of the plot directive. + + """ + # Build the test document. + localsrc = SRCDIR / "plot_directive_caption" + build_dir = pathlib.Path(tmpdir) + app = build_test_doc(localsrc, build_dir) + + # Get the main document and filter out the figures in it. + index = app.env.get_doctree('index') + figures = NodeFilter.filter_children(index, index, [figure]) + + # The captions we expect to find. + expected = [ + None, + 'Caption for inline plot.', + None, + 'This is a caption in the content.', + 'This is a caption in the options.', + 'The content caption should be used instead.', + ] + + # N.B., each plot directive generates two figures: + # one HTML only and one for other builders. + assert len(figures) == 2 * len(expected), \ + "Wrong number of figures in document." + + # Check the caption nodes are correct. + for i, figurenode in enumerate(figures): + n = i // 2 + captions = NodeFilter.filter_children(index, figurenode, [caption]) + + if expected[n]: + assert len(captions) > 0, f"Figure {n+1}: no caption found." + assert len(captions) < 2, f"Figure {n+1}: too many captions." + assert captions[0].astext().strip() == expected[n], \ + f"Figure {n+1}: wrong caption" + else: + assert len(captions) == 0, f"Figure {n+1}: unexpected caption." + def test_tinypages(tmpdir): html_dir = pjoin(str(tmpdir), 'html')