diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4cc3ac0e..f3a6b3cab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 diff --git a/plotly/__init__.py b/plotly/__init__.py index aae0a00b2a..9f18c1ab9d 100644 --- a/plotly/__init__.py +++ b/plotly/__init__.py @@ -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. diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index ec4038b7fa..b7bb73fb3c 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -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): """ diff --git a/plotly/io/_base_renderers.py b/plotly/io/_base_renderers.py index 25cadac4f1..7204f3bc90 100644 --- a/plotly/io/_base_renderers.py +++ b/plotly/io/_base_renderers.py @@ -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") @@ -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) diff --git a/plotly/io/_sg_scraper.py b/plotly/io/_sg_scraper.py index af15b7d1c3..e0da7d20b3 100644 --- a/plotly/io/_sg_scraper.py +++ b/plotly/io/_sg_scraper.py @@ -1,24 +1,69 @@ # 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 warnings 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 = ( + "" + "" +) + 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. - Since the sphinx_gallery renderer generates both html and static png - files, we simply crawl these files and give them the appropriate path. + 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. + + Static image export requires Kaleido and a Chromium-based browser (the + ``plotly_get_chrome`` command installs one); when unavailable, a warning + is emitted once per build and the examples fall back to placeholder + thumbnails, with the interactive figures unaffected. Parameters ---------- @@ -29,10 +74,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 ------- @@ -44,57 +88,164 @@ 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 + + +def _start_export_server(): + """Keep one browser running for the whole build. + + Without it, every static image export launches and tears down a browser + (~1.5 s each); with it, only the first does (~50 ms each after that). + Kaleido stops the server atexit. + """ + try: + import kaleido + + from plotly.io import defaults + + # The options plotly.io.to_image would otherwise pass per export. + kopts = {} + if defaults.plotlyjs: + kopts["plotlyjs"] = defaults.plotlyjs + if defaults.mathjax: + kopts["mathjax"] = defaults.mathjax + if getattr(defaults, "headers", None): + kopts["headers"] = defaults.headers + kaleido.start_sync_server(silence_warnings=True, **kopts) + except Exception: + pass # Kaleido v0 keeps a persistent instance itself; the probe + # reports any other problem + + +@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. + """ + _start_export_server() + try: + _export_image({"data": []}, None, "png") + 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 " + "(the `plotly_get_chrome` command installs one); see " + "https://plotly.com/python/static-image-export/ for details. " + "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 = ( + '
\n' + f"{html}\n" + "
" + ) + return _raw_html_rst(html) + + +def _raw_html_rst(html): + return "\n.. raw:: html\n\n" + textwrap.indent(html, " ") + "\n" + + +def _export_image(fig_dict, file, image_format): + """Export one static image (to memory when `file` is None).""" + with warnings.catch_warnings(): + # The kopts the export server was started with already apply + warnings.filterwarnings( + "ignore", message="The kopts argument", category=UserWarning + ) + if file is None: + plotly.io.to_image(fig_dict, format=image_format, validate=False) + else: + plotly.io.write_image(fig_dict, file, format=image_format, validate=False) + + +def _write_image(fig_dict, file, image_format): + """Write a static image, with a helpful message if that is not possible.""" + try: + _export_image(fig_dict, file, image_format) + 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 diff --git a/pyproject.toml b/pyproject.toml index 629f74e32d..ac8b057894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ dev_optional = [ "scikit-image", "scipy", "shapely", + "sphinx-gallery", "statsmodels", "vaex;python_version<='3.9'", "xarray" diff --git a/tests/test_io/test_renderers.py b/tests/test_io/test_renderers.py index b1019b8249..cd695af79d 100644 --- a/tests/test_io/test_renderers.py +++ b/tests/test_io/test_renderers.py @@ -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"]) @@ -306,8 +322,9 @@ def test_repr_html(renderer): plotlyjs_content = get_plotlyjs() sri_hash = _generate_sri_hash(plotlyjs_content) + # The fallback sizes like the html renderers: layout height, else 525px template = ( - '
\n " '