diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 615a4779f..7b6e2380b 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -548,6 +548,7 @@ quartodoc: - current_theme - dpi - figure_size + - figure_format - get_option - set_option diff --git a/doc/changelog.qmd b/doc/changelog.qmd index edf9dea3a..0d6eade90 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -8,14 +8,25 @@ title: Changelog ### API Changes - Requires python >= 3.9 + - The name of the calculated aesthetic of [](:class:`~plotnine.stats.stat_function`) changed from `y` to `fx`. + - [](:class:`~plotnine.stats.stat_ecdf`) has gained the `pad` parameter. The default is set to `True`, which pads the domain with `-inf` and `inf` so that the ECDF does not have discontinuities at the extremes. To get the behaviour, set `pad` to `False`. ({{< issue 725 >}}) + - Removed the environment parameter from `ggplot`. +- When a ggplot object is the last in a jupyter cell, the output image will + not be followed by string meta information about the figure/image. + + This will happen even if the backend is set to an interactive one. + + If you set the backend to an interactive one, use + [](:class:`~plotnine.ggplot.show`) to draw the plot. + ### New - Added symmetric logarithm transformation scales @@ -51,6 +62,17 @@ title: Changelog accept lists/tuples to set the values on individual text objects. ({{< issue 724 >}}) +- Gained the option [](:attr:`~plotnine.options.figure_format`) to set the format + of the inline figures in an interactive session. e.g. + + ```python + from plotnine.options import set_option + + set_option("figure_format", "svg") + ``` + + will output all subsequent figures in svg format. + ### Bug Fixes - Fixed handling of minor breaks in diff --git a/plotnine/_utils/__init__.py b/plotnine/_utils/__init__.py index 9c59daaf5..c7ca7356d 100644 --- a/plotnine/_utils/__init__.py +++ b/plotnine/_utils/__init__.py @@ -25,7 +25,6 @@ from typing import Any, Callable import numpy.typing as npt - from IPython.core.interactiveshell import InteractiveShell from matplotlib.typing import ColorType from typing_extensions import TypeGuard @@ -1186,17 +1185,6 @@ def __exit__(self, type, value, traceback): return self._cm.__exit__(type, value, traceback) -def get_ipython() -> "InteractiveShell | None": - """ - Return running IPython instance or None - """ - try: - from IPython.core.getipython import get_ipython - except ImportError: - return None - return get_ipython() - - def simple_table( rows: list[tuple[str, str]], headers: tuple[str, str], **kwargs ): diff --git a/plotnine/_utils/context.py b/plotnine/_utils/context.py new file mode 100644 index 000000000..31e696f78 --- /dev/null +++ b/plotnine/_utils/context.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + from typing_extensions import Self + + from plotnine import ggplot + + +class plot_context: + """ + Context to setup the environment within with the plot is built + + Parameters + ---------- + plot : + ggplot object to be built within the context. + exits. + show : + Whether to show the plot. + """ + + def __init__(self, plot: ggplot, show: bool = False): + self.plot = plot + self.show = show + + def __enter__(self) -> Self: + """ + Enclose in matplolib & pandas environments + """ + import matplotlib as mpl + + self.plot.theme._targets = {} + self.rc_context = mpl.rc_context(self.plot.theme.rcParams) + + # Pandas deprecated is_copy, and when we create new dataframes + # from slices we do not want complaints. We always uses the + # new frames knowing that they are separate from the original. + self.pd_option_context = pd.option_context( + "mode.chained_assignment", None + ) + self.rc_context.__enter__() + self.pd_option_context.__enter__() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """ + Exit matplotlib & pandas environments + """ + import matplotlib.pyplot as plt + + if exc_type is None: + if self.show: + plt.show() + else: + plt.close(self.plot.figure) + else: + # There is an exception, close any figure + if hasattr(self.plot, "figure"): + plt.close(self.plot.figure) + + self.rc_context.__exit__(exc_type, exc_value, exc_traceback) + self.pd_option_context.__exit__(exc_type, exc_value, exc_traceback) + delattr(self.plot.theme, "_targets") diff --git a/plotnine/_utils/ipython.py b/plotnine/_utils/ipython.py new file mode 100644 index 000000000..546791e1d --- /dev/null +++ b/plotnine/_utils/ipython.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable, Literal, TypeAlias + + from IPython.core.interactiveshell import InteractiveShell + + FigureFormat: TypeAlias = Literal[ + "png", "retina", "jpeg", "jpg", "svg", "pdf" + ] + + +def get_ipython() -> "InteractiveShell": + """ + Return running IPython instance or None + """ + try: + from IPython.core.getipython import get_ipython as _get_ipython + except ImportError as err: + raise type(err)("IPython is has not been installed.") from err + + ip = _get_ipython() + if ip is None: + raise RuntimeError("Not running in a juptyer session.") + + return ip + + +def is_inline_backend(): + """ + Return True if the inline_backend is on + + This can only be True if also running in an jupyter/ipython session. + """ + import matplotlib as mpl + + return "matplotlib_inline.backend_inline" in mpl.get_backend() + + +def get_display_function(format: FigureFormat) -> Callable[[bytes], None]: + """ + Return a function that will display the plot image + """ + from IPython.display import ( + SVG, + Image, + display_jpeg, + display_pdf, + display_png, + display_svg, + ) + + def png(b: bytes): + display_png(Image(b, format="png")) + + def retina(b: bytes): + display_png(Image(b, format="png", retina=True)) + + def jpeg(b: bytes): + display_jpeg(Image(b, format="jpeg")) + + def svg(b: bytes): + display_svg(SVG(b)) + + def pdf(b: bytes): + display_pdf(b, raw=True) + + lookup = { + "png": png, + "retina": retina, + "jpeg": jpeg, + "jpg": jpeg, + "svg": svg, + "pdf": pdf, + } + return lookup[format] diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 340892875..97bdce1df 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -2,23 +2,27 @@ import typing from collections.abc import Sequence -from copy import deepcopy +from copy import copy, deepcopy +from io import BytesIO from itertools import chain from pathlib import Path from types import SimpleNamespace as NS -from typing import Any, Dict, Iterable, Optional, Union +from typing import Any, Dict, Iterable, Optional from warnings import warn -import pandas as pd - from ._utils import ( from_inches, - get_ipython, is_data_like, order_as_data_mapping, to_inches, ungroup, ) +from ._utils.context import plot_context +from ._utils.ipython import ( + get_display_function, + get_ipython, + is_inline_backend, +) from .coords import coord_cartesian from .exceptions import PlotnineError, PlotnineWarning from .facets import facet_null @@ -104,22 +108,64 @@ def __str__(self) -> str: """ Print/show the plot """ - self.draw(show=True) - - # Return and empty string so that print(p) is "pretty" + self.show() + # Return and empty string so that print(p) is as clean as possible return "" def __repr__(self) -> str: """ Print/show the plot """ - figure = self.draw(show=True) + dpi = self.theme.themeables.property("dpi") + width, height = self.theme.themeables.property("figure_size") + W, H = int(width * dpi), int(height * dpi) + self.show() + return f"
" + + def _ipython_display_(self): + """ + Display plot in the output of the cell - dpi = figure.get_dpi() - W = int(figure.get_figwidth() * dpi) - H = int(figure.get_figheight() * dpi) + This method will always be called when a ggplot object is the + last in the cell. + """ + self._display() - return f"
" + def show(self): + """ + Show plot using the matplotlib backend set by the user + + Users should prefer this method instead of printing or repring + the object. + """ + self._display() if is_inline_backend() else self.draw(show=True) + + def _display(self): + """ + Display plot in the cells output + + This function is called for its side-effects. + + It plots the plot to an io buffer, then uses ipython display + methods to show the result + """ + ip = get_ipython() + format = get_option("figure_format") or ip.config.InlineBackend.get( + "figure_format", "retina" + ) + save_format = format + + # While jpegs can be displayed as retina, we restrict the output + # of "retina" to png + if format == "retina": + self = copy(self) + self.theme = self.theme.to_retina() + save_format = "png" + + buf = BytesIO() + self.save(buf, format=save_format, verbose=False) + display_func = get_display_function(format) + display_func(buf.getvalue()) def __deepcopy__(self, memo: dict[Any, Any]) -> ggplot: """ @@ -517,7 +563,7 @@ def _update_labels(self, layer: Layer): def save_helper( self: ggplot, - filename: Optional[Union[str, Path]] = None, + filename: Optional[str | Path | BytesIO] = None, format: Optional[str] = None, path: Optional[str] = None, width: Optional[float] = None, @@ -526,6 +572,7 @@ def save_helper( dpi: Optional[float] = None, limitsize: bool = True, verbose: bool = True, + retina: bool = False, **kwargs: Any, ) -> mpl_save_view: """ @@ -543,7 +590,7 @@ def save_helper( ext = format if format else "pdf" filename = self._save_filename(ext) - if path: + if path and isinstance(filename, (Path, str)): filename = Path(path) / filename fig_kwargs["fname"] = filename @@ -556,11 +603,8 @@ def save_helper( width = to_inches(width, units) height = to_inches(height, units) self += theme(figure_size=(width, height)) - elif ( - width is None - and height is not None - or width is not None - and height is None + elif (width is None and height is not None) or ( + width is not None and height is None ): raise PlotnineError("You must specify both width and height") @@ -582,6 +626,10 @@ def save_helper( warn(f"Saving {_w} x {_h} {units} image.", PlotnineWarning) warn(f"Filename: {filename}", PlotnineWarning) + if retina: + width, height = width * 2, height * 2 + self.theme = self.theme + theme(figure_size=(width, height)) + if dpi is not None: self.theme = self.theme + theme(dpi=dpi) @@ -590,7 +638,7 @@ def save_helper( def save( self, - filename: Optional[str | Path] = None, + filename: Optional[str | Path | BytesIO] = None, format: Optional[str] = None, path: str = "", width: Optional[float] = None, @@ -757,97 +805,3 @@ def facet_pages(column) fig = plot.draw() # Save as a page in the PDF file pdf.savefig(fig, **fig_kwargs) - - -class plot_context: - """ - Context to setup the environment within with the plot is built - - Parameters - ---------- - plot : - ggplot object to be built within the context. - exits. - show : - Whether to show the plot. - """ - - # Default to retina unless user chooses otherwise - _IPYTHON_CONFIG: dict[str, dict[str, Any]] = { - "InlineBackend": { - "figure_format": "retina", - "close_figures": True, - "print_figure_kwargs": {"bbox_inches": None}, - } - } - _ip_config_inlinebackend: dict[str, Any] = {} - - def __init__(self, plot: ggplot, show: bool = False): - self.plot = plot - self.show = show - - def __enter__(self) -> Self: - """ - Enclose in matplolib & pandas environments - """ - import matplotlib as mpl - - self.plot.theme._targets = {} - self.rc_context = mpl.rc_context(self.plot.theme.rcParams) - # Pandas deprecated is_copy, and when we create new dataframes - # from slices we do not want complaints. We always uses the - # new frames knowing that they are separate from the original. - self.pd_option_context = pd.option_context( - "mode.chained_assignment", None - ) - self.rc_context.__enter__() - self.pd_option_context.__enter__() - self._enter_ipython() - return self - - def __exit__(self, exc_type, exc_value, exc_traceback): - """ - Exit matplotlib & pandas environments - """ - import matplotlib.pyplot as plt - - if exc_type is None: - if self.show: - plt.show() - else: - plt.close(self.plot.figure) - else: - # There is an exception, close any figure - if hasattr(self.plot, "figure"): - plt.close(self.plot.figure) - - self.rc_context.__exit__(exc_type, exc_value, exc_traceback) - self.pd_option_context.__exit__(exc_type, exc_value, exc_traceback) - self._exit_ipython() - delattr(self.plot.theme, "_targets") - - def _enter_ipython(self): - """ - Setup ipython parameters in for the plot - """ - ip = get_ipython() - if not ip or not hasattr(ip.config, "InlineBackend"): - return - - for key, value in self._IPYTHON_CONFIG["InlineBackend"].items(): - if key not in ip.config.InlineBackend: - self._ip_config_inlinebackend[key] = key - ip.run_line_magic("config", f"InlineBackend.{key} = {value!r}") - - def _exit_ipython(self): - """ - Undo ipython parameters in for the plot - """ - ip = get_ipython() - if not ip or not hasattr(ip.config, "InlineBackend"): - return - - for key in self._ip_config_inlinebackend: - del ip.config["InlineBackend"][key] - - self._ip_config_inlinebackend = {} diff --git a/plotnine/options.py b/plotnine/options.py index 16669255f..83b6eba09 100644 --- a/plotnine/options.py +++ b/plotnine/options.py @@ -5,7 +5,7 @@ if typing.TYPE_CHECKING: from typing import Any, Literal, Optional, Type - from plotnine.typing import Theme + from plotnine.typing import FigureFormat, Theme close_all_figures = False """ @@ -38,6 +38,20 @@ Default figure size inches """ +figure_format: Optional[FigureFormat] = None +""" +The format for the inline figures outputed by the jupyter kernel. + +If `None`, it is the value of + + %config InlineBackend.figure_format + +If that has not been set, the default is "retina". +You can set it explicitly with: + + %config InlineBackend.figure_format = "retina" +""" + base_margin: float = 0.01 """ A size that is proportional of the figure width and diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 3f0075007..d02ff486b 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -404,6 +404,15 @@ def __deepcopy__(self, memo: dict) -> theme: return result + def to_retina(self) -> theme: + """ + Return a retina-sized version of this theme + + The result is a theme that has double the dpi. + """ + dpi = self.themeables.property("dpi") + return self + theme(dpi=dpi * 2) + def theme_get() -> theme: """ diff --git a/plotnine/typing.py b/plotnine/typing.py index fd3925a7d..53b95e525 100644 --- a/plotnine/typing.py +++ b/plotnine/typing.py @@ -136,6 +136,9 @@ def to_pandas(self) -> pd.DataFrame: # Mizani Trans: TypeAlias = trans +# Plotting +FigureFormat: TypeAlias = Literal["png", "retina", "jpeg", "jpg", "svg", "pdf"] + # Facet strip StripLabellingFuncNames: TypeAlias = Literal[ "label_value", "label_both", "label_context"