Skip to content

Commit 8ba2d3b

Browse files
committed
Prefer _repr_mimebundle_ for notebook display
These changes replace `_ipython_display_` with `_repr_mimebundle_` protocol displaying plots within notebooks. Previously, plotnine used `_ipython_display_()` for display, which works via side effects and assumes an IPython/Jupyter environment. Since IPython 6.1 (May 2017), the `_repr_mimebundle_()` protocol allows objects to return display data instead of triggering side effects. This decouples rendering from IPython, letting any compatible frontend (e.g., Jupyter, marimo) handle presentation without requiring the object to be aware of the runtime. The repr continues to generate a single format (PNG, SVG, etc.) based on configuration to match prior behavior with the `_ipython_display_`. References: - https://ipython.readthedocs.io/en/stable/config/integrating.html - https://ipython.readthedocs.io/en/stable/api/generated/IPython.core.formatters.html
1 parent 08088a0 commit 8ba2d3b

File tree

3 files changed

+97
-83
lines changed

3 files changed

+97
-83
lines changed

plotnine/_utils/ipython.py

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,29 @@
33
from typing import TYPE_CHECKING
44

55
if TYPE_CHECKING:
6-
from typing import Callable, Literal, TypeAlias
6+
from typing import Callable, Literal, NotRequired, TypeAlias, TypedDict
77

88
from IPython.core.interactiveshell import InteractiveShell
99

1010
FigureFormat: TypeAlias = Literal[
1111
"png", "retina", "jpeg", "jpg", "svg", "pdf"
1212
]
1313

14+
class DisplayMetadata(TypedDict):
15+
width: NotRequired[int]
16+
height: NotRequired[int]
1417

15-
def get_ipython() -> "InteractiveShell":
18+
19+
def get_ipython() -> "None | InteractiveShell":
1620
"""
1721
Return running IPython instance or None
1822
"""
1923
try:
2024
from IPython.core.getipython import get_ipython as _get_ipython
21-
except ImportError as err:
22-
raise type(err)("IPython is has not been installed.") from err
23-
24-
ip = _get_ipython()
25-
if ip is None:
26-
raise RuntimeError("Not running in a juptyer session.")
25+
except ImportError:
26+
return None
2727

28-
return ip
28+
return _get_ipython()
2929

3030

3131
def is_inline_backend():
@@ -39,44 +39,51 @@ def is_inline_backend():
3939
return "matplotlib_inline.backend_inline" in mpl.get_backend()
4040

4141

42-
def get_display_function(
43-
format: FigureFormat, figure_size_px: tuple[int, int]
44-
) -> Callable[[bytes], None]:
42+
def get_display_function() -> Callable[
43+
[dict[str, bytes], dict[str, DisplayMetadata]], None
44+
]:
4545
"""
4646
Return a function that will display the plot image
4747
"""
48-
from IPython.display import (
49-
SVG,
50-
Image,
51-
display_jpeg,
52-
display_pdf,
53-
display_png,
54-
display_svg,
55-
)
56-
57-
w, h = figure_size_px
58-
59-
def png(b: bytes):
60-
display_png(Image(b, format="png", width=w, height=h))
48+
from IPython.display import display
6149

62-
def retina(b: bytes):
63-
display_png(Image(b, format="png", retina=True))
50+
def display_func(
51+
data: dict[str, bytes], metadata: dict[str, DisplayMetadata]
52+
) -> None:
53+
display(data, metadata=metadata, raw=True)
6454

65-
def jpeg(b: bytes):
66-
display_jpeg(Image(b, format="jpeg", width=w, height=h))
55+
return display_func
6756

68-
def svg(b: bytes):
69-
display_svg(SVG(b))
7057

71-
def pdf(b: bytes):
72-
display_pdf(b, raw=True)
58+
def get_mimebundle(
59+
b: bytes, format: FigureFormat, figure_size_px: tuple[int, int]
60+
):
61+
"""
62+
Return a the display MIME bundle from image data
63+
64+
Parameters
65+
----------
66+
format :
67+
The figure format
68+
figure_size_px :
69+
The figure size in pixels (width, height)
70+
"""
7371

