Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
58 changes: 58 additions & 0 deletions test_optional_deps/_optional_deps_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""
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

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

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

ux.Grid.from_healpix(zoom=1)


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)
35 changes: 35 additions & 0 deletions test_optional_deps/test_installed_with_geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
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_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, match=r'pip install "uxarray\[viz\]"'):
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, match=r'pip install "uxarray\[viz\]"'):
check_requires_viz_and_geo()
38 changes: 38 additions & 0 deletions test_optional_deps/test_installed_with_no_opts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
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_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, 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\]"'):
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()
35 changes: 35 additions & 0 deletions test_optional_deps/test_installed_with_viz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
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_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, 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, match=r'pip install "uxarray\[geo\]"'):
check_requires_viz_and_geo()
32 changes: 32 additions & 0 deletions test_optional_deps/test_installed_with_viz_and_geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
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]"
"""

from _optional_deps_helpers import (
check_requires_no_opts,
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()
3 changes: 3 additions & 0 deletions uxarray/core/dataarray.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions uxarray/cross_sections/sample.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
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 @@ -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
Expand Down Expand Up @@ -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"]:
Expand Down
Loading