Source code for hypertools.plot.backend

"""
Module that deals with managing the matplotlib backend for interactive
and/or animated plots created via `hypertools.plot`.  Main
functionality is contained in
`set_interactive_backend` (sole front-end function) and `manage_backend`
(decorator for `hypertools.plot`).

======================= MODULE-SCOPED VARIABLES ========================
Various information about the current state of the plotting backend is
managed by a set of semi-global (module-scoped) variables. While this
probably isn't an ideal setup long-term, it solves a bunch of problems
that would otherwise require either completely overhauling the plotting
API, recomputing the same values for every call, or doing a bunch of
hacky, computationally expensive object inspection. And while this
approach isn't thread-safe, neither is `matplotlib` itself [1], so this
isn't really a limiting problem and therefore probably okay.

BACKEND_MAPPING : `hypertools.plot.backend.BackendMapping`
    see `BackendMapping` docstring
BACKEND_KEYS : dict
    Maps between compatible `matplotlib` backend keys in a standard
    Python environment and their corresponding keys in an IPython
    environment (format: `{python_key: ipython_key(s)}`). In cases where
    multiple IPython backend keys denote the same Python-equivalent,
    the "preferred" key (the one `BACKEND_MAPPING` will funnel others
    into) is the first in the list.
BACKEND_WARNING : str or None
    The warning to be issued upon trying to create an interactive or
    animated plot, if any. This is set under three conditions:
      1. No compatible interactive backends are available
      2. Hypertools was imported into a notebook that uses the "classic"
         notebook JS frontend, and the notebook-native interactive
         plotting backend (nbAgg) is not available. This should never
         happen, but theoretically could if the
         `ipython`/`jupyter`/`jupyter-core`/`notebook` installation is
         faulty.
      3. Hypertools was imported into a notebook that uses the
         JupyterLab JS frontend, the notebook server was launched from a
         different Python environment than is used by the IPython
         kernel, and the "ipympl" (a.k.a. "widget") interactive plotting
         backend is likely to not work properly because the `ipympl`
         package is not installed in the server environment. `ipympl` is
         a dependency of Hypertools, but when the notebook kernel and
         server environments are different, (a compatible version of) it
         must also be installed in the server environment to provide the
         various JS components needed to display interactive plots.
HYPERTOOLS_BACKEND : str
    The `matplotlib` backend used to create interactive and animated
    plots.
IN_SET_CONTEXT : bool
    A switch read by the `manage_backed` decorator to determine whether
    or not the wrapped call to `hypertools.plot` was made inside a
    `set_interactive_backend` context block. Backed by
    `_SET_CONTEXT_DEPTH` (a nesting-depth counter) so it stays True
    until the *outermost* context block exits, even when
    `set_interactive_backend` contexts are nested.
IPYTHON_INSTANCE : `ipykernel.zmqshell.ZMQInteractiveShell` or None
    The IPython InteractiveShell instance for the current
    IPython kernel, if `hypertools` was imported into a Jupyter
    notebook. Otherwise, None. Used to register/unregister IPython
    callback functions and run magic commands.
IS_NOTEBOOK : bool
    Whether or not `hypertools` was imported into a Jupyter notebook.
reset_backend : function
    The function called to switch back to the original `matplotlib`
    plotting backend after creating an interactive/animated plot.
    `_reset_backend_notebook` if imported into a Jupyter notebook,
    otherwise `matplotlib.pyplot.switch_backend`.
switch_backend : function
    The function called to switch to the temporary backend prior to
    plotting. `_switch_notebook_backend` if running in a Jupyter
    notebook, otherwise `matplotlib.pyplot.switch_backend`.

========================================================================

FUTURE: `matplotlib` project leader says `nbagg` backend will be retired
"in the next year or two" in favor of the `ipympl` backend [2]. For the
Hypertools 1.0 revamp, the two options should be given equal priority in
order to support the various possible combinations of new and older
`IPython`/`ipykernel`/`notebook` versions going forward

[1] https://matplotlib.org/stable/users/faq.html#work-with-threads
[2] https://github.com/ipython/ipython/issues/12190#issuecomment-599154335.
"""

import inspect
import os
import shlex
import shutil
import sys
import traceback
import warnings
from contextlib import contextmanager, redirect_stdout
from functools import wraps
from io import StringIO
from locale import getpreferredencoding
from subprocess import CalledProcessError, check_output
from typing import Iterable

import matplotlib as mpl
import matplotlib.pyplot as plt

from ..core.exceptions import HypertoolsBackendError


BACKEND_KEYS = {
    "TkAgg": "tk",
    "GTK3Agg": ["gtk3", "gtk"],
    "WXAgg": "wx",
    "Qt4Agg": "qt4",
    "Qt5Agg": ["qt5", "qt"],
    "MacOSX": "osx",
    "nbAgg": ["notebook", "nbagg"],
    "module://ipykernel.pylab.backend_inline": "inline",
    "module://matplotlib_inline.backend_inline": "inline",
    "module://ipympl.backend_nbagg": ["ipympl", "widget"],
}
BACKEND_MAPPING = None
BACKEND_WARNING = None
HYPERTOOLS_BACKEND = None
IN_SET_CONTEXT = False
# nesting depth of currently active `set_interactive_backend` context blocks;
# IN_SET_CONTEXT == (_SET_CONTEXT_DEPTH > 0). Kept as a separate counter so
# exiting an inner nested context doesn't flip IN_SET_CONTEXT to False while
# an outer context is still active (QC audit 2026-07, F06-004)
_SET_CONTEXT_DEPTH = 0
IPYTHON_INSTANCE = None
IS_NOTEBOOK = None
reset_backend = None
switch_backend = None
# Preferred RENDER backend ('plotly' or 'matplotlib') chosen via
# set_interactive_backend(); None means auto-detect (Colab/Kaggle -> plotly).
# This selects WHICH LIBRARY draws the plot (consulted by
# plotly_backend.resolve_backend), distinct from HYPERTOOLS_BACKEND, which is a
# matplotlib backend name for interactive/animated matplotlib plots.
PREFERRED_RENDER_BACKEND = None


