Skip to content

ENH: add a module for pyplot like UX #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
132 changes: 105 additions & 27 deletions mpl_gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
11 changes: 0 additions & 11 deletions mpl_gui/_creation.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -15,8 +12,6 @@ def figure(
edgecolor=None, # defaults to rc figure.edgecolor
frameon=True,
FigureClass=Figure,
clear=False,
auto_draw=True,
**kwargs,
):
"""
Expand Down Expand Up @@ -75,8 +70,6 @@ def figure(
frameon=frameon,
**kwargs,
)
if is_interactive():
promote_figure(fig, auto_draw=auto_draw)
return fig


Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion mpl_gui/_manage_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions mpl_gui/_promotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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())

Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions mpl_gui/registry.py
Original file line number Diff line number Diff line change
@@ -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",
]
15 changes: 13 additions & 2 deletions mpl_gui/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading