"""
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