Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add support for capturing svg, png, and jpeg and _repr_mimebundle_ #1138

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
command: |
pip install --progress-bar off --user --upgrade --only-binary ":all:" pip setuptools
pip install --progress-bar off --user --upgrade --only-binary ":all:" numpy matplotlib "pyqt5!=5.15.2,!=5.15.3,!=5.15.8" vtk
pip install --progress-bar off --user --upgrade seaborn statsmodels pillow joblib sphinx pytest traits pyvista memory_profiler "ipython!=8.7.0" plotly graphviz "docutils>=0.18" imageio
pip install --progress-bar off --user --upgrade seaborn statsmodels pillow joblib sphinx pytest traits pyvista memory_profiler "ipython!=8.7.0" plotly graphviz "docutils>=0.18" imageio sphinxcontrib-svg2pdfconverter[CairoSVG]
pip install --progress-bar off "jupyterlite-sphinx>=0.8.0,<0.9.0" "jupyterlite-pyodide-kernel<0.1.0" libarchive-c
pip install --progress-bar off --user --upgrade --pre pydata-sphinx-theme
- save_cache:
Expand Down
5 changes: 4 additions & 1 deletion azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ stages:
set -eo pipefail
python --version
python -m pip install --user --upgrade pip setuptools wheel
pip install --user --upgrade --progress-bar off "ipython!=8.7.0" numpy seaborn statsmodels matplotlib sphinx pillow pytest pytest-cov joblib "plotly>=4.0" imageio
pip install --user --upgrade --progress-bar off "ipython!=8.7.0" numpy seaborn statsmodels matplotlib sphinx pillow pytest pytest-cov joblib "plotly>=4.0" imageio sphinxcontrib-svg2pdfconverter[CairoSVG]
pip install --user --upgrade --progress-bar off --pre pydata-sphinx-theme
echo "Qt, plotly, VTK"
pip install --user --upgrade --progress-bar off pyvista "pyqt5!=5.15.8" plotly vtk
echo "JupyterLite-related dependencies"
pip install --user --upgrade --progress-bar off "jupyterlite-sphinx>=0.8.0,<0.9.0" "jupyterlite-pyodide-kernel<0.1.0" libarchive-c
echo "Install pre-build cairocffi with DLLs"
pip install --user --upgrade --progress-bar off pipwin
pipwin install cairocffi
displayName: Setup pip environment
- script: pip list
displayName: pip list
Expand Down
2 changes: 1 addition & 1 deletion continuous_integration/azure/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ if [ "$DISTRIB" == "conda" ]; then
echo "##vso[task.prependpath]$CONDA/bin"
export PATH=${CONDA}/bin:${PATH}
CONDA_TO_INSTALL="python=$PYTHON_VERSION pip numpy setuptools matplotlib pillow pytest pytest-cov coverage seaborn statsmodels 'plotly>=4.0' joblib flake8 wheel libiconv graphviz memory_profiler \"ipython!=8.7.0\" pypandoc"
PIP_DEPENDENCIES="check-manifest jupyterlite-sphinx>=0.8.0,<0.9.0 jupyterlite-pyodide-kernel<0.1.0 libarchive-c"
PIP_DEPENDENCIES="check-manifest jupyterlite-sphinx>=0.8.0,<0.9.0 jupyterlite-pyodide-kernel<0.1.0 libarchive-c sphinxcontrib-svg2pdfconverter[CairoSVG]"
if [ "$SPHINX_VERSION" == "" ]; then
PIP_DEPENDENCIES="${PIP_DEPENDENCIES} sphinx jinja2<=3.0.3"
elif [ "$SPHINX_VERSION" == "dev" ]; then
Expand Down
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ absl-py
graphviz
packaging
jupyterlite-sphinx
sphinxcontrib-svg2pdfconverter[CairoSVG]
16 changes: 10 additions & 6 deletions doc/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,16 @@ Write a custom image scraper
By default, Sphinx-Gallery supports image scraping for Matplotlib
(:func:`~sphinx_gallery.scrapers.matplotlib_scraper`). If you wish to capture
output from other python packages, first determine if the object you wish to
capture has a ``_repr_html_`` method. If so, you can use the configuration
``capture_repr`` (:ref:`capture_repr`) to control the display of the object,
without the need to write a custom scraper. This configuration allows capture
of the raw html output, in a process similar to other html-based displays such
as `jupyter <https://jupyter.org/>`_. If the first option does not work,
this section describes how to write a custom scraper.
capture has any of the other supported capture methods: ``_repr_html_``,
``_repr_png_``, ``_repr_jpeg_``, and ``_repr_svg_``. If so, you can use the
configuration ``capture_repr`` (:ref:`capture_repr`) to control the display of
the object, without the need to write a custom scraper. This configuration allows
capture of the raw html/png/jpeg/svg output, in a process similar to other enriched
displays such as `jupyter <https://jupyter.org/>`_. If the object supports
``_repr_mimebundle_``, adding, e.g., ``_repr_svg_`` to ``capture_repr`` will also
look for SVG in the returned MIME-bundle.

If the first option does not work, this section describes how to write a custom scraper.

Image scrapers are functions (or callable class instances) that do the following
things:
Expand Down
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ def setup(app):
'sklearn': ('https://scikit-learn.org/stable', None),
'sphinx': ('https://www.sphinx-doc.org/en/master', None),
'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None),
'ipython': ('https://ipython.readthedocs.io/en/stable/', None),
}

examples_dirs = ['../examples', '../tutorials']
Expand Down
17 changes: 15 additions & 2 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1856,8 +1856,21 @@ are:
* ``__str__`` - returns a string containing a nicely printable representation
of an object. This is what is used when you ``print()`` an object or pass it
to ``format()``.
* ``_repr_html_`` - returns a HTML version of the object. This method is only
present in some objects, for example, pandas dataframes.
* ``_repr_html_`` - returns an HTML version of the object.
* ``_repr_png_`` - returns a PNG version of the object.
* ``_repr_jpeg_`` - returns a JPEG version of the object.
* ``_repr_svg_`` - returns an SVG version of the object.

Note that the last four methods are only available for some objects. For example,
Pandas dataframes, SymPy expressions, and GraphViz graphs, support one or more of
these formats.

.. note::

Some objects support :py:meth:`~MyObject._repr_mimebundle_`, which is the preferred
way to access enriched representations. By specifying, e.g., ``_repr_svg_``,
Sphinx-Gallery will first look for an SVG in the MIME bundle.
If not, it will call ``_repr_svg_`` if available.

Output capture can be controlled globally by the ``capture_repr`` configuration
setting or file-by-file by adding a comment to the example file, which overrides
Expand Down
2 changes: 1 addition & 1 deletion examples/no_output/just_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

