Skip to content

Commit 80486e6

Browse files
authored
Merge pull request #28187 from asmeurer/output-base-name
Add a filename-prefix option to the Sphinx plot directive
2 parents f6b77d2 + f4f1fbf commit 80486e6

File tree

4 files changed

+128
-10
lines changed

4 files changed

+128
-10
lines changed

lib/matplotlib/sphinxext/plot_directive.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
4848
The ``.. plot::`` directive supports the following options:
4949
50+
``:filename-prefix:`` : str
51+
The base name (without the extension) of the outputted image and script
52+
files. The default is to use the same name as the input script, or the
53+
name of the RST document if no script is provided. The filename-prefix for
54+
each plot directive must be unique.
55+
5056
``:format:`` : {'python', 'doctest'}
5157
The format of the input. If unset, the format is auto-detected.
5258
@@ -163,8 +169,10 @@
163169
be customized by changing the *plot_template*. See the source of
164170
:doc:`/api/sphinxext_plot_directive_api` for the templates defined in *TEMPLATE*
165171
and *TEMPLATE_SRCSET*.
172+
166173
"""
167174

175+
from collections import defaultdict
168176
import contextlib
169177
import doctest
170178
from io import StringIO
@@ -182,6 +190,7 @@
182190
from docutils.parsers.rst.directives.images import Image
183191
import jinja2 # Sphinx dependency.
184192

193+
from sphinx.environment.collectors import EnvironmentCollector
185194
from sphinx.errors import ExtensionError
186195

187196
import matplotlib
@@ -265,6 +274,7 @@ class PlotDirective(Directive):
265274
'scale': directives.nonnegative_int,
266275
'align': Image.align,
267276
'class': directives.class_option,
277+
'filename-prefix': directives.unchanged,
268278
'include-source': _option_boolean,
269279
'show-source-link': _option_boolean,
270280
'format': _option_format,
@@ -312,9 +322,35 @@ def setup(app):
312322
app.connect('build-finished', _copy_css_file)
313323
metadata = {'parallel_read_safe': True, 'parallel_write_safe': True,
314324
'version': matplotlib.__version__}
325+
app.connect('builder-inited', init_filename_registry)
326+
app.add_env_collector(_FilenameCollector)
315327
return metadata
316328

317329

330+
# -----------------------------------------------------------------------------
331+
# Handle Duplicate Filenames
332+
# -----------------------------------------------------------------------------
333+
334+
def init_filename_registry(app):
335+
env = app.builder.env
336+
if not hasattr(env, 'mpl_plot_image_basenames'):
337+
env.mpl_plot_image_basenames = defaultdict(set)
338+
339+
340+
class _FilenameCollector(EnvironmentCollector):
341+
def process_doc(self, app, doctree):
342+
pass
343+
344+
def clear_doc(self, app, env, docname):
345+
if docname in env.mpl_plot_image_basenames:
346+
del env.mpl_plot_image_basenames[docname]
347+
348+
def merge_other(self, app, env, docnames, other):
349+
for docname in other.mpl_plot_image_basenames:
350+
env.mpl_plot_image_basenames[docname].update(
351+
other.mpl_plot_image_basenames[docname])
352+
353+
318354
# -----------------------------------------------------------------------------
319355
# Doctest handling
320356
# -----------------------------------------------------------------------------
@@ -600,6 +636,25 @@ def _parse_srcset(entries):
600636
return srcset
601637

602638

639+
def check_output_base_name(env, output_base):
640+
docname = env.docname
641+
642+
if '.' in output_base or '/' in output_base or '\\' in output_base:
643+
raise PlotError(
644+
f"The filename-prefix '{output_base}' is invalid. "
645+
f"It must not contain dots or slashes.")
646+
647+
for d in env.mpl_plot_image_basenames:
648+
if output_base in env.mpl_plot_image_basenames[d]:
649+
if d == docname:
650+
raise PlotError(
651+
f"The filename-prefix {output_base!r} is used multiple times.")
652+
raise PlotError(f"The filename-prefix {output_base!r} is used multiple"
653+
f"times (it is also used in {env.doc2path(d)}).")
654+
655+
env.mpl_plot_image_basenames[docname].add(output_base)
656+
657+
603658
def render_figures(code, code_path, output_dir, output_base, context,
604659
function_name, config, context_reset=False,
605660
close_figs=False,
@@ -722,7 +777,8 @@ def render_figures(code, code_path, output_dir, output_base, context,
722777

723778
def run(arguments, content, options, state_machine, state, lineno):
724779
document = state_machine.document
725-
config = document.settings.env.config
780+
env = document.settings.env
781+
config = env.config
726782
nofigs = 'nofigs' in options
727783

728784
if config.plot_srcset and setup.app.builder.name == 'singlehtml':
@@ -734,6 +790,7 @@ def run(arguments, content, options, state_machine, state, lineno):
734790

735791
options.setdefault('include-source', config.plot_include_source)
736792
options.setdefault('show-source-link', config.plot_html_show_source_link)
793+
options.setdefault('filename-prefix', None)
737794

738795
if 'class' in options:
739796
# classes are parsed into a list of string, and output by simply
@@ -775,14 +832,22 @@ def run(arguments, content, options, state_machine, state, lineno):
775832
function_name = None
776833

777834
code = Path(source_file_name).read_text(encoding='utf-8')
778-
output_base = os.path.basename(source_file_name)
835+
if options['filename-prefix']:
836+
output_base = options['filename-prefix']
837+
check_output_base_name(env, output_base)
838+
else:
839+
output_base = os.path.basename(source_file_name)
779840
else:
780841
source_file_name = rst_file
781842
code = textwrap.dedent("\n".join(map(str, content)))
782-
counter = document.attributes.get('_plot_counter', 0) + 1
783-
document.attributes['_plot_counter'] = counter
784-
base, ext = os.path.splitext(os.path.basename(source_file_name))
785-
output_base = '%s-%d.py' % (base, counter)
843+
if options['filename-prefix']:
844+
output_base = options['filename-prefix']
845+
check_output_base_name(env, output_base)
846+
else:
847+
base, ext = os.path.splitext(os.path.basename(source_file_name))
848+
counter = document.attributes.get('_plot_counter', 0) + 1
849+
document.attributes['_plot_counter'] = counter
850+
output_base = '%s-%d.py' % (base, counter)
786851
function_name = None
787852
caption = options.get('caption', '')
788853

@@ -846,7 +911,7 @@ def run(arguments, content, options, state_machine, state, lineno):
846911

847912
# save script (if necessary)
848913
if options['show-source-link']:
849-
Path(build_dir, output_base + source_ext).write_text(
914+
Path(build_dir, output_base + (source_ext or '.py')).write_text(
850915
doctest.script_from_examples(code)
851916
if source_file_name == rst_file and is_doctest
852917
else code,
@@ -906,7 +971,7 @@ def run(arguments, content, options, state_machine, state, lineno):
906971
# Not-None src_name signals the need for a source download in the
907972
# generated html
908973
if j == 0 and options['show-source-link']:
909-
src_name = output_base + source_ext
974+
src_name = output_base + (source_ext or '.py')
910975
else:
911976
src_name = None
912977
if config.plot_srcset:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
_build/
2+
doctrees/
3+
plot_directive/

lib/matplotlib/tests/data/tinypages/some_plots.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,22 @@ Plot 21 is generated via an include directive:
179179
Plot 22 uses a different specific function in a file with plot commands:
180180

181181
.. plot:: range6.py range10
182+
183+
Plots 23--25 use filename-prefix.
184+
185+
.. plot::
186+
:filename-prefix: custom-basename-6
187+
188+
plt.plot(range(6))
189+
190+
.. plot:: range4.py
191+
:filename-prefix: custom-basename-4
192+
193+
.. plot::
194+
:filename-prefix: custom-basename-4-6
195+
196+
plt.figure()
197+
plt.plot(range(4))
198+
199+
plt.figure()
200+
plt.plot(range(6))

lib/matplotlib/tests/test_sphinxext.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None):
4040

4141

4242
def test_tinypages(tmp_path):
43-
shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True)
43+
shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True,
44+
ignore=shutil.ignore_patterns('_build', 'doctrees',
45+
'plot_directive'))
4446
html_dir = tmp_path / '_build' / 'html'
4547
img_dir = html_dir / '_images'
4648
doctree_dir = tmp_path / 'doctrees'
@@ -92,6 +94,11 @@ def plot_directive_file(num):
9294
assert filecmp.cmp(range_6, plot_file(17))
9395
# plot 22 is from the range6.py file again, but a different function
9496
assert filecmp.cmp(range_10, img_dir / 'range6_range10.png')
97+
# plots 23--25 use a custom basename
98+
assert filecmp.cmp(range_6, img_dir / 'custom-basename-6.png')
99+
assert filecmp.cmp(range_4, img_dir / 'custom-basename-4.png')
100+
assert filecmp.cmp(range_4, img_dir / 'custom-basename-4-6_00.png')
101+
assert filecmp.cmp(range_6, img_dir / 'custom-basename-4-6_01.png')
95102

96103
# Modify the included plot
97104
contents = (tmp_path / 'included_plot_21.rst').read_bytes()
@@ -176,12 +183,37 @@ def test_show_source_link_false(tmp_path, plot_html_show_source_link):
176183
assert len(list(html_dir.glob("**/index-1.py"))) == 0
177184

178185

186+
def test_plot_html_show_source_link_custom_basename(tmp_path):
187+
# Test that source link filename includes .py extension when using custom basename
188+
shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py')
189+
shutil.copytree(tinypages / '_static', tmp_path / '_static')
190+
doctree_dir = tmp_path / 'doctrees'
191+
(tmp_path / 'index.rst').write_text("""
192+
.. plot::
193+
:filename-prefix: custom-name
194+
195+
plt.plot(range(2))
196+
""")
197+
html_dir = tmp_path / '_build' / 'html'
198+
build_sphinx_html(tmp_path, doctree_dir, html_dir)
199+
200+
# Check that source file with .py extension is generated
201+
assert len(list(html_dir.glob("**/custom-name.py"))) == 1
202+
203+
# Check that the HTML contains the correct link with .py extension
204+
html_content = (html_dir / 'index.html').read_text()
205+
assert 'custom-name.py' in html_content
206+
207+
179208
def test_srcset_version(tmp_path):
209+
shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True,
210+
ignore=shutil.ignore_patterns('_build', 'doctrees',
211+
'plot_directive'))
180212
html_dir = tmp_path / '_build' / 'html'
181213
img_dir = html_dir / '_images'
182214
doctree_dir = tmp_path / 'doctrees'
183215

184-
build_sphinx_html(tinypages, doctree_dir, html_dir,
216+
build_sphinx_html(tmp_path, doctree_dir, html_dir,
185217
extra_args=['-D', 'plot_srcset=2x'])
186218

187219
def plot_file(num, suff=''):

0 commit comments

Comments
 (0)