Skip to content

Commit 020ee67

Browse files
larsonerclaude
andcommitted
feat: degrade gracefully when static image export is unavailable
Probe Kaleido/browser availability once per build; when unavailable, emit a single sphinx warning (suppressible via suppress_warnings = ["plotly.sg_scraper"]), embed shown figures inline in the rst instead of via files (sphinx-gallery requires an image file for every image path consumed), and let examples fall back to placeholder thumbnails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a933f1b commit 020ee67

3 files changed

Lines changed: 102 additions & 18 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()` 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!
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, warns once (instead of failing the build) when static image export is unavailable, 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/io/_sg_scraper.py‎

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
# https://sphinx-gallery.github.io/
33
# which can be used by projects using plotly in their documentation.
44
import ast
5+
import functools
6+
import logging
57
import os
8+
import textwrap
69

710
import plotly
811
from plotly.basedatatypes import BaseFigure
@@ -30,6 +33,10 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
3033
that it can also serve as the thumbnail; its HTML is embedded by
3134
sphinx-gallery itself.
3235
36+
Static image export requires Kaleido and a Chromium-based browser; when
37+
unavailable, a warning is emitted once per build and the examples fall
38+
back to placeholder thumbnails, with the interactive figures unaffected.
39+
3340
Parameters
3441
----------
3542
block : tuple
@@ -64,8 +71,15 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
6471
# A figure both shown and repr-displayed only needs one image.
6572
if fig_dict not in sphinx_gallery_figures:
6673
figures.append((fig_dict, False))
67-
html_names = []
6874
try:
75+
if not _static_export_available():
76+
# No images means no thumbnails, and sphinx-gallery requires an
77+
# image for every path taken from the iterator, so embed shown
78+
# figures inline instead of via files.
79+
return "".join(
80+
_inline_html(fig_dict) for fig_dict, shown in figures if shown
81+
)
82+
html_names = []
6983
for (fig_dict, shown), image_path in zip(figures, image_path_iterator):
7084
# sphinx-gallery hands out one path per image; the HTML file sits
7185
# next to the image it is the interactive counterpart of.
@@ -85,11 +99,11 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
8599
validate=False,
86100
)
87101
html_names.append(f"{path_root}.html")
102+
# Use the `figure_rst` helper function to generate rST for image files
103+
return figure_rst(html_names, gallery_conf["src_dir"])
88104
finally:
89105
# Don't let figures leak into the next block if writing one failed.
90106
del sphinx_gallery_figures[:]
91-
# Use the `figure_rst` helper function to generate rST for image files
92-
return figure_rst(html_names, gallery_conf["src_dir"])
93107

94108

95109
def _trailing_repr_figure(block, block_vars):
@@ -112,20 +126,63 @@ def _trailing_repr_figure(block, block_vars):
112126
return figure
113127

114128

129+
# Whether static image export works at all, probed on the first scrape so
130+
# that a build without Kaleido or a browser warns once (per worker, for
131+
# parallel sphinx-gallery builds) instead of once per figure.
132+
_export_available = None
133+
134+
135+
def _static_export_available():
136+
global _export_available
137+
if _export_available is None:
138+
try:
139+
plotly.io.to_image({"data": []}, format="png", validate=False)
140+
except Exception as exc:
141+
_export_available = False
142+
try:
143+
from sphinx.util.logging import getLogger
144+
145+
warn = functools.partial(
146+
getLogger(__name__).warning, type="plotly", subtype="sg_scraper"
147+
)
148+
except Exception:
149+
warn = logging.getLogger(__name__).warning
150+
warn(
151+
"plotly static image export is unavailable, so example "
152+
"thumbnails will fall back to a placeholder image. Static "
153+
"export requires Kaleido and a Chromium-based browser; see "
154+
"https://plotly.com/python/static-image-export/ for "
155+
"installation instructions. The failure was: %s: %s",
156+
type(exc).__name__,
157+
exc,
158+
)
159+
else:
160+
_export_available = True
161+
return _export_available
162+
163+
164+
def _inline_html(fig_dict):
165+
"""Embed a figure into the rst directly, rather than via a file."""
166+
html = plotly.io.to_html(
167+
fig_dict,
168+
include_plotlyjs="cdn",
169+
full_html=False,
170+
default_width="100%",
171+
default_height=525,
172+
validate=False,
173+
)
174+
return "\n.. raw:: html\n\n" + textwrap.indent(html, " ") + "\n"
175+
176+
115177
def _write_image(fig_dict, file, image_format):
116178
"""Write a static image, with a helpful message if that is not possible."""
117179
try:
118180
plotly.io.write_image(fig_dict, file, format=image_format, validate=False)
119181
except Exception as exc:
120182
raise RuntimeError(
121-
f"Kaleido and a compatible browser are required to use the "
122-
f"`sphinx_gallery_png` renderer, but writing {file} failed with: "
123-
f"{type(exc).__name__}: {exc}\n"
183+
f"Writing {file} failed with:\n{type(exc).__name__}: {exc}\n"
124184
"See https://plotly.com/python/static-image-export/ for "
125-
"installation instructions. Alternatively, you can use the "
126-
"`sphinx_gallery` renderer without this scraper (note that "
127-
"thumbnails can only be generated with the `sphinx_gallery_png` "
128-
"renderer)."
185+
"requirements and installation instructions."
129186
) from exc
130187

131188

‎tests/test_io/test_sg_scraper.py‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import functools
1010
import importlib
11+
import logging
1112
import os
1213
from pathlib import Path
1314
from types import SimpleNamespace
@@ -66,13 +67,16 @@ def gallery(tmp_path, monkeypatch):
6667
from sphinx_gallery.gen_rst import save_thumbnail
6768
from sphinx_gallery.scrapers import ImagePathIterator, save_figures
6869

70+
import plotly.io._sg_scraper as sg_scraper
6971
from plotly.io._base_renderers import sphinx_gallery_figures
7072
from plotly.io._sg_scraper import plotly_sg_scraper
7173

7274
# Importing the scraper sets the renderer too, but only the first time it
7375
# is imported, which may have happened in another test already.
7476
monkeypatch.setattr(pio.renderers, "default", "sphinx_gallery_png")
7577
monkeypatch.setattr(pio, "write_image", dummy_image_writer())
78+
# Images come from the stand-in above, so skip the Kaleido probe.
79+
monkeypatch.setattr(sg_scraper, "_export_available", True)
7680

7781
example_dir = tmp_path / "auto_examples"
7882
thumb_dir = example_dir / "images" / "thumb"
@@ -205,19 +209,42 @@ def test_scraper_bad_format(gallery):
205209
gallery.scrape()
206210

207211

208-
def test_scraper_image_error(gallery, monkeypatch):
209-
"""A failure to write the image says how to fix it, and doesn't stick."""
212+
def test_scraper_no_static_export(gallery, monkeypatch, caplog):
213+
"""Without static export, warn once and keep the interactive figures.
214+
215+
Sphinx-gallery requires an image file for every image path taken, so no
216+
image paths may be consumed either; the examples then get sphinx-gallery's
217+
placeholder thumbnail.
218+
"""
219+
import plotly.io._sg_scraper as sg_scraper
210220

211221
def raise_no_browser(*args, **kwargs):
212222
raise ValueError("no browser")
213223

214-
monkeypatch.setattr(pio, "write_image", raise_no_browser)
215-
pio.show(go.Figure())
224+
monkeypatch.setattr(pio, "to_image", raise_no_browser)
225+
monkeypatch.setattr(sg_scraper, "_export_available", None)
226+
fig = go.Figure(data=[go.Scatter(x=[1, 2, 3], y=[3, 2, 1])])
227+
pio.show(fig)
228+
gallery.globals["___"] = fig
216229

217-
with pytest.raises(RuntimeError, match="Kaleido"):
218-
gallery.scrape()
230+
with caplog.at_level(logging.WARNING):
231+
rst = gallery.scrape("fig.show()")
219232

220-
assert gallery.scrape() == "" # the failed figure is not scraped again
233+
# The shown figure is embedded inline instead of via files
234+
assert rst.count(".. raw:: html") == 1
235+
assert "Scatter" not in rst # inlined as HTML, not as a repr
236+
assert "plotly-graph-div" in rst
237+
assert gallery.paths == []
238+
warnings = [r for r in caplog.records if "Kaleido" in r.getMessage()]
239+
assert len(warnings) == 1
240+
assert "no browser" in warnings[0].getMessage()
241+
242+
# Only one warning per build, however many blocks follow; repr-displayed
243+
# figures are embedded by sphinx-gallery itself so they scrape to nothing
244+
with caplog.at_level(logging.WARNING):
245+
assert gallery.scrape("fig") == ""
246+
assert gallery.paths == []
247+
assert len([r for r in caplog.records if "Kaleido" in r.getMessage()]) == 1
221248

222249

223250
def test_image_scrapers_by_name(monkeypatch):

0 commit comments

Comments
 (0)