class ParrotDict(dict):
    """
    Dictionary subclass with a few changes in behavior:
      1. all keys and values are stored and indexed as
         `HypertoolsBackend` instances
      2. indexing a `ParrotDict` with a key that doesn't exist returns
         the key (it's "parroted" back to you). The key is converted to
         a `HypertoolsBackend` instance if it is not already. Similar
         to `collections.defaultdict`, but does *not* add missing keys
         on indexing.

    Useful for filtering/replacing some values while leaving others
    when the to-be-replaced values are known in advance.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def __contains__(self, key):
        return HypertoolsBackend(key) in self.keys()

    def __getitem__(self, key):
        key = HypertoolsBackend(key)
        return super().__getitem__(key)

    def __missing__(self, key):
        return HypertoolsBackend(key)

    def __setitem__(self, key, value):
        key, value = HypertoolsBackend(key), HypertoolsBackend(value)
        return super().__setitem__(key, value)


class BackendMapping:
    """
    A two-way, non-unique dict-like mapping between keys used to set
    the matplotlib plotting backend in Python and IPython environments.
    Primarily used by `as_python()` and `as_ipython()` methods of
    `HypertoolsBackend`.  Funnels multiple equivalent keys within the
    same interpreter (Python vs. IPython) to a "default", then maps
    between that and the analog from the other interpreter type. At
    either step, a key with no corresponding value returns the key (see
    `ParrotDict` docstring for more info).
    """

    def __init__(self, _dict):
        # assumes format of _dict is {Python: IPython}
        self.py_to_ipy = ParrotDict()
        self.ipy_to_py = ParrotDict()
        self.equivalents = ParrotDict()

        for py_key, ipy_key in _dict.items():
            py_key_default = self._store_equivalents(py_key)
            ipy_key_default = self._store_equivalents(ipy_key)
            self.py_to_ipy[py_key_default] = ipy_key_default
            self.ipy_to_py[ipy_key_default] = py_key_default

    def _store_equivalents(self, keylist):
        if not isinstance(keylist, str) and isinstance(keylist, Iterable):
            default_key = keylist[0]
            for key_equiv in keylist[1:]:
                self.equivalents[key_equiv] = default_key
        else:
            default_key = keylist
        return default_key


class HypertoolsBackend(str):
    """
    A subclass of the `str` built-in, intended for easy(ish...)
    conversion between the different valid matplotlib backend keys in
    Python vs IPython and equality/membership checks.

    Notes
    -----
    Normally, a lot of this could be simplified and a lot of grief saved
    by subclassing `collections.UserString` rather than `str` directly.
    The issue is that these objects get passed to a ton of different
    low-level `matplotlib`/`ipython`/`ipykernel`/etc. functions that
    expect to be receiving strings, and subclasses of `UserString` fail
    type-checks for **actual** strings (i.e.,
    `isinstance(UserStringSubclass('a'), str)` returns False) while
    this approach doesn't. Since it's impossible to trace through every
    possible function these could be forwarded to in every possible
    scenario (and any of those functions could be changed at any time),
    this will hopefully be more stable long-term.
    """

    def __new__(cls, x):
        return super().__new__(cls, x)

    def __eq__(self, other):
        """
        case-insensitive comparison with both `str`s and other
        `HypertoolsBackend` instances
        """
        return str(self).casefold() == str(other).casefold()

    def __getattribute__(self, name):
        """
        Overrides `str.__getattribute__` in a way that causes all
        inherited `str` methods to return a `HypertoolsBackend`
        instance, rather than a `str`, which it would otherwise do.
        See class docstring for more information.

        Parameters
        ----------
        name : the attribute or method accessed

        Returns
        -------
        val : instance (or collection of instances) of
            `hypertools.plot.backend.HypertoolsBackend`. For inherited
            `str` attributes and methods, the return type is the same,
            but with all instances of `str` replaced with
            `hypertools.plot.backend.HypertoolsBackend`.
        """
        # only deal with string attributes/methods here
        if hasattr(str, name):

            def _subclass_method(self, *args, **kwargs):
                value = getattr(super(), name)(*args, **kwargs)
                if isinstance(value, str):
                    return HypertoolsBackend(value)
                elif isinstance(value, (list, tuple, set)):
                    return type(value)(HypertoolsBackend(v) for v in value)
                else:
                    return value

            # bind inner function to instance
            return _subclass_method.__get__(self)
        else:
            return super().__getattribute__(name)

    def __hash__(self):
        """
        needed to work for membership checks/lookups in dict/set/etc.
        """
        return str.__hash__(str(self).casefold())

    def as_ipython(self):
        """
        Return the IPython-compatible version of a matplotlib backend
        key, given either the Python-compatible or IPython-compatible
        version
        """
        default_key = BACKEND_MAPPING.equivalents[self]
        return HypertoolsBackend(BACKEND_MAPPING.py_to_ipy[default_key])

    def as_python(self):
        """
        Return the Python-compatible version of a matplotlib backend
        key, given either the Python-compatible or IPython-compatible
        version
        """
        default_key = BACKEND_MAPPING.equivalents[self]
        return HypertoolsBackend(BACKEND_MAPPING.ipy_to_py[default_key])

    def normalize(self):
        """
        Convert a given matplotlib backend key to its preferred
        equivalent for the correct interpreter
        """
        return self.as_ipython() if IS_NOTEBOOK else self.as_python()


def _block_greedy_completer_execution():
    """
    Handles an annoying edge case in `init_backend()`:
      - IPython uses "greedy" TAB-completion, meaning code is actually
        executed in order to determine autocomplete suggestions
          + there is a config setting to disable this, but it's enabled
            by default
      - if TAB-completion is used in an import statement, the module is
        actually imported if it hasn't been previously [1], which means
        that for `hypertools`, `init_backend()` will be run
      - because the TAB-completion happens in a non-IPython subprocess,
        the backend will be initialized for non-notebook use.

    To correct this, the function:
      - looks through the stack trace for a call made from IPython's
        TAB-completion module (`IPython/core/completerlib.py`)
          + this is probably the safest way to do the search, since A)
            the module name is less likely to change than the function
            name or line number, and B) searching for *any* IPython
            module would break importing `hypertools` in an IPython shell
          + this is also probably the fastest way to search, since A) it
            short-circuits on the first call specific to this scenario,
            and B) the call stack will usually be at most a few imports
            deep in non-notebook environments
          + at minimum, the last 3 calls will always be from `hypertools`
            so they're skipped to save time
      - if it finds one, removes both `hypertools.plot` and
        `hypertools.plot.backend` (and also `numpy`) from `sys.modules`...
          + the `import` statement (`importlib.__import__()`) checks
            `sys.modules` for already-loaded modules before importing,
            so removing these causes them to be reloaded when the
            `import` command is actually run
          + both `hypertools.plot` and `hypertools.plot.backend` need to
            be removed to avoid using the cached call to `init_backend()`
          + `numpy` is also unloaded, otherwise its C extensions get
            confused when `hypertools` is re-imported and issue a whole
            slew of warnings
      - ...and raises a generic exception (handled in [1]), which skips
        running the rest of `init_backend()` while still allowing the
        TAB-completer to keep searching other `hypertools` modules
          + Also, since both of the removed modules will already have
            been seen by the completer at this point, they'll still be
            shown as autocomplete options despite not being in
            `sys.modules`.

    [1] https://github.com/ipython/ipython/blob/2b4bc75ac735a2541125b3baf299504e5513994a/IPython/core/completerlib.py#L158
    """
    stack_trace = traceback.extract_stack()[-4::-1]
    completer_module = "IPython/core/completerlib.py"
    try:
        next(
            entry for entry in stack_trace if entry.filename.endswith(completer_module)
        )
    except StopIteration:
        return
    else:
        for module in ("hypertools.plot", "hypertools.plot.backend", "numpy"):
            try:
                sys.modules.pop(module)
            except KeyError:
                pass

        raise Exception


def _get_jupyter_frontend():
    """
    Given that hypertools has been imported into a Jupyter notebook,
    determine whether that notebook is being run through the "classic"
    Jupyter notebook interface (i.e., notebook < 7.0) or the newer
    JupyterLab interface (i.e., JupyterLab or notebook >= 7.0).

    Returns
    -------
    {'classic', 'lab'}
        The Jupyter frontend used by the importing notebook.

    Notes
    -----
    Adapted from `davos.core.config._get_jupyter_interface()`
    (https://github.com/ContextLab/davos/blob/0608a40/davos/core/config.py#L565).
    Structured to prioritize short-circuiting as early as possible to
    minimize runtime, since this function runs on import (in notebooks).
    """
    # first try to inspect the shell command used to start the jupyter
    # notebook/lab server from the parent process (i.e., `jupyter
    # notebook ...` or `jupyter lab ...`)
    shell_cmd = f"ps -o command= -p {os.getppid()}"
    try:
        parent_proc_cmd = check_output(
            shlex.split(shell_cmd), encoding=getpreferredencoding()
        )
    except (FileNotFoundError, CalledProcessError):
        # this could fail if the notebook is being run from something
        # like an IDE that manages more complex processes or doesn't
        # launch a typical jupyter server
        server_type = None
    else:
        # when launched normally from the command line, the 2nd item in
        # the list should be the notebook/lab executable, but safer to
        # check more generally in case the user has something unusual
        # like a custom script they called to launch the server
        for item in parent_proc_cmd.split():
            if item.endswith("lab"):
                # if the server was launched with `jupyter lab`, we know
                # we're using the JupyterLab frontend
                return "lab"
            elif item.endswith("notebook"):
                # if the server was launched with `jupyter notebook`, we
                # need to check the version below to determine the
                # frontend
                server_type = "notebook"
                break
        else:
            # if we can't identify the server type (or `ps` command
            # fails; see `except` block above), we'll need to use some
            # additional logic below to determine the frontend
            server_type = None

    # server was not launched with `jupyter lab`, so check installed
    # jupyter notebook version to determine the frontend.
    #
    # Run shell commands via `IPython.utils.process.system()` instead of
    # `subprocess.run()`, `os.system()`, etc. to ensure they're executed
    # in the notebook *server* environment, which could be different
    # from the notebook *kernel* environment.
    #
    # `IPython.utils.process.system()` prints output to stdout, so
    # temporarily capture it in a text buffer to get the version info.
    import IPython  # guaranteed to be installed at this point

    with redirect_stdout(StringIO()) as notebook_version_stdout:
        retcode = IPython.utils.process.system("jupyter notebook --version")
    if retcode == 0:
        # base notebook app is installed in the server environment
        notebook_version_stdout = notebook_version_stdout.getvalue().strip()
        try:
            # Handle potential multi-line output by taking the first line
            version_line = notebook_version_stdout.split("\n")[0].strip()
            # Only take version numbers (e.g., "7.0.0" from "7.0.0-rc1")
            version_parts = version_line.split(".")
            notebook_version_tup = tuple(
                int(
                    p.split("-")[0]
                    .split("+")[0]
                    .split("a")[0]
                    .split("b")[0]
                    .split("rc")[0]
                )
                for p in version_parts[:3]
            )
        except (ValueError, IndexError):
            # If version parsing fails (e.g., unexpected output format like
            # a path instead of version), treat as if notebook is not installed
            notebook_version_tup = None
        if notebook_version_tup is not None and notebook_version_tup >= (7, 0):
            # notebook >= 7.0 uses the JupyterLab frontend, so no matter
            # how the notebook and/or jupyter server were launched, or
            # whether or not JupyterLab itself is installed, we can
            # assume that's the frontend being used
            return "lab"
        elif server_type == "notebook":
            # notebook < 7.0 uses the classic notebook frontend, so if
            # the server was launched by invoking `jupyter notebook`
            # directly, we know that's what we're using
            return "classic"
    else:
        # base notebook app is not installed in the server environment.
        notebook_version_tup = None

    # two remaining possibilities:
    #   1. the base notebook app is not installed.
    #   2. the base notebook app *is* installed, and the installed
    #      version is an older one that uses the classic frontend, but
    #      the notebook app wasn't directly called to launch the jupyter
    #      server.
    # Since we already know JupyterLab wasn't invoked directly either,
    # this means the notebook is probably being run through some IDE (or
    # maybe an odd Wasm notebook frontend) that either doesn't use a
    # jupyter server, or internally uses a newer version of JupyterLab
    # that doesn't depend on the base notebook app. In either case,
    # the frontend being used is ambiguous, but our "best guess"
    # depends on whether or not JupyterLab is installed.
    # stdout is redirected only to swallow the version banner; the
    # captured text itself is never needed (only the return code is)
    with redirect_stdout(StringIO()):
        retcode = IPython.utils.process.system("jupyter lab --version")
    if retcode == 0:
        # JupyterLab is installed in the server environment, possibly
        # alongside an older version of the base notebook app. Notebook
        # could be running in an IDE or atypical web app. Either way,
        # JupyterLab frontend is more likely being used.
        return "lab"
    elif notebook_version_tup is not None:
        # JupyterLab is not installed, but an older version of the base
        # notebook app is, so assume the classic notebook frontend
        return "classic"
    # neither JupyterLab nor the base notebook app are installed. The
    # notebook is most likely being run through some app that doesn't
    # require a jupyter server. Fall back default assumption/old
    # behavior of assuming (probably mocked) classic notebook frontend.
    return "classic"


def _init_backend():
    """
    Runs when hypertools is initially imported and sets the matplotlib
    backend used for animated/interactive plots.
    """
    global \
        BACKEND_MAPPING, \
        BACKEND_WARNING, \
        HYPERTOOLS_BACKEND, \
        IPYTHON_INSTANCE, \
        IS_NOTEBOOK, \
        reset_backend, \
        switch_backend

    curr_backend = mpl.get_backend()
    # pre-assign so the `finally` block below can't raise UnboundLocalError
    # (masking the real exception) if something fails before a working
    # backend is found (QC audit 2026-07, F06-001)
    working_backend = None

    try:
        # `get_ipython()` function exists in the namespace if
        # `hypertools` was imported from an IPython shell or Jupyter
        # notebook
        IPYTHON_INSTANCE = get_ipython()
        assert "IPKernelApp" in IPYTHON_INSTANCE.config

    # NameError: raised if imported from a script
    # AssertionError: raised if imported from an IPython shell
    except (NameError, AssertionError):
        # see `_block_greedy_completer_execution()` docstring
        _block_greedy_completer_execution()

        IS_NOTEBOOK = False
        # (excluding WebAgg - no way to test in advance if it will work)
        backends = (
            "TkAgg",
            "QtAgg",
            "Qt5Agg",
            "Qt4Agg",
            "GTK4Agg",
            "GTK3Agg",
            "WXAgg",
        )
        if sys.platform == "darwin":
            # prefer cocoa backend on Mac - pretty much guaranteed to
            # work, appears to be faster, and Mac does NOT like Tkinter
            backends = ("MacOSX", *backends)

        # check for configurable environment variable (documented in the
        # `set_interactive_backend` and `hypertools.plot` docstrings)
        env_backend = os.getenv("HYPERTOOLS_BACKEND")
        # matplotlib's own explicit backend selection (MPLBACKEND). If the
        # user configured a backend there -- e.g. MPLBACKEND=Agg for a
        # headless run -- and did NOT override it with hypertools' own
        # HYPERTOOLS_BACKEND, respect it: do NOT force a GUI backend on top
        # of an explicit, deliberate choice. Before the 2026-07 release
        # audit, hypertools unconditionally preferred MacOSX on macOS and
        # the animate path then switched to it, producing an uncatchable
        # native abort on genuinely headless Macs even under MPLBACKEND=Agg.
        mpl_env_backend = os.getenv("MPLBACKEND")
        if env_backend is None and mpl_env_backend:
            working_backend = mpl.get_backend()
        else:
            if env_backend is not None:
                # prefer user-specified backend, if set. If it's already in
                # the candidate list, remove the existing entry
                # (case-insensitively) so it isn't tried twice.
                # NB: compare against the LOCAL `env_backend` -- the module
                # global HYPERTOOLS_BACKEND is still None at this point, and
                # looking it up here crashed `import hypertools` whenever
                # $HYPERTOOLS_BACKEND named a candidate backend (QC audit
                # 2026-07, F06-001)
                backends_lower = tuple(map(str.lower, backends))
                if env_backend.lower() in backends_lower:
                    env_ix = backends_lower.index(env_backend.lower())
                    backends = (*backends[:env_ix], *backends[env_ix + 1 :])

                backends = (env_backend, *backends)

            for b in backends:
                try:
                    mpl.use(b)
                    working_backend = b
                    break

                except (ImportError, NameError, ValueError):
                    # ImportError/NameError:
                    #     raised if backend's dependencies aren't installed
                    # ValueError:
                    #     raised if named backed isn't supported by
                    #     installed matplotlib version
                    continue

            else:
                BACKEND_WARNING = (
                    "Failed to switch to any interactive backend "
                    f"({', '.join(backends)}). Falling back to 'Agg'."
                )
                working_backend = "Agg"

        if env_backend is not None and working_backend.lower() != env_backend.lower():
            # The only time a plotting-related warning should be issued
            # on import rather than on call to hypertools.plot is if
            # $HYPERTOOLS_BACKEND specifies an incompatible backend,
            # since that will have been set manually.
            warnings.warn(
                "failed to set matplotlib backend to backend "
                f"specified in environment ('{env_backend}'). "
                f"Falling back to '{working_backend}'"
            )

        switch_backend = reset_backend = _switch_backend_regular

    else:
        IS_NOTEBOOK = True
        working_backend = "inline"
        # if running in a notebook, the backend to use depends on the
        # user's Jupyter version and frontend. In "classic" Jupyter
        # notebooks (i.e., notebook < 7.0), use `nbAgg` since it's the
        # most stable, best supported, available out-of-the-box, etc.
        # However, that backend no longer works with the new JupyterLab
        # JS frontend (which is also used by notebook >= 7.0), so in
        # those cases, use the "ipympl"/"widget" plotting backend
        # instead.
        #
        # The user can technically override this by setting the
        # HYPERTOOLS_BACKEND environment variable, but for now this is
        # undocumented and the two officially supported methods of
        # changing the backend hypertools uses for interactive plots are
        # to (1) manually set the desired backend after importing
        # hypertools with `hypertools.set_interactive_backend`, or (2)
        # pass the desired backend identifier to the `mpl_backend` kwarg
        # of `hypertools.plot`
        #
        # Note: the "ipympl"/"widget" backend requires the `ipympl`
        # library be installed in both the notebook kernel environment
        # AND the notebook server environment, if the two aren't the
        # same. The former is guaranteed by hypertools's dependencies,
        # but the latter is not, so we must check for it manually.
        notebook_frontend = _get_jupyter_frontend()
        if notebook_frontend == "lab":
            notebook_backend = "module://ipympl.backend_nbagg"
        else:
            notebook_backend = "nbAgg"

        try:
            mpl.use(notebook_backend)
        except (ImportError, ModuleNotFoundError, ValueError):
            # ValueError: matplotlib >= 3.9 raises this (rather than
            # ImportError) when a "module://..." backend such as ipympl is
            # missing or incompatible -- common on Colab/Kaggle kernels
            BACKEND_WARNING = (
                "Failed to switch to interactive notebook backend "
                f"('{notebook_backend}'). Falling back to inline static plots."
            )
            working_backend = "inline"
        else:
            working_backend = notebook_backend
            if notebook_frontend == "lab":
                # if the notebook uses the JupyterLab JS frontend and
                # the ipympl plotting backend was successfully found in
                # the notebook kernel environment (`try` block above),
                # determine whether the notebook server environment is
                # the same or different
                kernel_python = sys.executable
                server_python = shutil.which("python")
                if server_python != kernel_python:
                    # if they're different, check whether `ipympl` is
                    # installed in the server environment
                    import IPython  # guaranteed to be installed at this point

                    with open(os.devnull, "w") as devnull, redirect_stdout(devnull):
                        retcode = IPython.utils.process.system("pip show ipympl")
                    if retcode != 0:
                        # NOTE: this is not currently checked, but the
                        #  `ipympl` versions installed in the server and
                        #  kernel environments must also be compatible
                        #  with each other. See
                        #  https://matplotlib.org/ipympl/installing.html#compatibility-table
                        # NOTE: this check is imperfect and will result
                        #  in a false positive if the user has installed
                        #  just the carved-off JS components from
                        #  `ipympl` via the `jupyter-matplotlib`
                        #  extension instead of installing the full
                        #  package. We *could* account for this by
                        #  additionally checking the output of
                        #  `jupyter labextension check jupyter-matplotlib`,
                        #  but then we'd get into differentiating
                        #  the extension not being installed at all vs.
                        #  being installed but not enabled, etc.
                        #  Ultimately this is a pretty minor edge case
                        #  and the cost of a false positive is pretty
                        #  low (a harmless warning message IF the user
                        #  creates an interactive plot), so IMO it's
                        #  not worth the extra overhead of running more
                        #  shell commands every time hypertools is
                        #  imported.
                        BACKEND_WARNING = (
                            "The `ipympl` package is not installed in the "
                            "Jupyter server's environment. Interactive and "
                            "animated plots may not appear. To fix this, "
                            "pip-install `ipympl` and restart the Jupyer "
                            "server."
                        )

        switch_backend = _switch_backend_notebook
        reset_backend = _reset_backend_notebook

    finally:
        # restore backend
        mpl.use(curr_backend)
        BACKEND_MAPPING = BackendMapping(BACKEND_KEYS)
        if working_backend is not None:
            HYPERTOOLS_BACKEND = HypertoolsBackend(working_backend).normalize()


def _switch_backend_regular(backend):
    """
    Switch the plotting backend via `matplotlib.pyplot.switch_backend()`.

    Used to set/reset the backend in non-notebook environments, and as a
    fallback method of doing so in notebook environments when the
    IPython magic command fails.

    Parameters
    ----------
    backend : str
        the matplotlib backend to switch to

    Raises
    ------
    HypertoolsBackendError
        if switching the backend fails

    """
    backend = backend.as_python()

    try:
        plt.switch_backend(backend)
    except Exception as e:
        if isinstance(e, (ImportError, ModuleNotFoundError)):
            err_msg = (
                f"Failed to switch the plotting backend to "
                f"{backend}. You may be missing required dependencies, "
                "or this backend may not be available for your system"
            )
        else:
            err_msg = (
                "An unexpected error occurred while trying to switch "
                f"the plotting backend to {backend}"
            )

        raise HypertoolsBackendError(err_msg) from e


def _switch_backend_notebook(backend):
    """
    Handles switching the matplotlib backend when running in a Jupyter
    notebook.

    Parameters
    ----------
    backend : str
        the interactive matplotlib backend to switch to

    Notes
    -----
    1. `flush_figures` is a post-cell execution callback that
       `plt.show()`s & `plt.close()`s all figures created in a cell so
       that later `plt.plot()` calls create new figures. There's a weird
       circular matplotlib/IPython interaction where:
         - `matplotlib.pyplot` (via `IPython.core.pylabtools`) registers
            `flush_figures` when it's imported into an IPython
            environment
         - The `%matplotlib inline` magic command also registers a
           `flush_figures` call each it's run, whether or not one has
           been registered already
         - IPython runs `%matplotlib inline` if it detects
           `matplotlib.pyplot` has been imported and no backend is set
           in the same cell.
       So depending on import order, whether imports happen across
       multiple cells, and whether/when/how many times the backend has
       been switched, there may be any number of `flush_figures`
       callbacks registered. Switching to the interactive notebook
       backend unregisters one `flush_figures` callback but leaves the
       other(s), and creating an interactive figure with `flush_figures`
       registered closes the figure immediately after the cell executes
       and causes the matplotlib event loop to throw an error. So we
       need to ensure all `flush_figures` instances are unregistered
       before plotting.
    2. In certain situations, the IPython magic command fails to switch
       the backend but the matplotlib command will succeed. And for some
       unfathomable reason, when the magic command fails, IPython
       **prints** a warning message rather than issuing it via
       `warnings.warn()` [1]. So the only way to catch this warning is
       to temporarily suppress and capture stdout.

    [1] https://github.com/ipython/ipython/blob/e394d65a6b499b5d91df7ca0306c1cb88c543f43/IPython/core/interactiveshell.py#L3495
    """
    # ipykernel is only guaranteed to be installed if running in notebook
    try:
        from matplotlib_inline.backend_inline import flush_figures
    except (ImportError, ModuleNotFoundError):
        from ipykernel.pylab.backend_inline import flush_figures

    backend = backend.as_ipython()
    tmp_stdout = StringIO()
    exc = None

    with redirect_stdout(tmp_stdout):
        try:
            IPYTHON_INSTANCE.run_line_magic("matplotlib", backend)
        except KeyError as e:
            exc = e
            IPYTHON_INSTANCE.run_line_magic("matplotlib", "-l")

    output_msg = tmp_stdout.getvalue().strip()
    tmp_stdout.close()
    if exc is not None:
        # just in case something else was somehow sent to stdout while
        # redirected, or if we managed to catch a different KeyError
        backends_avail = output_msg.splitlines()[-1]
        raise ValueError(
            f"{backend} is not a valid IPython plotting backend.\n{backends_avail}"
        ) from exc

    elif output_msg.startswith("Warning: Cannot change to a different GUI toolkit"):
        try:
            _switch_backend_regular(backend)
        except HypertoolsBackendError as e:
            err_msg = (
                f'Failed to switch plotting backend to "{backend}" via '
                f"IPython with the following message:\n\t{output_msg}\n\n"
                f"Fell back to switching via matplotlib and failed with "
                f"the above error"
            )
            raise HypertoolsBackendError(err_msg) from e

    if backend != "inline":
        while flush_figures in IPYTHON_INSTANCE.events.callbacks["post_execute"]:
            IPYTHON_INSTANCE.events.unregister("post_execute", flush_figures)


def _reset_backend_notebook(backend):
    """
    Handles resetting the matplotlib backend after displaying an
    animated/interactive plot in a Jupyter notebook. This needs to be
    done in a slightly roundabout way to handle various IPython/Jupyter
    notebook behaviors (see Notes below for details).

    Parameters
    ----------
    backend : str
        the matplotlib backend prior to running `hypertools.plot`

    Notes
    -----
    1. Changing the matplotlib backend in a Jupyter notebook immediately
       closes any open figures (killing any animation and/or
       interactivity). So the reset can't happen as part of the main
       `hypertools.plot` or even in the same cell, otherwise the plots
       would be closed as soon as they're rendered. To get around this,
       `_reset_backend_notebook` registers an IPython callback function
       (`_deferred_reset_cb()`) that runs *before* the code in the
       *next* cell is run and resets the backend. This way, the
       animation runs and the plot is interactive until the user runs
       the next cell, but the backend is still reset before any other
       code is executed. (Note: this does not require cells to be run in
       order or for the next cell to be pre-queued)
    2. The command to switch the `matplotlib` backend closes open
       figures when called, even if "switching" to the currently set
       backend. Normally, registered callbacks run for all future cells,
       which means the `_deferred_reset_cb()` callback would interfere
       with future animated plots and plots created across multiple
       cells. To prevent this, `_deferred_reset_cb()` unregisters itself
       after resetting the backend so it only exists for the first cell
       run after creating the plot. This also prevents polluting the
       list of registered callbacks with duplicates, which can slow down
       cell execution.
    3. IPython callbacks are required to have the same function
       signature as the prototype for the event that triggers them, and
       the `pre_run_cell` prototype takes no arguments. This means the
       callback to reset the backend has to be defined and registered
       inside a wrapper function, because:
         - The top-level function to reset the backend needs to have the
           same signature as `matplotlib.pyplot.switch_backend` (which
           takes the backend as an argument) so the two can be used
           interchangeably depending on the current interpreter.
         - The registered callback needs to reference the backend it's
           switching to, but can't take it as a parameter
         - The callback can't be wrapped with the backend it's switching
           to via `functools.partial`, because it also needs to
           reference its own function object in order to unregister
           itself and a partial function would be a separate object.
    4. The `_deferred_reset_cb()` callback is registered up to one time
       per cell (i.e., creating multiple interactive/animated plots in
       the same cell without the `set_interactive_backend` context
       manager doesn't lead to duplicate callbacks). To do this, the
       list of registered callbacks has to be checked by name rather
       than by object, since the inner `_deferred_reset_cb` function is
       re-defined as a different object each time.
    """

    def _deferred_reset_cb(*args):
        _switch_backend_notebook(backend)
        IPYTHON_INSTANCE.events.unregister("pre_run_cell", _deferred_reset_cb)

    def _reset_cb_registered():
        for func in IPYTHON_INSTANCE.events.callbacks["pre_run_cell"]:
            if func.__name__ == "_deferred_reset_cb":
                return True
        return False

    backend = backend.as_ipython()
    if not _reset_cb_registered():
        IPYTHON_INSTANCE.events.register("pre_run_cell", _deferred_reset_cb)


def _get_runtime_args(func, *func_args, **func_kwargs):
    """
    Does some quick introspection to determine runtime values assigned
    to all parameters of a function for a given call, whether passed as
    args, kwargs, or defaults.

    Parameters
    ----------
    func : function
        The function to introspect
    func_args : tuple
        positional arguments passed to `func` at runtime
    func_kwargs : dict
        keyword arguments passed to `func` at runtime

    Returns
    -------
    runtime_vals : dict
        {parameter: value} mapping of runtime values
    """
    func_signature = inspect.signature(func)
    bound_args = func_signature.bind(*func_args, **func_kwargs)
    bound_args.apply_defaults()
    return bound_args.arguments


def _valid_matplotlib_backends():
    """
    Build the set of recognized matplotlib backend names (lowercased),
    including the IPython aliases from `BACKEND_KEYS`. Used by
    `set_interactive_backend` to validate its argument eagerly, so typos
    fail immediately with a clear message rather than at the next
    interactive/animated plot (QC audit 2026-07, F06-003).

    Returns
    -------
    set of str
        lowercased matplotlib backend names and aliases
    """
    names = set()
    for py_key, ipy_keys in BACKEND_KEYS.items():
        names.add(str(py_key).lower())
        if isinstance(ipy_keys, str):
            ipy_keys = (ipy_keys,)
        names.update(str(k).lower() for k in ipy_keys)
    try:
        from matplotlib.backends import backend_registry

        names.update(str(b).lower() for b in backend_registry.list_all())
    except ImportError:
        # matplotlib < 3.9 has no backend registry
        names.update(str(b).lower() for b in mpl.rcsetup.all_backends)
    return names


[docs] class set_interactive_backend: """ Set the plotting backend `hypertools` uses. Accepts two kinds of values: 1. A *render* backend -- ``'matplotlib'`` or ``'plotly'`` (case-insensitive) -- selecting which library draws `hypertools` plots. By default, `hypertools` auto-detects: it renders with plotly on Google Colab and Kaggle (where plotly is preinstalled and matplotlib figures are static) and with matplotlib everywhere else. Passing ``'auto'`` (or ``None``) restores this default auto-detection after a render backend has been set. 2. A *matplotlib* backend name (e.g., ``'TkAgg'``, ``'QtAgg'``, ``'MacOSX'``) -- the matplotlib GUI backend used for interactive and animated plots when rendering with matplotlib. Whereas `hypertools.plot`'s `backend` and `mpl_backend` keyword arguments can be used to specify these for a single plot, `hypertools.set_interactive_backend` is useful for doing so for multiple (or all) plots at once, and can be used in two different ways: 1. directly, to change the backend for all subsequent plots:: import hypertools as hyp data = hyp.load('weights_avg') hyp.plot(data) # rendered with matplotlib hyp.set_interactive_backend('plotly') hyp.plot(data) # rendered with plotly hyp.plot(data, animate=True) # animated plotly figure hyp.set_interactive_backend('auto') # restore auto-detection 2. as a context manager with the `with` statement, to temporarily change the backend:: import hypertools as hyp data = hyp.load('weights_avg') with hyp.set_interactive_backend('plotly'): hyp.plot(data, interactive=True) # rendered with plotly hyp.plot(data, animate=True) # default backend again Parameters ---------- backend : str or None Either a render backend (``'matplotlib'`` or ``'plotly'``), ``'auto'``/``None`` to restore render-backend auto-detection, or the `matplotlib` backend to use for interactive plots (e.g., ``'TkAgg'``). Applies temporarily when used as a context manager with `with`, or for the life of the interpreter when called as a function. Raises ------ TypeError If `backend` is not a string (or None). ValueError If `backend` is not ``'auto'``, a render backend, or a recognized matplotlib backend name. The error message lists the valid choices. HypertoolsBackendError When entering the context manager with a matplotlib backend that cannot be switched to on the current system (e.g., ``'TkAgg'`` without a working Tk installation). In that case, the pre-existing backend settings are left unchanged. Notes ----- 1. `set_interactive_backend` is technically a class, but it shouldn't typically be used as one and is only designed this way to enable it to work as both a regular function and a context manager. 2. Calling this directly does *not* immediately change the plotting backend; it changes the backend `hypertools` will use to create interactive plots going forward. 3. However, when used as a context manager with a *matplotlib* backend name, the backend passed to `hypertools.set_interactive_backend` will be used for *all* plots created inside the context block, regardless of whether: - they are interactive/animated or static - the `mpl_backend` keyword argument is passed to `hypertools.plot` - they were created with `hypertools`, `matplotlib`, or a different `matplotlib`-based library (e.g., `seaborn`, `quail`, `umap-learn`) There are a few reasons for this behavior: - being able to skip inspecting the arguments passed to each `hypertools.plot` call means almost no overhead is added for calls after the first, and makes wrapping multiple calls much more efficient - the plotting backend is an attribute of `matplotlib` itself and `matplotlib` doesn't support running multiple backends simultaneously in the same namespace, so it's impossible to avoid it affecting other `matplotlib`-based plotting libraries - it's reasonable to assume this was the desired outcome when multiple plots are generated inside a context block, since A) the context block will always have been created manually by the user, and B) the API provides multiple other ways to set the backend without this effect 4. The `manage_backend` decorator for `hypertools.plot` determines whether it's being called inside the `hypertools.set_interactive_backend` context manager by checking the value of a global variable (`IN_SET_CONTEXT`), which is backed by a nesting-depth counter (`_SET_CONTEXT_DEPTH`) so it remains `True` until the outermost context block exits. This global-flag approach is used instead of the alternatives -- A) using something like `inspect.getframeinfo` or `traceback.extract_stack` to look for the context manager every time `hypertools.plot` is called, or B) re-running the same runtime argument checks every time -- either of which would be much less efficient. 5. The `$HYPERTOOLS_BACKEND` environment variable provides a third way of setting the backend: if set before `hypertools` is imported, it overrides the default *matplotlib* backend used for interactive/animated matplotlib plots. It accepts matplotlib backend names only -- to prefer the plotly renderer, use `hypertools.set_interactive_backend('plotly')` or `hypertools.plot`'s `backend='plotly'` keyword argument instead. """
[docs] def __init__(self, backend): global BACKEND_WARNING, HYPERTOOLS_BACKEND, PREFERRED_RENDER_BACKEND # None means "restore render-backend auto-detection", same as # 'auto' (QC audit 2026-07, F06-007) if backend is None: backend = "auto" if not isinstance(backend, str): raise TypeError( "backend must be a string: 'auto', 'matplotlib', " "'plotly', or a matplotlib backend name (e.g., 'TkAgg', " f"'QtAgg', 'MacOSX'); got {type(backend).__name__}: " f"{backend!r}. Pass the backend name as a string instead." ) self.new_interactive_backend = HypertoolsBackend(backend).normalize() # 'plotly'/'matplotlib' are RENDER-backend selectors (which library # draws the plot), NOT matplotlib backend names. Route them to the # render preference consulted by plotly_backend.resolve_backend(), and # do NOT try to switch matplotlib's backend to them -- QC 2026-07: # set_interactive_backend('plotly') followed by an animated plot raised # HypertoolsBackendError trying to switch matplotlib to a nonexistent # 'plotly' backend, and it silently did nothing for static plots. # match case-insensitively ('Plotly' etc.): otherwise a capitalized # render-backend name was treated as an mpl backend and, with an # animated plot, raised the exact HypertoolsBackendError this routing # exists to prevent (QC 2026-07 red-team). Store the canonical lowercase # value so resolve_backend()'s preference check matches. _bk_str = str(self.new_interactive_backend).lower() self.is_render_backend = _bk_str in ('auto', 'plotly', 'matplotlib') if self.is_render_backend: self.old_render_backend = PREFERRED_RENDER_BACKEND # 'auto' restores environment-based auto-detection (plotly on # Colab/Kaggle, matplotlib elsewhere) PREFERRED_RENDER_BACKEND = None if _bk_str == 'auto' else _bk_str if _bk_str == 'plotly': # warn now (rather than silently falling back to matplotlib # at plot time) if the requested preference can't be honored # (QC audit 2026-07, F06-008). Lazy import avoids a # backend <-> plotly_backend import cycle at module load. from .plotly_backend import _has_plotly if not _has_plotly(): warnings.warn( "set_interactive_backend('plotly'): plotly is not " "installed, so plots will fall back to matplotlib. " "To use the plotly backend, run:\n" ' pip install "hypertools[interactive]"' ) self.new_is_different = False self.backend_switched = False return # eagerly validate matplotlib backend names so typos fail here, # with a clear message, instead of corrupting module state and # surfacing only at the next animated/interactive plot (QC audit # 2026-07, F06-003). "module://..." paths are always legal # matplotlib backend specs, so they're accepted as-is. valid_mpl_backends = _valid_matplotlib_backends() if not (_bk_str.startswith('module://') or _bk_str in valid_mpl_backends): valid_names = ', '.join( sorted(n for n in valid_mpl_backends if not n.startswith('module://')) ) raise ValueError( f"{backend!r} is not a recognized backend. Valid options " "are 'auto' (restore render-backend auto-detection), " "'matplotlib' or 'plotly' (render backends), a " "'module://...' backend path, or a matplotlib backend " f"name: {valid_names}" ) self.old_interactive_backend = HYPERTOOLS_BACKEND.normalize() self.old_backend_warning = BACKEND_WARNING self.new_is_different = ( self.new_interactive_backend != self.old_interactive_backend ) self.backend_switched = False if self.new_is_different: HYPERTOOLS_BACKEND = self.new_interactive_backend BACKEND_WARNING = None
def __enter__(self): global BACKEND_WARNING, HYPERTOOLS_BACKEND, IN_SET_CONTEXT global _SET_CONTEXT_DEPTH if not self.is_render_backend: # (a render-backend preference needs no matplotlib backend # switch) self.curr_backend = HypertoolsBackend(mpl.get_backend()).normalize() if self.curr_backend != self.new_interactive_backend: try: switch_backend(self.new_interactive_backend) except Exception: # if the switch fails, the `with` block is never # entered and `self.__exit__()` never runs, so roll # back the module state set in `self.__init__()` # before re-raising -- otherwise a single failed # switch left IN_SET_CONTEXT/HYPERTOOLS_BACKEND/ # BACKEND_WARNING corrupted for the rest of the # session (QC audit 2026-07, F06-002) if self.new_is_different: HYPERTOOLS_BACKEND = self.old_interactive_backend BACKEND_WARNING = self.old_backend_warning raise self.backend_switched = True # only mark the context as entered once any backend switch has # succeeded. IN_SET_CONTEXT is backed by a nesting-depth counter # so exiting an inner nested context doesn't flip it to False # while an outer context is still active (QC audit 2026-07, # F06-004) _SET_CONTEXT_DEPTH += 1 IN_SET_CONTEXT = True def __exit__(self, exc_type, exc_value, traceback): global BACKEND_WARNING, HYPERTOOLS_BACKEND, IN_SET_CONTEXT global PREFERRED_RENDER_BACKEND, _SET_CONTEXT_DEPTH _SET_CONTEXT_DEPTH = max(0, _SET_CONTEXT_DEPTH - 1) IN_SET_CONTEXT = _SET_CONTEXT_DEPTH > 0 if self.is_render_backend: PREFERRED_RENDER_BACKEND = self.old_render_backend return if self.new_is_different: HYPERTOOLS_BACKEND = self.old_interactive_backend BACKEND_WARNING = self.old_backend_warning if self.backend_switched: reset_backend(self.curr_backend)
@contextmanager def _null_backend_context(dummy_backend): """ A dummy context manager that does nothing, equivalent to `contextlib.nullcontext` (which isn't implemented in Python<3.7). Used in `manage_backend` when the decorated call to `hypertools.plot` happens inside the `hypertools.set_interactive_backend` context. Parameters ---------- dummy_backend : object Arbitrary value that is never used, but is required to make this function syntactically match `hypertools.set_interactive_backend` """ yield def manage_backend(plot_func): """ Decorator for hypertools.plot that prevents unexpected changes to matplotlib rcParams (https://github.com/ContextLab/hypertools/issues/243) and handles temporarily changing the matplotlib backend for interactive and animated plots, as necessary. Parameters ---------- plot_func : function Function around which to set/reset the plotting backend and rcParams (currently, always `hypertools.plot`). Returns ------- plot_wrapper : function The decorated function. Notes ----- 1. Capturing & restoring the rcParams needs to happen here rather than in `set_interactive_backend` so it's done independently for each plot 2. Written in a slightly roundabout way in order to skip unnecessary & duplicate calls when the decorated call to `hypertools.plot` happens inside the `hypertools.set_interactive_backend` context. """ @wraps(plot_func) def plot_wrapper(*args, **kwargs): """Call `plot_func`, managing rcParams and the matplotlib backend around it. Snapshots `mpl.rcParams` beforehand (setting `pdf.fonttype`/ `ps.fonttype` to 42 for editable-text vector exports) and restores it afterward; also temporarily switches to an interactive/animation-capable backend when needed, skipping that switch entirely when already running inside a `hypertools.set_interactive_backend` context. """ # record current rcParams old_rcParams = mpl.rcParams.copy() # Editable text in vector (PDF/PS) exports is a useful default for a # scientific-figure library, but it must NOT leak into the user's # global matplotlib config as an import-time side effect (issue #259). # Set it here, inside the managed scope: any save that happens during # the wrapped ``plot()`` call uses TrueType fonts, and the ``finally`` # block below restores the user's original rcParams afterward. mpl.rcParams["pdf.fonttype"] = 42 mpl.rcParams["ps.fonttype"] = 42 # assume using the mock-`contextlib.nullcontext` context backend_context = _null_backend_context tmp_backend = None if not IN_SET_CONTEXT: plot_kwargs = _get_runtime_args(plot_func, *args, **kwargs) want_interactive = (plot_kwargs.get("animate") or plot_kwargs.get("interactive")) # A GUI/interactive matplotlib backend is only needed to DISPLAY # a live figure. Rendering an animation to a file (save_path=) or # with show=False works on any non-interactive backend (Agg # included), so switching to a GUI backend in those cases is # unnecessary -- and aborts uncatchably on headless systems # (release audit 2026-07). Only switch when the figure will # actually be shown interactively. will_display = (plot_kwargs.get("show", True) and not plot_kwargs.get("save_path")) if want_interactive and will_display: # Only matplotlib-rendered plots need an interactive/animation- # capable matplotlib backend. When the plot renders with plotly # (the frames are embedded in the plotly Figure), skip the # matplotlib backend switch entirely -- otherwise a render # preference of 'plotly' resolved to a matplotlib backend name # and crashed (QC 2026-07). from .plotly_backend import resolve_backend as _resolve_backend _render = _resolve_backend(plot_kwargs.get("backend", "auto")) if _render != "plotly": curr_backend = HypertoolsBackend(mpl.get_backend()).normalize() tmp_backend = plot_kwargs.get("mpl_backend") if tmp_backend == "auto": tmp_backend = HYPERTOOLS_BACKEND.normalize() # 'plotly' is a render backend, not a matplotlib backend -- # never try to switch matplotlib to it if tmp_backend not in ("disable", "plotly", curr_backend): # if all conditions are met, use the real context backend_context = set_interactive_backend try: try: with backend_context(tmp_backend): if BACKEND_WARNING is not None: warnings.warn(BACKEND_WARNING) return plot_func(*args, **kwargs) except Exception as e: # A GUI toolkit that imports cleanly can still fail at # window-creation time (e.g., broken Tcl/Tk installs raise # _tkinter.TclError -- seen on GitHub's windows/py3.13 # runners). The `with` block has already restored the # original backend, so retry the plot once on it instead of # crashing. if (backend_context is set_interactive_backend and type(e).__name__ == "TclError"): warnings.warn( f"Failed to render plot using interactive backend " f"'{tmp_backend}' ({e}). Falling back to " f"'{mpl.get_backend()}'." ) return plot_func(*args, **kwargs) raise finally: # restore rcParams prior to plot with warnings.catch_warnings(): # if the matplotlibrc was cached from <=v3.3.0, a TON of # (harmless as of v3.2.0) MatplotlibDeprecationWarnings # about `axes.Axes3D`-related rcParams fields are issued warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) mpl.rcParams.update(**old_rcParams) return plot_wrapper