diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 09235f6..e4c45e3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,7 +8,7 @@ env: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install Python dependencies diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b01b4bb..a016ff1 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.9', '3.10', '3.11', '3.12'] fail-fast: false steps: diff --git a/docs/source/api.rst b/docs/source/api.rst index b3f007d..f016221 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -1,16 +1,18 @@ -mpl gui -======= +mpl gui API Reference +===================== + +.. automodule:: mpl_gui + :no-undoc-members: -.. module:: mpl_gui -Show ----- +Select the backend +------------------ .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.show + + mpl_gui.select_gui_toolkit Interactivity @@ -18,37 +20,48 @@ Interactivity .. autosummary:: :toctree: _as_gen - :nosignatures: + mpl_gui.ion mpl_gui.ioff mpl_gui.is_interactive -Figure Fabrication ------------------- +Unmanaged Figures +----------------- -Un-managed -++++++++++ +Figure Creation ++++++++++++++++ +These are not strictly necessary as they are only thin wrappers around creating +a `matplotlib.figure.Figure` instance and creating children in one line. .. autosummary:: :toctree: _as_gen - :nosignatures: + + mpl_gui.figure mpl_gui.subplots mpl_gui.subplot_mosaic + +Display ++++++++ + .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.promote_figure -Managed -+++++++ + + mpl_gui.display + mpl_gui.demote_figure + + + +Locally Managed Figures +----------------------- .. autoclass:: mpl_gui.FigureRegistry @@ -56,31 +69,107 @@ Managed :show-inheritance: +.. autoclass:: mpl_gui.FigureContext + :no-undoc-members: + :show-inheritance: + +Create Figures and Axes ++++++++++++++++++++++++ + .. autosummary:: :toctree: _as_gen - :nosignatures: + mpl_gui.FigureRegistry.figure mpl_gui.FigureRegistry.subplots mpl_gui.FigureRegistry.subplot_mosaic + + +Access managed figures +++++++++++++++++++++++ + +.. autosummary:: + :toctree: _as_gen + + mpl_gui.FigureRegistry.by_label + mpl_gui.FigureRegistry.by_number + mpl_gui.FigureRegistry.figures + + + +Show and close managed Figures +++++++++++++++++++++++++++++++ + + +.. autosummary:: + :toctree: _as_gen + + mpl_gui.FigureRegistry.show_all mpl_gui.FigureRegistry.close_all + mpl_gui.FigureRegistry.show + mpl_gui.FigureRegistry.close -.. autoclass:: mpl_gui.FigureContext + + +Globally managed +---------------- + + +.. automodule:: mpl_gui.global_figures :no-undoc-members: - :show-inheritance: +Create Figures and Axes ++++++++++++++++++++++++ +.. autosummary:: + :toctree: _as_gen + + + mpl_gui.global_figures.figure + mpl_gui.global_figures.subplots + mpl_gui.global_figures.subplot_mosaic + + +Access managed figures +++++++++++++++++++++++ -Select the backend ------------------- .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.select_gui_toolkit + + mpl_gui.global_figures.by_label + + +Show and close managed Figures +++++++++++++++++++++++++++++++ + + +.. autosummary:: + :toctree: _as_gen + + + + + mpl_gui.global_figures.show + mpl_gui.global_figures.show_all + mpl_gui.global_figures.close_all + mpl_gui.global_figures.close + + +Interactivity ++++++++++++++ + +.. autosummary:: + :toctree: _as_gen + + + + mpl_gui.global_figures.ion + mpl_gui.global_figures.ioff + mpl_gui.global_figures.is_interactive diff --git a/docs/source/conf.py b/docs/source/conf.py index d94b651..8e7bafc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -129,6 +129,9 @@ # and CI builds https://github.com/pydata/pydata-sphinx-theme/pull/386 "collapse_navigation": not is_release_build, "show_prev_next": False, + "navigation_with_keys": False, + # "secondary_sidebar_items": "page-toc.html", + "footer_start": ["copyright", "sphinx-version", "doc_version"], } include_analytics = is_release_build if include_analytics: @@ -153,9 +156,6 @@ # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { - "**": [ - "relations.html", # needs 'show_related': True theme option to display - ] } diff --git a/docs/source/index.rst b/docs/source/index.rst index 8844303..72bbf95 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,9 +6,10 @@ ======================= mpl-gui Documentation ======================= +.. highlight:: python .. toctree:: - :maxdepth: 2 + :maxdepth: 1 api release_history @@ -30,18 +31,12 @@ The pyplot module current serves two critical, but unrelated functions: While it can be very convenient when working at the prompt, the state-full API can lead to brittle code that depends on the global state in confusing ways, particularly when used in library code. On the other hand, -``matplotlib.pyplot`` does a very good job of hiding from the user the fact +`matplotlib.pyplot` does a very good job of hiding from the user the fact that they are developing a GUI application and handling, along with IPython, many of the details involved in running a GUI application in parallel with Python. -Examples -======== - -.. highlight:: python - - If you want to be sure that this code does not secretly depend on pyplot run :: import sys @@ -51,10 +46,30 @@ If you want to be sure that this code does not secretly depend on pyplot run :: which will prevent pyplot from being imported! -showing -------- -The core of the API is `~.show` :: +Selecting the GUI toolkit +========================= + +`mpl_gui` makes use of `Matplotlib backends +`_ for actually +providing the GUI bindings. Analagous to `matplotlib.use` and +`matplotlib.pyplot.switch_backend` `mpl_gui` provides +`mpl_gui.select_gui_toolkit` to select which GUI toolkit is used. +`~mpl_gui.select_gui_toolkit` has the same fall-back behavior as +`~matplotlib.pyplot` and stores its state in :rc:`backend`. + +`mpl_gui` will +consistently co-exist with `matplotlib.pyplot` managed Figures in the same +process. + + + +User Managed Figures +==================== + +There are cases where having such a registry may be too much implicit state. +For such cases the underlying tools that `.FigureRegistry` are built on are +explicitly available :: import mpl_gui as mg from matplotlib.figure import Figure @@ -63,20 +78,16 @@ The core of the API is `~.show` :: fig2 = Figure() - mg.show([fig1, fig2]) + mg.display(fig1, fig2) which will show both figures and block until they are closed. As part of the "showing" process, the correct GUI objects will be created, put on the screen, and the event loop for the host GUI framework is run. - -blocking (or not) -+++++++++++++++++ - Similar to `plt.ion` and `plt.ioff`, we provide `mg.ion()` and -`mg.ioff()` which have identical semantics. Thus :: +`mg.ioff()` which have identical semantics. Thus :: import mpl_gui as mg from matplotlib.figure import Figure @@ -85,59 +96,35 @@ Similar to `plt.ion` and print(mg.is_interactive()) fig = Figure() - mg.show([fig]) # will not block + mg.display([fig]) # will not block mg.ioff() print(mg.is_interactive()) - mg.show([fig]) # will block! + mg.display(fig) # will block! As with `plt.show`, you can explicitly control the -blocking behavior of `mg.show<.show>` via the *block* keyword argument :: +blocking behavior of `mg.display` via the *block* keyword argument :: import mpl_gui as mg from matplotlib.figure import Figure fig = Figure(label='control blocking') - mg.show([fig], block=False) # will never block - mg.show([fig], block=True) # will always block + mg.display(fig, block=False) # will never block + mg.display(fig, block=True) # will always block The interactive state is shared Matplotlib and can also be controlled with `matplotlib.interactive` and queried via `matplotlib.is_interactive`. -Figure and Axes Creation ------------------------- - -In analogy with `matplotlib.pyplot` we also provide `~mpl_gui.figure`, -`~mpl_gui.subplots` and `~mpl_gui.subplot_mosaic` :: - import mpl_gui as mg - fig1 = mg.figure() - fig2, axs = mg.subplots(2, 2) - fig3, axd = mg.subplot_mosaic('AA\nBC') - - mg.show([fig1, fig2, fig3]) - -If `mpl_gui` is in "interactive mode", `mpl_gui.figure`, `mpl_gui.subplots` and -`mpl_gui.subplot_mosaic` will automatically put the new Figure in a window on -the screen (but not run the event loop). - - - -FigureRegistry --------------- +Locally Managed Figures +======================= -In the above examples it is the responsibility of the user to keep track of the -`~matplotlib.figure.Figure` instances that are created. If the user does not keep a hard -reference to the ``fig`` object, either directly or indirectly through its -children, then it will be garbage collected like any other Python object. -While this can be advantageous in some cases (such as scripts or functions that -create many transient figures). It loses the convenience of -`matplotlib.pyplot` keeping track of the instances for you. To this end we -also have provided `.FigureRegistry` :: +To avoid the issues with global state the objects you can create a local `.FigureRegistry`. +It keeps much of the convenience of the ``pyplot`` API but without the risk of global state :: import mpl_gui as mg @@ -153,60 +140,86 @@ also have provided `.FigureRegistry` :: fr.close_all() # will close all three figures fr.close('all') # alias for pyplot compatibility -Thus, if you are only using this restricted set of the pyplot API then you can change :: - import matplotlib.pyplot as plt - -to :: - - import mpl_gui as mg - plt = mg.FigureRegistry() - -and have a (mostly) drop-in replacement. - -Additionally, there is a `.FigureRegistry.by_label` accessory that returns -a dictionary mapping the Figures' labels to each Figure :: +Additionally, there are the `.FigureRegistry.by_label`, `.FigureRegistry.by_number`, +`.FigureRegistry.figures` accessors that returns a dictionary mapping the +Figures' labels to each Figure, the figures number to Figure, and a tuple of known Figures:: import mpl_gui as mg fr = mg.FigureRegistry() figA = fr.figure(label='A') - figB = fr.subplots(2, 2, label='B') + figB, axs = fr.subplots(2, 2, label='B') fr.by_label['A'] is figA fr.by_label['B'] is figB -FigureContext -------------- + fr.by_number[0] is figA + fr.by_number[1] is figB + + fr.figures == (figA, figB) + + fr.show() + +The `.FigureRegistry` is local state so that if the user drops all references +to it it will be eligible for garbage collection. If there are no other +references to the ``Figure`` objects it is likely that they may be closed when +the garbage collector runs! + A very common use case is to make several figures and then show them all -together at the end. To facilitate this we provide a sub-class of -`.FigureRegistry` that can be used as a context manager that (locally) keeps +together at the end. To facilitate this we provide a `.FigureContext` that is +a `.FigureRegistry` that can be used as a context manager that (locally) keeps track of the created figures and shows them on exit :: import mpl_gui as mg - with mg.FigureContext() as fc: + with mg.FigureContext(block=None) as fc: fc.subplot_mosaic('AA\nBC') fc.figure() fc.subplots(2, 2) This will create 3 figures and block on ``__exit__``. The blocking -behavior depends on ``mg.is_interacitve()`` (and follow the behavior of -``mg.show`` or can explicitly controlled via the *block* keyword argument). +behavior depends on `~mpl_gui.is_interactive()` (and follow the behavior of +`.display` and `.FigureRegistry.show` can explicitly controlled via the *block* keyword argument). +The `.global_figures` module is implemented by having a singleton `.FigureRegistry` +at the module level. -Selecting the GUI toolkit -------------------------- -`mpl_gui` makes use of `Matplotlib backends -`_ for actually -providing the GUI bindings. Analagous to `matplotlib.use` and -`matplotlib.pyplot.switch_backend` `mpl_gui` provides -`mpl_gui.select_gui_toolkit` to select which GUI toolkit is used. -`~mpl_gui.select_gui_toolkit` has the same fall-back behavior as -`~matplotlib.pyplot` and stores its state in :rc:`backend`. `mpl_gui` will -consistently co-exist with `matplotlib.pyplot` managed Figures in the same -process. + + +Globally Managed Figures +======================== + + +The `mpl_gui.global_figures` module provides a direct analogy to the +`matplotlib.pyplot` behavior of having a global registry of figures. Thus, any +figures created via the functions in `.global_figures` will remain alive until they +have been cleared from the registry (and the user has dropped all other +references). While it can be convenient, it carries with it the risk inherent +in any use of global state. + +The `matplotlib.pyplot` API related to figure creation, showing, and closing is a drop-in replacement: + +:: + + import mpl_gui.global_figures as gfigs + + fig = gfigs.figure() + fig, ax = gfigs.subplots() + fig, axd = gfigs.subplot_mosaic('AA\nCD') + + gfigs.show(block=True) # blocks until all figures are closed + gfigs.show(block=True, timeout=1000) # blocks for up to 1s or all figures are closed + gfigs.show(block=False) # does not block + gfigs.show() # depends on if in "interacitve mode" + + gfigs.ion() # turn on interactive mode + gfigs.ioff() # turn off interactive mode + gfigs.is_interactive() # query interactive state + + gfigs.close('all') # close all open figures + gfigs.close(fig) # close a particular figure diff --git a/mpl_gui/__init__.py b/mpl_gui/__init__.py index 999a216..2dcb674 100644 --- a/mpl_gui/__init__.py +++ b/mpl_gui/__init__.py @@ -22,11 +22,23 @@ 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_interactive import ( # noqa: F401 + ion as ion, + ioff as ioff, + is_interactive as is_interactive, +) +from ._manage_backend import select_gui_toolkit as 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 +from ._promotion import ( + promote_figure as _promote_figure, + demote_figure as demote_figure, +) +from ._creation import ( + figure as figure, + subplots as subplots, + subplot_mosaic as subplot_mosaic, +) + from ._version import get_versions @@ -37,13 +49,13 @@ _log = logging.getLogger(__name__) -def show(figs, *, block=None, timeout=0): +def display(*figs, block=None, timeout=0): """ Show the figures and maybe block. Parameters ---------- - figs : List[Figure] + *figs : Figure The figures to show. If they do not currently have a GUI aware canvas + manager attached they will be promoted. @@ -60,6 +72,9 @@ def show(figs, *, block=None, timeout=0): Defaults to True in non-interactive mode and to False in interactive mode (see `.is_interactive`). + timeout : float, optional + How long to run the event loop in msec if blocking. + """ # TODO handle single figure @@ -70,7 +85,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, num=None)) + managers.append(_promote_figure(fig, num=None)) if block is None: block = not is_interactive() @@ -151,7 +166,7 @@ def registry_cleanup(fig_wr): fig.set_label(f"{self._prefix}{fignum:d}") self._fig_to_number[fig] = fignum if is_interactive(): - promote_figure(fig, num=fignum) + _promote_figure(fig, num=fignum) return fig @property @@ -201,7 +216,7 @@ def subplot_mosaic(self, *args, **kwargs): 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]) + _promote_figure(f, num=self._fig_to_number[f]) def show_all(self, *, block=None, timeout=None): """ @@ -235,7 +250,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) + display(*self.figures, block=self._block, timeout=self._timeout) # alias to easy pyplot compatibility show = show_all @@ -252,7 +267,7 @@ def close_all(self): 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`. + passing it to `mpl_gui.display`. """ for fig in list(self.figures): @@ -267,16 +282,16 @@ def close(self, val): - 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. + - Remove the `~matplotlib.figure.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 + the user does the `~matplotlib.figure.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. + If the user still has a reference to the `~matplotlib.figure.Figure` they can re-show the + figure via `show`, but the `.FigureRegistry` will not be aware of it. Parameters ---------- @@ -285,9 +300,9 @@ def close(self, val): - 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 + - If an integer it is interpreted as a key in `.FigureRegistry.by_number` and that Figure is closed - - If it is a `Figure` instance, then that figure is closed + - If it is a `~matplotlib.figure.Figure` instance, then that figure is closed """ if val == "all": @@ -356,7 +371,7 @@ def __enter__(self): 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) + display(*self.figures, block=self._block, timeout=self._timeout) # from mpl_gui import * # is a langauge miss-feature diff --git a/mpl_gui/_manage_interactive.py b/mpl_gui/_manage_interactive.py index e66e682..129f111 100644 --- a/mpl_gui/_manage_interactive.py +++ b/mpl_gui/_manage_interactive.py @@ -14,21 +14,21 @@ def is_interactive(): - newly created figures will be shown immediately; - figures will automatically redraw on change; - - `mpl_gui.show` will not block by default. + - `.display` will not block by default. - `mpl_gui.FigureContext` will not block on ``__exit__`` by default. In non-interactive mode: - newly created figures and changes to figures will not be reflected until explicitly asked to be; - - `mpl_gui.show` will block by default. + - `.display` will block by default. - `mpl_gui.FigureContext` will block on ``__exit__`` by default. See Also -------- ion : Enable interactive mode. ioff : Disable interactive mode. - show : Show all figures (and maybe block). + mpl_gui.display : Show all figures (and maybe block). """ return _is_interact() @@ -89,7 +89,7 @@ def ioff(): -------- ion : Enable interactive mode. is_interactive : Whether interactive mode is enabled. - show : Show all figures (and maybe block). + mpl_gui.display : Show all figures (and maybe block). Notes ----- @@ -124,7 +124,7 @@ def ion(): -------- ioff : Disable interactive mode. is_interactive : Whether interactive mode is enabled. - show : Show all figures (and maybe block). + mpl_gui.display : Show all figures (and maybe block). Notes ----- diff --git a/mpl_gui/_promotion.py b/mpl_gui/_promotion.py index d2aa086..4fc3751 100644 --- a/mpl_gui/_promotion.py +++ b/mpl_gui/_promotion.py @@ -72,29 +72,38 @@ def promote_figure(fig, *, auto_draw=True, num): # HACK: the callback in backend_bases uses GCF.destroy which misses these # figures by design! - def _destroy(event): + def _destroy_on_hotkey(event): if event.key in mpl.rcParams["keymap.quit"]: # grab the manager off the event mgr = event.canvas.manager if mgr is None: - raise RuntimeError("Should never be here, please report a bug") - fig = event.canvas.figure - # remove this callback. Callbacks lives on the Figure so survive - # the canvas being replaced. - old_cid = getattr(mgr, "_destroy_cid", None) - if old_cid is not None: - fig.canvas.mpl_disconnect(old_cid) - mgr._destroy_cid = None + raise RuntimeError("Should never be here, please report a bug.") # close the window mgr.destroy() - # disconnect the manager from the canvas - fig.canvas.manager = None - # reset the dpi - fig.dpi = getattr(fig, "_original_dpi", fig.dpi) - # Go back to "base" canvas - # (this sets state on fig in the canvas init) - FigureCanvasBase(fig) - manager._destroy_cid = fig.canvas.mpl_connect("key_press_event", _destroy) + # remove this callback. Callbacks live on the Figure so survive the canvas + # being replaced. + fig._destroy_cid = fig.canvas.mpl_connect("key_press_event", _destroy_on_hotkey) return manager + + +def demote_figure(fig): + """Fully clear all GUI elements from the `~matplotlib.figure.Figure`. + + The opposite of what is done during `mpl_gui.display`. + + Parameters + ---------- + fig : matplotlib.figure.Figure + + """ + fig.canvas.destroy() + fig.canvas.manager = None + original_dpi = getattr(fig, "_original_dpi", fig.dpi) + if (cid := getattr(fig, '_destroy_cid', None)) is not None: + fig.canvas.mpl_disconnect(cid) + FigureCanvasBase(fig) + fig.dpi = original_dpi + + return fig diff --git a/mpl_gui/registry.py b/mpl_gui/global_figures.py similarity index 82% rename from mpl_gui/registry.py rename to mpl_gui/global_figures.py index e8919b2..e3958b3 100644 --- a/mpl_gui/registry.py +++ b/mpl_gui/global_figures.py @@ -1,8 +1,11 @@ """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 +from ._manage_interactive import ( + ion as ion, + ioff as ioff, + is_interactive as is_interactive, +) _fr = _FigureRegistry() @@ -32,7 +35,6 @@ def get_fignums(): # 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/test_examples.py b/mpl_gui/tests/test_examples.py index 0306312..56740d8 100644 --- a/mpl_gui/tests/test_examples.py +++ b/mpl_gui/tests/test_examples.py @@ -14,16 +14,10 @@ def test_no_pyplot(): def test_promotion(): fig = mg.Figure(label="test") assert fig.canvas.manager is None - mg.show([fig], block=False) + mg.display(*[fig], block=False) assert fig.canvas.manager is not None -def test_smoke_test_creation(): - mg.figure() - mg.subplots() - mg.subplot_mosaic("A\nB") - - def test_smoke_test_context(): with mg.FigureContext(block=False) as fc: fc.figure() @@ -34,10 +28,11 @@ def test_smoke_test_context(): def test_ion(): with mg.ion(): assert mg.is_interactive() - fig, ax = mg.subplots() + fig = mg.Figure() + ax = fig.subplots() (ln,) = ax.plot(range(5)) ln.set_color("k") - mg.show([fig], timeout=1) + mg.display(*[fig], timeout=1) assert "start_event_loop" not in fig.canvas.call_info @@ -48,7 +43,7 @@ def test_ioff(): def test_timeout(): fig = mg.Figure() - mg.show([fig], block=True, timeout=1) + mg.display(*[fig], block=True, timeout=1) assert "start_event_loop" in fig.canvas.call_info @@ -94,7 +89,7 @@ def test_close_all(): # test revive old_canvas = fig.canvas - mg.show([fig]) + mg.display(fig) assert fig.canvas is not old_canvas