Skip to content

Commit 92652b9

Browse files
authored
Merge pull request #3 from matplotlib/enh_registry-module
ENH: add a module for pyplot like UX
2 parents 6d6ee5e + aff3c3d commit 92652b9

File tree

7 files changed

+193
-49
lines changed

7 files changed

+193
-49
lines changed

mpl_gui/__init__.py

Lines changed: 105 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
to have smooth integration with the GUI event loop as with pyplot.
1313
1414
"""
15-
import logging
15+
from collections import Counter
1616
import functools
17-
from itertools import count
17+
import logging
18+
import warnings
19+
import weakref
1820

1921
from matplotlib.backend_bases import FigureCanvasBase as _FigureCanvasBase
2022

@@ -68,7 +70,7 @@ def show(figs, *, block=None, timeout=0):
6870
if fig.canvas.manager is not None:
6971
managers.append(fig.canvas.manager)
7072
else:
71-
managers.append(promote_figure(fig))
73+
managers.append(promote_figure(fig, num=None))
7274

7375
if block is None:
7476
block = not is_interactive()
@@ -115,32 +117,41 @@ def __init__(self, *, block=None, timeout=0, prefix="Figure "):
115117
# settings stashed to set defaults on show
116118
self._timeout = timeout
117119
self._block = block
118-
# Settings / state to control the default figure label
119-
self._count = count()
120-
self._prefix = prefix
121120
# the canonical location for storing the Figures this registry owns.
122-
# any additional views must never include a figure not in the list but
121+
# any additional views must never include a figure that is not a key but
123122
# may omit figures
124-
self.figures = []
123+
self._fig_to_number = dict()
124+
# Settings / state to control the default figure label
125+
self._prefix = prefix
126+
127+
@property
128+
def figures(self):
129+
return tuple(self._fig_to_number)
125130

126131
def _register_fig(self, fig):
127132
# if the user closes the figure by any other mechanism, drop our
128133
# reference to it. This is important for getting a "pyplot" like user
129134
# experience
130-
fig.canvas.mpl_connect(
131-
"close_event",
132-
lambda e: self.figures.remove(fig) if fig in self.figures else None,
133-
)
134-
# hold a hard reference to the figure.
135-
self.figures.append(fig)
135+
def registry_cleanup(fig_wr):
136+
fig = fig_wr()
137+
if fig is not None:
138+
if fig.canvas is not None:
139+
fig.canvas.mpl_disconnect(cid)
140+
self.close(fig)
141+
142+
fig_wr = weakref.ref(fig)
143+
cid = fig.canvas.mpl_connect("close_event", lambda e: registry_cleanup(fig_wr))
136144
# Make sure we give the figure a quasi-unique label. We will never set
137145
# the same label twice, but will not over-ride any user label (but
138146
# empty string) on a Figure so if they provide duplicate labels, change
139147
# the labels under us, or provide a label that will be shadowed in the
140148
# future it will be what it is.
141-
fignum = next(self._count)
149+
fignum = max(self._fig_to_number.values(), default=-1) + 1
142150
if fig.get_label() == "":
143151
fig.set_label(f"{self._prefix}{fignum:d}")
152+
self._fig_to_number[fig] = fignum
153+
if is_interactive():
154+
promote_figure(fig, num=fignum)
144155
return fig
145156

146157
@property
@@ -150,7 +161,27 @@ def by_label(self):
150161
151162
If there are duplicate labels, newer figures will take precedence.
152163
"""
153-
return {fig.get_label(): fig for fig in self.figures}
164+
mapping = {fig.get_label(): fig for fig in self.figures}
165+
if len(mapping) != len(self.figures):
166+
counts = Counter(fig.get_label() for fig in self.figures)
167+
multiples = {k: v for k, v in counts.items() if v > 1}
168+
warnings.warn(
169+
(
170+
f"There are repeated labels ({multiples!r}), but only the newest figure with that label can "
171+
"be returned. "
172+
),
173+
stacklevel=2,
174+
)
175+
return mapping
176+
177+
@property
178+
def by_number(self):
179+
"""
180+
Return a dictionary of the current mapping number -> figures.
181+
182+
"""
183+
self._ensure_all_figures_promoted()
184+
return {fig.canvas.manager.num: fig for fig in self.figures}
154185

