Skip to content

Commit 2fc174d

Browse files
committed
ENH: Improve getting output with quarto and the knitr engine
closes #970
1 parent 2f0fb8a commit 2fc174d

File tree

7 files changed

+160
-55
lines changed

7 files changed

+160
-55
lines changed

doc/changelog.qmd

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ title: Changelog
99

1010
- Added [](:class:`~plotnine.scale_stroke_identity`) which was conspicuously missing!
1111

12+
- Added [](:class:`~plotnine.composition.Compose.show`), making it possible to output/show
13+
plot composition objects when they are not the last object in a cell.
14+
15+
### Enhancements
16+
17+
- In a jupyter environment, the output when the plot is the last in a cell is
18+
now rendered without side effects. ({{< pr 969 >}})
19+
20+
- When using the knitr engine in a Quarto document, you no longer need to
21+
class `plot.show()` to get output if the `plot` is the last object in
22+
the cell. ({{< issue 970 >}})
23+
1224
### Bug Fixes
1325

1426
- Fixed labels set with the `labs` call so that they are only ever overwritten

plotnine/_utils/context.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from dataclasses import dataclass
34
from typing import TYPE_CHECKING
45

56
import pandas as pd
@@ -8,17 +9,40 @@
89
from typing_extensions import Self
910

1011
from plotnine import ggplot
12+
from plotnine.composition import Compose
13+
14+
15+
def reopen(fig):
16+
"""
17+
Reopen an MPL figure that has been closed with plt.close
18+
19+
When drawing compositions, plot_composition_context will nest
20+
plot_context. In this case, plot_context may close an MPL figure
21+
that belongs to composition. A closed figure cannot be shown with
22+
plt.show, so for compositions compose.show (if called) will do nothing.
23+
"""
24+
from matplotlib._pylab_helpers import Gcf
25+
26+
Gcf.set_active(fig.canvas.manager)
27+
28+
29+
def is_closed(fig) -> bool:
30+
"""
31+
Return True if figure is closed
32+
"""
33+
import matplotlib.pyplot as plt
34+
35+
return not plt.fignum_exists(fig.number)
1136

1237

1338
class plot_context:
1439
"""
15-
Context to setup the environment within with the plot is built
40+
Context within which the plot is built
1641
1742
Parameters
1843
----------
1944
plot :
2045
ggplot object to be built within the context.
21-
exits.
2246
show :
2347
Whether to show the plot.
2448
"""
@@ -66,3 +90,55 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
6690

6791
self.rc_context.__exit__(exc_type, exc_value, exc_traceback)
6892
self.pd_option_context.__exit__(exc_type, exc_value, exc_traceback)
93+
94+
95+
@dataclass
96+
class plot_composition_context:
97+
"""
98+
Context within which a plot composition is built
99+
100+
Parameters
101+
----------
102+
cmp :
103+
composition object to be built within the context.
104+
show :
105+
Whether to show the plot.
106+
"""
107+
108+
cmp: Compose
109+
show: bool
110+
111+
def __post_init__(self):
112+
import matplotlib as mpl
113+
114+
# The dpi is needed when the figure is created, either as
115+
# a parameter to plt.figure() or an rcParam.
116+
# https://github.com/matplotlib/matplotlib/issues/24644
117+
# When drawing the Composition, the dpi themeable is infective
118+
# because it sets the rcParam after this figure is created.
119+
rcParams = {"figure.dpi": self.cmp.last_plot.theme.getp("dpi")}
120+
self._rc_context = mpl.rc_context(rcParams)
121+
122+
def __enter__(self) -> Self:
123+
"""
124+
Enclose in matplolib & pandas environments
125+
"""
126+
self._rc_context.__enter__()
127+
return self
128+
129+
def __exit__(self, exc_type, exc_value, exc_traceback):
130+
import matplotlib.pyplot as plt
131+
132+
if exc_type is None:
133+
if self.show:
134+
if is_closed(self.cmp.figure):
135+
reopen(self.cmp.figure)
136+
plt.show()
137+
else:
138+
plt.close(self.cmp.figure)
139+
else:
140+
# There is an exception, close any figure
141+
if hasattr(self.cmp, "figure"):
142+
plt.close(self.cmp.figure)
143+
144+
self._rc_context.__exit__(exc_type, exc_value, exc_traceback)

plotnine/_utils/quarto.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
import sys
3+
from functools import lru_cache
24

35

46
def is_quarto_environment() -> bool:
@@ -31,3 +33,22 @@ def set_options_from_quarto():
3133
set_option("dpi", dpi)
3234
set_option("figure_size", figure_size)
3335
set_option("figure_format", figure_format)
36+
37+
38+
# We do not expect the contents the file stored in the QUARTO_EXECUTE_INFO
39+
# variable to change. We can can cache the output
40+
@lru_cache()
41+
def is_knitr_engine() -> bool:
42+
"""
43+
Return True if knitr is executing the code
44+
"""
45+
46+
if filename := os.environ.get("QUARTO_EXECUTE_INFO"): # Quarto >= 1.8.21
47+
import json
48+
from pathlib import Path
49+
50+
info = json.loads(Path(filename).read_text())
51+
return info["format"]["execute"]["engine"] == "knitr"
52+
else:
53+
# NOTE: Remove this branch some time after quarto 1.9 is released
54+
return "rpytools" in sys.modules

plotnine/composition/_beside.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from plotnine.ggplot import ggplot
1010

1111

