diff --git a/test/test_plot.py b/test/test_plot.py index ae245b392..24cf9de43 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -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. diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index e97919d6c..e5c8d3f8b 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -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)