Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bc31177
ENH: Allow to register standalone figures with pyplot
timhoffm Apr 1, 2025
c718eae
Clarifying comment
timhoffm Jul 27, 2025
a38f9bb
Fix: properly decouple from pyplot and specific backends when destroying
timhoffm Jul 27, 2025
eab4a89
TST: do not use non-baseclass methods to get renderer in tests
tacaswell Jul 30, 2025
2396e90
TST: fix wx window closing test
tacaswell Jul 30, 2025
b7a5cfd
TST: try forcing the dpi when testing saving after closing window
tacaswell Jul 30, 2025
c578ec6
FIX: do not add super().destroy to Canvas destroy method
tacaswell Jul 30, 2025
06b97a8
Add what's new note
timhoffm Jul 30, 2025
db30cbe
MNT: restore the old DPI when resetting the canvas
tacaswell Jul 31, 2025
1ea856d
TST: verify that dpi is restored on close
tacaswell Jul 31, 2025
0e078a7
Update lib/matplotlib/figure.py
timhoffm Aug 1, 2025
1784c73
Add docs to state that you should not keep a reference to the canvas
timhoffm Aug 2, 2025
782750e
Reword what's new message
timhoffm Aug 22, 2025
8b77fff
DOC: move whats new to new location
tacaswell Aug 22, 2025
5906a4f
Update what's new according to suggestions
timhoffm Aug 31, 2025
545892a
Move all super().destroy() calls to the end
timhoffm Aug 31, 2025
6af9062
Add note on incompatibility with blitting of widgets
timhoffm Sep 7, 2025
6b1797b
Fix assertion error
timhoffm Sep 8, 2025
b42ce64
Fix macosx
timhoffm Sep 8, 2025
774d02c
Call super().destroy() in _maxosx.m FigureManager
timhoffm Sep 9, 2025
ccf1994
Better document super call
timhoffm Sep 11, 2025
623fe4d
Fix typos
timhoffm Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions doc/release/next_whats_new/pyplot-register-figure.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
Figures can be attached to and removed from pyplot
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Figures can now be attached to and removed from management through pyplot, which in
the background also means a less strict coupling to backends.

In particular, standalone figures (created with the `.Figure` constructor) can now be
registered with the `.pyplot` module by calling ``plt.figure(fig)``. This allows to
show them with ``plt.show()`` as you would do with any figure created with pyplot
factory methods such as ``plt.figure()`` or ``plt.subplots()``.

When closing a shown figure window, the related figure is reset to the standalone
state, i.e. it's not visible to pyplot anymore, but if you still hold a reference
to it, you can continue to work with it (e.g. do ``fig.savefig()``, or re-add it
to pyplot with ``plt.figure(fig)`` and then show it again).

The following is now possible - though the example is exaggerated to show what's
possible. In practice, you'll stick with much simpler versions for better
consistency ::

import matplotlib.pyplot as plt
from matplotlib.figure import Figure

# Create a standalone figure
fig = Figure()
ax = fig.add_subplot()
ax.plot([1, 2, 3], [4, 5, 6])

# Register it with pyplot
plt.figure(fig)

# Modify the figure through pyplot
plt.xlabel("x label")

# Show the figure
plt.show()

# Close the figure window through the GUI

# Continue to work on the figure
fig.savefig("my_figure.png")
ax.set_ylabel("y label")

