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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy