Source code for mplotter.saving

# Copyright (c) 2021 Marco Rigobello, MIT License
"""
Saving matplotlib figures to file.

`Reproducibility` is a key requirement for publishing scientific work.
As far as illustrations are concerned, reproducibility can be achieved
using a `VCS <https://en.wikipedia.org/wiki/Version_control>`_ to track
changes to the plotting code. When the CWD is in a `git <https://git-
scm.com/>`_ repository, the function :func:`save_fig` provided by this
module stores the commit hash of HEAD in the "Creator" metadata of the
saved figure. Some file formats are supported only through `exiftool
<http://exiftool.sourceforge.net/>`_ (see :data:`SUPPORTED_FORMATS` and
:data:`EXIFTOOL_FORMATS`). The metadata can be retrieved e.g. via
``exiftool -Creator <filename(s)>`` or, for svg files, under the tag
``/svg/metadata/RDF/Work/creator/Agent/title``.
"""

from contextlib import contextmanager
from logging import getLogger
from subprocess import run
from pathlib import Path

import matplotlib as mpl

__all__ = ["fig_dir", "save_fig"]

logger = getLogger(__package__)

SUPPORTED_FORMATS = {"eps", "ps", "svg", "pdf", "png"}
"""set of str: Default supported file formats for git revision."""
EXIFTOOL_FORMATS = {"jpg", "jpeg", "tif", "tiff"}
"""set of str: Exiftool supported file formats for git revision."""

try:
    run("exiftool", capture_output=True)
except FileNotFoundError:
    exiftool = False
else:
    exiftool = True


def _resolve_path(path=None):
    """
    Resolves an absolute or relative path.

    User constructs are expanded and :rc:`savefig.directory` is used as anchor.

    Parameters
    ----------
    path : str or path-like, default '.'
        The path to resolve, absolute or relative.

    Returns
    -------
    :class:`pathlib.Path`
        The resolved path.
    """
    segments = (mpl.rcParams["savefig.directory"], path or ".")
    return Path(*(Path(p).expanduser() for p in segments))


[docs] def get_fig_label(fig): """Returns a figure's label or, as a fallback, its number.""" return fig.get_label() or fig.number
[docs] @contextmanager def fig_dir(dest): """ A context manager for temporarily changing the plotting directory. Sets :rc:`savefig.directory` via :func:`~matplotlib.rc_context`. Parameters ---------- dest : str or path-like The new destination directory. If a relative path, :rc:`savefig.directory` is taken as anchor. """ with mpl.rc_context({"savefig.directory": str(_resolve_path(dest))}): yield
[docs] def save_fig(fig, dest=None, close=True, **savefig_kw): """ Saves a figure and, optionally, closes it. The way in which the destination path is specified differs from the one used by :meth:`~matplotlib.figure.Figure.savefig`. Moreover, if the CWD is in a git repository, attempts to store the commit hash of HEAD in the "Creator" metadata of the file. Supported file formats: ps, eps, pdf, png, svg; (jpeg and tiff via exiftool). Monitoring information is emitted through python's :mod:`logging` module. Parameters ---------- fig : :class:`~matplotlib.figure.Figure` The figure to be saved. dest : str or path-like or file-like, default the figure label The destination file, or file name (path without extension). If a relative path, :rc:`savefig.directory` is taken as anchor. The file format (extension to append to path) can be specified via ``savefig_kw['format']`` and defaults to :rc:`savefig.format`. close : bool, default True Whether to call :func:`matplotlib.pyplot.close` on the figure after saving. **savefig_kw : Keyword arguments for :meth:`~matplotlib.figure.Figure.savefig`. Returns ------- :class:`pathlib.Path` The destination path, with extension. """ fmt = savefig_kw.get("format") or mpl.rcParams["savefig.format"] try: dest = Path(dest or fig.get_label()) except TypeError: # file-like dest, path stored for logging reasons path = Path(dest.name) else: dest = _resolve_path(dest).with_suffix(f"{dest.suffix}.{fmt}") dest.parent.mkdir(parents=True, exist_ok=True) path = dest path = path.resolve() # git revision in metadata (matplotlib) git_rev = run(["git", "rev-parse", "HEAD"], capture_output=True, text=True) git_rev = git_rev.stdout.strip() if git_rev: metadata = savefig_kw.setdefault("metadata", {}) metadata.setdefault("Creator", git_rev) fig.savefig(dest, **savefig_kw) logger.info(f"Plotted figure {get_fig_label(fig)} to {path}") # git revision in metadata (exiftool) # TODO: maybe replacable with exif pil_kwargs? if git_rev and exiftool and fmt in EXIFTOOL_FORMATS: cmd = ["exiftool", "-overwrite_original", f"-Creator={git_rev}", path] run(cmd, check=True, capture_output=True) if close: import matplotlib.pyplot as plt logger.debug(f"Closed figure {get_fig_label(fig)}") plt.close(fig) return path