diff --git a/test/test_dependencies.py b/test/test_dependencies.py new file mode 100644 index 000000000..fb27bfbce --- /dev/null +++ b/test/test_dependencies.py @@ -0,0 +1,41 @@ +""" +Purpose: tests related to dependencies +e.g.: hvplot is optional, so it shouldn't be imported by default. +Related issues: #1224, #1539 +""" +import subprocess +import sys + + +def _assert_not_imported_after_import_uxarray(module_name): + """Run ``import uxarray`` in a fresh interpreter and assert that + ``module_name`` was not imported as a side effect. + + A subprocess is used so the check is immune to test ordering: other tests + in the same session may have already imported optional deps (e.g. plotting + tests import ``hvplot``), which would pollute this process's ``sys.modules``. + """ + code = ( + "import sys; import uxarray; " + f"assert {module_name!r} not in sys.modules, " + f"'{module_name} was imported by `import uxarray`'" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"{module_name} should not be imported by default:\n{result.stderr}" + ) + + +def test_hvplot_optional(): + """Test that hvplot is actually optional and not imported by default. + (hvplot in particular is "slow" to import (~1s to import hvplot.pandas), + so it should not be imported until actually plotting something.) + """ + _assert_not_imported_after_import_uxarray("hvplot") + + +# TODO: similar tests for cartopy, holoviews, and other optional deps. diff --git a/uxarray/plot/accessor.py b/uxarray/plot/accessor.py index ec279cb9c..0cf0e2bc4 100644 --- a/uxarray/plot/accessor.py +++ b/uxarray/plot/accessor.py @@ -12,11 +12,41 @@ from uxarray.core.dataset import UxDataset from uxarray.grid import Grid -import hvplot.pandas -import hvplot.xarray - from uxarray.plot.utils import backend as plotting_backend +# import speedup trick: +# code here uses obj.hvplot, which requires import hvplot.pandas and/or hvplot.xarray. +# But, those lines are "slow", so instead of doing them at top-level here, +# only run them when actually creating a plot accessor, inside the code later in this file. +# (timing test: import uxarray takes ~1.7 seconds before change; 0.7 seconds after change.) + +_IMPORTED_HVPLOT = False + + +def _ensure_hvplot_imported() -> None: + """Import hvplot.pandas and hvplot.xarray if not already imported. + (importing hvplot.pandas may output horizontal gray bar(s) in Jupyter, so only ever do it once.) + """ + global _IMPORTED_HVPLOT + if not _IMPORTED_HVPLOT: + # workaround for hvplot issue #1735; + # import hvplot.pandas and hvplot.xarray always adjust the hvplot.extension(). + # To respect previously-setup extension value, need to remember and restore it. + # NB: the active backend lives on holoviews.Store (hvplot has no ``Store``), + # and we only restore a backend that was actually loaded -- on a fresh import + # Store.current_backend defaults to "matplotlib" while Store.registry is empty, + # and restoring that unloaded backend would break rendering. + from holoviews import Store as _store + + _backend_orig = _store.current_backend + import hvplot.pandas + import hvplot.xarray + + if _backend_orig in _store.registry: + _store.set_current_backend(_backend_orig) + + _IMPORTED_HVPLOT = True + class GridPlotAccessor: """Plotting accessor for :class:`Grid`. @@ -36,6 +66,7 @@ class GridPlotAccessor: __slots__ = ("_uxgrid",) def __init__(self, uxgrid: Grid) -> None: + _ensure_hvplot_imported() self._uxgrid = uxgrid def __call__(self, **kwargs) -> Any: @@ -340,6 +371,7 @@ class UxDataArrayPlotAccessor: __slots__ = ("_uxda",) def __init__(self, uxda: UxDataArray) -> None: + _ensure_hvplot_imported() self._uxda = uxda def __call__(self, **kwargs) -> Any: @@ -521,6 +553,7 @@ class UxDatasetPlotAccessor: __slots__ = ("_uxds",) def __init__(self, uxds: UxDataset) -> None: + _ensure_hvplot_imported() self._uxds = uxds def __call__(self, **kwargs) -> Any: