Skip to content

ENH: mpl_gui to main library #29836

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

Closed
wants to merge 2 commits into from
Closed
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
1 change: 1 addition & 0 deletions lib/matplotlib/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ subdir('_api')
subdir('axes')
subdir('backends')
subdir('mpl-data')
subdir('mpl_gui')
subdir('projections')
subdir('sphinxext')
subdir('style')
Expand Down
352 changes: 352 additions & 0 deletions lib/matplotlib/mpl_gui/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
"""
Prototype project for new Matplotlib GUI management.

The pyplot module current serves two critical, but unrelated functions:

1. provide a state-full implicit API that rhymes / was inspired by MATLAB
2. provide the management of interaction between Matplotlib and the GUI event
loop

This project is prototype for separating the second function from the first.
This will enable users to both only use the explicit API (nee OO interface) and
to have smooth integration with the GUI event loop as with pyplot.

"""
from collections import Counter
from itertools import count
import functools
import logging
import warnings

from matplotlib.backend_bases import FigureCanvasBase as _FigureCanvasBase

from ._figure import Figure # noqa: F401

from ._manage_interactive import ion, ioff, is_interactive # noqa: F401
from ._manage_backend import select_gui_toolkit # noqa: F401
from ._manage_backend import current_backend_module as _cbm
from ._promotion import promote_figure as promote_figure
from ._creation import figure, subplots, subplot_mosaic # noqa: F401

_log = logging.getLogger(__name__)


def show(figs, *, block=None, timeout=0):
"""
Show the figures and maybe block.

Parameters
----------
figs : List[Figure]
The figures to show. If they do not currently have a GUI aware
canvas + manager attached they will be promoted.

block : bool, optional
Whether to wait for all figures to be closed before returning.

If `True` block and run the GUI main loop until all figure windows
are closed.

If `False` ensure that all figure windows are displayed and return
immediately. In this case, you are responsible for ensuring
that the event loop is running to have responsive figures.

Defaults to True in non-interactive mode and to False in interactive
mode (see `.is_interactive`).

"""
# TODO handle single figure

# call this to ensure a backend is indeed selected
backend = _cbm()
managers = []

Check warning on line 62 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L61-L62

Added lines #L61 - L62 were not covered by tests
for fig in figs:
if fig.canvas.manager is not None:
managers.append(fig.canvas.manager)

Check warning on line 65 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L65

Added line #L65 was not covered by tests
else:
managers.append(promote_figure(fig))

Check warning on line 67 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L67

Added line #L67 was not covered by tests

if block is None:
block = not is_interactive()

Check warning on line 70 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L70

Added line #L70 was not covered by tests

if block and len(managers):
if timeout == 0:
backend.show_managers(managers=managers, block=block)

Check warning on line 74 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L74

Added line #L74 was not covered by tests
elif len(managers):
manager, *_ = managers
manager.canvas.start_event_loop(timeout=timeout)

Check warning on line 77 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L76-L77

Added lines #L76 - L77 were not covered by tests


