Skip to content

Restore matplotlib backend after HoloViews matplotlib plot#1538

Open
rajeeja wants to merge 4 commits into
mainfrom
rajeeja/fix_matplotlib_backend
Open

Restore matplotlib backend after HoloViews matplotlib plot#1538
rajeeja wants to merge 4 commits into
mainfrom
rajeeja/fix_matplotlib_backend

Conversation

@rajeeja

@rajeeja rajeeja commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Calling plot(backend="matplotlib") runs hv.extension("matplotlib"), which switches the active matplotlib backend and clobbers the IPython inline display hook, silently breaking any subsequent native matplotlib/xarray .plot() calls. This restores the original matplotlib backend right after the HoloViews extension switch, which is safe because HoloViews objects display through Store.current_backend rather than the active matplotlib backend. Verified in real Jupyter kernels that the reported sequence now works, with a new regression test and all plotting tests/relevant docs notebooks passing; closes #1537.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a Jupyter/IPython plotting regression where uxarray.plot(..., backend="matplotlib") triggers hv.extension("matplotlib"), which can alter Matplotlib’s active backend and break subsequent native matplotlib/xarray plotting in the same session.

Changes:

  • Restore the Matplotlib backend immediately after switching HoloViews to the matplotlib backend.
  • Update reset_mpl_backend() documentation to describe intended behavior.
  • Add a regression test covering backend restoration after a UXarray matplotlib-backed plot.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
uxarray/plot/utils.py Adds post-hv.extension("matplotlib") backend restoration and updates backend reset docstring.
test/test_plot.py Adds a regression test asserting Matplotlib backend state and subsequent xarray plotting still works.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread uxarray/plot/utils.py
Comment thread uxarray/plot/utils.py
Comment thread test/test_plot.py Outdated
@cmdupuis3 cmdupuis3 added the run-benchmark Run ASV benchmark workflow label Jul 2, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [df6ef32] After [f8a7d97] Ratio Benchmark (Parameter)
- 630M 443M 0.7 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
- 756M 442M 0.58 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
- 552M 435M 0.79 mpas_ocean.Gradient.peakmem_gradient('480km')

Benchmarks that have stayed the same:

Change Before [df6ef32] After [f8a7d97] Ratio Benchmark (Parameter)
441M 441M 1.00 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
471M 473M 1.00 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
14.3±0.1ms 14.4±0.05ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
3.66±0.04ms 3.67±0.04ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
18.8±0.06ms 18.9±0.07ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
2.19±0.01ms 2.15±0.01ms 0.98 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
4.89±0.02s 4.85±0.04s 0.99 import.Imports.timeraw_import_uxarray
921±4ns 926±2ns 1.01 mpas_ocean.CheckNorm.time_check_norm('120km')
894±6ns 891±6ns 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
829±7ms 847±9ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
54.1±0.1ms 53.1±0.2ms 0.98 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
660±20μs 653±10μs 0.99 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
598±20μs 581±20μs 0.97 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.42±0.03ms 5.36±0.03ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.98±0.02ms 3.90±0.04ms 0.98 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
3.47±0.02s 3.48±0.01s 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
224±0.6ms 224±1ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.1±0.03ms 18.1±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.01±0.01ms 1.01±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.5±0.01ms 10.5±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
706±7μs 689±10μs 0.98 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
701±3ms 698±5ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 1)
351±2ms 356±1ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 2)
182±0.5ms 184±0.7ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 4)
543±2ms 549±3ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 1)
275±0.6ms 274±0.6ms 0.99 mpas_ocean.CrossSections.time_const_lat('480km', 2)
142±0.4ms 141±0.8ms 0.99 mpas_ocean.CrossSections.time_const_lat('480km', 4)
24.2±0.04ms 24.6±0.2ms 1.02 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.09±0.05ms 3.12±0.04ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
940±10ms 966±5ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
53.5±1ms 53.9±1ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
83.7±0.9ms 85.4±2ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.75±0.06ms 5.81±0.06ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
456M 456M 1.00 mpas_ocean.Gradient.peakmem_gradient('120km')
175±0.3ms 173±0.5ms 0.99 mpas_ocean.Gradient.time_gradient('120km')
12.2±0.09ms 12.4±0.1ms 1.02 mpas_ocean.Gradient.time_gradient('480km')
225±0.9μs 226±1μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
132±0.7μs 132±0.5μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
405M 405M 1.00 mpas_ocean.Integrate.peakmem_integrate('120km')
384M 384M 1.00 mpas_ocean.Integrate.peakmem_integrate('480km')
217±2μs 216±0.9μs 0.99 mpas_ocean.Integrate.time_integrate('120km')
202±0.5μs 201±1μs 0.99 mpas_ocean.Integrate.time_integrate('480km')
132±0.7ms 137±2ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
132±1ms 133±1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
132±0.9ms 135±0.9ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
10.5±0.05ms 10.4±0.04ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
10.4±0.1ms 10.6±0.1ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
10.5±0.09ms 10.4±0.04ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
failed failed n/a mpas_ocean.PointInPolygon.time_face_search('120km')
failed failed n/a mpas_ocean.PointInPolygon.time_face_search('480km')
241±0.8ms 245±1ms 1.02 mpas_ocean.RemapDownsample.time_bilinear_remapping
300±5ms 293±4ms 0.98 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
1.43±0.01s 1.43±0.01s 1.00 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.3±0.6ms 35.7±0.4ms 0.98 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
9.36±0.1ms 9.50±0.1ms 1.02 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
28.2±0.3ms 28.4±0.2ms 1.01 mpas_ocean.ZonalAverage.time_zonal_average('120km')
6.66±0.02ms 6.46±0.07ms 0.97 mpas_ocean.ZonalAverage.time_zonal_average('480km')
380M 380M 1.00 quad_hexagon.QuadHexagon.peakmem_open_dataset
379M 379M 1.00 quad_hexagon.QuadHexagon.peakmem_open_grid
6.97±0.1ms 6.91±0.08ms 0.99 quad_hexagon.QuadHexagon.time_open_dataset
5.96±0.07ms 5.99±0.1ms 1.00 quad_hexagon.QuadHexagon.time_open_grid

Benchmarks that have got worse:

Change Before [df6ef32] After [f8a7d97] Ratio Benchmark (Parameter)
+ 4.29±0.03ms 4.75±0.01ms 1.11 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping

@Sevans711

Copy link
Copy Markdown
Collaborator

I was taking a quick look to see if I could review and get this merged to main, but I ended up with a couple quick questions.

(1) Can you provide a small example that definitely causes the unexpected behavior mentioned in #1537 before this fix, and does not cause it anymore after this fix?

On main currently (i.e. without this fix yet), in a Jupyter notebook, I ran the following, putting each plot() call in its own cell. I tried in Python 3.10 and Python 3.13. In both cases, every plot call displays a plot; I can't reproduce the failure to make plots. Each of these calls successfully makes a plot.

import uxarray as ux
uxds = ux.tutorial.open_dataset("outCSne30-vortex")
uxds["psi"].plot(backend='matplotlib')  # makes a matplotlib-style plot
uxds["psi"].plot()  # makes a matplotlib-style plot
# try switching to bokeh just in case it is related to switching back and forth:
uxds["psi"].plot(backend='bokeh')  # makes a bokeh-style plot
uxds["psi"].plot()  # makes a bokeh-style plot
# switch back again:
uxds["psi"].plot(backend='matplotlib')  # makes a matplotlib-style plot
uxds["psi"].plot()  # makes a matplotlib-style plot

(2) Is it desirable for the backend to be changed "permanently" instead of just a temporary change for the current plot? I naively would expect a kwarg being passed to plot() to only affect behavior for that one plot, not to affect global state. E.g., I would expect the following:

import holoviews as hv
hv.extension('bokeh')
uxds["psi"].plot()  # makes a bokeh-style plot
uxds["psi"].plot(backend='matplotlib')  # makes a matplotlib-style plot
uxds["psi"].plot()  # I would expect a bokeh-style plot, but actually it is matplotlib-style.

Please let me know if this second point belongs in a separate issue instead.

@rajeeja

rajeeja commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I’ll keep this separate from #1541. Test added.

@Sevans711

Copy link
Copy Markdown
Collaborator

I agree this should stay separate from #1541. I thought both of my points were unrelated to 1541, though.

For (1), I see you added regression tests, with good comments detailing how they aren't actually producing a real Jupyter notebook to see the issue occur (that would be challenging to set up with pytest). Were you able to reproduce the original issue on a real Jupyter notebook before this fix?

Ideally, I would like to run a small piece of code which can reproduce the issue when in main in a real Jupyter notebook; then change to this branch, rerun the code, and see that the issue does not occur anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

simple uxarray.plot(backend="matplotlib") seems to break subsequent xarray.plot functionality

5 participants