# Re-register the figure and show it again
plt.figure(fig)
plt.show()

Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is
replaced by a backend-dependent subclass when registering with pyplot, and is
reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses
the current canvas to save the figure (if possible). Since `.FigureCanvasBase`
can not render the figure, when saving the figure, it will fallback to a suitable
canvas subclass, e.g. `.FigureCanvasAgg` for raster outputs such as png.
Any Agg-based backend will create the same file output. However, there may be
slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as
interactive backend, ``fig.savefig("file.png")`` may create a slightly different
image depending on whether the figure is registered with pyplot or not. In
general, you should not store a reference to the canvas, but rather always
obtain it from the figure with ``fig.canvas``. This will return the current
canvas, which is either the original `.FigureCanvasBase` or a backend-dependent
subclass, depending on whether the figure is registered with pyplot or not.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment that there are currently known limitations wrt. blitting/widgets (#30503)? We can always edit that out once that issue is fixed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Additionally, the swapping of the canvas currently does not play well with
blitting of matplotlib widgets; in such cases either deactivate blitting or do not
continue to use the figure (e.g. saving it after closing the window).

Additionally, the swapping of the canvas currently does not play well with
blitting of matplotlib widgets; in such cases either deactivate blitting or do not
continue to use the figure (e.g. saving it after closing the window).
2 changes: 2 additions & 0 deletions lib/matplotlib/_pylab_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def destroy(cls, num):
two managers share the same number.
"""
if all(hasattr(num, attr) for attr in ["num", "destroy"]):
# num is a manager-like instance (not necessarily a
# FigureManagerBase subclass)
manager = num
if cls.figs.get(manager.num) is manager:
cls.figs.pop(manager.num)
Expand Down
4 changes: 3 additions & 1 deletion lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2723,7 +2723,9 @@ def show(self):
f"shown")

def destroy(self):
pass
# managers may have swapped the canvas to a GUI-framework specific one.
# restore the base canvas when the manager is destroyed.
self.canvas.figure._set_base_canvas()

def full_screen_toggle(self):
pass
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/_backend_gtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def destroy(self, *args):
self._destroying = True
self.window.destroy()
self.canvas.destroy()
super().destroy()

@classmethod
def start_main_loop(cls):
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ def delayed_destroy():
else:
self.window.update()
delayed_destroy()
super().destroy()

def get_window_title(self):
return self.window.wm_title()
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_gtk3.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def __init__(self, figure=None):

def destroy(self):
CloseEvent("close_event", self)._process()
super().destroy()

def set_cursor(self, cursor):
# docstring inherited
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_nbagg.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def destroy(self):
for comm in list(self.web_sockets):
comm.on_close()
self.clearup_closed()
super().destroy()

def clearup_closed(self):
"""Clear up any closed Comms."""
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ def destroy(self, *args):
if self.toolbar:
self.toolbar.destroy()
self.window.close()
super().destroy()

def get_window_title(self):
return self.window.windowTitle()
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ def destroy(self, *args):
# As this can be called from non-GUI thread from plt.close use
# wx.CallAfter to ensure thread safety.
wx.CallAfter(frame.Close)
super().destroy()

def full_screen_toggle(self):
# docstring inherited
Expand Down
16 changes: 15 additions & 1 deletion lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -2642,7 +2642,7 @@ def __init__(self,
self._set_artist_props(self.patch)
self.patch.set_antialiased(False)

FigureCanvasBase(self) # Set self.canvas.
self._set_base_canvas()

if subplotpars is None:
subplotpars = SubplotParams()
Expand Down Expand Up @@ -2996,6 +2996,20 @@ def get_constrained_layout_pads(self, relative=False):

return w_pad, h_pad, wspace, hspace

def _set_base_canvas(self):
"""
Initialize self.canvas with a FigureCanvasBase instance.

This is used upon initialization of the Figure, but also
to reset the canvas when decoupling from pyplot.
"""
# check if we have changed the DPI due to hi-dpi screens
orig_dpi = getattr(self, '_original_dpi', self._dpi)
FigureCanvasBase(self) # Set self.canvas as a side-effect
# put it back to what it was
if orig_dpi != self._dpi:
self.dpi = orig_dpi

def set_canvas(self, canvas):
"""
Set the canvas that contains the figure
Expand Down
23 changes: 19 additions & 4 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,10 @@ def figure(
window title is set to this value. If num is a ``SubFigure``, its
parent ``Figure`` is activated.

If *num* is a Figure instance that is already tracked in pyplot, it is
activated. If *num* is a Figure instance that is not tracked in pyplot,
it is added to the tracked figures and activated.

figsize : (float, float) or (float, float, str), default: :rc:`figure.figsize`
The figure dimensions. This can be

Expand Down Expand Up @@ -1019,21 +1023,32 @@ def figure(
in the matplotlibrc file.
"""
allnums = get_fignums()
next_num = max(allnums) + 1 if allnums else 1

if isinstance(num, FigureBase):
# type narrowed to `Figure | SubFigure` by combination of input and isinstance
has_figure_property_parameters = (
any(param is not None for param in [figsize, dpi, facecolor, edgecolor])
or not frameon or kwargs
)

root_fig = num.get_figure(root=True)
if root_fig.canvas.manager is None:
raise ValueError("The passed figure is not managed by pyplot")
elif (any(param is not None for param in [figsize, dpi, facecolor, edgecolor])
or not frameon or kwargs) and root_fig.canvas.manager.num in allnums:
if has_figure_property_parameters:
raise ValueError(
"You cannot pass figure properties when calling figure() with "
"an existing Figure instance")
backend = _get_backend_mod()
manager_ = backend.new_figure_manager_given_figure(next_num, root_fig)
_pylab_helpers.Gcf._set_new_active_manager(manager_)
return manager_.canvas.figure
elif has_figure_property_parameters and root_fig.canvas.manager.num in allnums:
_api.warn_external(
"Ignoring specified arguments in this call because figure "
f"with num: {root_fig.canvas.manager.num} already exists")
_pylab_helpers.Gcf.set_active(root_fig.canvas.manager)
return root_fig

next_num = max(allnums) + 1 if allnums else 1
fig_label = ''
if num is None:
num = next_num
Expand Down
7 changes: 3 additions & 4 deletions lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8223,10 +8223,9 @@ def color_boxes(fig, ax):
"""
fig.canvas.draw()

renderer = fig.canvas.get_renderer()
bbaxis = []
for nn, axx in enumerate([ax.xaxis, ax.yaxis]):
bb = axx.get_tightbbox(renderer)
bb = axx.get_tightbbox()
if bb:
axisr = mpatches.Rectangle(
(bb.x0, bb.y0), width=bb.width, height=bb.height,
Expand All @@ -8237,7 +8236,7 @@ def color_boxes(fig, ax):

bbspines = []
for nn, a in enumerate(['bottom', 'top', 'left', 'right']):
bb = ax.spines[a].get_window_extent(renderer)
bb = ax.spines[a].get_window_extent()
spiner = mpatches.Rectangle(
(bb.x0, bb.y0), width=bb.width, height=bb.height,
linewidth=0.7, edgecolor="green", facecolor="none", transform=None,
Expand All @@ -8253,7 +8252,7 @@ def color_boxes(fig, ax):
fig.add_artist(rect2)
bbax = bb

bb2 = ax.get_tightbbox(renderer)
bb2 = ax.get_tightbbox()
rect2 = mpatches.Rectangle(
(bb2.x0, bb2.y0), width=bb2.width, height=bb2.height,
linewidth=3, edgecolor="red", facecolor="none", transform=None,
Expand Down
4 changes: 4 additions & 0 deletions lib/matplotlib/tests/test_backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ def set_device_pixel_ratio(ratio):
assert qt_canvas.get_width_height() == (600, 240)
assert (fig.get_size_inches() == (5, 2)).all()

# check that closing the figure restores the original dpi
plt.close(fig)
assert fig.dpi == 120


@pytest.mark.backend('QtAgg', skip_on_importerror=True)
def test_subplottool():
Expand Down
21 changes: 14 additions & 7 deletions lib/matplotlib/tests/test_backends_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def _test_interactive_impl():

import matplotlib as mpl
from matplotlib import pyplot as plt
from matplotlib.backend_bases import KeyEvent
from matplotlib.backend_bases import KeyEvent, FigureCanvasBase
mpl.rcParams.update({
"webagg.open_in_browser": False,
"webagg.port_retries": 1,
Expand Down Expand Up @@ -220,19 +220,23 @@ def check_alt_backend(alt_backend):
fig.canvas.mpl_connect("close_event", print)

result = io.BytesIO()
fig.savefig(result, format='png')
fig.savefig(result, format='png', dpi=100)

plt.show()

# Ensure that the window is really closed.
plt.pause(0.5)

# Test that saving works after interactive window is closed, but the figure
# is not deleted.
# When the figure is closed, its manager is removed and the canvas is reset to
# FigureCanvasBase. Saving should still be possible.
assert type(fig.canvas) == FigureCanvasBase, str(fig.canvas)
result_after = io.BytesIO()
fig.savefig(result_after, format='png')
fig.savefig(result_after, format='png', dpi=100)

assert result.getvalue() == result_after.getvalue()
if backend.endswith("agg"):
# agg-based interactive backends should save the same image as a non-interactive
# figure
assert result.getvalue() == result_after.getvalue()


@pytest.mark.parametrize("env", _get_testable_interactive_backends())
Expand Down Expand Up @@ -285,10 +289,13 @@ def _test_thread_impl():
future = ThreadPoolExecutor().submit(fig.canvas.draw)
plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176)
future.result() # Joins the thread; rethrows any exception.
# stash the current canvas as closing the figure will reset the canvas on
# the figure
canvas = fig.canvas
plt.close() # backend is responsible for flushing any events here
if plt.rcParams["backend"].lower().startswith("wx"):
# TODO: debug why WX needs this only on py >= 3.8
fig.canvas.flush_events()
canvas.flush_events()


_thread_safe_backends = _get_testable_interactive_backends()
Expand Down
2 changes: 0 additions & 2 deletions lib/matplotlib/tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,6 @@ def test_figure_label():
assert plt.get_figlabels() == ['', 'today']
plt.figure(fig_today)
assert plt.gcf() == fig_today
with pytest.raises(ValueError):
plt.figure(Figure())


def test_figure_label_replaced():
Expand Down
24 changes: 24 additions & 0 deletions lib/matplotlib/tests/test_pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,30 @@ def test_multiple_same_figure_calls():
assert fig is fig3


def test_register_existing_figure_with_pyplot():
from matplotlib.figure import Figure
# start with a standalone figure
fig = Figure()
assert fig.canvas.manager is None
with pytest.raises(AttributeError):
# Heads-up: This will change to returning None in the future
# See docstring for the Figure.number property
fig.number
# register the Figure with pyplot
plt.figure(fig)
assert fig.number == 1
# the figure can now be used in pyplot
plt.suptitle("my title")
assert fig.get_suptitle() == "my title"
# it also has a manager that is properly wired up in the pyplot state
assert plt._pylab_helpers.Gcf.get_fig_manager(fig.number) is fig.canvas.manager
# and we can regularly switch the pyplot state
fig2 = plt.figure()
assert fig2.number == 2
assert plt.figure(1) is fig
assert plt.gcf() is fig


def test_close_all_warning():
fig1 = plt.figure()

Expand Down
21 changes: 21 additions & 0 deletions src/_macosx.m
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name)
},
};

static PyTypeObject FigureManagerType; // forward declaration, needed in destroy()

typedef struct {
PyObject_HEAD
Window* window;
Expand Down Expand Up @@ -686,6 +688,25 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name)
{
[self->window close];
self->window = NULL;

// call super(self, FigureManager).destroy() - it seems we need the
// explicit arguments, and just super() doesn't work in the C API.
PyObject *super_obj = PyObject_CallFunctionObjArgs(
(PyObject *)&PySuper_Type,
(PyObject *)&FigureManagerType,
self,
NULL
);
if (super_obj == NULL) {
return NULL; // error
}
PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL);
Py_DECREF(super_obj);
if (result == NULL) {
return NULL; // error
}
Py_DECREF(result);

Py_RETURN_NONE;
}

Expand Down
Loading