From 5ffcb4d09ee23b3f8f1c350741120dc54a53ca46 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 29 Jun 2026 19:38:35 -0500 Subject: [PATCH 1/3] Restore matplotlib backend after HoloViews matplotlib plot plot(backend='matplotlib') calls hv.extension('matplotlib'), which switches the active matplotlib backend and clobbers the IPython inline display hook, silently breaking subsequent native matplotlib/xarray .plot() calls. Restore the original matplotlib backend right after the HoloViews extension switch; HoloViews objects still display via Store.current_backend, so this is safe. Closes #1537 --- test/test_plot.py | 26 ++++++++++++++++++++++++++ uxarray/plot/utils.py | 18 +++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/test/test_plot.py b/test/test_plot.py index eb7cdcb8f..c4ddf0c88 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -229,3 +229,29 @@ def test_to_raster_pixel_ratio(gridpath, r1, r2): f = r2 / r1 d = np.array(raster2.shape) - f * np.array(raster1.shape) assert (d >= 0).all() and (d <= f - 1).all() + + +def test_matplotlib_backend_does_not_clobber_mpl_state(gridpath): + """Regression test for #1537. + + ``plot(backend="matplotlib")`` calls ``hv.extension("matplotlib")`` which + switches the active Matplotlib backend and breaks subsequent native + Matplotlib/xarray ``.plot()`` calls. UXarray should restore the original + Matplotlib backend so plain Matplotlib plotting still works afterwards. + """ + mesh_path = gridpath("mpas", "QU", "oQU480.231010.nc") + uxds = ux.open_dataset(mesh_path, mesh_path) + + backend_before = matplotlib.get_backend() + + # UXarray plot using the matplotlib backend (the trigger in #1537) + uxds["bottomDepth"].plot(backend="matplotlib") + + # The active matplotlib backend must be restored. + assert matplotlib.get_backend() == backend_before + + # A subsequent plain xarray/matplotlib plot must still work without error. + fig, ax = plt.subplots() + xr.DataArray(np.random.rand(5, 5), dims=["y", "x"], name="plain").plot(ax=ax) + assert len(ax.collections) + len(ax.images) > 0 + plt.close(fig) diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index e97919d6c..97fa6900e 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -30,8 +30,24 @@ def assign(self, backend: str): # only call hv.extension if it needs to be changed hv.extension(backend) + if backend == "matplotlib": + # ``hv.extension("matplotlib")`` switches the active Matplotlib + # backend (e.g. to ``agg`` in Jupyter), which clobbers the + # IPython inline display hook. This silently breaks any + # subsequent native ``matplotlib``/``xarray`` ``.plot()`` calls, + # which render nothing. HoloViews objects still display because + # they render through ``hv.Store.current_backend`` rather than + # the active Matplotlib backend, so restoring the original + # backend here is safe and fixes downstream plotting. See + # https://github.com/UXARRAY/uxarray/issues/1537 + self.reset_mpl_backend() + def reset_mpl_backend(self): - """Resets the default backend for the ``matplotlib`` module.""" + """Restores the original ``matplotlib`` backend (and its IPython inline + display hook) that was active before a HoloViews backend switch.""" + if self.matplotlib_backend is None: + return + import matplotlib as mpl mpl.use(self.matplotlib_backend) From bac9039bc772a115d37b840bfc8b1a3000f33ded Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 29 Jun 2026 21:38:27 -0500 Subject: [PATCH 2/3] Address review: capture backend at switch, accurate docstring, effective test --- test/test_plot.py | 38 ++++++++++++++++++++------------------ uxarray/plot/utils.py | 39 ++++++++++++--------------------------- 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/test/test_plot.py b/test/test_plot.py index c4ddf0c88..e7187addd 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -231,27 +231,29 @@ def test_to_raster_pixel_ratio(gridpath, r1, r2): assert (d >= 0).all() and (d <= f - 1).all() -def test_matplotlib_backend_does_not_clobber_mpl_state(gridpath): - """Regression test for #1537. +def test_matplotlib_backend_restored_after_switch(monkeypatch): + """Regression test for #1537: switching HoloViews to matplotlib must restore + the Matplotlib backend captured beforehand. - ``plot(backend="matplotlib")`` calls ``hv.extension("matplotlib")`` which - switches the active Matplotlib backend and breaks subsequent native - Matplotlib/xarray ``.plot()`` calls. UXarray should restore the original - Matplotlib backend so plain Matplotlib plotting still works afterwards. + A bare pytest can't reproduce the Jupyter inline-hook flip, so we simulate + hv.extension flipping the backend and assert assign() restores it. """ - mesh_path = gridpath("mpas", "QU", "oQU480.231010.nc") - uxds = ux.open_dataset(mesh_path, mesh_path) + import holoviews as hv + + from uxarray.plot.utils import HoloviewsBackend - backend_before = matplotlib.get_backend() + original = matplotlib.get_backend() + try: + matplotlib.use("svg") # the "user's" backend before plotting - # UXarray plot using the matplotlib backend (the trigger in #1537) - uxds["bottomDepth"].plot(backend="matplotlib") + # 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")) - # The active matplotlib backend must be restored. - assert matplotlib.get_backend() == backend_before + be = HoloviewsBackend() + be.assign("matplotlib") - # A subsequent plain xarray/matplotlib plot must still work without error. - fig, ax = plt.subplots() - xr.DataArray(np.random.rand(5, 5), dims=["y", "x"], name="plain").plot(ax=ax) - assert len(ax.collections) + len(ax.images) > 0 - plt.close(fig) + # assign() must restore the backend that was active before the switch. + assert matplotlib.get_backend() == "svg" + finally: + matplotlib.use(original) diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index 97fa6900e..32239a3d4 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -2,49 +2,34 @@ 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), which clobbers the - # IPython inline display hook. This silently breaks any - # subsequent native ``matplotlib``/``xarray`` ``.plot()`` calls, - # which render nothing. HoloViews objects still display because - # they render through ``hv.Store.current_backend`` rather than - # the active Matplotlib backend, so restoring the original - # backend here is safe and fixes downstream plotting. See - # https://github.com/UXARRAY/uxarray/issues/1537 + # 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): - """Restores the original ``matplotlib`` backend (and its IPython inline - display hook) that was active before a HoloViews backend switch.""" + """Switch Matplotlib back to the backend captured before the last switch.""" if self.matplotlib_backend is None: return From a1aefdce78c35a698e6d52ea54cd4b066ad86dff Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 10 Jul 2026 10:54:44 -0500 Subject: [PATCH 3/3] Reconfigure IPython inline display hook when restoring backend mpl.use() restores the matplotlib backend name but does not re-register IPython's inline display integration that hv.extension('matplotlib') clobbers. In a Jupyter kernel without an explicit %matplotlib inline, native matplotlib/xarray .plot() calls after a uxarray matplotlib plot still failed to render. Re-run configure_inline_support when the restored backend is inline so the display hook is reinstated. See #1537. --- test/test_plot.py | 31 +++++++++++++++++++++++++++++++ uxarray/plot/utils.py | 14 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/test/test_plot.py b/test/test_plot.py index e7187addd..6e188b716 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -257,3 +257,34 @@ def test_matplotlib_backend_restored_after_switch(monkeypatch): assert matplotlib.get_backend() == "svg" finally: matplotlib.use(original) + + +def test_inline_backend_support_reconfigured(monkeypatch): + """Restoring the inline backend must also re-register IPython's display hook.""" + import sys + import types + + from uxarray.plot.utils import HoloviewsBackend + + inline_backend = "module://matplotlib_inline.backend_inline" + shell = object() + calls = [] + + ipython = types.ModuleType("IPython") + ipython.get_ipython = lambda: shell + matplotlib_inline = types.ModuleType("matplotlib_inline") + backend_inline = types.ModuleType("matplotlib_inline.backend_inline") + backend_inline.configure_inline_support = lambda sh, backend: calls.append( + (sh, backend) + ) + + monkeypatch.setitem(sys.modules, "IPython", ipython) + monkeypatch.setitem(sys.modules, "matplotlib_inline", matplotlib_inline) + monkeypatch.setitem(sys.modules, "matplotlib_inline.backend_inline", backend_inline) + monkeypatch.setattr(matplotlib, "use", lambda backend: calls.append(("use", backend))) + + be = HoloviewsBackend() + be.matplotlib_backend = inline_backend + be.reset_mpl_backend() + + assert calls == [("use", inline_backend), (shell, inline_backend)] diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index 32239a3d4..6819b05fb 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -37,5 +37,19 @@ def reset_mpl_backend(self): mpl.use(self.matplotlib_backend) + if self.matplotlib_backend in ( + "inline", + "module://matplotlib_inline.backend_inline", + ): + try: + from IPython import get_ipython + from matplotlib_inline.backend_inline import configure_inline_support + except ImportError: + return + + shell = get_ipython() + if shell is not None: + configure_inline_support(shell, self.matplotlib_backend) + backend = HoloviewsBackend()