Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions test/test_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,92 @@ def test_to_raster_pixel_ratio(gridpath, r1, r2):
d = np.array(raster2.shape) - f * np.array(raster1.shape)
assert (d >= 0).all() and (d <= f - 1).all()

def test_matplotlib_backend_restored_after_switch(monkeypatch):
"""Regression test for #1537: switching HoloViews to matplotlib must restore
the Matplotlib backend captured beforehand.

A bare pytest can't reproduce the Jupyter inline-hook flip, so we simulate
hv.extension flipping the backend and assert assign() restores it.
"""
import holoviews as hv

from uxarray.plot.utils import HoloviewsBackend

original = matplotlib.get_backend()
try:
matplotlib.use("svg") # the "user's" backend before plotting

# Simulate hv.extension("matplotlib") flipping the active backend to agg.
monkeypatch.setattr(hv.Store, "current_backend", "bokeh", raising=False)
monkeypatch.setattr(hv, "extension", lambda *a, **k: matplotlib.use("agg"))

be = HoloviewsBackend()
be.assign("matplotlib")

# assign() must restore the backend that was active before the switch.
assert matplotlib.get_backend() == "svg"
finally:
matplotlib.use(original)


def test_inline_backend_reactivated_via_shell(monkeypatch):
"""Inside IPython, restoring an inline backend must re-run the shell's own
backend activation (the public equivalent of ``%matplotlib inline``), which
rebuilds the full display integration that ``hv.extension`` tore down.

Simply calling ``mpl.use`` is not enough: it restores the backend name but
leaves last-line figure auto-display broken (see #1537 / #1538).
"""
import sys
import types

from uxarray.plot.utils import HoloviewsBackend

inline_backend = "module://matplotlib_inline.backend_inline"
calls = []

class FakeShell:
def enable_matplotlib(self, gui):
calls.append(("enable_matplotlib", gui))

ipython = types.ModuleType("IPython")
ipython.get_ipython = lambda: FakeShell()

monkeypatch.setitem(sys.modules, "IPython", ipython)
monkeypatch.setattr(
matplotlib, "use", lambda backend: calls.append(("use", backend))
)

be = HoloviewsBackend()
be.matplotlib_backend = inline_backend
be.reset_mpl_backend()

# The module:// inline backend must be mapped to the "inline" gui name and
# restored through the shell, without falling back to mpl.use.
assert calls == [("enable_matplotlib", "inline")]


def test_reset_backend_falls_back_to_mpl_use_outside_ipython(monkeypatch):
"""Outside IPython (get_ipython() is None), restoration falls back to
``mpl.use`` with the stored backend."""
import sys
import types

from uxarray.plot.utils import HoloviewsBackend

calls = []
ipython = types.ModuleType("IPython")
ipython.get_ipython = lambda: None
monkeypatch.setitem(sys.modules, "IPython", ipython)
monkeypatch.setattr(matplotlib, "use", lambda backend: calls.append(("use", backend)))

be = HoloviewsBackend()
be.matplotlib_backend = "svg"
be.reset_mpl_backend()

assert calls == [("use", "svg")]


def test_plot_with_features(gridpath, datasetpath):
"""ensure can render a multiplot layout with geoviews features.
Regression test for issue #1542.
Expand Down
66 changes: 49 additions & 17 deletions uxarray/plot/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,68 @@


class HoloviewsBackend:
"""Utility class to compare and set a HoloViews plotting backend for
visualization."""
"""Compare and set the HoloViews plotting backend."""

def __init__(self):
self.matplotlib_backend = None

def assign(self, backend: str):
"""Assigns a backend for use with HoloViews visualization.

Parameters
----------
backend : str
Plotting backend to use, one of 'matplotlib', 'bokeh'
"""

if self.matplotlib_backend is None:
import matplotlib as mpl

self.matplotlib_backend = mpl.get_backend()

"""Assign a HoloViews backend, one of 'matplotlib', 'bokeh'."""
if backend not in ["bokeh", "matplotlib", None]:
raise ValueError(
f"Unsupported backend. Expected one of ['bokeh', 'matplotlib'], but received {backend}"
)
if backend is not None and backend != hv.Store.current_backend:
# only call hv.extension if it needs to be changed
import matplotlib as mpl

# Capture the live backend now (not once at init) so a backend the
# user set later is what we restore.
self.matplotlib_backend = mpl.get_backend()
hv.extension(backend)

if backend == "matplotlib":
# hv.extension("matplotlib") switches the active Matplotlib
# backend (e.g. to agg in Jupyter), breaking subsequent native
# matplotlib/xarray .plot() calls. HoloViews renders through
# hv.Store.current_backend, so restoring is safe. See #1537.
self.reset_mpl_backend()

def reset_mpl_backend(self):
"""Resets the default backend for the ``matplotlib`` module."""
"""Restore the Matplotlib backend captured before the last switch.

``hv.extension("matplotlib")`` does not just change the active backend;
in an IPython/Jupyter kernel it also tears down the display integration
that auto-renders figures at the end of a cell. Simply calling
``mpl.use`` puts the backend name back but leaves that integration
broken, so subsequent native ``matplotlib``/``xarray`` ``.plot()`` calls
silently produce no output unless ``plt.show()`` is called explicitly.

Inside IPython we therefore re-run the shell's own backend activation
(the public equivalent of the ``%matplotlib`` magic), which rebuilds the
full integration in one step. Outside IPython we fall back to
``mpl.use``.
"""
if self.matplotlib_backend is None:
return

try:
from IPython import get_ipython

shell = get_ipython()
except ImportError:
shell = None

if shell is not None:
# Map the stored backend to the gui name enable_matplotlib expects.
gui = self.matplotlib_backend
if gui.startswith("module://") and "inline" in gui:
gui = "inline"
try:
shell.enable_matplotlib(gui)
return
except Exception:
pass

import matplotlib as mpl

mpl.use(self.matplotlib_backend)
Expand Down
Loading