Source code for bluesky_widgets.headless.figures
import collections.abc
from pathlib import Path
import matplotlib
from ..models.plot_specs import FigureSpec, FigureSpecList
from .._matplotlib_axes import MatplotlibAxes
from ..utils.dict_view import DictView
[docs]class HeadlessFigures:
    """
    A headless "view" for a FigureSpecList model.
    It does not produce a graphical user interface. Instead, it provides
    methods for exporting figures as images.
    Examples
    --------
    Export all the figures to a directory. They will be named by their title.
    If there are duplciate titles, a counting number will appended like
    x-1.png, x-2.png.
    >>> headless = HeadlessFigures(model)
    >>> headless.export_all("path/to/directory/")
    Control the format.
    >>> headless.export_all("path/to/directory/", format="png")
    >>> headless.export_all("path/to/directory/", format="jpg")
    """
    def __init__(self, model: FigureSpecList):
        self.model = model
        # Map Figure UUID to widget with HeadlessFigure
        self._figures = {}
        for figure_spec in model:
            self._add_figure(figure_spec)
        model.events.added.connect(self._on_figure_added)
        model.events.removed.connect(self._on_figure_removed)
    @property
    def figures(self):
        "Read-only access to the mapping FigureSpec UUID -> HeadlessFigure"
        return DictView(self._figures)
    def _on_figure_added(self, event):
        figure_spec = event.item
        self._add_figure(figure_spec)
    def _add_figure(self, figure_spec):
        "Create a new matplotlib Figure."
        figure = HeadlessFigure(figure_spec)
        self._figures[figure_spec.uuid] = figure
    def _on_figure_removed(self, event):
        "Remove the associated tab and close its canvas."
        figure_spec = event.item
        figure = self._figures[figure_spec.uuid]
        figure.close_figure()
        del self._figures[figure_spec.uuid]
    def close_figures(self):
        for figure in self._figures.values():
            figure.close_figure()
    close = close_figures
[docs]    def export_all(self, directory, format="png", **kwargs):
        """
        Export all figures.
        Parameters
        ----------
        directory : str | Path
        format : str, optional
            Default is "png".
        **kwargs :
            Passed through to matplotlib.figure.Figure.savefig
        Returns
        -------
        filenames : List[String]
        """
        # Avoid name collisions in the case of duplicate titles by appending
        # "-1", "-2", "-3", ... to duplicates.
        titles_tallied = {}
        filenames = []
        for figure_spec in self.model:
            title = figure_spec.title
            if title in titles_tallied:
                filename = f"{title}-{titles_tallied[title]}"
                titles_tallied[title] += 1
            else:
                filename = title
                titles_tallied[title] = 1
            filename = str(Path(directory, f"{filename}.{format}"))
            figure = self._figures[figure_spec.uuid]
            figure.export(filename, format=format, **kwargs)
            filenames.append(filename)
        return filenames
[docs]class HeadlessFigure:
    """
    A Headless "view" for a FigureSpec model. This always contains one Figure.
    Examples
    --------
    Export the figure.
    >>> headless = HeadlessFigure(model)
    >>> headless.export("my-figure.png")
    """
    def __init__(self, model: FigureSpec):
        self.model = model
        self.figure, self.axes_list = _make_figure(model)
        self.figure.suptitle(model.title)
        self._axes = {}
        for axes_spec, axes in zip(model.axes, self.axes_list):
            self._axes[axes_spec.uuid] = MatplotlibAxes(model=axes_spec, axes=axes)
        model.events.title.connect(self._on_title_changed)
        # The FigureSpec model does not currently allow axes to be added or
        # removed, so we do not need to handle changes in model.axes.
    @property
    def axes(self):
        "Read-only access to the mapping AxesSpec UUID -> MatplotlibAxes"
        return DictView(self._axes)
    def _on_title_changed(self, event):
        self.figure.suptitle(event.value)
    def close_figure(self):
        _close_figure(self.figure)
    close = close_figure
[docs]    def export(self, filename, format="png", **kwargs):
        """
        Export figure.
        Parameters
        ----------
        filename : str | Path
        format : str, optional
            Default is "png".
        **kwargs :
            Passed through to matplotlib.figure.Figure.savefig
        """
        self.figure.savefig(str(filename), format=format, **kwargs)
def _make_figure(figure_spec):
    "Create a Figure and Axes."
    matplotlib.use("Agg")  # must set before importing matplotlib.pyplot
    import matplotlib.pyplot as plt  # noqa
    # TODO Let FigureSpec give different options to subplots here,
    # but verify that number of axes created matches the number of axes
    # specified.
    fig, axes = plt.subplots(len(figure_spec.axes))
    # Handl return type instability in plt.subplots.
    if not isinstance(axes, collections.abc.Iterable):
        axes = [axes]
    return fig, axes
def _close_figure(figure):
    """
    Workaround for matplotlib regression relating to closing figures in Agg
    See https://github.com/matplotlib/matplotlib/pull/18184/
    """
    # TODO It would be better to switch the approach based on matplotlib
    # versions known to have this problem, rather than blindly trying. Update
    # this once a fixed has been released and we know the earliest version of
    # matplotlib that does not have this bug.
    try:
        figure.canvas.close()
    except AttributeError:
        from matplotlib._pylab_helpers import Gcf
        num = next(
            (
                manager.num
                for manager in Gcf.figs.values()
                if manager.canvas.figure == figure
            ),
            None,
        )
        if num is not None:
            Gcf.destroy(num)