155186
@functools.wraps(figure)
156187
def figure(self, *args, **kwargs):
@@ -167,6 +198,11 @@ def subplot_mosaic(self, *args, **kwargs):
167198
fig, axd = subplot_mosaic(*args, **kwargs)
168199
return self._register_fig(fig), axd
169200

201+
def _ensure_all_figures_promoted(self):
202+
for f in self.figures:
203+
if f.canvas.manager is None:
204+
promote_figure(f, num=self._fig_to_number[f])
205+
170206
def show_all(self, *, block=None, timeout=None):
171207
"""
172208
Show all of the Figures that the FigureRegistry knows about.
@@ -198,7 +234,7 @@ def show_all(self, *, block=None, timeout=None):
198234

199235
if timeout is None:
200236
timeout = self._timeout
201-
237+
self._ensure_all_figures_promoted()
202238
show(self.figures, block=self._block, timeout=self._timeout)
203239

204240
# alias to easy pyplot compatibility
@@ -219,20 +255,62 @@ def close_all(self):
219255
passing it to `show`.
220256
221257
"""
222-
for fig in self.figures:
223-
if fig.canvas.manager is not None:
224-
fig.canvas.manager.destroy()
258+
for fig in list(self.figures):
259+
self.close(fig)
260+
261+
def close(self, val):
262+
"""
263+
Close (meaning destroy the UI) and forget a managed Figure.
264+
265+
This will do two things:
266+
267+
- start the destruction process of an UI (the event loop may need to
268+
run to complete this process and if the user is holding hard
269+
references to any of the UI elements they may remain alive).
270+
- Remove the `Figure` from this Registry.
271+
272+
We will no longer have any hard references to the Figure, but if
273+
the user does the `Figure` (and its components) will not be garbage
274+
collected. Due to the circular references in Matplotlib these
275+
objects may not be collected until the full cyclic garbage collection
276+
runs.
277+
278+
If the user still has a reference to the `Figure` they can re-show the
279+
figure via `show`, but the `FigureRegistry` will not be aware of it.
280+
281+
Parameters
282+
----------
283+
val : 'all' or int or str or Figure
284+
285+
- The special case of 'all' closes all open Figures
286+
- If any other string is passed, it is interpreted as a key in
287+
`by_label` and that Figure is closed
288+
- If an integer it is interpreted as a key in `by_number` and that
289+
Figure is closed
290+
- If it is a `Figure` instance, then that figure is closed
291+
292+
"""
293+
if val == "all":
294+
return self.close_all()
295+
# or do we want to close _all_ of the figures with a given label / number?
296+
if isinstance(val, str):
297+
fig = self.by_label[val]
298+
elif isinstance(val, int):
299+
fig = self.by_number[val]
300+
else:
301+
fig = val
302+
if fig not in self.figures:
303+
raise ValueError(
304+
"Trying to close a figure not associated with this Registry."
305+
)
306+
if fig.canvas.manager is not None:
307+
fig.canvas.manager.destroy()
225308
# disconnect figure from canvas
226309
fig.canvas.figure = None
227310
# disconnect canvas from figure
228311
_FigureCanvasBase(figure=fig)
229-
self.figures.clear()
230-
231-
def close(self, val):
232-
if val != "all":
233-
# TODO close figures 1 at a time
234-
raise RuntimeError("can only close them all")
235-
self.close_all()
312+
assert fig.canvas.manager is None
313+
self._fig_to_number.pop(fig, None)
236314

237315

238316
class FigureContext(FigureRegistry):

mpl_gui/_creation.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
"""Helpers to create new Figures."""
22

3-
from matplotlib import is_interactive
4-
53
from ._figure import Figure
6-
from ._promotion import promote_figure
74

85

96
def figure(
@@ -15,8 +12,6 @@ def figure(
1512
edgecolor=None, # defaults to rc figure.edgecolor
1613
frameon=True,
1714
FigureClass=Figure,
18-
clear=False,
19-
auto_draw=True,
2015
**kwargs,
2116
):
2217
"""
@@ -75,8 +70,6 @@ def figure(
7570
frameon=frameon,
7671
**kwargs,
7772
)
78-
if is_interactive():
79-
promote_figure(fig, auto_draw=auto_draw)
8073
return fig
8174

8275

@@ -216,10 +209,6 @@ def subplots(
216209
# Note that this is the same as
217210
plt.subplots(2, 2, sharex=True, sharey=True)
218211
219-
# Create figure number 10 with a single subplot
220-
# and clears it if it already exists.
221-
fig, ax = plt.subplots(num=10, clear=True)
222-
223212
"""
224213
fig = figure(**fig_kw)
225214
axs = fig.subplots(

mpl_gui/_manage_backend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def select_gui_toolkit(newbackend=None):
6868
candidates = [best_guess]
6969
else:
7070
candidates = []
71-
candidates += ["macosx", "qt5agg", "gtk3agg", "tkagg", "wxagg"]
71+
candidates += ["macosx", "qtagg", "gtk3agg", "tkagg", "wxagg"]
7272

7373
# Don't try to fallback on the cairo-based backends as they each have
7474
# an additional dependency (pycairo) over the agg-based backend, and

mpl_gui/_promotion.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,9 @@ def _auto_draw_if_interactive(fig, val):
3737
fig.canvas.draw_idle()
3838

3939

40-
def promote_figure(fig, *, auto_draw=True):
40+
def promote_figure(fig, *, auto_draw=True, num):
4141
"""Create a new figure manager instance."""
4242
_backend_mod = current_backend_module()
43-
4443
if (
4544
getattr(_backend_mod.FigureCanvas, "required_interactive_framework", None)
4645
and threading.current_thread() is not threading.main_thread()
@@ -57,7 +56,10 @@ def promote_figure(fig, *, auto_draw=True):
5756
return fig.canvas.manager
5857
# TODO: do we want to make sure we poison / destroy / decouple the existing
5958
# canavs?
60-
manager = _backend_mod.new_figure_manager_given_figure(next(_figure_count), fig)
59+
next_num = next(_figure_count)
60+
manager = _backend_mod.new_figure_manager_given_figure(
61+
num if num is not None else next_num, fig
62+
)
6163
if fig.get_label():
6264
manager.set_window_title(fig.get_label())
6365

@@ -71,7 +73,6 @@ def promote_figure(fig, *, auto_draw=True):
7173
# HACK: the callback in backend_bases uses GCF.destroy which misses these
7274
# figures by design!
7375
def _destroy(event):
74-
7576
if event.key in mpl.rcParams["keymap.quit"]:
7677
# grab the manager off the event
7778
mgr = event.canvas.manager

mpl_gui/registry.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Reproduces the module-level pyplot UX for Figure management."""
2+
3+
from . import FigureRegistry as _FigureRegistry
4+
from ._manage_backend import select_gui_toolkit
5+
from ._manage_interactive import ion, ioff, is_interactive
6+
7+
_fr = _FigureRegistry()
8+
9+
_fr_exports = [
10+
"figure",
11+
"subplots",
12+
"subplot_mosaic",
13+
"by_label",
14+
"show",
15+
"show_all",
16+
"close",
17+
"close_all",
18+
]
19+
20+
for k in _fr_exports:
21+
locals()[k] = getattr(_fr, k)
22+
23+
24+
def get_figlabels():
25+
return list(_fr.by_label)
26+
27+
28+
def get_fignums():
29+
return sorted(_fr.by_number)
30+
31+
32+
# if one must. `from foo import *` is a language miss-feature, but provide
33+
# sensible behavior anyway.
34+
__all__ = _fr_exports + [
35+
"select_gui_toolkit",
36+
"ion",
37+
"ioff",
38+
"is_interactive",
39+
]

mpl_gui/tests/conftest.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@
88
import sys
99

1010

11-
# make sure we do not sneakily get pyplot
12-
sys.modules["matplotlib.pyplot"] = None
11+
def pytest_configure(config):
12+
# config is initialized here rather than in pytest.ini so that `pytest
13+
# --pyargs matplotlib` (which would not find pytest.ini) works. The only
14+
# entries in pytest.ini set minversion (which is checked earlier),
15+
# testpaths/python_files, as they are required to properly find the tests
16+
for key, value in [
17+
("filterwarnings", "error"),
18+
]:
19+
config.addinivalue_line(key, value)
20+
21+
# make sure we do not sneakily get pyplot
22+
assert sys.modules.get("matplotlib.pyplot") is None
23+
sys.modules["matplotlib.pyplot"] = None
1324

1425

1526
class TestManger(FigureManagerBase):

0 commit comments

Comments
 (0)