7472
lookup = {
75-
"png": png,
76-
"retina": retina,
77-
"jpeg": jpeg,
78-
"jpg": jpeg,
79-
"svg": svg,
80-
"pdf": pdf,
73+
"png": "image/png",
74+
"retina": "image/png",
75+
"jpeg": "image/jpeg",
76+
"svg": "image/svg+xml",
77+
"pdf": "application/pdf",
8178
}
82-
return lookup[format]
79+
mimetype = lookup[format]
80+
81+
metadata: dict[str, DisplayMetadata] = {}
82+
w, h = figure_size_px
83+
if format in ("png", "jpeg"):
84+
metadata = {mimetype: {"width": w, "height": h}}
85+
elif format == "retina":
86+
# `retina=True` in IPython.display.Image just halves width/height
87+
metadata = {mimetype: {"width": w // 2, "height": h // 2}}
88+
89+
return {mimetype: b}, metadata

plotnine/composition/_compose.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@
66
from io import BytesIO
77
from typing import TYPE_CHECKING, overload
88

9-
from .._utils.ipython import (
10-
get_display_function,
11-
get_ipython,
12-
)
13-
from ..options import get_option
149
from ._plotspec import plotspec
1510

1611
if TYPE_CHECKING:
@@ -20,8 +15,8 @@
2015
from matplotlib.figure import Figure
2116

2217
from plotnine._mpl.gridspec import p9GridSpec
23-
from plotnine._utils.ipython import FigureFormat
2418
from plotnine.ggplot import PlotAddable, ggplot
19+
from plotnine.typing import FigureFormat
2520

2621

2722
@dataclass
@@ -218,11 +213,28 @@ def __getitem__(
218213
def __setitem__(self, key, value):
219214
self.items[key] = value
220215

221-
def _ipython_display_(self):
216+
def _repr_mimebundle_(self, **kwargs):
222217
"""
223-
Display plot in the output of the cell
218+
Return dynamic MIME bundle for composition display
224219
"""
225-
return self._display()
220+
from plotnine._utils.ipython import get_ipython, get_mimebundle
221+
from plotnine.options import get_option
222+
223+
ip = get_ipython()
224+
format: FigureFormat = (
225+
get_option("figure_format")
226+
or (ip and ip.config.InlineBackend.get("figure_format"))
227+
or "retina"
228+
)
229+
230+
if format == "retina":
231+
self = deepcopy(self)
232+
self._to_retina()
233+
234+
buf = BytesIO()
235+
self.save(buf, "png" if format == "retina" else format)
236+
figure_size_px = self.last_plot.theme._figure_size_px
237+
return get_mimebundle(buf.getvalue(), format, figure_size_px)
226238

227239
@property
228240
def nrow(self) -> int:
@@ -357,20 +369,11 @@ def _display(self):
357369
It draws the plot to an io buffer then uses ipython display
358370
methods to show the result.
359371
"""
360-
ip = get_ipython()
361-
format: FigureFormat = get_option(
362-
"figure_format"
363-
) or ip.config.InlineBackend.get("figure_format", "retina")
372+
from plotnine._utils.ipython import get_display_function
364373

365-
if format == "retina":
366-
self = deepcopy(self)
367-
self._to_retina()
368-
369-
buf = BytesIO()
370-
self.save(buf, "png" if format == "retina" else format)
371-
figure_size_px = self.last_plot.theme._figure_size_px
372-
display_func = get_display_function(format, figure_size_px)
373-
display_func(buf.getvalue())
374+
data, metadata = self._repr_mimebundle_()
375+
display_func = get_display_function()
376+
display_func(data, metadata)
374377

375378
def draw(self, *, show: bool = False) -> Figure:
376379
"""

plotnine/ggplot.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ._utils.ipython import (
2828
get_display_function,
2929
get_ipython,
30+
get_mimebundle,
3031
is_inline_backend,
3132
)
3233
from ._utils.quarto import is_quarto_environment
@@ -55,7 +56,7 @@
5556
from plotnine.composition import Compose
5657
from plotnine.coords.coord import coord
5758
from plotnine.facets.facet import facet
58-
from plotnine.typing import DataLike
59+
from plotnine.typing import DataLike, FigureFormat
5960

6061
class PlotAddable(Protocol):
6162
"""
@@ -137,14 +138,29 @@ def __str__(self) -> str:
137138
w, h = self.theme._figure_size_px
138139
return f"<ggplot: ({w} x {h})>"
139140

140-
def _ipython_display_(self):
141+
def _repr_mimebundle_(self, **kwargs):
141142
"""
142-
Display plot in the output of the cell
143+
Return dynamic MIME bundle for plot display
143144
144-
This method will always be called when a ggplot object is the
145-
last in the cell.
145+
This method is called when a ggplot object is the last in the cell.
146146
"""
147-
self._display()
147+
ip = get_ipython()
148+
format: FigureFormat = (
149+
get_option("figure_format")
150+
or (ip and ip.config.InlineBackend.get("figure_format"))
151+
or "retina"
152+
)
153+
154+
# While jpegs can be displayed as retina, we restrict the output
155+
# of "retina" to png
156+
if format == "retina":
157+
self = copy(self)
158+
self.theme = self.theme.to_retina()
159+
160+
buf = BytesIO()
161+
self.save(buf, "png" if format == "retina" else format, verbose=False)
162+
figure_size_px = self.theme._figure_size_px
163+
return get_mimebundle(buf.getvalue(), format, figure_size_px)
148164

149165
def show(self):
150166
"""
@@ -174,21 +190,9 @@ def _display(self):
174190
It plots the plot to an io buffer, then uses ipython display
175191
methods to show the result
176192
"""
177-
ip = get_ipython()
178-
format = get_option("figure_format") or ip.config.InlineBackend.get(
179-
"figure_format", "retina"
180-
)
181-
# While jpegs can be displayed as retina, we restrict the output
182-
# of "retina" to png
183-
if format == "retina":
184-
self = copy(self)
185-
self.theme = self.theme.to_retina()
186-
187-
buf = BytesIO()
188-
self.save(buf, "png" if format == "retina" else format, verbose=False)
189-
figure_size_px = self.theme._figure_size_px
190-
display_func = get_display_function(format, figure_size_px)
191-
display_func(buf.getvalue())
193+
data, metadata = self._repr_mimebundle_()
194+
display_func = get_display_function()
195+
display_func(data, metadata)
192196

193197
def __deepcopy__(self, memo: dict[Any, Any]) -> ggplot:
194198
"""

0 commit comments

Comments
 (0)