class FigureRegistry:
"""
A registry to wrap the creation of figures and track them.

This instance will keep a hard reference to created Figures to ensure
that they do not get garbage collected.

Parameters
----------
block : bool, optional
Whether to wait for all figures to be closed before returning from
show_all.

If `True` block and run the GUI main loop until all figure windows
are closed.

If `False` ensure that all figure windows are displayed and return
immediately. In this case, you are responsible for ensuring
that the event loop is running to have responsive figures.

Defaults to True in non-interactive mode and to False in interactive
mode (see `.is_interactive`).

timeout : float, optional
Default time to wait for all of the Figures to be closed if blocking.

If 0 block forever.

"""

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
# may omit figures
self.figures = []

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(

Check warning on line 126 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L126

Added line #L126 was not covered by tests
"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)

Check warning on line 131 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L131

Added line #L131 was not covered by tests
# 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)

Check warning on line 137 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L137

Added line #L137 was not covered by tests
if fig.get_label() == "":
fig.set_label(f"{self._prefix}{fignum:d}")

Check warning on line 139 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L139

Added line #L139 was not covered by tests
# TODO: is there a better way to track this than monkey patching?
fig._mpl_gui_fignum = fignum
return fig

Check warning on line 142 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L141-L142

Added lines #L141 - L142 were not covered by tests

@property
def by_label(self):
"""
Return a dictionary of the current mapping labels -> figures.

If there are duplicate labels, newer figures will take precedence.
"""
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(

Check warning on line 155 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L153-L155

Added lines #L153 - L155 were not covered by tests
(
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}

Check warning on line 171 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L170-L171

Added lines #L170 - L171 were not covered by tests

@functools.wraps(figure)
def figure(self, *args, **kwargs):
fig = figure(*args, **kwargs)
return self._register_fig(fig)

Check warning on line 176 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L175-L176

Added lines #L175 - L176 were not covered by tests

@functools.wraps(subplots)
def subplots(self, *args, **kwargs):
fig, axs = subplots(*args, **kwargs)
return self._register_fig(fig), axs

Check warning on line 181 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L180-L181

Added lines #L180 - L181 were not covered by tests

@functools.wraps(subplot_mosaic)
def subplot_mosaic(self, *args, **kwargs):
fig, axd = subplot_mosaic(*args, **kwargs)
return self._register_fig(fig), axd

Check warning on line 186 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L185-L186

Added lines #L185 - L186 were not covered by tests

def _ensure_all_figures_promoted(self):
for f in self.figures:
if f.canvas.manager is None:
promote_figure(f, num=f._mpl_gui_fignum)

Check warning on line 191 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L191

Added line #L191 was not covered by tests

def show_all(self, *, block=None, timeout=None):
"""
Show all of the Figures that the FigureRegistry knows about.

Parameters
----------
block : bool, optional
Whether to wait for all figures to be closed before returning from
show_all.

If `True` block and run the GUI main loop until all figure windows
are closed.

If `False` ensure that all figure windows are displayed and return
immediately. In this case, you are responsible for ensuring
that the event loop is running to have responsive figures.

Defaults to the value set on the Registry at init

timeout : float, optional
time to wait for all of the Figures to be closed if blocking.

If 0 block forever.

Defaults to the timeout set on the Registry at init
"""
if block is None:
block = self._block

Check warning on line 220 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L220

Added line #L220 was not covered by tests

if timeout is None:
timeout = self._timeout
self._ensure_all_figures_promoted()
show(self.figures, block=self._block, timeout=self._timeout)

Check warning on line 225 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L223-L225

Added lines #L223 - L225 were not covered by tests

# alias to easy pyplot compatibility
show = show_all

def close_all(self):
"""
Close all Figures know to this Registry.

This will do four things:

1. call the ``.destroy()`` method on the manager
2. clears the Figure on the canvas instance
3. replace the canvas on each Figure with a new `~matplotlib.backend_bases.
FigureCanvasBase` instance
4. drops its hard reference to the Figure

If the user still holds a reference to the Figure it can be revived by
passing it to `show`.

"""
for fig in list(self.figures):
self.close(fig)

Check warning on line 247 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L247

Added line #L247 was not covered by tests

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()

Check warning on line 282 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L282

Added line #L282 was not covered by tests
# or do we want to close _all_ of the figures with a given label / number?
if isinstance(val, str):
fig = self.by_label[val]

Check warning on line 285 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L285

Added line #L285 was not covered by tests
elif isinstance(val, int):
fig = self.by_number[val]

Check warning on line 287 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L287

Added line #L287 was not covered by tests
else:
fig = val

Check warning on line 289 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L289

Added line #L289 was not covered by tests
if fig not in self.figures:
raise ValueError(

Check warning on line 291 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L291

Added line #L291 was not covered by tests
"Trying to close a figure not associated with this Registry."
)
if fig.canvas.manager is not None:
fig.canvas.manager.destroy()

Check warning on line 295 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L295

Added line #L295 was not covered by tests
# disconnect figure from canvas
fig.canvas.figure = None

Check warning on line 297 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L297

Added line #L297 was not covered by tests
# disconnect canvas from figure
_FigureCanvasBase(figure=fig)
assert fig.canvas.manager is None

Check warning on line 300 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L299-L300

Added lines #L299 - L300 were not covered by tests
if fig in self.figures:
self.figures.remove(fig)

Check warning on line 302 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L302

Added line #L302 was not covered by tests


class FigureContext(FigureRegistry):
"""
Extends FigureRegistry to be used as a context manager.

All figures known to the Registry will be shown on exiting the context.

Parameters
----------
block : bool, optional
Whether to wait for all figures to be closed before returning from
show_all.

If `True` block and run the GUI main loop until all figure windows
are closed.

If `False` ensure that all figure windows are displayed and return
immediately. In this case, you are responsible for ensuring
that the event loop is running to have responsive figures.

Defaults to True in non-interactive mode and to False in interactive
mode (see `.is_interactive`).

timeout : float, optional
Default time to wait for all of the Figures to be closed if blocking.

If 0 block forever.

forgive_failure : bool, optional
If True, block to show the figure before letting the exception
propagate

"""

def __init__(self, *, forgive_failure=False, **kwargs):
super().__init__(**kwargs)
self._forgive_failure = forgive_failure

Check warning on line 340 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L339-L340

Added lines #L339 - L340 were not covered by tests

def __enter__(self):
return self

Check warning on line 343 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L343

Added line #L343 was not covered by tests

def __exit__(self, exc_type, exc_value, traceback):
if exc_value is not None and not self._forgive_failure:
return
show(self.figures, block=self._block, timeout=self._timeout)

Check warning on line 348 in lib/matplotlib/mpl_gui/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/mpl_gui/__init__.py#L347-L348

Added lines #L347 - L348 were not covered by tests


# from mpl_gui import * # is a language mis-feature
__all__ = []

Check failure on line 352 in lib/matplotlib/mpl_gui/__init__.py

View workflow job for this annotation

GitHub Actions / mypy

[mypy] reported by reviewdog 🐶 Need type annotation for "__all__" (hint: "__all__: list[<type>] = ...") [var-annotated] Raw Output: lib/matplotlib/mpl_gui/__init__.py:352: error: Need type annotation for "__all__" (hint: "__all__: list[<type>] = ...") [var-annotated]
Loading
Loading