From 5ffcb4d09ee23b3f8f1c350741120dc54a53ca46 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 29 Jun 2026 19:38:35 -0500 Subject: [PATCH 1/2] 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/2] 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