12-
@dataclass
12+
@dataclass(repr=False)
1313
class Beside(Compose):
1414
"""
1515
Place plots or compositions side by side

plotnine/composition/_compose.py

Lines changed: 38 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@
66
from io import BytesIO
77
from typing import TYPE_CHECKING, overload
88

9+
from .._utils.context import plot_composition_context
910
from .._utils.ipython import (
1011
get_ipython,
1112
get_mimebundle,
1213
is_inline_backend,
1314
)
14-
from .._utils.quarto import is_quarto_environment
15+
from .._utils.quarto import is_knitr_engine, is_quarto_environment
1516
from ..options import get_option
1617
from ._plotspec import plotspec
1718

1819
if TYPE_CHECKING:
1920
from pathlib import Path
20-
from typing import Generator, Iterator, Self
21+
from typing import Generator, Iterator
2122

2223
from matplotlib.figure import Figure
2324

@@ -101,6 +102,22 @@ def __post_init__(self):
101102
for op in self.items
102103
]
103104

105+
def __repr__(self):
106+
"""
107+
repr
108+
109+
Notes
110+
-----
111+
Subclasses that are dataclasses should be declared with
112+
`@dataclass(repr=False)`.
113+
"""
114+
# knitr relies on __repr__ to automatically print the last object
115+
# in a cell.
116+
if is_knitr_engine():
117+
self.show()
118+
return ""
119+
return super().__repr__()
120+
104121
@abc.abstractmethod
105122
def __or__(self, rhs: ggplot | Compose) -> Compose:
106123
"""
@@ -321,6 +338,15 @@ def _create_gridspec(self, figure, nest_into):
321338
self.nrow, self.ncol, figure, nest_into=nest_into
322339
)
323340

341+
def _setup(self) -> Figure:
342+
"""
343+
Setup this instance for the building process
344+
"""
345+
if not hasattr(self, "figure"):
346+
self._create_figure()
347+
348+
return self.figure
349+
324350
def _create_figure(self):
325351
import matplotlib.pyplot as plt
326352

@@ -364,6 +390,13 @@ def _make_plotspecs(
364390
self.figure = plt.figure()
365391
self.plotspecs = list(_make_plotspecs(self, None))
366392

393+
def _draw_plots(self):
394+
"""
395+
Draw all plots in the composition
396+
"""
397+
for ps in self.plotspecs:
398+
ps.plot.draw()
399+
367400
def show(self):
368401
"""
369402
Display plot in the cells output
@@ -400,15 +433,9 @@ def draw(self, *, show: bool = False) -> Figure:
400433
from .._mpl.layout_manager import PlotnineCompositionLayoutEngine
401434

402435
with plot_composition_context(self, show):
403-
self._create_figure()
404-
figure = self.figure
405-
406-
for ps in self.plotspecs:
407-
ps.plot.draw()
408-
409-
self.figure.set_layout_engine(
410-
PlotnineCompositionLayoutEngine(self)
411-
)
436+
figure = self._setup()
437+
self._draw_plots()
438+
figure.set_layout_engine(PlotnineCompositionLayoutEngine(self))
412439
return figure
413440

414441
def save(
@@ -442,42 +469,3 @@ def save(
442469
plot = (self + theme(dpi=dpi)) if dpi else self
443470
figure = plot.draw()
444471
figure.savefig(filename, format=format)
445-
446-
447-
@dataclass
448-
class plot_composition_context:
449-
cmp: Compose
450-
show: bool
451-
452-
def __post_init__(self):
453-
import matplotlib as mpl
454-
455-
# The dpi is needed when the figure is created, either as
456-
# a parameter to plt.figure() or an rcParam.
457-
# https://github.com/matplotlib/matplotlib/issues/24644
458-
# When drawing the Composition, the dpi themeable is infective
459-
# because it sets the rcParam after this figure is created.
460-
rcParams = {"figure.dpi": self.cmp.last_plot.theme.getp("dpi")}
461-
self._rc_context = mpl.rc_context(rcParams)
462-
463-
def __enter__(self) -> Self:
464-
"""
465-
Enclose in matplolib & pandas environments
466-
"""
467-
self._rc_context.__enter__()
468-
return self
469-
470-
def __exit__(self, exc_type, exc_value, exc_traceback):
471-
import matplotlib.pyplot as plt
472-
473-
if exc_type is None:
474-
if self.show:
475-
plt.show()
476-
else:
477-
plt.close(self.cmp.figure)
478-
else:
479-
# There is an exception, close any figure
480-
if hasattr(self.cmp, "figure"):
481-
plt.close(self.cmp.figure)
482-
483-
self._rc_context.__exit__(exc_type, exc_value, exc_traceback)

plotnine/composition/_stack.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from plotnine.ggplot import ggplot
1010

1111

12-
@dataclass
12+
@dataclass(repr=False)
1313
class Stack(Compose):
1414
"""
1515
Place plots or compositions on top of each other

plotnine/ggplot.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
get_mimebundle,
3030
is_inline_backend,
3131
)
32-
from ._utils.quarto import is_quarto_environment
32+
from ._utils.quarto import is_knitr_engine, is_quarto_environment
3333
from .coords import coord_cartesian
3434
from .exceptions import PlotnineError, PlotnineWarning
3535
from .facets import facet_null
@@ -137,6 +137,14 @@ def __str__(self) -> str:
137137
w, h = self.theme._figure_size_px
138138
return f"<ggplot: ({w} x {h})>"
139139

140+
def __repr__(self):
141+
# knitr relies on __repr__ to automatically print the last object
142+
# in a cell.
143+
if is_knitr_engine():
144+
self.show()
145+
return ""
146+
return super().__repr__()
147+
140148
def _repr_mimebundle_(self, include=None, exclude=None) -> MimeBundle:
141149
"""
142150
Return dynamic MIME bundle for plot display

0 commit comments

Comments
 (0)