diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fc9f855 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of your workflow files + schedule: + interval: "weekly" # Options: daily, weekly, monthly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..aac1713 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "39 10 * * 5" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2d81622..961398d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,23 +1,35 @@ name: Docs +permissions: + contents: read on: [push, pull_request] +env: + IS_RELEASE: | + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install Python dependencies run: pip install -r requirements-doc.txt - name: Install mpl-gui run: python -m pip install -v . - name: Build - run: make -Cdocs html + run: | + if [ "x${IS_RELEASE}" == "xtrue" ]; then + O="-t release" + fi + make -Cdocs html O="$O" + - name: Publish - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - uses: peaceiris/actions-gh-pages@v3 + if: ${{ env.IS_RELEASE == 'true' }} + uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs/build/html diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b01b4bb..fc7b57e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,4 +1,6 @@ name: Unit Tests +permissions: + contents: read on: push: @@ -12,16 +14,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.11', '3.12', '3.11'] fail-fast: false steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -41,4 +44,4 @@ jobs: coverage report - name: Upload code coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 1f14bbd..8ff1b90 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -13,7 +13,7 @@ Types of Contributions Report Bugs ~~~~~~~~~~~ -Report bugs at https://github.com/tacaswell/mpl-gui/issues. +Report bugs at https://github.com/matplotlib/mpl-gui/issues. If you are reporting a bug, please include: @@ -42,7 +42,7 @@ or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ -The best way to send feedback is to file an issue at https://github.com/tacaswell/mpl-gui/issues. +The best way to send feedback is to file an issue at https://github.com/matplotlib/mpl-gui/issues. If you are proposing a feature: @@ -61,33 +61,41 @@ Ready to contribute? Here's how to set up `mpl-gui` for local development. $ git clone git@github.com:your_name_here/mpl-gui.git -3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: +3. Install your local copy into a virtualenv. Using the standard-libary `venv`, see + [the Python documentation](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) for details :: + + $ python -m venv mpl-gui-dev + $ source mpl-gui-dev/activate # unix + $ mpl-gui-dev\Scripts\activate.bat # windows + + or other virtual environment tool (e.g. conda, canopy) of choice. + +4. Install the development dependencies :: - $ mkvirtualenv mpl-gui $ cd mpl-gui/ - $ python setup.py develop + $ pip install -r requirements-dev.txt + $ pip install -v --no-build-isolation . -4. Create a branch for local development:: +5. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. -5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: +6. When you're done making changes, check that your changes pass flake8 and the tests:: $ flake8 mpl_gui tests - $ python setup.py test - $ tox + $ pytest - To get flake8 and tox, just pip install them into your virtualenv. + To get flake8 and pytest, pip install them into your virtualenv. -6. Commit your changes and push your branch to GitHub:: +7. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature -7. Submit a pull request through the GitHub website. +8. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- @@ -98,7 +106,4 @@ Before you submit a pull request, check that it meets these guidelines: 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. -3. The pull request should work for Python 2.7, 3.3, 3.4, 3.5 and for PyPy. Check - https://travis-ci.org/tacaswell/mpl-gui/pull_requests - and make sure that the tests pass for all supported Python versions. - +3. The pull request should work for Python 3.7 and up. diff --git a/README.rst b/README.rst index 13e261d..3e29c5e 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ mpl-gui Prototype project for splitting pyplot in half * Free software: 3-clause BSD license -* Documentation: https://tacaswell.github.io/mpl-gui. +* Documentation: https://matplotlib.github.io/mpl-gui. Motivation ---------- diff --git a/docs/source/api.rst b/docs/source/api.rst index b3f007d..90c7985 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 + + select_gui_toolkit Interactivity @@ -18,69 +20,156 @@ Interactivity .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.ion - mpl_gui.ioff - mpl_gui.is_interactive + ion + ioff + is_interactive -Figure Fabrication ------------------- -Un-managed -++++++++++ +Unmanaged Figures +----------------- + +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 + figure + subplots + subplot_mosaic + + + +Display ++++++++ + .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.promote_figure -Managed -+++++++ + + display + demote_figure + + + +Locally Managed Figures +----------------------- + + +.. autoclass:: FigureRegistry + :no-undoc-members: + :show-inheritance: -.. autoclass:: mpl_gui.FigureRegistry +.. autoclass:: FigureContext :no-undoc-members: :show-inheritance: +Create Figures and Axes ++++++++++++++++++++++++ + +.. autosummary:: + :toctree: _as_gen + + + FigureRegistry.figure + FigureRegistry.subplots + FigureRegistry.subplot_mosaic + + +Access managed figures +++++++++++++++++++++++ .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.FigureRegistry.figure - mpl_gui.FigureRegistry.subplots - mpl_gui.FigureRegistry.subplot_mosaic - mpl_gui.FigureRegistry.by_label - mpl_gui.FigureRegistry.show_all - mpl_gui.FigureRegistry.close_all + + FigureRegistry.by_label + FigureRegistry.by_number + FigureRegistry.figures + + + +Show and close managed Figures +++++++++++++++++++++++++++++++ + + +.. autosummary:: + :toctree: _as_gen -.. autoclass:: mpl_gui.FigureContext + FigureRegistry.show_all + FigureRegistry.close_all + FigureRegistry.show + FigureRegistry.close + + + + +Globally managed +---------------- + + +.. automodule:: mpl_gui.global_figures :no-undoc-members: - :show-inheritance: +Create Figures and Axes ++++++++++++++++++++++++ + +.. autosummary:: + :toctree: _as_gen + + figure + subplots + subplot_mosaic + + +Access managed figures +++++++++++++++++++++++ -Select the backend ------------------- .. autosummary:: :toctree: _as_gen - :nosignatures: - mpl_gui.select_gui_toolkit + + by_label + + +Show and close managed Figures +++++++++++++++++++++++++++++++ + + +.. autosummary:: + :toctree: _as_gen + + + + + show + show_all + close_all + close + + +Interactivity ++++++++++++++ + +.. autosummary:: + :toctree: _as_gen + + + + ion + ioff + is_interactive diff --git a/docs/source/conf.py b/docs/source/conf.py index b316225..8453e3f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -95,7 +95,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +# language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -108,10 +108,6 @@ # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - default_role = "obj" nitpicky = True @@ -124,12 +120,14 @@ html_theme = "mpl_sphinx_theme" html_theme_options = { - "native_site": False, - "logo_link": "index", + "navbar_links": ("absolute", "server-stable"), # collapse_navigation in pydata-sphinx-theme is slow, so skipped for local # 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: @@ -154,10 +152,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 - "searchbox.html", - ] } 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 8e737e3..278f505 100644 --- a/mpl_gui/__init__.py +++ b/mpl_gui/__init__.py @@ -12,19 +12,33 @@ 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 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 @@ -35,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. @@ -58,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 @@ -68,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)) + managers.append(_promote_figure(fig, num=None)) if block is None: block = not is_interactive() @@ -115,32 +132,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 +176,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 +213,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,8 +249,8 @@ def show_all(self, *, block=None, timeout=None): if timeout is None: timeout = self._timeout - - show(self.figures, block=self._block, timeout=self._timeout) + self._ensure_all_figures_promoted() + display(*self.figures, block=self._block, timeout=timeout) # alias to easy pyplot compatibility show = show_all @@ -216,23 +267,67 @@ 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 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 `~matplotlib.figure.Figure` from this Registry. + + We will no longer have any hard references to the Figure, but if + 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 `~matplotlib.figure.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 `.FigureRegistry.by_number` and that + Figure is closed + - If it is a `~matplotlib.figure.Figure` instance, then that figure is closed + + """ + if val == "all": + self.close_all() + return + # 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) + return class FigureContext(FigureRegistry): @@ -278,7 +373,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/_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 cd4a586..a9b62fc 100644 --- a/mpl_gui/_manage_backend.py +++ b/mpl_gui/_manage_backend.py @@ -5,6 +5,7 @@ from matplotlib import cbook, rcsetup from matplotlib import rcParams, rcParamsDefault +from matplotlib.backends.registry import backend_registry import matplotlib.backend_bases @@ -68,7 +69,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 @@ -79,10 +80,9 @@ def select_gui_toolkit(newbackend=None): except ImportError: continue - else: - # Switching to Agg should always succeed; if it doesn't, let the - # exception propagate out. - return select_gui_toolkit("agg") + # Switching to Agg should always succeed; if it doesn't, let the + # exception propagate out. + return select_gui_toolkit("agg") if isinstance(newbackend, str): # Backends are implemented as modules, but "inherit" default method @@ -93,7 +93,7 @@ def select_gui_toolkit(newbackend=None): if newbackend.lower() == "tkagg": backend_name = f"mpl_gui._patched_backends.{newbackend.lower()}" else: - backend_name = cbook._backend_module_name(newbackend) + backend_name = backend_registry._backend_module_name(newbackend) mod = importlib.import_module(backend_name) if hasattr(mod, "Backend"): @@ -166,6 +166,9 @@ def show_managers(cls, *, managers, block): # if so are we in an IPython session ip = mod_ipython.get_ipython() if ip: + # macosx -> osx mapping for the osx backend in ipython + if required_framework == "macosx": + required_framework = "osx" ip.enable_gui(required_framework) # remember to set the global variable 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 b497a21..4fc3751 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()) @@ -70,30 +72,38 @@ 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): - + 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/global_figures.py b/mpl_gui/global_figures.py new file mode 100644 index 0000000..e3958b3 --- /dev/null +++ b/mpl_gui/global_figures.py @@ -0,0 +1,41 @@ +"""Reproduces the module-level pyplot UX for Figure management.""" + +from . import FigureRegistry as _FigureRegistry +from ._manage_interactive import ( + ion as ion, + ioff as ioff, + is_interactive as 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 + [ + "ion", + "ioff", + "is_interactive", +] diff --git a/mpl_gui/tests/conftest.py b/mpl_gui/tests/conftest.py index 7d11b9f..e4d8d87 100644 --- a/mpl_gui/tests/conftest.py +++ b/mpl_gui/tests/conftest.py @@ -8,17 +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) - -class TestCanvas(FigureCanvasBase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.call_info = {} - - def start_event_loop(self, timeout=0): - self.call_info["start_event_loop"] = {"timeout": timeout} + # make sure we do not sneakily get pyplot + assert sys.modules.get("matplotlib.pyplot") is None + sys.modules["matplotlib.pyplot"] = None class TestManger(FigureManagerBase): @@ -35,6 +37,17 @@ def destroy(self): self.call_info["destroy"] = {} +class TestCanvas(FigureCanvasBase): + manager_class = TestManger + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.call_info = {} + + def start_event_loop(self, timeout=0): + self.call_info["start_event_loop"] = {"timeout": timeout} + + class TestShow(ShowBase): def mainloop(self): ... diff --git a/mpl_gui/tests/test_examples.py b/mpl_gui/tests/test_examples.py index c10e448..56740d8 100644 --- a/mpl_gui/tests/test_examples.py +++ b/mpl_gui/tests/test_examples.py @@ -8,23 +8,16 @@ def test_no_pyplot(): - assert sys.modules.get("matplotlib.pyplot", None) is None 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() @@ -35,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 @@ -49,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 @@ -75,7 +69,6 @@ class TestException(Exception): if forgiving: assert "start_event_loop" in fig.canvas.call_info else: - assert isinstance(fig.canvas, FigureCanvasBase) @@ -96,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 @@ -113,10 +106,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 +133,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 diff --git a/requirements-doc.txt b/requirements-doc.txt index 4896ed6..a234e53 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -2,7 +2,7 @@ ipython jinja2>3 matplotlib -mpl-sphinx-theme +mpl-sphinx-theme~=3.9.0 numpydoc sphinx sphinx-copybutton diff --git a/requirements.txt b/requirements.txt index 7260f92..188b1f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ # List required packages in this file, one per line. -matplotlib +matplotlib>3.9 diff --git a/setup.py b/setup.py index d9caae6..25aae60 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ # NOTE: This file must remain Python 2 compatible for the foreseeable future, # to ensure that we error out properly for people with outdated setuptools # and/or pip. -min_version = (3, 7) +min_version = (3, 11) if sys.version_info < min_version: error = """ mpl-gui does not support Python {0}.{1}. @@ -41,7 +41,7 @@ long_description=readme, author="Thomas A Caswell", author_email='tcaswell@gmail.com', - url='https://github.com/tacaswell/mpl-gui', + url='https://github.com/matplotlib/mpl-gui', python_requires='>={}'.format('.'.join(str(n) for n in min_version)), packages=find_packages(exclude=['docs', 'tests']), entry_points={ @@ -63,5 +63,6 @@ 'Development Status :: 2 - Pre-Alpha', 'Natural Language :: English', 'Programming Language :: Python :: 3', + 'Framework :: Matplotlib', ], ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy