Skip to content

Commit 5ef36f3

Browse files
committed
fix: make the sphinx-gallery scraper capture every shown figure
1 parent de4f21b commit 5ef36f3

7 files changed

Lines changed: 1054 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
77
### Fixed
88
- Fix `hex_to_rgb` parsing of 3-digit shorthand hexadecimal colors such as `#FFF` [[#5662](https://github.com/plotly/plotly.py/pull/5662)], with thanks to @genrichez for the contribution!
99
- Add `<!doctype html>` to the `to_html()` template to comply with modern web standards [[#5693](https://github.com/plotly/plotly.py/pull/5693)], with thanks to @mishrakushal for the contribution!
10+
- Fix the sphinx-gallery scraper so that it generates thumbnails for figures shown with `fig.show()` and no longer scrapes files belonging to other examples during parallel builds [[#4722](https://github.com/plotly/plotly.py/issues/4722), [#4959](https://github.com/plotly/plotly.py/issues/4959)], with thanks to @larsoner for the contribution!
1011

1112

1213
## [6.9.0] - 2026-07-09

plotly/io/_base_renderers.py

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from os.path import isdir
77

88
from plotly import optional_imports
9-
from plotly.io import to_json, to_image, write_image, write_html
9+
from plotly.io import to_json, to_image
1010
from plotly.io._utils import plotly_cdn_url
1111
from plotly.offline.offline import _get_jconfig, get_plotlyjs
12-
from plotly.tools import return_figure_from_figure_or_data
1312

1413
ipython_display = optional_imports.get_module("IPython.display")
1514
IPython = optional_imports.get_module("IPython")
@@ -821,26 +820,21 @@ def to_mimebundle(self, fig_dict):
821820
return {"text/html": html}
822821

823822

823+
# Figures shown with the "sphinx_gallery_png" renderer are queued here until
824+
# plotly.io._sg_scraper.plotly_sg_scraper collects them, so the renderer itself
825+
# does not need to know where sphinx-gallery wants the files to be written.
826+
sphinx_gallery_figures = []
827+
828+
824829
class SphinxGalleryOrcaRenderer(ExternalRenderer):
830+
"""Renderer used together with the sphinx-gallery image scraper.
831+
832+
Instead of displaying the figure, this renderer queues it in
833+
``plotly.io._base_renderers.sphinx_gallery_figures``;
834+
:func:`plotly.io._sg_scraper.plotly_sg_scraper` then writes each queued
835+
figure to the gallery's image directory, both as an interactive HTML file
836+
and as a static image used for the gallery thumbnail.
837+
"""
838+
825839
def render(self, fig_dict):
826-
stack = inspect.stack()
827-
# Name of script from which plot function was called is retrieved
828-
try:
829-
filename = stack[3].filename # let's hope this is robust...
830-
except Exception: # python 2
831-
filename = stack[3][1]
832-
filename_root, _ = os.path.splitext(filename)
833-
filename_html = filename_root + ".html"
834-
filename_png = filename_root + ".png"
835-
figure = return_figure_from_figure_or_data(fig_dict, True)
836-
_ = write_html(fig_dict, file=filename_html, include_plotlyjs="cdn")
837-
try:
838-
write_image(figure, filename_png)
839-
except (ValueError, ImportError):
840-
raise ImportError(
841-
"orca and psutil are required to use the `sphinx-gallery-orca` renderer. "
842-
"See https://plotly.com/python/static-image-export/ for instructions on "
843-
"how to install orca. Alternatively, you can use the `sphinx-gallery` "
844-
"renderer (note that png thumbnails can only be generated with "
845-
"the `sphinx-gallery-orca` renderer)."
846-
)
840+
sphinx_gallery_figures.append(fig_dict)

plotly/io/_sg_scraper.py

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# This module defines an image scraper for sphinx-gallery
22
# https://sphinx-gallery.github.io/
33
# which can be used by projects using plotly in their documentation.
4-
from glob import glob
54
import os
6-
import shutil
75

86
import plotly
7+
from plotly.io._base_renderers import sphinx_gallery_figures
98

109
plotly.io.renderers.default = "sphinx_gallery_png"
1110

@@ -14,11 +13,15 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
1413
"""Scrape Plotly figures for galleries of examples using
1514
sphinx-gallery.
1615
17-
Examples should use ``plotly.io.show()`` to display the figure with
18-
the custom sphinx_gallery renderer.
16+
Examples should use ``plotly.io.show()`` (or the equivalent
17+
``fig.show()``) to display the figure with the custom
18+
``sphinx_gallery_png`` renderer, which is made the default renderer as a
19+
side effect of importing this module.
1920
20-
Since the sphinx_gallery renderer generates both html and static png
21-
files, we simply crawl these files and give them the appropriate path.
21+
Every figure shown that way is written to the gallery image directory
22+
twice: once as an interactive HTML file, which is embedded in the page,
23+
and once as a static image, which sphinx-gallery uses to generate the
24+
thumbnail of the example.
2225
2326
Parameters
2427
----------
@@ -29,10 +32,9 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
2932
gallery_conf : dict
3033
Contains the configuration of Sphinx-Gallery
3134
**kwargs : dict
32-
Additional keyword arguments to pass to
33-
:meth:`~matplotlib.figure.Figure.savefig`, e.g. ``format='svg'``.
34-
The ``format`` kwarg in particular is used to set the file extension
35-
of the output file (currently only 'png' and 'svg' are supported).
35+
Additional keyword arguments.
36+
The ``format`` kwarg is used to set the file extension
37+
of the static images (currently only 'png' and 'svg' are supported).
3638
3739
Returns
3840
-------
@@ -44,54 +46,73 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
4446
-----
4547
Add this function to the image scrapers
4648
"""
47-
examples_dir = os.path.dirname(block_vars["src_file"])
48-
pngs = sorted(glob(os.path.join(examples_dir, "*.png")))
49-
htmls = sorted(glob(os.path.join(examples_dir, "*.html")))
49+
image_format = kwargs.get("format", "png")
50+
if image_format not in ("png", "svg"):
51+
raise ValueError(f"format must be one of 'png' or 'svg', got {image_format!r}")
5052
image_path_iterator = block_vars["image_path_iterator"]
51-
image_names = list()
52-
seen = set()
53-
for html, png in zip(htmls, pngs):
54-
if png not in seen:
55-
seen |= set(png)
56-
this_image_path_png = next(image_path_iterator)
57-
this_image_path_html = os.path.splitext(this_image_path_png)[0] + ".html"
58-
image_names.append(this_image_path_html)
59-
shutil.move(png, this_image_path_png)
60-
shutil.move(html, this_image_path_html)
53+
html_names = []
54+
try:
55+
for fig_dict, image_path in zip(sphinx_gallery_figures, image_path_iterator):
56+
# sphinx-gallery hands out one path per image; the HTML file sits
57+
# next to the image it is the interactive counterpart of.
58+
path_root = os.path.splitext(image_path)[0]
59+
_write_image(fig_dict, f"{path_root}.{image_format}", image_format)
60+
plotly.io.write_html(
61+
fig_dict,
62+
file=f"{path_root}.html",
63+
include_plotlyjs="cdn",
64+
full_html=False,
65+
default_width="100%",
66+
default_height=525,
67+
validate=False,
68+
)
69+
html_names.append(f"{path_root}.html")
70+
finally:
71+
# Don't let figures leak into the next block if writing one failed.
72+
del sphinx_gallery_figures[:]
6173
# Use the `figure_rst` helper function to generate rST for image files
62-
return figure_rst(image_names, gallery_conf["src_dir"])
74+
return figure_rst(html_names, gallery_conf["src_dir"])
75+
76+
77+
def _write_image(fig_dict, file, image_format):
78+
"""Write a static image, with a helpful message if that is not possible."""
79+
try:
80+
plotly.io.write_image(fig_dict, file, format=image_format, validate=False)
81+
except Exception as exc:
82+
raise RuntimeError(
83+
f"Kaleido and a compatible browser are required to use the "
84+
f"`sphinx_gallery_png` renderer, but writing {file} failed with: "
85+
f"{type(exc).__name__}: {exc}\n"
86+
"See https://plotly.com/python/static-image-export/ for "
87+
"installation instructions. Alternatively, you can use the "
88+
"`sphinx_gallery` renderer without this scraper (note that "
89+
"thumbnails can only be generated with the `sphinx_gallery_png` "
90+
"renderer)."
91+
) from exc
6392

6493

6594
def figure_rst(figure_list, sources_dir):
66-
"""Generate RST for a list of PNG filenames.
67-
68-
Depending on whether we have one or more figures, we use a
69-
single rst call to 'image' or a horizontal list.
95+
"""Generate RST for a list of HTML filenames.
7096
7197
Parameters
7298
----------
7399
figure_list : list
74100
List of strings of the figures' absolute paths.
75101
sources_dir : str
76-
absolute path of Sphinx documentation sources
102+
absolute path of Sphinx documentation sources (unused, kept for
103+
compatibility with the equivalent sphinx-gallery helper)
77104
78105
Returns
79106
-------
80107
images_rst : str
81108
rst code to embed the images in the document
82109
"""
83-
84-
figure_paths = [
85-
os.path.relpath(figure_path, sources_dir).replace(os.sep, "/").lstrip("/")
110+
# The HTML files live in the "images" directory next to the document that
111+
# includes them, so the paths are relative to that document.
112+
return "".join(
113+
SINGLE_HTML % ("images/" + os.path.basename(figure_path))
86114
for figure_path in figure_list
87-
]
88-
images_rst = ""
89-
if not figure_paths:
90-
return images_rst
91-
figure_name = figure_paths[0]
92-
figure_path = os.path.join("images", os.path.basename(figure_name))
93-
images_rst = SINGLE_HTML % figure_path
94-
return images_rst
115+
)
95116

96117

97118
SINGLE_HTML = """

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ dev_optional = [
7676
"scikit-image",
7777
"scipy",
7878
"shapely",
79+
"sphinx-gallery",
7980
"statsmodels",
8081
"vaex;python_version<='3.9'",
8182
"xarray"

tests/test_io/test_renderers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,22 @@ def open_url(url, new=0, autoraise=True):
270270
assert_offline(html)
271271

272272

273+
# Sphinx-Gallery
274+
# --------------
275+
@pytest.mark.parametrize("show", [lambda fig: pio.show(fig), lambda fig: fig.show()])
276+
def test_sphinx_gallery_png_renderer_show(fig1, show):
277+
"""Figures must be queued for the scraper however `show` was called."""
278+
from plotly.io._base_renderers import sphinx_gallery_figures
279+
280+
pio.renderers.default = "sphinx_gallery_png"
281+
del sphinx_gallery_figures[:]
282+
try:
283+
show(fig1)
284+
assert sphinx_gallery_figures == [fig1.to_dict()]
285+
finally:
286+
del sphinx_gallery_figures[:]
287+
288+
273289
# Validation
274290
# ----------
275291
@pytest.mark.parametrize("renderer", ["bogus", "json+bogus", "bogus+chrome"])

0 commit comments

Comments
 (0)