From 61d3ea2472da89b261b4ed5b6460465194ea4b4a Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:08:31 -0400 Subject: [PATCH 01/15] add _raise_hint_if_optional_deps_missing --- uxarray/core/dataarray.py | 3 ++ uxarray/cross_sections/sample.py | 3 ++ uxarray/errors.py | 6 +++ uxarray/grid/geometry.py | 8 +++ uxarray/grid/grid.py | 3 +- uxarray/io/_geopandas.py | 3 +- uxarray/io/_healpix.py | 3 ++ uxarray/plot/accessor.py | 4 ++ uxarray/plot/matplotlib.py | 2 + uxarray/plot/utils.py | 6 +++ uxarray/utils/imports.py | 90 ++++++++++++++++++++++++++++++++ 11 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 uxarray/utils/imports.py diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 28fa22886..5c6adf3b7 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -39,6 +39,7 @@ from uxarray.plot.accessor import UxDataArrayPlotAccessor from uxarray.remap.accessor import RemapAccessor from uxarray.subset import DataArraySubsetAccessor +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing if TYPE_CHECKING: import cartopy.crs as ccrs @@ -473,6 +474,7 @@ def to_raster( >>> ax.imshow(raster, origin="lower", extent=ax.get_xlim() + ax.get_ylim()) """ + _raise_hint_if_optional_deps_missing("cartopy") from cartopy.mpl.geoaxes import GeoAxes from uxarray.constants import INT_DTYPE @@ -517,6 +519,7 @@ def _is_default_extent() -> bool: if _is_default_extent(): try: + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs lon_min = float(self.uxgrid.node_lon.min(skipna=True).values) diff --git a/uxarray/cross_sections/sample.py b/uxarray/cross_sections/sample.py index 881e5dca4..37d1bd573 100644 --- a/uxarray/cross_sections/sample.py +++ b/uxarray/cross_sections/sample.py @@ -1,6 +1,8 @@ import numpy as np from numba import njit, prange +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing + @njit(parallel=True) def _fill_numba(flat_orig, face_idx, n_face, n_steps): @@ -16,6 +18,7 @@ def _fill_numba(flat_orig, face_idx, n_face, n_steps): def sample_geodesic( start: tuple[float, float], end: tuple[float, float], steps: int ) -> tuple[np.ndarray, np.ndarray]: + _raise_hint_if_optional_deps_missing("pyproj") from pyproj import Geod lon0, lat0 = start diff --git a/uxarray/errors.py b/uxarray/errors.py index 9e5cbab23..51b281405 100644 --- a/uxarray/errors.py +++ b/uxarray/errors.py @@ -38,3 +38,9 @@ class GridsMismatchError(ValueError): class YacNotAvailableError(RuntimeError): """Raised when the YAC backend is requested but unavailable.""" + + +# # # ----- Miscellaneous Errors ----- # # # + +class OptionalDependencyNotFoundError(ModuleNotFoundError): + """indicates functionality relies on a not-yet-installed optional dependency.""" diff --git a/uxarray/grid/geometry.py b/uxarray/grid/geometry.py index f1843096f..4c5cdfd10 100644 --- a/uxarray/grid/geometry.py +++ b/uxarray/grid/geometry.py @@ -15,6 +15,7 @@ ) from uxarray.grid.point_in_face import _face_contains_point from uxarray.grid.utils import _get_cartesian_face_edge_nodes +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing POLE_POINTS_XYZ = { "North": np.array([0.0, 0.0, 1.0]), @@ -116,6 +117,7 @@ def _build_polygon_shells( ): """Builds an array of polygon shells, which can be used with Shapely to construct polygons.""" + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs closed_face_nodes = _pad_closed_face_nodes( @@ -145,6 +147,7 @@ def _correct_central_longitude(node_lon, node_lat, projection): """Shifts the central longitude of an unstructured grid, which moves the antimeridian when visualizing, which is used when projections have a central longitude other than 0.0.""" + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs if projection: @@ -169,6 +172,7 @@ def _correct_central_longitude(node_lon, node_lat, projection): def _grid_to_polygon_geodataframe(grid, periodic_elements, projection, project, engine): """Converts the faces of a ``Grid`` into a ``spatialpandas.GeoDataFrame`` or ``geopandas.GeoDataFrame`` with a geometry column of polygons.""" + _raise_hint_if_optional_deps_missing("geopandas", "spatialpandas") import geopandas import shapely import spatialpandas @@ -260,6 +264,7 @@ def _build_geodataframe_without_antimeridian( """Builds a ``spatialpandas.GeoDataFrame`` or ``geopandas.GeoDataFrame``excluding any faces that cross the antimeridian.""" + _raise_hint_if_optional_deps_missing("geopandas", "spatialpandas") import geopandas import shapely import spatialpandas @@ -296,6 +301,7 @@ def _build_geodataframe_with_antimeridian( ): """Builds a ``spatialpandas.GeoDataFrame`` or ``geopandas.GeoDataFrame`` including any faces that cross the antimeridian.""" + _raise_hint_if_optional_deps_missing("geopandas", "spatialpandas") import geopandas import spatialpandas from spatialpandas.geometry import MultiPolygonArray @@ -441,6 +447,7 @@ def _grid_to_matplotlib_polycollection( grid, periodic_elements, projection=None, **kwargs ): """Constructs and returns a ``matplotlib.collections.PolyCollection``""" + _raise_hint_if_optional_deps_missing("cartopy", "matplotlib") import cartopy.crs as ccrs from matplotlib.collections import PolyCollection @@ -647,6 +654,7 @@ def _grid_to_matplotlib_linecollection( grid, periodic_elements, projection=None, **kwargs ): """Constructs and returns a ``matplotlib.collections.LineCollection``""" + _raise_hint_if_optional_deps_missing("cartopy", "matplotlib") import cartopy.crs as ccrs from matplotlib.collections import LineCollection diff --git a/uxarray/grid/grid.py b/uxarray/grid/grid.py index 6fa02b069..34e26bf8d 100644 --- a/uxarray/grid/grid.py +++ b/uxarray/grid/grid.py @@ -100,6 +100,7 @@ from uxarray.io.utils import _parse_grid_type from uxarray.plot.accessor import GridPlotAccessor from uxarray.subset import GridSubsetAccessor +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing if TYPE_CHECKING: import cartopy.crs as ccrs @@ -2296,7 +2297,7 @@ def to_geodataframe( gdf : spatialpandas.GeoDataFrame or geopandas.GeoDataFrame The output ``GeoDataFrame`` with a filled out "geometry" column of polygons. """ - + _raise_hint_if_optional_deps_missing("spatialpandas") from spatialpandas import GeoDataFrame if engine not in ["spatialpandas", "geopandas"]: diff --git a/uxarray/io/_geopandas.py b/uxarray/io/_geopandas.py index a47606ac2..b2b1a5c73 100644 --- a/uxarray/io/_geopandas.py +++ b/uxarray/io/_geopandas.py @@ -3,6 +3,7 @@ from uxarray.constants import INT_DTYPE, INT_FILL_VALUE, WGS84_CRS from uxarray.conventions import ugrid +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing def _read_geodataframe(filepath, driver=None, **kwargs): @@ -63,7 +64,7 @@ def _gpd_read(filepath, driver=None, **kwargs): int Maximum number of nodes in a polygon/multipolygon. """ - + _raise_hint_if_optional_deps_missing("geopandas") import geopandas as gpd try: diff --git a/uxarray/io/_healpix.py b/uxarray/io/_healpix.py index 3e6bdfef7..38f4d155a 100644 --- a/uxarray/io/_healpix.py +++ b/uxarray/io/_healpix.py @@ -6,6 +6,7 @@ import uxarray.conventions.ugrid as ugrid from uxarray.constants import INT_DTYPE +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing def get_zoom_from_cells(cells): @@ -67,6 +68,7 @@ def pix2corner_ang( ---- This will be updated when https://github.com/ntessore/healpix/issues/66 is implemented. """ + _raise_hint_if_optional_deps_missing("healpix") import healpix as hp if nest: @@ -103,6 +105,7 @@ def _pixels_to_ugrid(zoom, nest): A dataset containing pixel longitude and latitude coordinates along with related attributes. """ + _raise_hint_if_optional_deps_missing("healpix") import healpix as hp ds = xr.Dataset() diff --git a/uxarray/plot/accessor.py b/uxarray/plot/accessor.py index 2f8bb56fd..24456570c 100644 --- a/uxarray/plot/accessor.py +++ b/uxarray/plot/accessor.py @@ -14,6 +14,7 @@ from uxarray.grid import Grid from uxarray.plot.utils import backend as plotting_backend +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing # import speedup trick: # code here uses obj.hvplot, which requires import hvplot.pandas and/or hvplot.xarray. @@ -30,6 +31,7 @@ def _ensure_hvplot_imported() -> None: """ global _IMPORTED_HVPLOT if not _IMPORTED_HVPLOT: + _raise_hint_if_optional_deps_missing("holoviews", "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. @@ -244,6 +246,7 @@ def edges( gdf.hvplot.paths : hvplot.paths A paths plot of the edges of the unstructured grid """ + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs plotting_backend.assign(backend) @@ -445,6 +448,7 @@ def polygons( gdf.hvplot.polygons : hvplot.polygons A shaded polygon plot """ + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs plotting_backend.assign(backend) diff --git a/uxarray/plot/matplotlib.py b/uxarray/plot/matplotlib.py index 186b7d65f..3a823572c 100644 --- a/uxarray/plot/matplotlib.py +++ b/uxarray/plot/matplotlib.py @@ -5,6 +5,7 @@ import numpy as np from uxarray.errors import DimensionError +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing if TYPE_CHECKING: from cartopy.mpl.geoaxes import GeoAxes @@ -126,6 +127,7 @@ def _get_points_from_axis(ax: GeoAxes, *, pixel_ratio: float = 1): ny : int Number of rows (height) in the pixel grid. """ + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index 17275fe25..d4e9bc6b9 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -1,3 +1,6 @@ + +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing + class HoloviewsBackend: """Compare and set the HoloViews plotting backend.""" @@ -6,6 +9,7 @@ def __init__(self): def assign(self, backend: str): """Assign a HoloViews backend, one of 'matplotlib', 'bokeh'.""" + _raise_hint_if_optional_deps_missing("holoviews") import holoviews as hv if backend not in ["bokeh", "matplotlib", None]: @@ -13,6 +17,7 @@ def assign(self, backend: str): f"Unsupported backend. Expected one of ['bokeh', 'matplotlib'], but received {backend}" ) if backend is not None and backend != hv.Store.current_backend: + _raise_hint_if_optional_deps_missing("matplotlib") import matplotlib as mpl # Capture the live backend now (not once at init) so a backend the @@ -63,6 +68,7 @@ def reset_mpl_backend(self): except Exception: pass + _raise_hint_if_optional_deps_missing("matplotlib") import matplotlib as mpl mpl.use(self.matplotlib_backend) diff --git a/uxarray/utils/imports.py b/uxarray/utils/imports.py new file mode 100644 index 000000000..79e887d95 --- /dev/null +++ b/uxarray/utils/imports.py @@ -0,0 +1,90 @@ +""" +Purpose: utils related to imports, e.g. handling optional dependency imports +""" +import importlib + +from uxarray.errors import OptionalDependencyNotFoundError + +# Mapping from optional dependency to corresponding extras, to help improve +# error messages in case of forgetting to install necessary optional deps. +# Hard-coded intentionally to avoid extra overhead of looking up package info, +# and to avoid any confusion if installed from wheels. +# Intentionally excluded "dev" because "dev" is mostly for tests. +_OPTIONAL_DEPS_TO_EXTRAS = { + "cartopy": ("geo", "viz"), + "geopandas": "geo", + "healpix": "geo", + "pyproj": "geo", + "spatialpandas": "geo", + "datashader": "viz", + "matplotlib": "viz", + "geoviews": "viz", + "holoviews": "viz", + "hvplot": "viz", +} + +def _raise_hint_if_optional_deps_missing(*packages: str): + """try to import these optional dependencies; raise helpful hint if any ModuleNotFoundError. + + packages: str + names of packages to try to import. + Must be keys in _OPTIONAL_DEPS_TO_EXTRAS, i.e. one or more of the following: + cartopy, geopandas, healpix, pyproj, spatialpandas, + datashader, matplotlib, geoviews, holoviews, hvplot + """ + # whitelist package names; crash if anything unexpected is provided. + _unknown = [pkg for pkg in packages if pkg not in _OPTIONAL_DEPS_TO_EXTRAS] + if len(_unknown) > 0: + raise ValueError( + f"Unrecognized package names in _raise_hint_if_optional_deps_missing(): {_unknown}. " + f"Recognized names are: {list(_OPTIONAL_DEPS_TO_EXTRAS.keys())}" + ) # (if this error occurs, it is almost certainly a bug in the UXarray codebase itself.) + + # note: want to provide one error covering all missing modules, to improve user experience. + # also note: if cartopy is one of the modules, need to be smart about error message. + missing = [] + last_err = None + for pkg in packages: + try: + importlib.import_module(pkg) + except ModuleNotFoundError as err: + missing.append(pkg) + last_err = err # will raise result from last_err to keep some error traceback info. + + if len(missing) == 0: + pass # nothing to do; all requested packages imported successfully! + else: # raise error with helpful message. + # Trying to be slightly smart with the message here, to be improve user experience: + # (1) if everything would be covered by one extra, suggest it. (E.g. holoviews & cartopy --> [viz]) + # (2) if everything would easily be covered by doing multiple extras, suggest them, + # and also mention [all] as an option. (E.g. healpix & holoviews --> [geo,viz] or [all]) + # (3) if just one missing package, with multiple extras, suggest "or" (E.g. just cartopy --> [geo] or [viz]) + # (4) in any other case, stop trying to be smart; just suggest [all]. + need_extras = set() + or_extras = [] + missing_extras = {pkg: _OPTIONAL_DEPS_TO_EXTRAS[pkg] for pkg in missing} + one_extra = {pkg: extra for pkg, extra in missing_extras.items() if isinstance(extra, str)} + many_extras = {pkg: extras for pkg, extras in missing_extras.items() if not isinstance(extras, str)} + assert all(len(extras) >= 2 for extras in many_extras.values()) # else wrong format in _OPTIONAL_DEPS_TO_EXTRAS. + for pkg, extra in one_extra.items(): + need_extras.add(extra) # definitely need to include all of these + for pkg, extras in many_extras.items(): + if any(extra in need_extras for extra in extras): # still maybe in case (1) or (2). + pass # this package is already covered by other needed extras! + elif len(extras) == 0: # case (3) + need_extras.add(extras[0]) + or_extras.extend(extras[1:]) + else: # case (4) + need_extras = set(["all"]) + break + need_extras_str = ",".join(sorted(need_extras)) # sort is just for style + errmsg = "Failed to import: " + ", ".join(sorted(missing)) + errmsg += f'.\nConsider running ``pip install "uxarray[{need_extras_str}]"``' + if len(need_extras) >= 2: + errmsg += ' or ``pip install "uxarray[all]"``' + elif len(or_extras) == 1: + errmsg += f' or ``pip install "uxarray[{or_extras[0]}]"``' + elif len(or_extras) >= 2: + errmsg += f' or pip install with any of {set(or_extras)} instead' + errmsg += ', then try again.' + raise OptionalDependencyNotFoundError(errmsg) from last_err From b958afd7a21ac830801a353f15c177a0b7e2d770 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:14:45 -0400 Subject: [PATCH 02/15] optional deps test ensure helpful hint gets raised --- test_optional_deps/test_installed_with_geo.py | 2 +- test_optional_deps/test_installed_with_no_opts.py | 4 ++-- test_optional_deps/test_installed_with_viz.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test_optional_deps/test_installed_with_geo.py b/test_optional_deps/test_installed_with_geo.py index 5895dbe26..7f7d01231 100644 --- a/test_optional_deps/test_installed_with_geo.py +++ b/test_optional_deps/test_installed_with_geo.py @@ -20,7 +20,7 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): check_requires_only_viz() diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index 4408078f6..0ba7ed5cd 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -20,13 +20,13 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): check_requires_only_viz() def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): check_requires_only_geo() diff --git a/test_optional_deps/test_installed_with_viz.py b/test_optional_deps/test_installed_with_viz.py index fd551169d..94d740ef5 100644 --- a/test_optional_deps/test_installed_with_viz.py +++ b/test_optional_deps/test_installed_with_viz.py @@ -25,7 +25,7 @@ def test_check_requires_only_viz(): def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): check_requires_only_geo() From 6fb1bfd5f5533fd5eb9b719b1d8aa0d7cc5e953c Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:20:26 -0400 Subject: [PATCH 03/15] forgot pre-commit ruff formatting --- uxarray/errors.py | 1 + uxarray/plot/utils.py | 2 +- uxarray/utils/imports.py | 28 +++++++++++++++++++++------- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/uxarray/errors.py b/uxarray/errors.py index 51b281405..c1f3b5131 100644 --- a/uxarray/errors.py +++ b/uxarray/errors.py @@ -42,5 +42,6 @@ class YacNotAvailableError(RuntimeError): # # # ----- Miscellaneous Errors ----- # # # + class OptionalDependencyNotFoundError(ModuleNotFoundError): """indicates functionality relies on a not-yet-installed optional dependency.""" diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index d4e9bc6b9..c19ad5798 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -1,6 +1,6 @@ - from uxarray.utils.imports import _raise_hint_if_optional_deps_missing + class HoloviewsBackend: """Compare and set the HoloViews plotting backend.""" diff --git a/uxarray/utils/imports.py b/uxarray/utils/imports.py index 79e887d95..a93825155 100644 --- a/uxarray/utils/imports.py +++ b/uxarray/utils/imports.py @@ -1,6 +1,7 @@ """ Purpose: utils related to imports, e.g. handling optional dependency imports """ + import importlib from uxarray.errors import OptionalDependencyNotFoundError @@ -23,6 +24,7 @@ "hvplot": "viz", } + def _raise_hint_if_optional_deps_missing(*packages: str): """try to import these optional dependencies; raise helpful hint if any ModuleNotFoundError. @@ -53,7 +55,7 @@ def _raise_hint_if_optional_deps_missing(*packages: str): if len(missing) == 0: pass # nothing to do; all requested packages imported successfully! - else: # raise error with helpful message. + else: # raise error with helpful message. # Trying to be slightly smart with the message here, to be improve user experience: # (1) if everything would be covered by one extra, suggest it. (E.g. holoviews & cartopy --> [viz]) # (2) if everything would easily be covered by doing multiple extras, suggest them, @@ -63,13 +65,25 @@ def _raise_hint_if_optional_deps_missing(*packages: str): need_extras = set() or_extras = [] missing_extras = {pkg: _OPTIONAL_DEPS_TO_EXTRAS[pkg] for pkg in missing} - one_extra = {pkg: extra for pkg, extra in missing_extras.items() if isinstance(extra, str)} - many_extras = {pkg: extras for pkg, extras in missing_extras.items() if not isinstance(extras, str)} - assert all(len(extras) >= 2 for extras in many_extras.values()) # else wrong format in _OPTIONAL_DEPS_TO_EXTRAS. + one_extra = { + pkg: extra + for pkg, extra in missing_extras.items() + if isinstance(extra, str) + } + many_extras = { + pkg: extras + for pkg, extras in missing_extras.items() + if not isinstance(extras, str) + } + assert all( + len(extras) >= 2 for extras in many_extras.values() + ) # else wrong format in _OPTIONAL_DEPS_TO_EXTRAS. for pkg, extra in one_extra.items(): need_extras.add(extra) # definitely need to include all of these for pkg, extras in many_extras.items(): - if any(extra in need_extras for extra in extras): # still maybe in case (1) or (2). + if any( + extra in need_extras for extra in extras + ): # still maybe in case (1) or (2). pass # this package is already covered by other needed extras! elif len(extras) == 0: # case (3) need_extras.add(extras[0]) @@ -85,6 +99,6 @@ def _raise_hint_if_optional_deps_missing(*packages: str): elif len(or_extras) == 1: errmsg += f' or ``pip install "uxarray[{or_extras[0]}]"``' elif len(or_extras) >= 2: - errmsg += f' or pip install with any of {set(or_extras)} instead' - errmsg += ', then try again.' + errmsg += f" or pip install with any of {set(or_extras)} instead" + errmsg += ", then try again." raise OptionalDependencyNotFoundError(errmsg) from last_err From 8e2a07aa62d49c902f1ca888f01c694f7dbb9796 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:41:09 -0400 Subject: [PATCH 04/15] fix opt deps error hint tests typos also add string matching to test_check_requires_viz_and_geo in both the installed_with_geo and the installed_with_viz cases, and left a comment about why it wasn't added in the installed_with_no_opts case. --- test_optional_deps/test_installed_with_geo.py | 4 ++-- test_optional_deps/test_installed_with_no_opts.py | 6 ++++-- test_optional_deps/test_installed_with_viz.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/test_optional_deps/test_installed_with_geo.py b/test_optional_deps/test_installed_with_geo.py index 7f7d01231..dba615af3 100644 --- a/test_optional_deps/test_installed_with_geo.py +++ b/test_optional_deps/test_installed_with_geo.py @@ -20,7 +20,7 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[viz\]"'): check_requires_only_viz() @@ -31,5 +31,5 @@ def test_check_requires_only_geo(): def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r'pip install "uxarray\[viz\]"'): check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index 0ba7ed5cd..e3cdf6b75 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -20,17 +20,19 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[viz\]"'): check_requires_only_viz() def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[geo\]"'): check_requires_only_geo() def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" with pytest.raises(ImportError): + # ^no match "uxarray[geo,viz]" here; might crash in a viz-only or a geo-only method, + # even though the check itself ultimately requires both viz and geo. check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_viz.py b/test_optional_deps/test_installed_with_viz.py index 94d740ef5..4090f94b1 100644 --- a/test_optional_deps/test_installed_with_viz.py +++ b/test_optional_deps/test_installed_with_viz.py @@ -25,11 +25,11 @@ def test_check_requires_only_viz(): def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[geo\]"'): check_requires_only_geo() def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r'pip install "uxarray\[geo\]"'): check_requires_viz_and_geo() From 89d812816b8e76feb5467fb7592de2a67b42fb3a Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:20:21 -0400 Subject: [PATCH 05/15] add test_optional_deps files & ci commands --- .github/workflows/ci.yml | 44 ++++++++++++++++++ pyproject.toml | 3 ++ test_optional_deps/_optional_deps_helpers.py | 46 +++++++++++++++++++ test_optional_deps/test_installed_with_geo.py | 31 +++++++++++++ .../test_installed_with_no_opts.py | 32 +++++++++++++ test_optional_deps/test_installed_with_viz.py | 31 +++++++++++++ .../test_installed_with_viz_and_geo.py | 29 ++++++++++++ 7 files changed, 216 insertions(+) create mode 100644 test_optional_deps/_optional_deps_helpers.py create mode 100644 test_optional_deps/test_installed_with_geo.py create mode 100644 test_optional_deps/test_installed_with_no_opts.py create mode 100644 test_optional_deps/test_installed_with_viz.py create mode 100644 test_optional_deps/test_installed_with_viz_and_geo.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08df536e..b1d27c684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,47 @@ jobs: cd docs echo 'nb_execution_mode = "off"' >> conf.py make linkcheck + + test-optional-deps: + # Ensures can install with various combinations of optional dependencies, + # and that some corresponding tests pass or crash appropriately. + # Just a single machine and single Python version should be good enough, + # the goal here is to spot-check that optional deps work as expected, + # not to run an exhaustive set of tests with each combination of deps. + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + + - name: setup-python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: no optional deps + run: | + python -m venv "$RUNNER_TEMP/venv-none" + source "$RUNNER_TEMP/venv-none/bin/activate" + python -m pip install "." pytest + python -m pytest test_optional_deps/test_installed_with_no_opts.py + + - name: geo only + run: | + python -m venv "$RUNNER_TEMP/venv-geo" + source "$RUNNER_TEMP/venv-geo/bin/activate" + python -m pip install ".[geo]" pytest + python -m pytest test_optional_deps/test_installed_with_geo.py + + - name: viz only + run: | + python -m venv "$RUNNER_TEMP/venv-viz" + source "$RUNNER_TEMP/venv-viz/bin/activate" + python -m pip install ".[viz]" pytest + python -m pytest test_optional_deps/test_installed_with_viz.py + + - name: viz and geo + run: | + python -m venv "$RUNNER_TEMP/venv-viz_and_geo" + source "$RUNNER_TEMP/venv-viz_and_geo/bin/activate" + python -m pip install ".[viz,geo]" pytest + python -m pytest test_optional_deps/test_installed_with_viz_and_geo.py diff --git a/pyproject.toml b/pyproject.toml index 166ba8657..5503429f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,3 +98,6 @@ known-first-party = ["uxarray"] [tool.ruff.format] docstring-code-format = true + +[tool.pytest.ini_options] +testpaths = ["tests"] # (intentionally excludes test_optional_deps) diff --git a/test_optional_deps/_optional_deps_helpers.py b/test_optional_deps/_optional_deps_helpers.py new file mode 100644 index 000000000..04d7864cc --- /dev/null +++ b/test_optional_deps/_optional_deps_helpers.py @@ -0,0 +1,46 @@ +""" +File Purpose: defines helper functions to be used for testing optional dependencies. + +The goal here is to spot-check that optional deps work as expected, +not to run an exhaustive set of tests with each combination of deps. +""" + +def check_requires_no_opts(): + """run some checks which should not require any optional dependencies""" + import uxarray as ux + uxds = ux.tutorial.open_dataset('quad-hexagon') + uxds.compute() + +def check_requires_only_viz(): + """run some checks which should require viz optional dependencies, + but not any other optional dependencies. + """ + import uxarray as ux + uxds = ux.tutorial.open_dataset('quad-hexagon') + plot_obj = uxds.plot.points() # points() doesn't need geo projection details. + + # actually try to render the plot, too: + import holoviews as hv + renderer = hv.renderer('matplotlib') + renderer.get_plot(plot_obj) + +def check_requires_only_geo(): + """run some checks which should require geo optional dependencies, + but not any other optional dependencies. + """ + import uxarray as ux + arr = ux.tutorial.open_dataset('quad-hexagon')['t2m'] + arr.to_geodataframe() + +def check_requires_viz_and_geo(): + """run some checks which should require both viz and geo optional dependencies, + but not any other optional dependencies. + """ + import uxarray as ux + arr = ux.tutorial.open_dataset('quad-hexagon')['t2m'] + plot_obj = arr.plot.polygons() # polygons() uses geo projection details. + + # actually try to render the plot, too: + import holoviews as hv + renderer = hv.renderer('matplotlib') + renderer.get_plot(plot_obj) diff --git a/test_optional_deps/test_installed_with_geo.py b/test_optional_deps/test_installed_with_geo.py new file mode 100644 index 000000000..2550200fd --- /dev/null +++ b/test_optional_deps/test_installed_with_geo.py @@ -0,0 +1,31 @@ +""" +Purpose: test expected behaviors when installed with only geo optional dependency. +Tests should all pass if and only if installed accordingly, i.e. something like: + pip install ".[geo]" +""" +import pytest + +from _optional_deps_helpers import ( + check_requires_no_opts, + check_requires_only_viz, + check_requires_only_geo, + check_requires_viz_and_geo, +) + +def test_check_requires_no_opts(): + """ensure success for checks which should not require any optional dependencies""" + check_requires_no_opts() + +def test_check_requires_only_viz(): + """ensure failure for checks which should require viz optional dependencies""" + with pytest.raises(ImportError): + check_requires_only_viz() + +def test_check_requires_only_geo(): + """ensure success for checks which should require geo optional dependencies""" + check_requires_only_geo() + +def test_check_requires_viz_and_geo(): + """ensure failure for checks which should require both viz and geo optional dependencies""" + with pytest.raises(ImportError): + check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py new file mode 100644 index 000000000..b1a9bc446 --- /dev/null +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -0,0 +1,32 @@ +""" +Purpose: test expected behaviors when installed without any optional dependencies. +Tests should all pass if and only if installed accordingly, i.e. something like: + pip install "." +""" +import pytest + +from _optional_deps_helpers import ( + check_requires_no_opts, + check_requires_only_viz, + check_requires_only_geo, + check_requires_viz_and_geo, +) + +def test_check_requires_no_opts(): + """ensure success for checks which should not require any optional dependencies""" + check_requires_no_opts() + +def test_check_requires_only_viz(): + """ensure failure for checks which should require viz optional dependencies""" + with pytest.raises(ImportError): + check_requires_only_viz() + +def test_check_requires_only_geo(): + """ensure failure for checks which should require geo optional dependencies""" + with pytest.raises(ImportError): + check_requires_only_geo() + +def test_check_requires_viz_and_geo(): + """ensure failure for checks which should require both viz and geo optional dependencies""" + with pytest.raises(ImportError): + check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_viz.py b/test_optional_deps/test_installed_with_viz.py new file mode 100644 index 000000000..8bcc12152 --- /dev/null +++ b/test_optional_deps/test_installed_with_viz.py @@ -0,0 +1,31 @@ +""" +Purpose: test expected behaviors when installed with only viz optional dependency. +Tests should all pass if and only if installed accordingly, i.e. something like: + pip install ".[viz]" +""" +import pytest + +from _optional_deps_helpers import ( + check_requires_no_opts, + check_requires_only_viz, + check_requires_only_geo, + check_requires_viz_and_geo, +) + +def test_check_requires_no_opts(): + """ensure success for checks which should not require any optional dependencies""" + check_requires_no_opts() + +def test_check_requires_only_viz(): + """ensure success for checks which should require viz optional dependencies""" + check_requires_only_viz() + +def test_check_requires_only_geo(): + """ensure failure for checks which should require geo optional dependencies""" + with pytest.raises(ImportError): + check_requires_only_geo() + +def test_check_requires_viz_and_geo(): + """ensure failure for checks which should require both viz and geo optional dependencies""" + with pytest.raises(ImportError): + check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_viz_and_geo.py b/test_optional_deps/test_installed_with_viz_and_geo.py new file mode 100644 index 000000000..a910fdfc0 --- /dev/null +++ b/test_optional_deps/test_installed_with_viz_and_geo.py @@ -0,0 +1,29 @@ +""" +Purpose: test expected behaviors when installed with geo and viz optional dependencies. +Tests should all pass if and only if installed accordingly, i.e. something like: + pip install ".[geo,viz]" +""" +import pytest + +from _optional_deps_helpers import ( + check_requires_no_opts, + check_requires_only_viz, + check_requires_only_geo, + check_requires_viz_and_geo, +) + +def test_check_requires_no_opts(): + """ensure success for checks which should not require any optional dependencies""" + check_requires_no_opts() + +def test_check_requires_only_viz(): + """ensure success for checks which should require viz optional dependencies""" + check_requires_only_viz() + +def test_check_requires_only_geo(): + """ensure success for checks which should require geo optional dependencies""" + check_requires_only_geo() + +def test_check_requires_viz_and_geo(): + """ensure success for checks which should require both viz and geo optional dependencies""" + check_requires_viz_and_geo() From 804330827850fdfba4796a98f136058997fb3391 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:57:38 -0400 Subject: [PATCH 06/15] forgot pre-commit ruff formatting --- test_optional_deps/_optional_deps_helpers.py | 22 ++++++++++++++----- test_optional_deps/test_installed_with_geo.py | 8 +++++-- .../test_installed_with_no_opts.py | 8 +++++-- test_optional_deps/test_installed_with_viz.py | 8 +++++-- .../test_installed_with_viz_and_geo.py | 8 +++++-- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/test_optional_deps/_optional_deps_helpers.py b/test_optional_deps/_optional_deps_helpers.py index 04d7864cc..a3a37e1a3 100644 --- a/test_optional_deps/_optional_deps_helpers.py +++ b/test_optional_deps/_optional_deps_helpers.py @@ -5,42 +5,52 @@ not to run an exhaustive set of tests with each combination of deps. """ + def check_requires_no_opts(): """run some checks which should not require any optional dependencies""" import uxarray as ux - uxds = ux.tutorial.open_dataset('quad-hexagon') + + uxds = ux.tutorial.open_dataset("quad-hexagon") uxds.compute() + def check_requires_only_viz(): """run some checks which should require viz optional dependencies, but not any other optional dependencies. """ import uxarray as ux - uxds = ux.tutorial.open_dataset('quad-hexagon') + + uxds = ux.tutorial.open_dataset("quad-hexagon") plot_obj = uxds.plot.points() # points() doesn't need geo projection details. # actually try to render the plot, too: import holoviews as hv - renderer = hv.renderer('matplotlib') + + renderer = hv.renderer("matplotlib") renderer.get_plot(plot_obj) + def check_requires_only_geo(): """run some checks which should require geo optional dependencies, but not any other optional dependencies. """ import uxarray as ux - arr = ux.tutorial.open_dataset('quad-hexagon')['t2m'] + + arr = ux.tutorial.open_dataset("quad-hexagon")["t2m"] arr.to_geodataframe() + def check_requires_viz_and_geo(): """run some checks which should require both viz and geo optional dependencies, but not any other optional dependencies. """ import uxarray as ux - arr = ux.tutorial.open_dataset('quad-hexagon')['t2m'] + + arr = ux.tutorial.open_dataset("quad-hexagon")["t2m"] plot_obj = arr.plot.polygons() # polygons() uses geo projection details. # actually try to render the plot, too: import holoviews as hv - renderer = hv.renderer('matplotlib') + + renderer = hv.renderer("matplotlib") renderer.get_plot(plot_obj) diff --git a/test_optional_deps/test_installed_with_geo.py b/test_optional_deps/test_installed_with_geo.py index 2550200fd..5895dbe26 100644 --- a/test_optional_deps/test_installed_with_geo.py +++ b/test_optional_deps/test_installed_with_geo.py @@ -3,28 +3,32 @@ Tests should all pass if and only if installed accordingly, i.e. something like: pip install ".[geo]" """ -import pytest +import pytest from _optional_deps_helpers import ( check_requires_no_opts, - check_requires_only_viz, check_requires_only_geo, + check_requires_only_viz, check_requires_viz_and_geo, ) + def test_check_requires_no_opts(): """ensure success for checks which should not require any optional dependencies""" check_requires_no_opts() + def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" with pytest.raises(ImportError): check_requires_only_viz() + def test_check_requires_only_geo(): """ensure success for checks which should require geo optional dependencies""" check_requires_only_geo() + def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" with pytest.raises(ImportError): diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index b1a9bc446..4408078f6 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -3,29 +3,33 @@ Tests should all pass if and only if installed accordingly, i.e. something like: pip install "." """ -import pytest +import pytest from _optional_deps_helpers import ( check_requires_no_opts, - check_requires_only_viz, check_requires_only_geo, + check_requires_only_viz, check_requires_viz_and_geo, ) + def test_check_requires_no_opts(): """ensure success for checks which should not require any optional dependencies""" check_requires_no_opts() + def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" with pytest.raises(ImportError): check_requires_only_viz() + def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" with pytest.raises(ImportError): check_requires_only_geo() + def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" with pytest.raises(ImportError): diff --git a/test_optional_deps/test_installed_with_viz.py b/test_optional_deps/test_installed_with_viz.py index 8bcc12152..fd551169d 100644 --- a/test_optional_deps/test_installed_with_viz.py +++ b/test_optional_deps/test_installed_with_viz.py @@ -3,28 +3,32 @@ Tests should all pass if and only if installed accordingly, i.e. something like: pip install ".[viz]" """ -import pytest +import pytest from _optional_deps_helpers import ( check_requires_no_opts, - check_requires_only_viz, check_requires_only_geo, + check_requires_only_viz, check_requires_viz_and_geo, ) + def test_check_requires_no_opts(): """ensure success for checks which should not require any optional dependencies""" check_requires_no_opts() + def test_check_requires_only_viz(): """ensure success for checks which should require viz optional dependencies""" check_requires_only_viz() + def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" with pytest.raises(ImportError): check_requires_only_geo() + def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" with pytest.raises(ImportError): diff --git a/test_optional_deps/test_installed_with_viz_and_geo.py b/test_optional_deps/test_installed_with_viz_and_geo.py index a910fdfc0..3ca2e9c63 100644 --- a/test_optional_deps/test_installed_with_viz_and_geo.py +++ b/test_optional_deps/test_installed_with_viz_and_geo.py @@ -3,27 +3,31 @@ Tests should all pass if and only if installed accordingly, i.e. something like: pip install ".[geo,viz]" """ -import pytest +import pytest from _optional_deps_helpers import ( check_requires_no_opts, - check_requires_only_viz, check_requires_only_geo, + check_requires_only_viz, check_requires_viz_and_geo, ) + def test_check_requires_no_opts(): """ensure success for checks which should not require any optional dependencies""" check_requires_no_opts() + def test_check_requires_only_viz(): """ensure success for checks which should require viz optional dependencies""" check_requires_only_viz() + def test_check_requires_only_geo(): """ensure success for checks which should require geo optional dependencies""" check_requires_only_geo() + def test_check_requires_viz_and_geo(): """ensure success for checks which should require both viz and geo optional dependencies""" check_requires_viz_and_geo() From e0484767e5dcc98204483df6d44bce5516775523 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:45:45 -0400 Subject: [PATCH 07/15] fix ruff complaint about unused import --- test_optional_deps/test_installed_with_viz_and_geo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test_optional_deps/test_installed_with_viz_and_geo.py b/test_optional_deps/test_installed_with_viz_and_geo.py index 3ca2e9c63..b2cabc398 100644 --- a/test_optional_deps/test_installed_with_viz_and_geo.py +++ b/test_optional_deps/test_installed_with_viz_and_geo.py @@ -4,7 +4,6 @@ pip install ".[geo,viz]" """ -import pytest from _optional_deps_helpers import ( check_requires_no_opts, check_requires_only_geo, From c213dad59cac3faef69f7cae8736b25cb66ebf84 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:34:49 -0400 Subject: [PATCH 08/15] add healpix-sensitive optional deps test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (maybe not necessary… but also trying to re-trigger CI jobs here, due to github actions downtime yesterday causing stalled jobs with no "rerun jobs" button available.) --- test_optional_deps/_optional_deps_helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test_optional_deps/_optional_deps_helpers.py b/test_optional_deps/_optional_deps_helpers.py index a3a37e1a3..158b85a21 100644 --- a/test_optional_deps/_optional_deps_helpers.py +++ b/test_optional_deps/_optional_deps_helpers.py @@ -39,6 +39,8 @@ def check_requires_only_geo(): arr = ux.tutorial.open_dataset("quad-hexagon")["t2m"] arr.to_geodataframe() + ux.Grid.from_healpix(zoom=1) + def check_requires_viz_and_geo(): """run some checks which should require both viz and geo optional dependencies, From 8ebb674d3c694606ca9a4d963077adff2c6f9619 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:39:18 -0400 Subject: [PATCH 09/15] fix optional deps test: cannot plot UxDataset --- test_optional_deps/_optional_deps_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_optional_deps/_optional_deps_helpers.py b/test_optional_deps/_optional_deps_helpers.py index 158b85a21..186e2452b 100644 --- a/test_optional_deps/_optional_deps_helpers.py +++ b/test_optional_deps/_optional_deps_helpers.py @@ -20,8 +20,8 @@ def check_requires_only_viz(): """ import uxarray as ux - uxds = ux.tutorial.open_dataset("quad-hexagon") - plot_obj = uxds.plot.points() # points() doesn't need geo projection details. + arr = ux.tutorial.open_dataset("quad-hexagon")["t2m"] + plot_obj = arr.plot.points() # points() doesn't need geo projection details. # actually try to render the plot, too: import holoviews as hv From b54f9d29066446934b99fbe5acd60fdfb03ea1d7 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:08:31 -0400 Subject: [PATCH 10/15] add _raise_hint_if_optional_deps_missing --- uxarray/core/dataarray.py | 3 ++ uxarray/cross_sections/sample.py | 3 ++ uxarray/errors.py | 6 +++ uxarray/grid/geometry.py | 8 +++ uxarray/grid/grid.py | 3 +- uxarray/io/_geopandas.py | 3 +- uxarray/io/_healpix.py | 3 ++ uxarray/plot/accessor.py | 4 ++ uxarray/plot/matplotlib.py | 2 + uxarray/plot/utils.py | 6 +++ uxarray/utils/imports.py | 90 ++++++++++++++++++++++++++++++++ 11 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 uxarray/utils/imports.py diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index cf9419775..82db8ddb0 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -39,6 +39,7 @@ from uxarray.plot.accessor import UxDataArrayPlotAccessor from uxarray.remap.accessor import RemapAccessor from uxarray.subset import DataArraySubsetAccessor +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing if TYPE_CHECKING: import cartopy.crs as ccrs @@ -473,6 +474,7 @@ def to_raster( >>> ax.imshow(raster, origin="lower", extent=ax.get_xlim() + ax.get_ylim()) """ + _raise_hint_if_optional_deps_missing("cartopy") from cartopy.mpl.geoaxes import GeoAxes from uxarray.constants import INT_DTYPE @@ -517,6 +519,7 @@ def _is_default_extent() -> bool: if _is_default_extent(): try: + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs lon_min = float(self.uxgrid.node_lon.min(skipna=True).values) diff --git a/uxarray/cross_sections/sample.py b/uxarray/cross_sections/sample.py index 881e5dca4..37d1bd573 100644 --- a/uxarray/cross_sections/sample.py +++ b/uxarray/cross_sections/sample.py @@ -1,6 +1,8 @@ import numpy as np from numba import njit, prange +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing + @njit(parallel=True) def _fill_numba(flat_orig, face_idx, n_face, n_steps): @@ -16,6 +18,7 @@ def _fill_numba(flat_orig, face_idx, n_face, n_steps): def sample_geodesic( start: tuple[float, float], end: tuple[float, float], steps: int ) -> tuple[np.ndarray, np.ndarray]: + _raise_hint_if_optional_deps_missing("pyproj") from pyproj import Geod lon0, lat0 = start diff --git a/uxarray/errors.py b/uxarray/errors.py index 9e5cbab23..51b281405 100644 --- a/uxarray/errors.py +++ b/uxarray/errors.py @@ -38,3 +38,9 @@ class GridsMismatchError(ValueError): class YacNotAvailableError(RuntimeError): """Raised when the YAC backend is requested but unavailable.""" + + +# # # ----- Miscellaneous Errors ----- # # # + +class OptionalDependencyNotFoundError(ModuleNotFoundError): + """indicates functionality relies on a not-yet-installed optional dependency.""" diff --git a/uxarray/grid/geometry.py b/uxarray/grid/geometry.py index f1843096f..4c5cdfd10 100644 --- a/uxarray/grid/geometry.py +++ b/uxarray/grid/geometry.py @@ -15,6 +15,7 @@ ) from uxarray.grid.point_in_face import _face_contains_point from uxarray.grid.utils import _get_cartesian_face_edge_nodes +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing POLE_POINTS_XYZ = { "North": np.array([0.0, 0.0, 1.0]), @@ -116,6 +117,7 @@ def _build_polygon_shells( ): """Builds an array of polygon shells, which can be used with Shapely to construct polygons.""" + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs closed_face_nodes = _pad_closed_face_nodes( @@ -145,6 +147,7 @@ def _correct_central_longitude(node_lon, node_lat, projection): """Shifts the central longitude of an unstructured grid, which moves the antimeridian when visualizing, which is used when projections have a central longitude other than 0.0.""" + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs if projection: @@ -169,6 +172,7 @@ def _correct_central_longitude(node_lon, node_lat, projection): def _grid_to_polygon_geodataframe(grid, periodic_elements, projection, project, engine): """Converts the faces of a ``Grid`` into a ``spatialpandas.GeoDataFrame`` or ``geopandas.GeoDataFrame`` with a geometry column of polygons.""" + _raise_hint_if_optional_deps_missing("geopandas", "spatialpandas") import geopandas import shapely import spatialpandas @@ -260,6 +264,7 @@ def _build_geodataframe_without_antimeridian( """Builds a ``spatialpandas.GeoDataFrame`` or ``geopandas.GeoDataFrame``excluding any faces that cross the antimeridian.""" + _raise_hint_if_optional_deps_missing("geopandas", "spatialpandas") import geopandas import shapely import spatialpandas @@ -296,6 +301,7 @@ def _build_geodataframe_with_antimeridian( ): """Builds a ``spatialpandas.GeoDataFrame`` or ``geopandas.GeoDataFrame`` including any faces that cross the antimeridian.""" + _raise_hint_if_optional_deps_missing("geopandas", "spatialpandas") import geopandas import spatialpandas from spatialpandas.geometry import MultiPolygonArray @@ -441,6 +447,7 @@ def _grid_to_matplotlib_polycollection( grid, periodic_elements, projection=None, **kwargs ): """Constructs and returns a ``matplotlib.collections.PolyCollection``""" + _raise_hint_if_optional_deps_missing("cartopy", "matplotlib") import cartopy.crs as ccrs from matplotlib.collections import PolyCollection @@ -647,6 +654,7 @@ def _grid_to_matplotlib_linecollection( grid, periodic_elements, projection=None, **kwargs ): """Constructs and returns a ``matplotlib.collections.LineCollection``""" + _raise_hint_if_optional_deps_missing("cartopy", "matplotlib") import cartopy.crs as ccrs from matplotlib.collections import LineCollection diff --git a/uxarray/grid/grid.py b/uxarray/grid/grid.py index 3c86890f2..ec3497468 100644 --- a/uxarray/grid/grid.py +++ b/uxarray/grid/grid.py @@ -100,6 +100,7 @@ from uxarray.io.utils import _parse_grid_type from uxarray.plot.accessor import GridPlotAccessor from uxarray.subset import GridSubsetAccessor +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing if TYPE_CHECKING: import cartopy.crs as ccrs @@ -2296,7 +2297,7 @@ def to_geodataframe( gdf : spatialpandas.GeoDataFrame or geopandas.GeoDataFrame The output ``GeoDataFrame`` with a filled out "geometry" column of polygons. """ - + _raise_hint_if_optional_deps_missing("spatialpandas") from spatialpandas import GeoDataFrame if engine not in ["spatialpandas", "geopandas"]: diff --git a/uxarray/io/_geopandas.py b/uxarray/io/_geopandas.py index a47606ac2..b2b1a5c73 100644 --- a/uxarray/io/_geopandas.py +++ b/uxarray/io/_geopandas.py @@ -3,6 +3,7 @@ from uxarray.constants import INT_DTYPE, INT_FILL_VALUE, WGS84_CRS from uxarray.conventions import ugrid +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing def _read_geodataframe(filepath, driver=None, **kwargs): @@ -63,7 +64,7 @@ def _gpd_read(filepath, driver=None, **kwargs): int Maximum number of nodes in a polygon/multipolygon. """ - + _raise_hint_if_optional_deps_missing("geopandas") import geopandas as gpd try: diff --git a/uxarray/io/_healpix.py b/uxarray/io/_healpix.py index 3e6bdfef7..38f4d155a 100644 --- a/uxarray/io/_healpix.py +++ b/uxarray/io/_healpix.py @@ -6,6 +6,7 @@ import uxarray.conventions.ugrid as ugrid from uxarray.constants import INT_DTYPE +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing def get_zoom_from_cells(cells): @@ -67,6 +68,7 @@ def pix2corner_ang( ---- This will be updated when https://github.com/ntessore/healpix/issues/66 is implemented. """ + _raise_hint_if_optional_deps_missing("healpix") import healpix as hp if nest: @@ -103,6 +105,7 @@ def _pixels_to_ugrid(zoom, nest): A dataset containing pixel longitude and latitude coordinates along with related attributes. """ + _raise_hint_if_optional_deps_missing("healpix") import healpix as hp ds = xr.Dataset() diff --git a/uxarray/plot/accessor.py b/uxarray/plot/accessor.py index 2f8bb56fd..24456570c 100644 --- a/uxarray/plot/accessor.py +++ b/uxarray/plot/accessor.py @@ -14,6 +14,7 @@ from uxarray.grid import Grid from uxarray.plot.utils import backend as plotting_backend +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing # import speedup trick: # code here uses obj.hvplot, which requires import hvplot.pandas and/or hvplot.xarray. @@ -30,6 +31,7 @@ def _ensure_hvplot_imported() -> None: """ global _IMPORTED_HVPLOT if not _IMPORTED_HVPLOT: + _raise_hint_if_optional_deps_missing("holoviews", "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. @@ -244,6 +246,7 @@ def edges( gdf.hvplot.paths : hvplot.paths A paths plot of the edges of the unstructured grid """ + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs plotting_backend.assign(backend) @@ -445,6 +448,7 @@ def polygons( gdf.hvplot.polygons : hvplot.polygons A shaded polygon plot """ + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs plotting_backend.assign(backend) diff --git a/uxarray/plot/matplotlib.py b/uxarray/plot/matplotlib.py index 186b7d65f..3a823572c 100644 --- a/uxarray/plot/matplotlib.py +++ b/uxarray/plot/matplotlib.py @@ -5,6 +5,7 @@ import numpy as np from uxarray.errors import DimensionError +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing if TYPE_CHECKING: from cartopy.mpl.geoaxes import GeoAxes @@ -126,6 +127,7 @@ def _get_points_from_axis(ax: GeoAxes, *, pixel_ratio: float = 1): ny : int Number of rows (height) in the pixel grid. """ + _raise_hint_if_optional_deps_missing("cartopy") import cartopy.crs as ccrs ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index 17275fe25..d4e9bc6b9 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -1,3 +1,6 @@ + +from uxarray.utils.imports import _raise_hint_if_optional_deps_missing + class HoloviewsBackend: """Compare and set the HoloViews plotting backend.""" @@ -6,6 +9,7 @@ def __init__(self): def assign(self, backend: str): """Assign a HoloViews backend, one of 'matplotlib', 'bokeh'.""" + _raise_hint_if_optional_deps_missing("holoviews") import holoviews as hv if backend not in ["bokeh", "matplotlib", None]: @@ -13,6 +17,7 @@ def assign(self, backend: str): f"Unsupported backend. Expected one of ['bokeh', 'matplotlib'], but received {backend}" ) if backend is not None and backend != hv.Store.current_backend: + _raise_hint_if_optional_deps_missing("matplotlib") import matplotlib as mpl # Capture the live backend now (not once at init) so a backend the @@ -63,6 +68,7 @@ def reset_mpl_backend(self): except Exception: pass + _raise_hint_if_optional_deps_missing("matplotlib") import matplotlib as mpl mpl.use(self.matplotlib_backend) diff --git a/uxarray/utils/imports.py b/uxarray/utils/imports.py new file mode 100644 index 000000000..79e887d95 --- /dev/null +++ b/uxarray/utils/imports.py @@ -0,0 +1,90 @@ +""" +Purpose: utils related to imports, e.g. handling optional dependency imports +""" +import importlib + +from uxarray.errors import OptionalDependencyNotFoundError + +# Mapping from optional dependency to corresponding extras, to help improve +# error messages in case of forgetting to install necessary optional deps. +# Hard-coded intentionally to avoid extra overhead of looking up package info, +# and to avoid any confusion if installed from wheels. +# Intentionally excluded "dev" because "dev" is mostly for tests. +_OPTIONAL_DEPS_TO_EXTRAS = { + "cartopy": ("geo", "viz"), + "geopandas": "geo", + "healpix": "geo", + "pyproj": "geo", + "spatialpandas": "geo", + "datashader": "viz", + "matplotlib": "viz", + "geoviews": "viz", + "holoviews": "viz", + "hvplot": "viz", +} + +def _raise_hint_if_optional_deps_missing(*packages: str): + """try to import these optional dependencies; raise helpful hint if any ModuleNotFoundError. + + packages: str + names of packages to try to import. + Must be keys in _OPTIONAL_DEPS_TO_EXTRAS, i.e. one or more of the following: + cartopy, geopandas, healpix, pyproj, spatialpandas, + datashader, matplotlib, geoviews, holoviews, hvplot + """ + # whitelist package names; crash if anything unexpected is provided. + _unknown = [pkg for pkg in packages if pkg not in _OPTIONAL_DEPS_TO_EXTRAS] + if len(_unknown) > 0: + raise ValueError( + f"Unrecognized package names in _raise_hint_if_optional_deps_missing(): {_unknown}. " + f"Recognized names are: {list(_OPTIONAL_DEPS_TO_EXTRAS.keys())}" + ) # (if this error occurs, it is almost certainly a bug in the UXarray codebase itself.) + + # note: want to provide one error covering all missing modules, to improve user experience. + # also note: if cartopy is one of the modules, need to be smart about error message. + missing = [] + last_err = None + for pkg in packages: + try: + importlib.import_module(pkg) + except ModuleNotFoundError as err: + missing.append(pkg) + last_err = err # will raise result from last_err to keep some error traceback info. + + if len(missing) == 0: + pass # nothing to do; all requested packages imported successfully! + else: # raise error with helpful message. + # Trying to be slightly smart with the message here, to be improve user experience: + # (1) if everything would be covered by one extra, suggest it. (E.g. holoviews & cartopy --> [viz]) + # (2) if everything would easily be covered by doing multiple extras, suggest them, + # and also mention [all] as an option. (E.g. healpix & holoviews --> [geo,viz] or [all]) + # (3) if just one missing package, with multiple extras, suggest "or" (E.g. just cartopy --> [geo] or [viz]) + # (4) in any other case, stop trying to be smart; just suggest [all]. + need_extras = set() + or_extras = [] + missing_extras = {pkg: _OPTIONAL_DEPS_TO_EXTRAS[pkg] for pkg in missing} + one_extra = {pkg: extra for pkg, extra in missing_extras.items() if isinstance(extra, str)} + many_extras = {pkg: extras for pkg, extras in missing_extras.items() if not isinstance(extras, str)} + assert all(len(extras) >= 2 for extras in many_extras.values()) # else wrong format in _OPTIONAL_DEPS_TO_EXTRAS. + for pkg, extra in one_extra.items(): + need_extras.add(extra) # definitely need to include all of these + for pkg, extras in many_extras.items(): + if any(extra in need_extras for extra in extras): # still maybe in case (1) or (2). + pass # this package is already covered by other needed extras! + elif len(extras) == 0: # case (3) + need_extras.add(extras[0]) + or_extras.extend(extras[1:]) + else: # case (4) + need_extras = set(["all"]) + break + need_extras_str = ",".join(sorted(need_extras)) # sort is just for style + errmsg = "Failed to import: " + ", ".join(sorted(missing)) + errmsg += f'.\nConsider running ``pip install "uxarray[{need_extras_str}]"``' + if len(need_extras) >= 2: + errmsg += ' or ``pip install "uxarray[all]"``' + elif len(or_extras) == 1: + errmsg += f' or ``pip install "uxarray[{or_extras[0]}]"``' + elif len(or_extras) >= 2: + errmsg += f' or pip install with any of {set(or_extras)} instead' + errmsg += ', then try again.' + raise OptionalDependencyNotFoundError(errmsg) from last_err From b3a58e83aa6ee59b5abebd469d4eef6ff76bfe90 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:14:45 -0400 Subject: [PATCH 11/15] optional deps test ensure helpful hint gets raised --- test_optional_deps/test_installed_with_geo.py | 2 +- test_optional_deps/test_installed_with_no_opts.py | 4 ++-- test_optional_deps/test_installed_with_viz.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test_optional_deps/test_installed_with_geo.py b/test_optional_deps/test_installed_with_geo.py index 5895dbe26..7f7d01231 100644 --- a/test_optional_deps/test_installed_with_geo.py +++ b/test_optional_deps/test_installed_with_geo.py @@ -20,7 +20,7 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): check_requires_only_viz() diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index 4408078f6..0ba7ed5cd 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -20,13 +20,13 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): check_requires_only_viz() def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): check_requires_only_geo() diff --git a/test_optional_deps/test_installed_with_viz.py b/test_optional_deps/test_installed_with_viz.py index fd551169d..94d740ef5 100644 --- a/test_optional_deps/test_installed_with_viz.py +++ b/test_optional_deps/test_installed_with_viz.py @@ -25,7 +25,7 @@ def test_check_requires_only_viz(): def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): check_requires_only_geo() From 71036d1ed39b2a6eb642961e58a7b2e975a0cba9 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:20:26 -0400 Subject: [PATCH 12/15] forgot pre-commit ruff formatting --- uxarray/errors.py | 1 + uxarray/plot/utils.py | 2 +- uxarray/utils/imports.py | 28 +++++++++++++++++++++------- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/uxarray/errors.py b/uxarray/errors.py index 51b281405..c1f3b5131 100644 --- a/uxarray/errors.py +++ b/uxarray/errors.py @@ -42,5 +42,6 @@ class YacNotAvailableError(RuntimeError): # # # ----- Miscellaneous Errors ----- # # # + class OptionalDependencyNotFoundError(ModuleNotFoundError): """indicates functionality relies on a not-yet-installed optional dependency.""" diff --git a/uxarray/plot/utils.py b/uxarray/plot/utils.py index d4e9bc6b9..c19ad5798 100644 --- a/uxarray/plot/utils.py +++ b/uxarray/plot/utils.py @@ -1,6 +1,6 @@ - from uxarray.utils.imports import _raise_hint_if_optional_deps_missing + class HoloviewsBackend: """Compare and set the HoloViews plotting backend.""" diff --git a/uxarray/utils/imports.py b/uxarray/utils/imports.py index 79e887d95..a93825155 100644 --- a/uxarray/utils/imports.py +++ b/uxarray/utils/imports.py @@ -1,6 +1,7 @@ """ Purpose: utils related to imports, e.g. handling optional dependency imports """ + import importlib from uxarray.errors import OptionalDependencyNotFoundError @@ -23,6 +24,7 @@ "hvplot": "viz", } + def _raise_hint_if_optional_deps_missing(*packages: str): """try to import these optional dependencies; raise helpful hint if any ModuleNotFoundError. @@ -53,7 +55,7 @@ def _raise_hint_if_optional_deps_missing(*packages: str): if len(missing) == 0: pass # nothing to do; all requested packages imported successfully! - else: # raise error with helpful message. + else: # raise error with helpful message. # Trying to be slightly smart with the message here, to be improve user experience: # (1) if everything would be covered by one extra, suggest it. (E.g. holoviews & cartopy --> [viz]) # (2) if everything would easily be covered by doing multiple extras, suggest them, @@ -63,13 +65,25 @@ def _raise_hint_if_optional_deps_missing(*packages: str): need_extras = set() or_extras = [] missing_extras = {pkg: _OPTIONAL_DEPS_TO_EXTRAS[pkg] for pkg in missing} - one_extra = {pkg: extra for pkg, extra in missing_extras.items() if isinstance(extra, str)} - many_extras = {pkg: extras for pkg, extras in missing_extras.items() if not isinstance(extras, str)} - assert all(len(extras) >= 2 for extras in many_extras.values()) # else wrong format in _OPTIONAL_DEPS_TO_EXTRAS. + one_extra = { + pkg: extra + for pkg, extra in missing_extras.items() + if isinstance(extra, str) + } + many_extras = { + pkg: extras + for pkg, extras in missing_extras.items() + if not isinstance(extras, str) + } + assert all( + len(extras) >= 2 for extras in many_extras.values() + ) # else wrong format in _OPTIONAL_DEPS_TO_EXTRAS. for pkg, extra in one_extra.items(): need_extras.add(extra) # definitely need to include all of these for pkg, extras in many_extras.items(): - if any(extra in need_extras for extra in extras): # still maybe in case (1) or (2). + if any( + extra in need_extras for extra in extras + ): # still maybe in case (1) or (2). pass # this package is already covered by other needed extras! elif len(extras) == 0: # case (3) need_extras.add(extras[0]) @@ -85,6 +99,6 @@ def _raise_hint_if_optional_deps_missing(*packages: str): elif len(or_extras) == 1: errmsg += f' or ``pip install "uxarray[{or_extras[0]}]"``' elif len(or_extras) >= 2: - errmsg += f' or pip install with any of {set(or_extras)} instead' - errmsg += ', then try again.' + errmsg += f" or pip install with any of {set(or_extras)} instead" + errmsg += ", then try again." raise OptionalDependencyNotFoundError(errmsg) from last_err From 1210ae0d0b364728daca80a88502a6003454aa43 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:41:09 -0400 Subject: [PATCH 13/15] fix opt deps error hint tests typos also add string matching to test_check_requires_viz_and_geo in both the installed_with_geo and the installed_with_viz cases, and left a comment about why it wasn't added in the installed_with_no_opts case. --- test_optional_deps/test_installed_with_geo.py | 4 ++-- test_optional_deps/test_installed_with_no_opts.py | 6 ++++-- test_optional_deps/test_installed_with_viz.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/test_optional_deps/test_installed_with_geo.py b/test_optional_deps/test_installed_with_geo.py index 7f7d01231..dba615af3 100644 --- a/test_optional_deps/test_installed_with_geo.py +++ b/test_optional_deps/test_installed_with_geo.py @@ -20,7 +20,7 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[viz\]"'): check_requires_only_viz() @@ -31,5 +31,5 @@ def test_check_requires_only_geo(): def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r'pip install "uxarray\[viz\]"'): check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index 0ba7ed5cd..e3cdf6b75 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -20,17 +20,19 @@ def test_check_requires_no_opts(): def test_check_requires_only_viz(): """ensure failure for checks which should require viz optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[viz\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[viz\]"'): check_requires_only_viz() def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[geo\]"'): check_requires_only_geo() def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" with pytest.raises(ImportError): + # ^no match "uxarray[geo,viz]" here; might crash in a viz-only or a geo-only method, + # even though the check itself ultimately requires both viz and geo. check_requires_viz_and_geo() diff --git a/test_optional_deps/test_installed_with_viz.py b/test_optional_deps/test_installed_with_viz.py index 94d740ef5..4090f94b1 100644 --- a/test_optional_deps/test_installed_with_viz.py +++ b/test_optional_deps/test_installed_with_viz.py @@ -25,11 +25,11 @@ def test_check_requires_only_viz(): def test_check_requires_only_geo(): """ensure failure for checks which should require geo optional dependencies""" - with pytest.raises(ImportError, match=r"pip install uxarray\[geo\]"): + with pytest.raises(ImportError, match=r'pip install "uxarray\[geo\]"'): check_requires_only_geo() def test_check_requires_viz_and_geo(): """ensure failure for checks which should require both viz and geo optional dependencies""" - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=r'pip install "uxarray\[geo\]"'): check_requires_viz_and_geo() From b89b9442c0b08dc763a2aca25a7393ed8043c975 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:54:14 -0400 Subject: [PATCH 14/15] fix and test messages of missing opt deps hints --- .../test_installed_with_no_opts.py | 67 +++++++++++++++++++ uxarray/utils/imports.py | 15 +++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index e3cdf6b75..16c2a2fc9 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -36,3 +36,70 @@ def test_check_requires_viz_and_geo(): # ^no match "uxarray[geo,viz]" here; might crash in a viz-only or a geo-only method, # even though the check itself ultimately requires both viz and geo. check_requires_viz_and_geo() + + +def test_messages_of_raise_hint_if_optional_deps_missing(): + """additional tests for reasonable-looking messages from _raise_hint_if_optional_deps_missing. + Hard-codes expected messages to prove it is working as expected in a variety of cases. + Only including this test in the no_opts case because it covers all kinds of messages. + (The "no error raised" case already gets covered by the other optional deps test files, + so this test here is just about testing cases where an error is actually raised.) + """ + import uxarray as ux + import uxarray.utils.imports + + # first, check that expected error messages get raised: + # (A) check that the stack is an OptionalDependencyNotFoundError on top of a ModuleNotFoundError. + try: + uxarray.utils.imports._raise_hint_if_optional_deps_missing("healpix") + except ux.errors.OptionalDependencyNotFoundError as err: + assert isinstance(err.__cause__, ModuleNotFoundError) + + # (B) check that the error is instead a ValueError if an unrecognized package name is provided. + with pytest.raises(ValueError, match="Unrecognized package names"): + uxarray.utils.imports._raise_hint_if_optional_deps_missing("_unrecognized_package_name_") + + # next, check error messages. Hard-code expected messages to make this test easier to read & maintain later. + def _get_errmsg(*packages): + try: + uxarray.utils.imports._raise_hint_if_optional_deps_missing(*packages) + except ux.errors.OptionalDependencyNotFoundError as err: + errmsg = str(err) + else: + assert False, f"Expected OptionalDependencyNotFoundError, got no error. packages={packages}" + return errmsg + + assert _get_errmsg("hvplot") == ('Failed to import: hvplot.' + '\nConsider running ``pip install "uxarray[viz]"``, then try again.') + assert _get_errmsg("holoviews", "geoviews") == ('Failed to import: geoviews, holoviews.' + '\nConsider running ``pip install "uxarray[viz]"``, then try again.') + assert _get_errmsg("healpix", "pyproj", "geopandas") == ('Failed to import: geopandas, healpix, pyproj.' + '\nConsider running ``pip install "uxarray[geo]"``, then try again.') + assert _get_errmsg("hvplot", "geopandas") == ('Failed to import: geopandas, hvplot.' + '\nConsider running ``pip install "uxarray[geo,viz]"`` or ``pip install "uxarray[all]"``, then try again.') + assert _get_errmsg("cartopy", "geopandas") == ('Failed to import: cartopy, geopandas.' + '\nConsider running ``pip install "uxarray[geo]"``, then try again.') + assert _get_errmsg("cartopy", "hvplot") == ('Failed to import: cartopy, hvplot.' + '\nConsider running ``pip install "uxarray[viz]"``, then try again.') + assert _get_errmsg("cartopy", "geopandas", "hvplot") == ('Failed to import: cartopy, geopandas, hvplot.' + '\nConsider running ``pip install "uxarray[geo,viz]"`` or ``pip install "uxarray[all]"``, then try again.') + + # to fully test the _raise_hint_if_optional_deps_missing() function, + # need to check cases with more than 2 extras. Add corresponding "fake packages" here. + uxarray.utils.imports._OPTIONAL_DEPS_TO_EXTRAS.update({ + "_fakepackage1_": "_fakeextra1_", + "_fakepackage2_": ("_fakeextra1_", "_fakeextra2_"), + "_fakepackage3_": ("_fakeextra1_", "_fakeextra2_", "_fakeextra3_"), + }) + + assert _get_errmsg("matplotlib", "spatialpandas", "_fakepackage1_") == ( + 'Failed to import: _fakepackage1_, matplotlib, spatialpandas.\n' + 'Consider running ``pip install "uxarray[_fakeextra1_,geo,viz]"`` or ' + '``pip install "uxarray[all]"``, then try again.') + assert _get_errmsg("_fakepackage1_", "_fakepackage2_") == ('Failed to import: _fakepackage1_, _fakepackage2_.' + '\nConsider running ``pip install "uxarray[_fakeextra1_]"``, then try again.') + assert _get_errmsg("_fakepackage3_", "_fakepackage2_") == ('Failed to import: _fakepackage2_, _fakepackage3_.' + '\nConsider running ``pip install "uxarray[all]"``, then try again.') + assert _get_errmsg("_fakepackage3_") == ('Failed to import: _fakepackage3_.\n' + 'Consider running ``pip install "uxarray[_fakeextra1_]"`` or pip install with ' + '[_fakeextra2_], [_fakeextra3_], or [all], then try again.') diff --git a/uxarray/utils/imports.py b/uxarray/utils/imports.py index a93825155..5f4b8e468 100644 --- a/uxarray/utils/imports.py +++ b/uxarray/utils/imports.py @@ -85,9 +85,11 @@ def _raise_hint_if_optional_deps_missing(*packages: str): extra in need_extras for extra in extras ): # still maybe in case (1) or (2). pass # this package is already covered by other needed extras! - elif len(extras) == 0: # case (3) + elif len(many_extras) == 1: # case (3) need_extras.add(extras[0]) - or_extras.extend(extras[1:]) + or_extras.extend(extra for extra in extras[1:] if extra not in or_extras) + if "all" not in or_extras: + or_extras.append("all") else: # case (4) need_extras = set(["all"]) break @@ -98,7 +100,12 @@ def _raise_hint_if_optional_deps_missing(*packages: str): errmsg += ' or ``pip install "uxarray[all]"``' elif len(or_extras) == 1: errmsg += f' or ``pip install "uxarray[{or_extras[0]}]"``' - elif len(or_extras) >= 2: - errmsg += f" or pip install with any of {set(or_extras)} instead" + elif len(or_extras) == 2: + errmsg += f', ``pip install "uxarray[{or_extras[0]}]"``, or ``pip install "uxarray[{or_extras[1]}]"``' + elif len(or_extras) >= 3: + sorted_or_extras = sorted(or_extras, key=lambda s: (s=="all", s)) # put "all" last + options_str = ", ".join(f'[{extra}]' for extra in sorted_or_extras[:-1]) + options_str += f", or [{sorted_or_extras[-1]}]" + errmsg += f" or pip install with {options_str}" errmsg += ", then try again." raise OptionalDependencyNotFoundError(errmsg) from last_err From da84ef682390fd0be4556404e69c6e2f5cb8ae72 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:59:41 -0400 Subject: [PATCH 15/15] forgot pre-commit ruff formatting --- .../test_installed_with_no_opts.py | 89 ++++++++++++------- uxarray/utils/imports.py | 10 ++- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/test_optional_deps/test_installed_with_no_opts.py b/test_optional_deps/test_installed_with_no_opts.py index 16c2a2fc9..558eaaab8 100644 --- a/test_optional_deps/test_installed_with_no_opts.py +++ b/test_optional_deps/test_installed_with_no_opts.py @@ -57,7 +57,9 @@ def test_messages_of_raise_hint_if_optional_deps_missing(): # (B) check that the error is instead a ValueError if an unrecognized package name is provided. with pytest.raises(ValueError, match="Unrecognized package names"): - uxarray.utils.imports._raise_hint_if_optional_deps_missing("_unrecognized_package_name_") + uxarray.utils.imports._raise_hint_if_optional_deps_missing( + "_unrecognized_package_name_" + ) # next, check error messages. Hard-code expected messages to make this test easier to read & maintain later. def _get_errmsg(*packages): @@ -66,40 +68,65 @@ def _get_errmsg(*packages): except ux.errors.OptionalDependencyNotFoundError as err: errmsg = str(err) else: - assert False, f"Expected OptionalDependencyNotFoundError, got no error. packages={packages}" + assert False, ( + f"Expected OptionalDependencyNotFoundError, got no error. packages={packages}" + ) return errmsg - assert _get_errmsg("hvplot") == ('Failed to import: hvplot.' - '\nConsider running ``pip install "uxarray[viz]"``, then try again.') - assert _get_errmsg("holoviews", "geoviews") == ('Failed to import: geoviews, holoviews.' - '\nConsider running ``pip install "uxarray[viz]"``, then try again.') - assert _get_errmsg("healpix", "pyproj", "geopandas") == ('Failed to import: geopandas, healpix, pyproj.' - '\nConsider running ``pip install "uxarray[geo]"``, then try again.') - assert _get_errmsg("hvplot", "geopandas") == ('Failed to import: geopandas, hvplot.' - '\nConsider running ``pip install "uxarray[geo,viz]"`` or ``pip install "uxarray[all]"``, then try again.') - assert _get_errmsg("cartopy", "geopandas") == ('Failed to import: cartopy, geopandas.' - '\nConsider running ``pip install "uxarray[geo]"``, then try again.') - assert _get_errmsg("cartopy", "hvplot") == ('Failed to import: cartopy, hvplot.' - '\nConsider running ``pip install "uxarray[viz]"``, then try again.') - assert _get_errmsg("cartopy", "geopandas", "hvplot") == ('Failed to import: cartopy, geopandas, hvplot.' - '\nConsider running ``pip install "uxarray[geo,viz]"`` or ``pip install "uxarray[all]"``, then try again.') + assert _get_errmsg("hvplot") == ( + "Failed to import: hvplot." + '\nConsider running ``pip install "uxarray[viz]"``, then try again.' + ) + assert _get_errmsg("holoviews", "geoviews") == ( + "Failed to import: geoviews, holoviews." + '\nConsider running ``pip install "uxarray[viz]"``, then try again.' + ) + assert _get_errmsg("healpix", "pyproj", "geopandas") == ( + "Failed to import: geopandas, healpix, pyproj." + '\nConsider running ``pip install "uxarray[geo]"``, then try again.' + ) + assert _get_errmsg("hvplot", "geopandas") == ( + "Failed to import: geopandas, hvplot." + '\nConsider running ``pip install "uxarray[geo,viz]"`` or ``pip install "uxarray[all]"``, then try again.' + ) + assert _get_errmsg("cartopy", "geopandas") == ( + "Failed to import: cartopy, geopandas." + '\nConsider running ``pip install "uxarray[geo]"``, then try again.' + ) + assert _get_errmsg("cartopy", "hvplot") == ( + "Failed to import: cartopy, hvplot." + '\nConsider running ``pip install "uxarray[viz]"``, then try again.' + ) + assert _get_errmsg("cartopy", "geopandas", "hvplot") == ( + "Failed to import: cartopy, geopandas, hvplot." + '\nConsider running ``pip install "uxarray[geo,viz]"`` or ``pip install "uxarray[all]"``, then try again.' + ) # to fully test the _raise_hint_if_optional_deps_missing() function, # need to check cases with more than 2 extras. Add corresponding "fake packages" here. - uxarray.utils.imports._OPTIONAL_DEPS_TO_EXTRAS.update({ - "_fakepackage1_": "_fakeextra1_", - "_fakepackage2_": ("_fakeextra1_", "_fakeextra2_"), - "_fakepackage3_": ("_fakeextra1_", "_fakeextra2_", "_fakeextra3_"), - }) - - assert _get_errmsg("matplotlib", "spatialpandas", "_fakepackage1_") == ( - 'Failed to import: _fakepackage1_, matplotlib, spatialpandas.\n' + uxarray.utils.imports._OPTIONAL_DEPS_TO_EXTRAS.update( + { + "_fakepackage1_": "_fakeextra1_", + "_fakepackage2_": ("_fakeextra1_", "_fakeextra2_"), + "_fakepackage3_": ("_fakeextra1_", "_fakeextra2_", "_fakeextra3_"), + } + ) + + assert _get_errmsg("matplotlib", "spatialpandas", "_fakepackage1_") == ( + "Failed to import: _fakepackage1_, matplotlib, spatialpandas.\n" 'Consider running ``pip install "uxarray[_fakeextra1_,geo,viz]"`` or ' - '``pip install "uxarray[all]"``, then try again.') - assert _get_errmsg("_fakepackage1_", "_fakepackage2_") == ('Failed to import: _fakepackage1_, _fakepackage2_.' - '\nConsider running ``pip install "uxarray[_fakeextra1_]"``, then try again.') - assert _get_errmsg("_fakepackage3_", "_fakepackage2_") == ('Failed to import: _fakepackage2_, _fakepackage3_.' - '\nConsider running ``pip install "uxarray[all]"``, then try again.') - assert _get_errmsg("_fakepackage3_") == ('Failed to import: _fakepackage3_.\n' + '``pip install "uxarray[all]"``, then try again.' + ) + assert _get_errmsg("_fakepackage1_", "_fakepackage2_") == ( + "Failed to import: _fakepackage1_, _fakepackage2_." + '\nConsider running ``pip install "uxarray[_fakeextra1_]"``, then try again.' + ) + assert _get_errmsg("_fakepackage3_", "_fakepackage2_") == ( + "Failed to import: _fakepackage2_, _fakepackage3_." + '\nConsider running ``pip install "uxarray[all]"``, then try again.' + ) + assert _get_errmsg("_fakepackage3_") == ( + "Failed to import: _fakepackage3_.\n" 'Consider running ``pip install "uxarray[_fakeextra1_]"`` or pip install with ' - '[_fakeextra2_], [_fakeextra3_], or [all], then try again.') + "[_fakeextra2_], [_fakeextra3_], or [all], then try again." + ) diff --git a/uxarray/utils/imports.py b/uxarray/utils/imports.py index 5f4b8e468..d87abc525 100644 --- a/uxarray/utils/imports.py +++ b/uxarray/utils/imports.py @@ -87,7 +87,9 @@ def _raise_hint_if_optional_deps_missing(*packages: str): pass # this package is already covered by other needed extras! elif len(many_extras) == 1: # case (3) need_extras.add(extras[0]) - or_extras.extend(extra for extra in extras[1:] if extra not in or_extras) + or_extras.extend( + extra for extra in extras[1:] if extra not in or_extras + ) if "all" not in or_extras: or_extras.append("all") else: # case (4) @@ -103,8 +105,10 @@ def _raise_hint_if_optional_deps_missing(*packages: str): elif len(or_extras) == 2: errmsg += f', ``pip install "uxarray[{or_extras[0]}]"``, or ``pip install "uxarray[{or_extras[1]}]"``' elif len(or_extras) >= 3: - sorted_or_extras = sorted(or_extras, key=lambda s: (s=="all", s)) # put "all" last - options_str = ", ".join(f'[{extra}]' for extra in sorted_or_extras[:-1]) + sorted_or_extras = sorted( + or_extras, key=lambda s: (s == "all", s) + ) # put "all" last + options_str = ", ".join(f"[{extra}]" for extra in sorted_or_extras[:-1]) options_str += f", or [{sorted_or_extras[-1]}]" errmsg += f" or pip install with {options_str}" errmsg += ", then try again."