Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- 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!
- 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!
- 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!


## [6.9.0] - 2026-07-09
Expand Down
14 changes: 14 additions & 0 deletions plotly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ def hist_series(data_frame, **kwargs):
return histogram(data_frame, **new_kwargs)


def _get_sg_image_scraper():
"""Called by sphinx-gallery when ``"plotly"`` is listed in ``image_scrapers``.

See https://sphinx-gallery.github.io/stable/advanced.html#integrate-custom-scrapers-with-sphinx-gallery
"""
import plotly.io as pio
from plotly.io._sg_scraper import plotly_sg_scraper

# Not left to the import side effect: sphinx-gallery resolves the scraper
# repeatedly, so this also undoes any later renderer change.
pio.renderers.default = "sphinx_gallery_png"
return plotly_sg_scraper


def _jupyter_labextension_paths():
"""Called by Jupyter Lab Server to detect if it is a valid labextension and
to install the extension.
Expand Down
9 changes: 8 additions & 1 deletion plotly/basedatatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,14 @@ def _repr_html_(self):
if "text/html" in bundle:
return bundle["text/html"]
else:
return self.to_html(full_html=False, include_plotlyjs="cdn")
# Size like the html renderers do: "100%" height collapses or
# overflows in plain-HTML consumers such as sphinx-gallery.
return self.to_html(
full_html=False,
include_plotlyjs="cdn",
default_width="100%",
default_height=525,
)

def _repr_mimebundle_(self, include=None, exclude=None, validate=True, **kwargs):
"""
Expand Down
40 changes: 17 additions & 23 deletions plotly/io/_base_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
from os.path import isdir

from plotly import optional_imports
from plotly.io import to_json, to_image, write_image, write_html
from plotly.io import to_json, to_image
from plotly.io._utils import plotly_cdn_url
from plotly.offline.offline import _get_jconfig, get_plotlyjs
from plotly.tools import return_figure_from_figure_or_data

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


# Figures shown with the "sphinx_gallery_png" renderer are queued here until
# plotly.io._sg_scraper.plotly_sg_scraper collects them, so the renderer itself
# does not need to know where sphinx-gallery wants the files to be written.
sphinx_gallery_figures = []


class SphinxGalleryOrcaRenderer(ExternalRenderer):
"""Renderer used together with the sphinx-gallery image scraper.

Instead of displaying the figure, this renderer queues it in
``plotly.io._base_renderers.sphinx_gallery_figures``;
:func:`plotly.io._sg_scraper.plotly_sg_scraper` then writes each queued
figure to the gallery's image directory, both as an interactive HTML file
and as a static image used for the gallery thumbnail.
"""

def render(self, fig_dict):
stack = inspect.stack()
# Name of script from which plot function was called is retrieved
try:
filename = stack[3].filename # let's hope this is robust...
except Exception: # python 2
filename = stack[3][1]
filename_root, _ = os.path.splitext(filename)
filename_html = filename_root + ".html"
filename_png = filename_root + ".png"
figure = return_figure_from_figure_or_data(fig_dict, True)
_ = write_html(fig_dict, file=filename_html, include_plotlyjs="cdn")
try:
write_image(figure, filename_png)
except (ValueError, ImportError):
raise ImportError(
"orca and psutil are required to use the `sphinx-gallery-orca` renderer. "
"See https://plotly.com/python/static-image-export/ for instructions on "
"how to install orca. Alternatively, you can use the `sphinx-gallery` "
"renderer (note that png thumbnails can only be generated with "
"the `sphinx-gallery-orca` renderer)."
)
sphinx_gallery_figures.append(fig_dict)
232 changes: 170 additions & 62 deletions plotly/io/_sg_scraper.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,67 @@
# This module defines an image scraper for sphinx-gallery
# https://sphinx-gallery.github.io/
# which can be used by projects using plotly in their documentation.
from glob import glob
import ast
import functools
import logging
import os
import shutil
import textwrap

import plotly
from plotly.basedatatypes import BaseFigure
from plotly.io._base_renderers import sphinx_gallery_figures

plotly.io.renderers.default = "sphinx_gallery_png"

# Fix-up markup for the figures of a code block, both the ones this scraper
# embeds and the repr-captured ones sphinx-gallery embeds itself (both sit in
# an ``output_subarea`` div). The card keeps the light background baked into
# the figures presentable on dark pages, detected via ``data-theme`` (themes
# with a toggle) or the OS preference (theme-less pages); on light pages it is
# invisible. The resize fixes up figures that drew while the page was still
# laying out and so can be sized to a container whose width then changed.
_CARD = "background:#fff;border-radius:0.25rem;padding:0.5rem"
_SELECTOR = "div.output_subarea:has(.plotly-graph-div)"
_FIXUP_HTML = (
"<style>"
f'html[data-theme="dark"] {_SELECTOR}{{{_CARD}}}'
"@media (prefers-color-scheme: dark){"
f'html:not([data-theme="light"]) {_SELECTOR}{{{_CARD}}}'
"}"
"</style>"
"<script>"
"if (!window.plotlySphinxGalleryResize) {"
"window.plotlySphinxGalleryResize = true;"
'window.addEventListener("load", function () {'
'document.querySelectorAll(".plotly-graph-div").forEach('
"function (gd) { Plotly.Plots.resize(gd); });"
"});"
"}"
"</script>"
)


def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
"""Scrape Plotly figures for galleries of examples using
sphinx-gallery.

Examples should use ``plotly.io.show()`` to display the figure with
the custom sphinx_gallery renderer.
Examples should use ``plotly.io.show()`` (or the equivalent
``fig.show()``) to display the figure with the custom
``sphinx_gallery_png`` renderer, which is made the default renderer as a
side effect of importing this module.

Every figure shown that way is embedded in the page as interactive HTML,
and written to the gallery image directory as a static image, which
sphinx-gallery uses to generate the thumbnail of the example.

A figure that is instead displayed by making it the last expression of a
code block (sphinx-gallery's repr capture) gets a static image too, so
that it can also serve as the thumbnail; its HTML is embedded by
sphinx-gallery itself.

Since the sphinx_gallery renderer generates both html and static png
files, we simply crawl these files and give them the appropriate path.
Static image export requires Kaleido and a Chromium-based browser; when
unavailable, a warning is emitted once per build and the examples fall
back to placeholder thumbnails, with the interactive figures unaffected.

Parameters
----------
Expand All @@ -29,10 +72,9 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
gallery_conf : dict
Contains the configuration of Sphinx-Gallery
**kwargs : dict
Additional keyword arguments to pass to
:meth:`~matplotlib.figure.Figure.savefig`, e.g. ``format='svg'``.
The ``format`` kwarg in particular is used to set the file extension
of the output file (currently only 'png' and 'svg' are supported).
Additional keyword arguments.
The ``format`` kwarg is used to set the file extension
of the static images (currently only 'png' and 'svg' are supported).

Returns
-------
Expand All @@ -44,57 +86,123 @@ def plotly_sg_scraper(block, block_vars, gallery_conf, **kwargs):
-----
Add this function to the image scrapers
"""
examples_dir = os.path.dirname(block_vars["src_file"])
pngs = sorted(glob(os.path.join(examples_dir, "*.png")))
htmls = sorted(glob(os.path.join(examples_dir, "*.html")))
image_format = kwargs.get("format", "png")
if image_format not in ("png", "svg"):
raise ValueError(f"format must be one of 'png' or 'svg', got {image_format!r}")
image_path_iterator = block_vars["image_path_iterator"]
image_names = list()
seen = set()
for html, png in zip(htmls, pngs):
if png not in seen:
seen |= set(png)
this_image_path_png = next(image_path_iterator)
this_image_path_html = os.path.splitext(this_image_path_png)[0] + ".html"
image_names.append(this_image_path_html)
shutil.move(png, this_image_path_png)
shutil.move(html, this_image_path_html)
# Use the `figure_rst` helper function to generate rST for image files
return figure_rst(image_names, gallery_conf["src_dir"])


def figure_rst(figure_list, sources_dir):
"""Generate RST for a list of PNG filenames.

Depending on whether we have one or more figures, we use a
single rst call to 'image' or a horizontal list.

Parameters
----------
figure_list : list
List of strings of the figures' absolute paths.
sources_dir : str
absolute path of Sphinx documentation sources

Returns
-------
images_rst : str
rst code to embed the images in the document
figures = [(fig_dict, True) for fig_dict in sphinx_gallery_figures]
repr_figure = _trailing_repr_figure(block, block_vars)
if repr_figure is not None:
fig_dict = repr_figure.to_dict()
# A figure both shown and repr-displayed only needs one image.
if fig_dict not in sphinx_gallery_figures:
figures.append((fig_dict, False))
try:
export_available = _static_export_available()
rst = ""
for fig_dict, shown in figures:
if export_available:
# sphinx-gallery requires an image at every path taken from
# the iterator, so don't consume paths when export failed.
image_path = next(image_path_iterator)
path_root = os.path.splitext(image_path)[0]
_write_image(fig_dict, f"{path_root}.{image_format}", image_format)
if shown:
# Repr-displayed figures are embedded by sphinx-gallery
# itself; their static image only serves as the thumbnail.
rst += _inline_html(fig_dict)
if figures:
rst += _raw_html_rst(_FIXUP_HTML)
return rst
finally:
# Don't let figures leak into the next block if writing one failed.
del sphinx_gallery_figures[:]


def _trailing_repr_figure(block, block_vars):
"""Return the figure displayed via repr capture in this block, if any.

Sphinx-gallery stores a code block's trailing expression value as ``___``
in the example globals so that its repr can be embedded in the page.
"""

figure_paths = [
os.path.relpath(figure_path, sources_dir).replace(os.sep, "/").lstrip("/")
for figure_path in figure_list
]
images_rst = ""
if not figure_paths:
return images_rst
figure_name = figure_paths[0]
figure_path = os.path.join("images", os.path.basename(figure_name))
images_rst = SINGLE_HTML % figure_path
return images_rst


SINGLE_HTML = """
.. raw:: html
:file: %s
"""
figure = block_vars.get("example_globals", {}).get("___")
if not isinstance(figure, BaseFigure):
return None
try:
body = ast.parse(block[1]).body
except SyntaxError:
return None
# ``___`` survives blocks without a trailing expression, so require one to
# know the value was set by this block rather than an earlier one.
if not (body and isinstance(body[-1], ast.Expr)):
return None
return figure


@functools.lru_cache(maxsize=None) # functools.cache needs Python 3.9
def _static_export_available():
"""Whether static image export works, probed on the first scrape.

Cached so that a build without Kaleido or a browser warns once (per
worker, for parallel sphinx-gallery builds) instead of once per figure.
"""
try:
plotly.io.to_image({"data": []}, format="png", validate=False)
except Exception as exc:
try:
from sphinx.util.logging import getLogger

warn = functools.partial(
getLogger(__name__).warning, type="plotly", subtype="sg_scraper"
)
except Exception:
warn = logging.getLogger(__name__).warning
warn(
"plotly static image export is unavailable, so example "
"thumbnails will fall back to a placeholder image. Static "
"export requires Kaleido and a Chromium-based browser; see "
"https://plotly.com/python/static-image-export/ for "
"installation instructions. The failure was: %s: %s",
type(exc).__name__,
exc,
)
return False
return True


def _inline_html(fig_dict):
"""Embed a figure into the rst directly, rather than via a file.

The figure is wrapped in the same div that sphinx-gallery wraps captured
HTML reprs in, so that the fix-up markup applies to both kinds of embed.
"""
html = plotly.io.to_html(
fig_dict,
include_plotlyjs="cdn",
full_html=False,
default_width="100%",
default_height=525,
validate=False,
)
html = (
'<div class="output_subarea output_html rendered_html output_result">\n'
f"{html}\n"
"</div>"
)
return _raw_html_rst(html)


def _raw_html_rst(html):
return "\n.. raw:: html\n\n" + textwrap.indent(html, " ") + "\n"


def _write_image(fig_dict, file, image_format):
"""Write a static image, with a helpful message if that is not possible."""
try:
plotly.io.write_image(fig_dict, file, format=image_format, validate=False)
except Exception as exc:
raise RuntimeError(
f"Writing {file} failed with:\n{type(exc).__name__}: {exc}\n"
"See https://plotly.com/python/static-image-export/ for "
"requirements and installation instructions."
) from exc
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dev_optional = [
"scikit-image",
"scipy",
"shapely",
"sphinx-gallery",
"statsmodels",
"vaex;python_version<='3.9'",
"xarray"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_io/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,22 @@ def open_url(url, new=0, autoraise=True):
assert_offline(html)


# Sphinx-Gallery
# --------------
@pytest.mark.parametrize("show", [lambda fig: pio.show(fig), lambda fig: fig.show()])
def test_sphinx_gallery_png_renderer_show(fig1, show):
"""Figures must be queued for the scraper however `show` was called."""
from plotly.io._base_renderers import sphinx_gallery_figures

pio.renderers.default = "sphinx_gallery_png"
del sphinx_gallery_figures[:]
try:
show(fig1)
assert sphinx_gallery_figures == [fig1.to_dict()]
finally:
del sphinx_gallery_figures[:]


# Validation
# ----------
@pytest.mark.parametrize("renderer", ["bogus", "json+bogus", "bogus+chrome"])
Expand Down
Loading
Loading