From 2d1c9aae179dfbe893ca853050e50e711d6da83c Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sun, 3 May 2026 16:10:07 -0700 Subject: [PATCH] Validate grid/bounds/precision params in reproject (#1433) reproject() and merge() accept several user-controlled grid parameters that previously raised ZeroDivisionError or IndexError deep in the resampler, or silently produced backwards coords: - resolution=0 / NaN / Inf / negative: ZeroDivisionError or wrong- signed res_x. - width / height = 0 / negative: ZeroDivisionError or silently inverted output. - bounds with right<=left or top<=bottom: produced descending output coords and all-nodata silently. - transform_precision=-1: IndexError on empty np.linspace. Add _validate_grid_params() helper in _grid.py and call from both reproject() and merge() before any helper consumes these values. 24 new tests in TestValidateGridParams / TestValidateMergeGridParams. --- xrspatial/reproject/__init__.py | 19 +++++ xrspatial/reproject/_grid.py | 84 ++++++++++++++++++++++ xrspatial/tests/test_reproject.py | 115 ++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/xrspatial/reproject/__init__.py b/xrspatial/reproject/__init__.py index 5d041bf2..ac9f93cd 100644 --- a/xrspatial/reproject/__init__.py +++ b/xrspatial/reproject/__init__.py @@ -20,6 +20,7 @@ _compute_chunk_layout, _compute_output_grid, _make_output_coords, + _validate_grid_params, ) from ._interpolate import ( _resample_cupy, @@ -527,6 +528,15 @@ def reproject( f"got {type(raster).__name__}" ) + _validate_grid_params( + resolution=resolution, + bounds=bounds, + width=width, + height=height, + transform_precision=transform_precision, + func_name='reproject', + ) + _validate_resampling(resampling) # Resolve CRS @@ -1350,6 +1360,15 @@ def merge( if not rasters: raise ValueError("merge(): rasters list must not be empty") + _validate_grid_params( + resolution=resolution, + bounds=bounds, + width=None, + height=None, + transform_precision=None, + func_name='merge', + ) + _validate_resampling(resampling) _validate_strategy(strategy) diff --git a/xrspatial/reproject/_grid.py b/xrspatial/reproject/_grid.py index 69085fda..5eeea117 100644 --- a/xrspatial/reproject/_grid.py +++ b/xrspatial/reproject/_grid.py @@ -4,6 +4,90 @@ import numpy as np +def _validate_grid_params(*, resolution, bounds, width, height, + transform_precision, func_name): + """Range-check user-supplied grid parameters.""" + if resolution is not None: + if isinstance(resolution, (tuple, list)): + if len(resolution) != 2: + raise ValueError( + f"{func_name}(): resolution tuple must have length 2, " + f"got length {len(resolution)}" + ) + res_values = list(resolution) + else: + res_values = [resolution] + for r in res_values: + try: + r_float = float(r) + except (TypeError, ValueError): + raise ValueError( + f"{func_name}(): resolution must be a positive finite " + f"number, got {r!r}" + ) + if not (np.isfinite(r_float) and r_float > 0): + raise ValueError( + f"{func_name}(): resolution must be a positive finite " + f"number, got {r!r}" + ) + + for label, val in (('width', width), ('height', height)): + if val is None: + continue + if not isinstance(val, (int, np.integer)) or isinstance(val, bool): + raise ValueError( + f"{func_name}(): {label} must be a positive integer, got {val!r}" + ) + if val <= 0: + raise ValueError( + f"{func_name}(): {label} must be a positive integer, got {val!r}" + ) + + if bounds is not None: + try: + left, bottom, right, top = bounds + except (TypeError, ValueError): + raise ValueError( + f"{func_name}(): bounds must be a 4-tuple " + f"(left, bottom, right, top), got {bounds!r}" + ) + for label, v in (('left', left), ('bottom', bottom), + ('right', right), ('top', top)): + try: + v_float = float(v) + except (TypeError, ValueError): + raise ValueError( + f"{func_name}(): bounds {label}={v!r} is not numeric" + ) + if not np.isfinite(v_float): + raise ValueError( + f"{func_name}(): bounds {label} must be finite, got {v!r}" + ) + if float(right) <= float(left): + raise ValueError( + f"{func_name}(): bounds right ({right}) must be greater " + f"than left ({left})" + ) + if float(top) <= float(bottom): + raise ValueError( + f"{func_name}(): bounds top ({top}) must be greater " + f"than bottom ({bottom})" + ) + + if transform_precision is not None: + if (not isinstance(transform_precision, (int, np.integer)) + or isinstance(transform_precision, bool)): + raise ValueError( + f"{func_name}(): transform_precision must be a non-negative " + f"integer, got {transform_precision!r}" + ) + if transform_precision < 0: + raise ValueError( + f"{func_name}(): transform_precision must be a non-negative " + f"integer, got {transform_precision!r}" + ) + + def _transform_boundary(source_crs, target_crs, xs, ys): """Transform coordinate arrays, preferring Numba fast path over pyproj. diff --git a/xrspatial/tests/test_reproject.py b/xrspatial/tests/test_reproject.py index a3ad6b8e..3fc14819 100644 --- a/xrspatial/tests/test_reproject.py +++ b/xrspatial/tests/test_reproject.py @@ -1517,3 +1517,118 @@ def test_numpy_chunk_source_window_guard(self): ) result = reproject(raster, target_crs='EPSG:3857') assert result.shape[0] > 0 and result.shape[1] > 0 + + +# ===================================================================== +# Issue #1433: grid/bounds/precision parameter validation +# ===================================================================== + +class TestValidateGridParams: + """reproject(): grid params reject zero / negative / non-finite.""" + + @staticmethod + def _good_raster(): + return xr.DataArray( + np.zeros((4, 4), dtype=np.float64), + dims=('y', 'x'), + coords={'y': np.arange(4), 'x': np.arange(4)}, + attrs={'crs': 'EPSG:4326'}, + ) + + @pytest.mark.parametrize("res", [0, 0.0, -1, -2.5, + float('inf'), float('-inf'), + float('nan')]) + def test_resolution_rejected(self, res): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="resolution"): + reproject(r, 'EPSG:4326', resolution=res) + + def test_resolution_tuple_with_zero_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="resolution"): + reproject(r, 'EPSG:4326', resolution=(1.0, 0.0)) + + def test_resolution_tuple_wrong_length_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="length 2"): + reproject(r, 'EPSG:4326', resolution=(1.0, 2.0, 3.0)) + + @pytest.mark.parametrize("w", [0, -1, 1.5]) + def test_width_rejected(self, w): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="width"): + reproject(r, 'EPSG:4326', width=w, height=10) + + @pytest.mark.parametrize("h", [0, -1, 1.5]) + def test_height_rejected(self, h): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="height"): + reproject(r, 'EPSG:4326', width=10, height=h) + + def test_bounds_collapsed_x_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="right"): + reproject(r, 'EPSG:4326', bounds=(10, 0, 10, 10)) + + def test_bounds_collapsed_y_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="top"): + reproject(r, 'EPSG:4326', bounds=(0, 10, 10, 10)) + + def test_bounds_inverted_x_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="right"): + reproject(r, 'EPSG:4326', bounds=(10, 0, 0, 10)) + + def test_bounds_nan_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="finite"): + reproject(r, 'EPSG:4326', bounds=(0, 0, float('nan'), 10)) + + def test_bounds_wrong_length_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="4-tuple"): + reproject(r, 'EPSG:4326', bounds=(0, 0, 10)) + + def test_transform_precision_negative_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="transform_precision"): + reproject(r, 'EPSG:4326', transform_precision=-1) + + def test_transform_precision_float_rejected(self): + from xrspatial.reproject import reproject + r = self._good_raster() + with pytest.raises(ValueError, match="transform_precision"): + reproject(r, 'EPSG:4326', transform_precision=1.5) + + +class TestValidateMergeGridParams: + @staticmethod + def _raster(): + return xr.DataArray( + np.zeros((4, 4), dtype=np.float64), + dims=('y', 'x'), + coords={'y': np.arange(4), 'x': np.arange(4)}, + attrs={'crs': 'EPSG:4326'}, + ) + + def test_merge_resolution_rejected(self): + from xrspatial.reproject import merge + with pytest.raises(ValueError, match="resolution"): + merge([self._raster()], resolution=-1.0) + + def test_merge_bounds_rejected(self): + from xrspatial.reproject import merge + with pytest.raises(ValueError, match="right"): + merge([self._raster()], bounds=(10, 0, 0, 10))