Skip to content

Commit a933f1b

Browse files
committed
feat: thumbnails for repr-displayed figures and image_scrapers=("plotly",)
1 parent 5ef36f3 commit a933f1b

4 files changed

Lines changed: 106 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +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!
10+
- Fix the sphinx-gallery scraper so that it generates thumbnails for figures shown with `fig.show()` or displayed as the last expression of a code block, 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!
1111

1212

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

plotly/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ def hist_series(data_frame, **kwargs):
182182
return histogram(data_frame, **new_kwargs)
183183

184184

185+
def _get_sg_image_scraper():
186+
"""Called by sphinx-gallery when ``"plotly"`` is listed in ``image_scrapers``.
187+
188+
See https://sphinx-gallery.github.io/stable/advanced.html#integrate-custom-scrapers-with-sphinx-gallery
189+
"""
190+
import plotly.io as pio
191+
from plotly.io._sg_scraper import plotly_sg_scraper
192+
193+
# Not left to the import side effect: sphinx-gallery resolves the scraper
194+
# repeatedly, so this also undoes any later renderer change.
195+
pio.renderers.default = "sphinx_gallery_png"
196+
return plotly_sg_scraper
197+
198+
185199
def _jupyter_labextension_paths():
186200
"""Called by Jupyter Lab Server to detect if it is a valid labextension and
187201
to install the extension.

plotly/io/_sg_scraper.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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+
import ast
45
import os
56

67
import plotly
8+
from plotly.basedatatypes import BaseFigure
79
from plotly.io._base_renderers import sphinx_gallery_figures
810

911
plotly.io.renderers.default = "sphinx_gallery_png"
@@ -23,6 +25,11 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
2325
and once as a static image, which sphinx-gallery uses to generate the
2426
thumbnail of the example.
2527
28+
A figure that is instead displayed by making it the last expression of a
29+
code block (sphinx-gallery's repr capture) gets a static image too, so
30+
that it can also serve as the thumbnail; its HTML is embedded by
31+
sphinx-gallery itself.
32+
2633
Parameters
2734
----------
2835
block : tuple
@@ -50,13 +57,24 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
5057
if image_format not in ("png", "svg"):
5158
raise ValueError(f"format must be one of 'png' or 'svg', got {image_format!r}")
5259
image_path_iterator = block_vars["image_path_iterator"]
60+
figures = [(fig_dict, True) for fig_dict in sphinx_gallery_figures]
61+
repr_figure = _trailing_repr_figure(block, block_vars)
62+
if repr_figure is not None:
63+
fig_dict = repr_figure.to_dict()
64+
# A figure both shown and repr-displayed only needs one image.
65+
if fig_dict not in sphinx_gallery_figures:
66+
figures.append((fig_dict, False))
5367
html_names = []
5468
try:
55-
for fig_dict, image_path in zip(sphinx_gallery_figures, image_path_iterator):
69+
for (fig_dict, shown), image_path in zip(figures, image_path_iterator):
5670
# sphinx-gallery hands out one path per image; the HTML file sits
5771
# next to the image it is the interactive counterpart of.
5872
path_root = os.path.splitext(image_path)[0]
5973
_write_image(fig_dict, f"{path_root}.{image_format}", image_format)
74+
if not shown:
75+
# Repr-displayed: sphinx-gallery embeds the HTML itself, the
76+
# static image only makes the figure available as a thumbnail.
77+
continue
6078
plotly.io.write_html(
6179
fig_dict,
6280
file=f"{path_root}.html",
@@ -74,6 +92,26 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
7492
return figure_rst(html_names, gallery_conf["src_dir"])
7593

7694

95+
def _trailing_repr_figure(block, block_vars):
96+
"""Return the figure displayed via repr capture in this block, if any.
97+
98+
Sphinx-gallery stores a code block's trailing expression value as ``___``
99+
in the example globals so that its repr can be embedded in the page.
100+
"""
101+
figure = block_vars.get("example_globals", {}).get("___")
102+
if not isinstance(figure, BaseFigure):
103+
return None
104+
try:
105+
body = ast.parse(block[1]).body
106+
except SyntaxError:
107+
return None
108+
# ``___`` survives blocks without a trailing expression, so require one to
109+
# know the value was set by this block rather than an earlier one.
110+
if not (body and isinstance(body[-1], ast.Expr)):
111+
return None
112+
return figure
113+
114+
77115
def _write_image(fig_dict, file, image_format):
78116
"""Write a static image, with a helpful message if that is not possible."""
79117
try:

tests/test_io/test_sg_scraper.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,12 @@ def gallery(tmp_path, monkeypatch):
8787
block_vars = {
8888
"image_path_iterator": ImagePathIterator(template),
8989
"src_file": src_file,
90+
"example_globals": {},
9091
}
9192

92-
def scrape():
93+
def scrape(content=""):
9394
"""Scrape one code block, as sphinx-gallery does after executing it."""
94-
return save_figures(("code", "", 1), block_vars, conf)
95+
return save_figures(("code", content, 1), block_vars, conf)
9596

9697
def thumbnail(**file_conf):
9798
"""Generate the gallery thumbnail and return the one file produced."""
@@ -102,6 +103,7 @@ def thumbnail(**file_conf):
102103
yield SimpleNamespace(
103104
conf=conf,
104105
example_dir=example_dir,
106+
globals=block_vars["example_globals"],
105107
paths=block_vars["image_path_iterator"].paths,
106108
scraper=plotly_sg_scraper,
107109
scrape=scrape,
@@ -162,6 +164,40 @@ def test_scraper_ignores_other_examples(gallery):
162164
assert rst.count(".. raw:: html") == 1
163165

164166

167+
def test_scraper_repr_figure(gallery):
168+
"""A figure displayed as a block's last expression still gets a thumbnail.
169+
170+
Sphinx-gallery embeds the HTML of such figures itself (repr capture), so
171+
the scraper must contribute only the static image.
172+
"""
173+
fig = go.Figure(data=[go.Scatter(x=[1, 2, 3], y=[3, 2, 1])])
174+
gallery.globals["___"] = fig # as sphinx-gallery's repr capture leaves it
175+
rst = gallery.scrape("fig.update_layout(title='hi')\nfig")
176+
177+
assert rst == ""
178+
assert len(gallery.paths) == 1
179+
root = os.path.splitext(gallery.paths[0])[0]
180+
assert not os.path.isfile(f"{root}.html")
181+
assert_image_color(Path(f"{root}.png"), COLORS[0], "png")
182+
assert_image_color(gallery.thumbnail(), COLORS[0], "png")
183+
184+
# ``___`` survives into blocks without a trailing expression; the stale
185+
# figure must not be scraped again
186+
assert gallery.scrape("x = 1") == ""
187+
assert len(gallery.paths) == 1
188+
189+
190+
def test_scraper_repr_of_shown_figure_not_duplicated(gallery):
191+
"""A figure that is both shown and the last expression is scraped once."""
192+
fig = go.Figure(data=[go.Scatter(x=[1, 2, 3], y=[3, 2, 1])])
193+
fig.show()
194+
gallery.globals["___"] = fig
195+
rst = gallery.scrape("fig.show()\nfig")
196+
197+
assert rst.count(".. raw:: html") == 1
198+
assert len(gallery.paths) == 1
199+
200+
165201
def test_scraper_bad_format(gallery):
166202
gallery.conf["image_scrapers"] = (functools.partial(gallery.scraper, format="pdf"),)
167203
pio.show(go.Figure())
@@ -184,6 +220,20 @@ def raise_no_browser(*args, **kwargs):
184220
assert gallery.scrape() == "" # the failed figure is not scraped again
185221

186222

223+
def test_image_scrapers_by_name(monkeypatch):
224+
"""`image_scrapers=("plotly",)` must resolve through sphinx-gallery."""
225+
from sphinx_gallery.gen_rst import _get_callables
226+
227+
from plotly.io._sg_scraper import plotly_sg_scraper
228+
229+
monkeypatch.setattr(pio.renderers, "default", "browser")
230+
(scraper,) = _get_callables({"image_scrapers": ("plotly",)}, "image_scrapers")
231+
assert scraper is plotly_sg_scraper
232+
# Resolving the scraper must select the renderer that it knows how to
233+
# scrape, so that no other configuration is needed.
234+
assert pio.renderers.default == "sphinx_gallery_png"
235+
236+
187237
def test_import_sets_default_renderer(monkeypatch):
188238
"""Importing the scraper selects the renderer that it knows how to scrape."""
189239
monkeypatch.setattr(pio.renderers, "default", "browser")

0 commit comments

Comments
 (0)