This demonstrates an example ``.py`` file that is not executed when gallery is
generated (see :ref:`build_pattern`) but nevertheless gets included as an
example. Note that no output is capture as this file is not executed.
example. Note that no output is captured as this file is not executed.
"""

# Code source: Óscar Nájera
Expand Down
2 changes: 1 addition & 1 deletion examples/plot_3_capture_repr.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
# default ``capture_repr`` setting, ``_repr_html_`` is attempted to be captured
# first. If this method does not exist, the ``__repr__`` method would be
# captured. If the ``__repr__`` also does not exist (unlikely for non-user
# defined objects), nothing would be captured. For example, if the the
# defined objects), nothing would be captured. For example, if the
# configuration was set to ``'capture_repr': ('_repr_html_')`` nothing would be
# captured for example 2 as ``b`` does not have a ``_repr_html_``.
# You can change the 'representations' in the ``capture_repr`` tuple to finely
Expand Down
2 changes: 1 addition & 1 deletion sphinx_gallery/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ class ImageSg(images.Image):
/plot_types/basic/images/sphx_glr_bar_001_2_00x.png 2.00x
:class: sphx-glr-single-img

The resulting html is::
The resulting HTML is::

<img src="sphx_glr_bar_001_hidpi.png"
srcset="_images/sphx_glr_bar_001.png,
Expand Down
5 changes: 3 additions & 2 deletions sphinx_gallery/gen_gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ def _fill_gallery_conf_defaults(sphinx_gallery_conf, app=None,

# Check capture_repr
capture_repr = gallery_conf['capture_repr']
supported_reprs = ['__repr__', '__str__', '_repr_html_']
supported_reprs = {'__repr__', '__str__', '_repr_html_', '_repr_png_',
'_repr_svg_', '_repr_jpeg_'}
if isinstance(capture_repr, tuple):
for rep in capture_repr:
if rep not in supported_reprs:
Expand Down Expand Up @@ -815,7 +816,7 @@ def _make_graph(fname, entries, gallery_conf):


def write_api_entry_usage(app, docname, source):
"""Write an html page describing which API entries are used and unused.
"""Write an HTML page describing which API entries are used and unused.

To document and graph only those API entries that are used by
autodoc, we have to wait for autodoc to finish and hook into the
Expand Down
57 changes: 50 additions & 7 deletions sphinx_gallery/gen_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@
import sys
import traceback
import codeop
from pathlib import PurePosixPath

from sphinx.errors import ExtensionError
import sphinx.util

from .scrapers import (save_figures, ImagePathIterator, clean_modules,
_find_image_ext)
_find_image_ext, figure_rst)
from .utils import (replace_py_ipynb, scale_image, get_md5sum, _replace_md5,
optipng, status_iterator)
from . import glr_path_static
Expand Down Expand Up @@ -726,12 +727,40 @@ def _exec_and_get_memory(compiler, ast_Module, code_ast, gallery_conf,
return is_last_expr, mem_max


# Map _repr_*_ to MIME type present in _repr_mimebundle_
MIME_MAPPING = {'_repr_svg_': 'image/svg+xml',
'_repr_jpeg_': 'image/jpeg',
'_repr_png_': 'image/png',
'_repr_html_': 'text/html'}
MIME_MAPPING_REPR = set(MIME_MAPPING.keys())


def _get_last_repr(capture_repr, ___):
"""Get a repr of the last expression, using first method in 'capture_repr'
available for the last expression."""
"""
Get a repr of the last expression, using first method in 'capture_repr'
available for the last expression.
"""
# First try `_repr_mimebundle_`for those representations that may be there
capture_repr_set = set(capture_repr)
included_mime_types = {MIME_MAPPING[repr] for repr in
capture_repr_set.intersection(MIME_MAPPING_REPR)}
excluded_mime_types = {MIME_MAPPING[repr] for repr in
MIME_MAPPING_REPR.difference(capture_repr_set)}
mimebundle = {}
if included_mime_types and hasattr(___, '_repr_mimebundle_'):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hasattr is unnecessary, the try below would catch the AttributeError below anyway. Might as well remove it

Suggested change
if included_mime_types and hasattr(___, '_repr_mimebundle_'):
if included_mime_types:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you are correct. I think the idea was to try to catch a slightly more narrow class of exceptions, where AttributeError wasn't one of them, but never managed to figure out what a suitable subset might be.

try:
mimebundle = ___._repr_mimebundle_(include=included_mime_types,
exclude=excluded_mime_types)
except Exception:
pass

for meth in capture_repr:
try:
last_repr = getattr(___, meth)()
if meth in MIME_MAPPING and MIME_MAPPING[meth] in mimebundle:
# Already generated in _repr_mimebundle_
last_repr = mimebundle[MIME_MAPPING[meth]]
else:
last_repr = getattr(___, meth)()
# for case when last statement is print()
if last_repr is None or last_repr == 'None':
repr_meth = None
Expand All @@ -741,13 +770,13 @@ def _get_last_repr(capture_repr, ___):
last_repr = None
repr_meth = None
else:
if isinstance(last_repr, str):
if isinstance(last_repr, (str, bytes)):
break
return last_repr, repr_meth


def _get_code_output(is_last_expr, example_globals, gallery_conf, logging_tee,
images_rst, file_conf):
images_rst, file_conf, script_vars):
"""Obtain standard output and html output in reST."""
last_repr = None
repr_meth = None
Expand Down Expand Up @@ -783,6 +812,20 @@ def _get_code_output(is_last_expr, example_globals, gallery_conf, logging_tee,
else:
captured_html = ''

# For other image representations, save to file
if repr_meth in ('_repr_png_', '_repr_jpeg_', '_repr_svg_'):
image_path = next(script_vars['image_path_iterator'])
image_path = PurePosixPath(image_path)
if repr_meth in ('_repr_jpeg_', '_repr_svg_'):
suffix = '.svg' if repr_meth == '_repr_svg_' else ".jpg"
image_path = image_path.with_suffix(suffix)
mode = 'w' if repr_meth == '_repr_svg_' else 'wb'
with open(image_path, mode) as f:
f.write(last_repr)
svg_image = repr_meth == '_repr_svg_'
images_rst += figure_rst([image_path], gallery_conf["src_dir"],
svg_image=svg_image)

code_output = f"\n{images_rst}\n\n{captured_std}\n{captured_html}\n\n"
return code_output

Expand Down Expand Up @@ -882,7 +925,7 @@ def execute_code_block(compiler, block, example_globals, script_vars,

code_output = _get_code_output(
is_last_expr, example_globals, gallery_conf, logging_tee,
images_rst, file_conf
images_rst, file_conf, script_vars
)
finally:
_reset_cwd_syspath(cwd, sys_path)
Expand Down
20 changes: 17 additions & 3 deletions sphinx_gallery/scrapers.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ def save_figures(block, block_vars, gallery_conf):
return all_rst


def figure_rst(figure_list, sources_dir, fig_titles='', srcsetpaths=None):
def figure_rst(figure_list, sources_dir, fig_titles='', srcsetpaths=None,
svg_image=False):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well add these because it's easy

Suggested change
svg_image=False):
*, svg_image=False):

"""Generate reST for a list of image filenames.

Depending on whether we have one or more figures, we use a
Expand All @@ -376,6 +377,10 @@ def figure_rst(figure_list, sources_dir, fig_titles='', srcsetpaths=None):
{0: /images/image.png, 2.0: /images/image_2_00x.png}
where the key is the multiplication factor and the contents
the path to the image created above.
svg_image : bool
Whether to use the standard ``sg_image`` wrapper (False, default) or a
special version of the ``sg_image`` for SVG images to enable later
conversions.

Returns
-------
Expand Down Expand Up @@ -416,8 +421,10 @@ def figure_rst(figure_list, sources_dir, fig_titles='', srcsetpaths=None):
figure_name = figure_paths[0]
hinames = srcsetpaths[0]
srcset = _get_srcset_st(sources_dir, hinames)
images_rst = SG_IMAGE % (figure_name, alt, srcset)

if svg_image:
images_rst = SVG_IMAGE % (figure_name, alt)
else:
images_rst = SG_IMAGE % (figure_name, alt, srcset)
elif len(figure_paths) > 1:
images_rst = HLIST_HEADER
for nn, figure_name in enumerate(figure_paths):
Expand Down Expand Up @@ -492,6 +499,13 @@ def _single_line_sanitize(s):
:class: sphx-glr-single-img
"""

# Used for SVGs
SVG_IMAGE = """
.. image:: /%s
:alt: %s
:class: sphx-glr-single-img
"""
Comment on lines +502 to +507
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY / this appears to be a duplicate of SINGLE_IMAGE below, it would be better just to use that directly (and fix the leading space in it), or below it write

SVG_IMAGE = SINGLE_IMAGE  # alias

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Except for that initial space. Since it said it was kept for backwards compatibility I wasn't sure if the space should still be there to not break anything. But maybe the space is always an issue?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think the space is a bug and can safely be removed


# keep around for back-compat:
SINGLE_IMAGE = """
.. image:: /%s
oscargus marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
40 changes: 39 additions & 1 deletion sphinx_gallery/tests/test_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

# total number of plot_*.py files in tinybuild/examples + examples_rst_index
# + examples_with_rst
N_EXAMPLES = 15 + 3 + 2
N_EXAMPLES = 19 + 3 + 2
N_FAILING = 2
N_GOOD = N_EXAMPLES - N_FAILING # galleries that run w/o error
# passthroughs examples_rst_index, examples_with_rst
Expand Down Expand Up @@ -334,6 +334,44 @@ def test_repr_html_classes(sphinx_app):
assert 'gallery-rendered-html.css' in lines


def test_repr_svg_classes(sphinx_app):
"""Test appropriate _repr_svg_ classes."""
example_file = op.join(
sphinx_app.outdir, 'auto_examples', 'plot_svg_repr.html')
with codecs.open(example_file, 'r', 'utf-8') as fid:
lines = fid.read()
assert 'sphx_glr_plot_svg_repr_001.svg' in lines


def test_repr_svg_from_mimebundle_classes(sphinx_app):
"""Test appropriate _repr_mimebundle_ classes."""
example_file = op.join(
sphinx_app.outdir, 'auto_examples', 'plot_mime_bundle.html')
with codecs.open(example_file, 'r', 'utf-8') as fid:
lines = fid.read()
assert "This should actually print" in lines
for i in range(1, 4):
assert f'sphx_glr_plot_mime_bundle_00{i}.svg' in lines


def test_capture_jpg(sphinx_app):
"""Test capturing the JPG from _repr_jpeg_."""
example_file = op.join(
sphinx_app.outdir, 'auto_examples', 'plot_random_jpg.html')
with codecs.open(example_file, 'r', 'utf-8') as fid:
lines = fid.read()
assert 'sphx_glr_plot_random_jpg_001.jpg' in lines


def test_capture_png(sphinx_app):
"""Test capturing the PNG from _repr_png_ of a Pillow Image."""
example_file = op.join(
sphinx_app.outdir, 'auto_examples', 'plot_random_png.html')
with codecs.open(example_file, 'r', 'utf-8') as fid:
lines = fid.read()
assert 'sphx_glr_plot_random_png_001.png' in lines


def test_embed_links_and_styles(sphinx_app):
"""Test that links and styles are embedded properly in doc."""
out_dir = sphinx_app.outdir
Expand Down
19 changes: 19 additions & 0 deletions sphinx_gallery/tests/test_gen_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,23 @@ def _repr_html_(self):

"""

code_repr_and_svg = """
class repr_and_svg_class():
def __init__(self):
pass

def __repr__(self):
return "This is the __repr__"

def _repr_svg_(self):
return ("<svg viewBox='0 0 50 50' xmlns='http://www.w3.org/2000/svg'>"
"<line x1='0' y1='0' x2='50' y2='50' stroke='black' />"
"</svg>")

class_inst = repr_and_svg_class()
class_inst
"""


def _clean_output(output):
is_text = '.. rst-class:: sphx-glr-script-out' in output
Expand Down Expand Up @@ -879,6 +896,8 @@ def _clean_output(output):
pytest.param(('_repr_html_', '__repr__'), code_repr_only,
'This is the __repr__', id='repr_only,(html,repr)'),
pytest.param(('_repr_html_',), code_plt, '', id='html_none'),
pytest.param(('__repr__', '_repr_svg_'), code_repr_and_svg,
'This is the __repr__', id='repr_and_svg,(repr,svg)'),
])
def test_capture_repr(gallery_conf, capture_repr, code, expected_out,
req_mpl, req_pil, script_vars):
Expand Down
3 changes: 2 additions & 1 deletion sphinx_gallery/tests/tinybuild/doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ def __call__(self, gallery_conf, fname):
'sphinx.ext.intersphinx',
'sphinx_gallery.gen_gallery',
'sphinx.ext.graphviz',
'jupyterlite_sphinx'
'jupyterlite_sphinx',
'sphinxcontrib.cairosvgconverter'
]
templates_path = ['_templates']
autosummary_generate = True
Expand Down
Loading