Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
61d3ea2
add _raise_hint_if_optional_deps_missing
Sevans711 Aug 7, 2026
b958afd
optional deps test ensure helpful hint gets raised
Sevans711 Aug 7, 2026
6fb1bfd
forgot pre-commit ruff formatting
Sevans711 Aug 7, 2026
8e2a07a
fix opt deps error hint tests typos
Sevans711 Aug 10, 2026
89d8128
add test_optional_deps files & ci commands
Sevans711 Aug 6, 2026
8043308
forgot pre-commit ruff formatting
Sevans711 Aug 6, 2026
e048476
fix ruff complaint about unused import
Sevans711 Aug 6, 2026
c213dad
add healpix-sensitive optional deps test
Sevans711 Aug 7, 2026
8ebb674
fix optional deps test: cannot plot UxDataset
Sevans711 Aug 7, 2026
b54f9d2
add _raise_hint_if_optional_deps_missing
Sevans711 Aug 7, 2026
b3a58e8
optional deps test ensure helpful hint gets raised
Sevans711 Aug 7, 2026
71036d1
forgot pre-commit ruff formatting
Sevans711 Aug 7, 2026
1210ae0
fix opt deps error hint tests typos
Sevans711 Aug 10, 2026
4ff7f7f
Merge branch 'sevans/_raise_hint_if_optional_deps_missing' of https:/…
Sevans711 Aug 14, 2026
72ece1c
Merge branch 'main' into sevans/_raise_hint_if_optional_deps_missing
Sevans711 Aug 14, 2026
0f638de
Merge branch 'sevans/tests-for-optional-deps' into sevans/_raise_hint…
Sevans711 Aug 14, 2026
b89b944
fix and test messages of missing opt deps hints
Sevans711 Aug 14, 2026
da84ef6
forgot pre-commit ruff formatting
Sevans711 Aug 14, 2026
7caf9b3
Merge branch 'sevans/tests-for-optional-deps' into sevans/_raise_hint…
Sevans711 Aug 17, 2026
c78f80a
test usage of _raise_hint_if_optional_deps_missing
Sevans711 Aug 19, 2026
c942ed7
forgot pre-commit ruff formatting
Sevans711 Aug 19, 2026
c5deb67
Merge branch 'sevans/tests-for-optional-deps' into sevans/_raise_hint…
Sevans711 Aug 19, 2026
35ee6dc
fix: specify utf-8 encoding to avoid windows crash
Sevans711 Aug 19, 2026
7a5e216
Merge branch 'main' into sevans/_raise_hint_if_optional_deps_missing
Sevans711 Aug 20, 2026
686d1f8
improve installation docs page
Sevans711 Aug 20, 2026
1e457ed
Revert "improve installation docs page"
Sevans711 Aug 20, 2026
f12f269
Merge branch 'sevans/tests-for-optional-deps' into sevans/_raise_hint…
Sevans711 Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions test/utils/test_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Purpose: testing tools from imports.py
"""

import os
import warnings

import pytest

from uxarray.utils.imports import _optional_import_usage_throughout

HERE = __file__ # e.g. path0/uxarray/test/utils/test_imports
SRC_ROOT = os.path.abspath(os.path.join(os.path.dirname(HERE), "..", "..", "uxarray"))
# e.g. SRC_ROOT = path0/uxarray/uxarray


def test_optional_dependency_imports_are_hinted():
"""Ensures that all optional dependencies are hinted for in the functions that use them.
I.e., in a function which imports optional dependencies "dep1", "dep2", "dep3",
need to call _raise_hint_if_optional_deps_missing("dep1", "dep2", "dep3").
"""
results = _optional_import_usage_throughout(SRC_ROOT)

missing = [r for r in results if r.missing_deps]
extra = [r for r in results if r.extra_deps]

for r in extra:
warnings.warn(
f"{r.filepath}:{r.lineno} in {r.qualname} — "
"_raise_hint_if_optional_deps_missing() lists deps that "
f"aren't actually imported here: {sorted(r.extra_deps)}",
stacklevel=1,
)

if missing:
details = "\n".join(
f" {r.filepath}:{r.lineno} in {r.qualname} — "
f"failed to hint for these deps: {sorted(r.missing_deps)}"
for r in missing
)
pytest.fail(
"Function(s) import optional dependencies without including all of them in "
f"_raise_hint_if_optional_deps_missing():\n{details}"
)
4 changes: 2 additions & 2 deletions test_optional_deps/test_installed_with_geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand All @@ -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()
100 changes: 98 additions & 2 deletions test_optional_deps/test_installed_with_no_opts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,113 @@ 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()


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()


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."
)
4 changes: 2 additions & 2 deletions test_optional_deps/test_installed_with_viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
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()
3 changes: 3 additions & 0 deletions uxarray/core/dataarray.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from uxarray.remap.accessor import RemapAccessor
from uxarray.subset import DataArraySubsetAccessor
from uxarray.utils.coords import _preserve_valid_coords
from uxarray.utils.imports import _raise_hint_if_optional_deps_missing

if TYPE_CHECKING:
import cartopy.crs as ccrs
Expand Down Expand Up @@ -474,6 +475,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
Expand Down Expand Up @@ -518,6 +520,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)
Expand Down
3 changes: 3 additions & 0 deletions uxarray/cross_sections/sample.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import numpy as np

from uxarray.utils.imports import _raise_hint_if_optional_deps_missing


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
Comment on lines +9 to 10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This pattern looks like it will be difficult to maintain consistently throughout the codebase as we develop it. If we're going to have this, there should definitely be some kind of linting for this so we'll know in the PRs if these are done correctly, and provide an easy way to fix it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for looking into this! Commit c78f80a adds a test to the pytest test suite which should fail if anyone defines a function that imports optional dependencies without all of them being properly included in a call to _raise_hint_if_optional_deps_missing(). The failure mode includes a helpful message which should make it clear enough on how to fix it.

(It also raises clear warnings if bonus optional dependencies are being included, unnecessarily, inside a call to the raise_hint... function.)

I chose to implement this as a pytest test instead of full-blown linting because I have a suspicion that this will be easier to maintain (e.g., I'm not yet familiar with building customized linting algorithms). There's no "fix it for me" button like a proper linting algorithm might provide, but I think that this should still be sufficient? I believe it grants the most important benefits of ensuring this pattern gets maintained, and providing clear instructions for how to fix if it if needed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Eh, it's better, but the "fix it for me" button is my ideal in this case.


lon0, lat0 = start
Expand Down
7 changes: 7 additions & 0 deletions uxarray/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,10 @@ 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."""
8 changes: 8 additions & 0 deletions uxarray/grid/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion uxarray/grid/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,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
Expand Down Expand Up @@ -2383,7 +2384,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"]:
Expand Down
3 changes: 2 additions & 1 deletion uxarray/io/_geopandas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions uxarray/io/_healpix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions uxarray/plot/accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading