diff --git a/mpl_gui/__init__.py b/mpl_gui/__init__.py index 8e737e3..999a216 100644 --- a/mpl_gui/__init__.py +++ b/mpl_gui/__init__.py @@ -12,9 +12,11 @@ to have smooth integration with the GUI event loop as with pyplot. """ -import logging +from collections import Counter import functools -from itertools import count +import logging +import warnings +import weakref from matplotlib.backend_bases import FigureCanvasBase as _FigureCanvasBase @@ -68,7 +70,7 @@ def show(figs, *, block=None, timeout=0): if fig.canvas.manager is not None: managers.append(fig.canvas.manager) else: - managers.append(promote_figure(fig)) + managers.append(promote_figure(fig, num=None)) if block is None: block = not is_interactive() @@ -115,32 +117,41 @@ def __init__(self, *, block=None, timeout=0, prefix="Figure "): # settings stashed to set defaults on show self._timeout = timeout self._block = block - # Settings / state to control the default figure label - self._count = count() - self._prefix = prefix # the canonical location for storing the Figures this registry owns. - # any additional views must never include a figure not in the list but + # any additional views must never include a figure that is not a key but # may omit figures - self.figures = [] + self._fig_to_number = dict() + # Settings / state to control the default figure label + self._prefix = prefix + + @property + def figures(self): + return tuple(self._fig_to_number) def _register_fig(self, fig): # if the user closes the figure by any other mechanism, drop our # reference to it. This is important for getting a "pyplot" like user # experience - fig.canvas.mpl_connect( - "close_event", - lambda e: self.figures.remove(fig) if fig in self.figures else None, - ) - # hold a hard reference to the figure. - self.figures.append(fig) + def registry_cleanup(fig_wr): + fig = fig_wr() + if fig is not None: + if fig.canvas is not None: + fig.canvas.mpl_disconnect(cid) + self.close(fig) + + fig_wr = weakref.ref(fig) + cid = fig.canvas.mpl_connect("close_event", lambda e: registry_cleanup(fig_wr)) # Make sure we give the figure a quasi-unique label. We will never set # the same label twice, but will not over-ride any user label (but # empty string) on a Figure so if they provide duplicate labels, change # the labels under us, or provide a label that will be shadowed in the # future it will be what it is. - fignum = next(self._count) + fignum = max(self._fig_to_number.values(), default=-1) + 1 if fig.get_label() == "": fig.set_label(f"{self._prefix}{fignum:d}") + self._fig_to_number[fig] = fignum + if is_interactive(): + promote_figure(fig, num=fignum) return fig @property @@ -150,7 +161,27 @@ def by_label(self): If there are duplicate labels, newer figures will take precedence. """ - return {fig.get_label(): fig for fig in self.figures} + mapping = {fig.get_label(): fig for fig in self.figures} + if len(mapping) != len(self.figures): + counts = Counter(fig.get_label() for fig in self.figures) + multiples = {k: v for k, v in counts.items() if v > 1} + warnings.warn( + ( + f"There are repeated labels ({multiples!r}), but only the newest figure with that label can " + "be returned. " + ), + stacklevel=2, + ) + return mapping + + @property + def by_number(self): + """ + Return a dictionary of the current mapping number -> figures. + + """ + self._ensure_all_figures_promoted() + return {fig.canvas.manager.num: fig for fig in self.figures} @functools.wraps(figure) def figure(self, *args, **kwargs): @@ -167,6 +198,11 @@ def subplot_mosaic(self, *args, **kwargs): fig, axd = subplot_mosaic(*args, **kwargs) return self._register_fig(fig), axd + def _ensure_all_figures_promoted(self): + for f in self.figures: + if f.canvas.manager is None: + promote_figure(f, num=self._fig_to_number[f]) + def show_all(self, *, block=None, timeout=None): """ Show all of the Figures that the FigureRegistry knows about. @@ -198,7 +234,7 @@ def show_all(self, *, block=None, timeout=None): if timeout is None: timeout = self._timeout - + self._ensure_all_figures_promoted() show(self.figures, block=self._block, timeout=self._timeout) # alias to easy pyplot compatibility @@ -219,20 +255,62 @@ def close_all(self): passing it to `show`. """ - for fig in self.figures: - if fig.canvas.manager is not None: - fig.canvas.manager.destroy() + for fig in list(self.figures): + self.close(fig) + + def close(self, val): + """ + Close (meaning destroy the UI) and forget a managed Figure. + + This will do two things: + + - start the destruction process of an UI (the event loop may need to + run to complete this process and if the user is holding hard + references to any of the UI elements they may remain alive). + - Remove the `Figure` from this Registry. + + We will no longer have any hard references to the Figure, but if + the user does the `Figure` (and its components) will not be garbage + collected. Due to the circular references in Matplotlib these + objects may not be collected until the full cyclic garbage collection + runs. + + If the user still has a reference to the `Figure` they can re-show the + figure via `show`, but the `FigureRegistry` will not be aware of it. + + Parameters + ---------- + val : 'all' or int or str or Figure + + - The special case of 'all' closes all open Figures + - If any other string is passed, it is interpreted as a key in + `by_label` and that Figure is closed + - If an integer it is interpreted as a key in `by_number` and that + Figure is closed + - If it is a `Figure` instance, then that figure is closed + + """ + if val == "all": + return self.close_all() + # or do we want to close _all_ of the figures with a given label / number? + if isinstance(val, str): + fig = self.by_label[val] + elif isinstance(val, int): + fig = self.by_number[val] + else: + fig = val + if fig not in self.figures: + raise ValueError( + "Trying to close a figure not associated with this Registry." + ) + if fig.canvas.manager is not None: + fig.canvas.manager.destroy() # disconnect figure from canvas fig.canvas.figure = None # disconnect canvas from figure _FigureCanvasBase(figure=fig) - self.figures.clear() - - def close(self, val): - if val != "all": - # TODO close figures 1 at a time - raise RuntimeError("can only close them all") - self.close_all() + assert fig.canvas.manager is None + self._fig_to_number.pop(fig, None) class FigureContext(FigureRegistry): diff --git a/mpl_gui/_creation.py b/mpl_gui/_creation.py index 689b623..7b83ef7 100644 --- a/mpl_gui/_creation.py +++ b/mpl_gui/_creation.py @@ -1,9 +1,6 @@ """Helpers to create new Figures.""" -from matplotlib import is_interactive - from ._figure import Figure -from ._promotion import promote_figure def figure( @@ -15,8 +12,6 @@ def figure( edgecolor=None, # defaults to rc figure.edgecolor frameon=True, FigureClass=Figure, - clear=False, - auto_draw=True, **kwargs, ): """ @@ -75,8 +70,6 @@ def figure( frameon=frameon, **kwargs, ) - if is_interactive(): - promote_figure(fig, auto_draw=auto_draw) return fig @@ -216,10 +209,6 @@ def subplots( # Note that this is the same as plt.subplots(2, 2, sharex=True, sharey=True) - # Create figure number 10 with a single subplot - # and clears it if it already exists. - fig, ax = plt.subplots(num=10, clear=True) - """ fig = figure(**fig_kw) axs = fig.subplots( diff --git a/mpl_gui/_manage_backend.py b/mpl_gui/_manage_backend.py index 1c2aadc..1929b08 100644 --- a/mpl_gui/_manage_backend.py +++ b/mpl_gui/_manage_backend.py @@ -68,7 +68,7 @@ def select_gui_toolkit(newbackend=None): candidates = [best_guess] else: candidates = [] - candidates += ["macosx", "qt5agg", "gtk3agg", "tkagg", "wxagg"] + candidates += ["macosx", "qtagg", "gtk3agg", "tkagg", "wxagg"] # Don't try to fallback on the cairo-based backends as they each have # an additional dependency (pycairo) over the agg-based backend, and diff --git a/mpl_gui/_promotion.py b/mpl_gui/_promotion.py index b497a21..d2aa086 100644 --- a/mpl_gui/_promotion.py +++ b/mpl_gui/_promotion.py @@ -37,10 +37,9 @@ def _auto_draw_if_interactive(fig, val): fig.canvas.draw_idle() -def promote_figure(fig, *, auto_draw=True): +def promote_figure(fig, *, auto_draw=True, num): """Create a new figure manager instance.""" _backend_mod = current_backend_module() - if ( getattr(_backend_mod.FigureCanvas, "required_interactive_framework", None) and threading.current_thread() is not threading.main_thread() @@ -57,7 +56,10 @@ def promote_figure(fig, *, auto_draw=True): return fig.canvas.manager # TODO: do we want to make sure we poison / destroy / decouple the existing # canavs? - manager = _backend_mod.new_figure_manager_given_figure(next(_figure_count), fig) + next_num = next(_figure_count) + manager = _backend_mod.new_figure_manager_given_figure( + num if num is not None else next_num, fig + ) if fig.get_label(): manager.set_window_title(fig.get_label()) @@ -71,7 +73,6 @@ def promote_figure(fig, *, auto_draw=True): # HACK: the callback in backend_bases uses GCF.destroy which misses these # figures by design! def _destroy(event): - if event.key in mpl.rcParams["keymap.quit"]: # grab the manager off the event mgr = event.canvas.manager diff --git a/mpl_gui/registry.py b/mpl_gui/registry.py new file mode 100644 index 0000000..e8919b2 --- /dev/null +++ b/mpl_gui/registry.py @@ -0,0 +1,39 @@ +"""Reproduces the module-level pyplot UX for Figure management.""" + +from . import FigureRegistry as _FigureRegistry +from ._manage_backend import select_gui_toolkit +from ._manage_interactive import ion, ioff, is_interactive + +_fr = _FigureRegistry() + +_fr_exports = [ + "figure", + "subplots", + "subplot_mosaic", + "by_label", + "show", + "show_all", + "close", + "close_all", +] + +for k in _fr_exports: + locals()[k] = getattr(_fr, k) + + +def get_figlabels(): + return list(_fr.by_label) + + +def get_fignums(): + return sorted(_fr.by_number) + + +# if one must. `from foo import *` is a language miss-feature, but provide +# sensible behavior anyway. +__all__ = _fr_exports + [ + "select_gui_toolkit", + "ion", + "ioff", + "is_interactive", +] diff --git a/mpl_gui/tests/conftest.py b/mpl_gui/tests/conftest.py index dea8c2d..e4d8d87 100644 --- a/mpl_gui/tests/conftest.py +++ b/mpl_gui/tests/conftest.py @@ -8,8 +8,19 @@ import sys -# make sure we do not sneakily get pyplot -sys.modules["matplotlib.pyplot"] = None +def pytest_configure(config): + # config is initialized here rather than in pytest.ini so that `pytest + # --pyargs matplotlib` (which would not find pytest.ini) works. The only + # entries in pytest.ini set minversion (which is checked earlier), + # testpaths/python_files, as they are required to properly find the tests + for key, value in [ + ("filterwarnings", "error"), + ]: + config.addinivalue_line(key, value) + + # make sure we do not sneakily get pyplot + assert sys.modules.get("matplotlib.pyplot") is None + sys.modules["matplotlib.pyplot"] = None class TestManger(FigureManagerBase): diff --git a/mpl_gui/tests/test_examples.py b/mpl_gui/tests/test_examples.py index c10e448..0306312 100644 --- a/mpl_gui/tests/test_examples.py +++ b/mpl_gui/tests/test_examples.py @@ -8,7 +8,6 @@ def test_no_pyplot(): - assert sys.modules.get("matplotlib.pyplot", None) is None @@ -75,7 +74,6 @@ class TestException(Exception): if forgiving: assert "start_event_loop" in fig.canvas.call_info else: - assert isinstance(fig.canvas, FigureCanvasBase) @@ -113,10 +111,12 @@ def test_labels_collision(): fr = mg.FigureRegistry(block=False) for j in range(5): fr.figure(label="aardvark") - assert list(fr.by_label) == ["aardvark"] + with pytest.warns(UserWarning, match="{'aardvark': 5}"): + assert list(fr.by_label) == ["aardvark"] assert len(fr.figures) == 5 assert len(set(fr.figures)) == 5 - assert fr.figures[-1] is fr.by_label["aardvark"] + with pytest.warns(UserWarning, match="{'aardvark': 5}"): + assert fr.figures[-1] is fr.by_label["aardvark"] fr.close_all() assert len(fr.by_label) == 0 @@ -138,3 +138,29 @@ def test_change_labels(): for j, f in enumerate(fr.by_label.values()): f.set_label(f"aardvark {j}") assert list(fr.by_label) == [f"aardvark {j}" for j in range(5)] + + +def test_close_one_at_a_time(): + fr = mg.FigureRegistry(block=False) + fig1 = fr.figure(label="a") + fig2 = fr.figure(label="b") + fig3 = fr.figure(label="c") + fr.figure(label="d") + assert len(fr.figures) == 4 + + fr.close(fig1) + assert len(fr.figures) == 3 + assert fig1 not in fr.figures + + fr.close(fig2.get_label()) + assert len(fr.figures) == 2 + assert fig2 not in fr.figures + + fr.show() + + fr.close(fig3.canvas.manager.num) + assert len(fr.figures) == 1 + assert fig3 not in fr.figures + + fr.close("all") + assert len(fr.figures) == 0