diff --git a/test/test_plot.py b/test/test_plot.py index eb7cdcb8f..e7187addd 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -229,3 +229,31 @@ 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_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) diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index e97919d6c..32239a3d4 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -2,36 +2,37 @@ 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.""" + """Switch Matplotlib back to the backend captured before the last switch.""" + if self.matplotlib_backend is None: + return + import matplotlib as mpl mpl.use(self.matplotlib_backend)