From ddac5e8bc8498cbeb20415438b3e793bf5c525b8 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:54:40 +0100 Subject: [PATCH 1/8] feat(helpers): auto-convert ee.Geometry to shapely in fit_geometry --- xee/helpers.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/xee/helpers.py b/xee/helpers.py index 48962a1..40ca859 100644 --- a/xee/helpers.py +++ b/xee/helpers.py @@ -98,9 +98,61 @@ def set_scale( affine_transform = affine.Affine(*crs_transform) return list(affine_transform)[:6] +def _coerce_to_shapely_geometry( + geometry: Union[shapely.geometry.base.BaseGeometry, ee.Geometry], +) -> shapely.geometry.base.BaseGeometry: + """Normalize a supported geometry input to a shapely geometry. + + Shapely geometries are returned unchanged. Earth Engine-like geometries are + automatically detected and converted. Any other input raises a ``TypeError`` + that names the expected type and includes the explicit conversion snippet. + + Args: + geometry: A shapely geometry or an Earth Engine-like geometry exposing + ``getInfo``. + + Returns: + An equivalent shapely geometry. + + Raises: + TypeError: If ``geometry`` is neither a shapely geometry nor convertible + from an Earth Engine-like geometry. + """ + if isinstance(geometry, shapely.geometry.base.BaseGeometry): + return geometry + + get_info = getattr(geometry, "getInfo", None) + + if callable(get_info): + # NOTE(abi): ``getInfo`` runs outside the try clock so that genuine EE + # runtime errors propagate unchanged. + geojson = get_info() + + try: + return shapely.geometry.shape(geojson) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + shapely.errors.GeometryTypeError, + ) as e: + raise TypeError( + "Could not convert the Earth Engine-like geometry to a shapely " + "geometry. Convert it explicitly before calling fit_geometry:\n" + " shapely.geometry.shape(ee_geom.getInfo())" + ) from e + + raise TypeError( + "fit_geometry expected a shapely geometry, but got " + f"{type(geometry).__name__!r}. If this is an Earth Engine geometry, " + "convert it with:\n" + " shapely.geometry.shape(ee_geom.getInfo())" + ) + def fit_geometry( - geometry: shapely.geometry.base.BaseGeometry, + geometry: Union[shapely.geometry.base.BaseGeometry, ee.Geometry], # All following parameters are keyword-only. *, geometry_crs: str = 'EPSG:4326', @@ -119,7 +171,8 @@ def fit_geometry( Args: geometry: Shapely geometry defining the area of interest (in - ``geometry_crs`` units). + ``geometry_crs`` units). An Earth Engine-like geometry exposing + ``getInfo`` is also accepted and converted automatically. geometry_crs: CRS of the input geometry (default WGS84). buffer: Optional positive distance in CRS units to expand the geometry. grid_crs: Target CRS for the output grid. @@ -142,6 +195,8 @@ def fit_geometry( "Exactly one of 'grid_scale' or 'grid_shape' must be specified." ) + geometry = _coerce_to_shapely_geometry(geometry) + transformer = Transformer.from_crs( crs_from=geometry_crs, crs_to=grid_crs, always_xy=True ) From 31f087045c428ec9ddb19d2f74a184901c419a8a Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:05:18 +0100 Subject: [PATCH 2/8] test(helpers): add fit_geometry EE-like unit tests --- xee/ext_test.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/xee/ext_test.py b/xee/ext_test.py index c04c08c..21c08ac 100644 --- a/xee/ext_test.py +++ b/xee/ext_test.py @@ -384,6 +384,64 @@ def test_fit_geometry_with_rounding(self): self.assertAlmostEqual(grid_dict['crs_transform'][0], 0.1) self.assertAlmostEqual(grid_dict['crs_transform'][4], -0.1) + def test_fit_geometry_accepts_ee_like_geometry(self): + """Test that an Earth Engine-like geometry is auto-converted via getInfo().""" + + class _FakeEEGeometry: + """Minimal ee.Geometry stand-in exposing getInfo().""" + + def __init__(self, geojson): + self._geojson = geojson + + def getInfo(self): + return self._geojson + + ee_like_geometry = _FakeEEGeometry( + { + 'type': 'Polygon', + 'coordinates': [[[10.1, 10.1], [10.1, 10.9], [11.9, 10.1]]], + } + ) + + grid_dict = helpers.fit_geometry( + geometry=ee_like_geometry, + grid_crs='EPSG:4326', + grid_scale=(0.5, -0.5), + ) + + self.assertEqual( + grid_dict['crs_transform'], (0.5, 0.0, 10.0, 0.0, -0.5, 11.0) + ) + self.assertEqual(grid_dict['shape_2d'], (4, 2)) + + def test_fit_geometry_ee_like_invalid_payload_raises(self): + """Test that an Earth Engine-like geometry with an invalid payload raises TypeError.""" + + class _FakeEEGeometry: + + def getInfo(self): + return {'type': 'not a geojson geometry'} + + with self.assertRaisesRegex( + TypeError, r'shapely\.geometry\.shape\(ee_geom\.getInfo\(\)\)' + ): + helpers.fit_geometry( + geometry=_FakeEEGeometry(), + grid_crs='EPSG:4326', + grid_scale=(0.5, -0.5), + ) + + def test_fit_geometry_unsupported_type_raises(self): + """Test that an unrelated input type raises a clear TypeError with guidance.""" + with self.assertRaisesRegex( + TypeError, r'shapely\.geometry\.shape\(ee_geom\.getInfo\(\)\)' + ): + helpers.fit_geometry( + geometry=42, + grid_crs='EPSG:4326', + grid_scale=(0.5, -0.5), + ) + if __name__ == '__main__': absltest.main() From 84ce644436c10d3bb07ebc9c65dd96cd6211c247 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:10:50 +0100 Subject: [PATCH 3/8] docs: update guide.md --- docs/guide.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/guide.md b/docs/guide.md index 5b24fae..adc202f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -57,6 +57,40 @@ grid_params = helpers.fit_geometry( ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid_params) ``` +## Using an Earth Engine Geometry as the AOI + +`fit_geometry` accepts an `ee.Geometry` directly. It is auto-converted to shapely via `shapely.geometry.shape(geometry.getInfo())`, so you can stay in an Earth Engine-native workflow without converting by hand. + +```python +import ee +import xarray as xr +from xee import helpers + +aoi_ee = ee.Geometry.Rectangle([113.33, -43.63, 153.56, -10.66]) +grid_params = helpers.fit_geometry( + geometry=aoi_ee, + grid_crs='EPSG:4326', + grid_shape=(256, 256), +) + +ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid_params) +``` + +If you prefer to convert explicitly or need the shapely geometry elsewhere, the equivalent end-to-end conversion is as follows: + +```python +import shapely + +aoi_shapely = shapely.geometry.shape(aoi_ee.getInfo()) +grid_params = helpers.fit_geometry( + geometry=aoi_shapely, + grid_crs='EPSG:4326', + grid_shape=(256, 256), +) +``` + +Non-geometry inputs (or geometries that can't be converted) raise a `TypeError` that includes the above conversion snippet. + ## Custom Region at Source Resolution Fit an AOI but keep original pixel size. From d63fcfcd34f815d6cb688c4d6b7e585155c626a9 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:16:16 +0100 Subject: [PATCH 4/8] docs: update faq.md --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 1324503..603554f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,7 +29,7 @@ To align with CF conventions (`[time, y, x]`) and reduce the need for transposes Your AOI may fall outside the dataset extent or the CRS mismatch caused an unexpected reprojection. Try matching source grid first to confirm availability. ## Do I need shapely geometries? -Helpers accept shapely for convenience. If you already have an EE geometry, you can convert it to shapely with `shapely.geometry.shape(ee_geom.getInfo())`. Shapely makes reprojection and area reasoning simpler client-side. +Helpers accept shapely for convenience, as it makes reprojection and area reasoning simpler client-side. However, `fit_geometry` also accepts an Earth Engine geometry (e.g. `ee.Geometry`) directly and converts it for you via `shapely.geometry.shape(ee_geom.getInfo())`. That said, if you do need a shapely geometry elsewhere, you may want to handle this conversion explicitly. ## `ds.to_netcdf()` fails with `ValueError: could not safely cast array from int64 to int32` Xee time coordinates are stored as `int64` (nanoseconds since epoch). The `scipy` netCDF writer only supports netCDF3, which is limited to `int32`, so the write fails when `scipy` is the only available backend. From 33794a4f1408666a4bf2ee86ce6b341165f361b0 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:16:25 +0100 Subject: [PATCH 5/8] docs: update quickstart.md --- docs/quickstart.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/quickstart.md b/docs/quickstart.md index 00d06ba..2890293 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -123,6 +123,12 @@ grid = helpers.fit_geometry( ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid) ``` +```{admonition} Already have an Earth Engine geometry? +:class: tip + +`helpers.fit_geometry` accepts an `ee.Geometry` directly. See the [User Guide](guide.md) for an end-to-end example. +``` + ## 7. Having trouble? See the [FAQ](faq.md) and open a [discussion](https://github.com/google/Xee/discussions) if needed. From 4d295513bfaad00bcac0a9071e673424ef2ef8c7 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:12:46 +0100 Subject: [PATCH 6/8] refactor(helpers): explicit type checking for ee.Geometry --- xee/helpers.py | 96 ++++++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/xee/helpers.py b/xee/helpers.py index 40ca859..b3756f4 100644 --- a/xee/helpers.py +++ b/xee/helpers.py @@ -98,57 +98,54 @@ def set_scale( affine_transform = affine.Affine(*crs_transform) return list(affine_transform)[:6] + def _coerce_to_shapely_geometry( geometry: Union[shapely.geometry.base.BaseGeometry, ee.Geometry], ) -> shapely.geometry.base.BaseGeometry: - """Normalize a supported geometry input to a shapely geometry. - - Shapely geometries are returned unchanged. Earth Engine-like geometries are - automatically detected and converted. Any other input raises a ``TypeError`` - that names the expected type and includes the explicit conversion snippet. - - Args: - geometry: A shapely geometry or an Earth Engine-like geometry exposing - ``getInfo``. - - Returns: - An equivalent shapely geometry. - - Raises: - TypeError: If ``geometry`` is neither a shapely geometry nor convertible - from an Earth Engine-like geometry. - """ - if isinstance(geometry, shapely.geometry.base.BaseGeometry): - return geometry - - get_info = getattr(geometry, "getInfo", None) - - if callable(get_info): - # NOTE(abi): ``getInfo`` runs outside the try clock so that genuine EE - # runtime errors propagate unchanged. - geojson = get_info() - - try: - return shapely.geometry.shape(geojson) - except ( - AttributeError, - KeyError, - TypeError, - ValueError, - shapely.errors.GeometryTypeError, - ) as e: - raise TypeError( - "Could not convert the Earth Engine-like geometry to a shapely " - "geometry. Convert it explicitly before calling fit_geometry:\n" - " shapely.geometry.shape(ee_geom.getInfo())" - ) from e + """Normalize a supported geometry input to a shapely geometry. - raise TypeError( - "fit_geometry expected a shapely geometry, but got " - f"{type(geometry).__name__!r}. If this is an Earth Engine geometry, " - "convert it with:\n" - " shapely.geometry.shape(ee_geom.getInfo())" - ) + Shapely geometries are returned unchanged. ee.Geometry inputs are + automatically converted. Any other input raises a ``TypeError`` + that names the expected type and includes the explicit conversion snippet. + + Args: + geometry: A shapely geometry or an ee.Geometry instance. + + Returns: + An equivalent shapely geometry. + + Raises: + TypeError: If ``geometry`` is neither a shapely geometry nor an ee.Geometry. + """ + if isinstance(geometry, shapely.geometry.base.BaseGeometry): + return geometry + + if isinstance(geometry, ee.Geometry): + # NOTE: ``getInfo`` runs outside the try block so that genuine EE + # runtime errors propagate unchanged. + geojson = geometry.getInfo() + + try: + return shapely.geometry.shape(geojson) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + shapely.errors.GeometryTypeError, + ) as e: + raise TypeError( + "Could not convert the ee.Geometry to a shapely geometry. " + "Convert it explicitly before calling fit_geometry:\n" + " shapely.geometry.shape(ee_geom.getInfo())" + ) from e + + raise TypeError( + "fit_geometry expected a shapely geometry or ee.Geometry, but got " + f"{type(geometry).__name__!r}. If using an ee.Feature or other " + "ee.ComputedObject, convert it explicitly with:\n" + " shapely.geometry.shape(ee_geom.getInfo())" + ) def fit_geometry( @@ -170,9 +167,8 @@ def fit_geometry( provided the scale is inferred uniformly over the geometry's bounding box. Args: - geometry: Shapely geometry defining the area of interest (in - ``geometry_crs`` units). An Earth Engine-like geometry exposing - ``getInfo`` is also accepted and converted automatically. + geometry: Shapely geometry or ee.Geometry defining the area of interest (in + ``geometry_crs`` units). An ee.Geometry is converted automatically. geometry_crs: CRS of the input geometry (default WGS84). buffer: Optional positive distance in CRS units to expand the geometry. grid_crs: Target CRS for the output grid. From 85cee6b99f63c11695c1e1f53cc427cd126679e1 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:14:21 +0100 Subject: [PATCH 7/8] refactor(ext_test): update fit_geometry tests to handle explicit types --- xee/ext_test.py | 43 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/xee/ext_test.py b/xee/ext_test.py index 21c08ac..c5aedc4 100644 --- a/xee/ext_test.py +++ b/xee/ext_test.py @@ -4,6 +4,7 @@ from absl.testing import parameterized import numpy as np import affine +import ee import shapely from unittest import mock import xee @@ -384,27 +385,17 @@ def test_fit_geometry_with_rounding(self): self.assertAlmostEqual(grid_dict['crs_transform'][0], 0.1) self.assertAlmostEqual(grid_dict['crs_transform'][4], -0.1) - def test_fit_geometry_accepts_ee_like_geometry(self): - """Test that an Earth Engine-like geometry is auto-converted via getInfo().""" + def test_fit_geometry_accepts_ee_geometry(self): + """Test that an ee.Geometry is auto-converted via getInfo().""" - class _FakeEEGeometry: - """Minimal ee.Geometry stand-in exposing getInfo().""" - - def __init__(self, geojson): - self._geojson = geojson - - def getInfo(self): - return self._geojson - - ee_like_geometry = _FakeEEGeometry( - { - 'type': 'Polygon', - 'coordinates': [[[10.1, 10.1], [10.1, 10.9], [11.9, 10.1]]], - } - ) + ee_geometry = mock.MagicMock(spec=ee.Geometry) + ee_geometry.getInfo.return_value = { + 'type': 'Polygon', + 'coordinates': [[[10.1, 10.1], [10.1, 10.9], [11.9, 10.1]]], + } grid_dict = helpers.fit_geometry( - geometry=ee_like_geometry, + geometry=ee_geometry, grid_crs='EPSG:4326', grid_scale=(0.5, -0.5), ) @@ -414,28 +405,24 @@ def getInfo(self): ) self.assertEqual(grid_dict['shape_2d'], (4, 2)) - def test_fit_geometry_ee_like_invalid_payload_raises(self): - """Test that an Earth Engine-like geometry with an invalid payload raises TypeError.""" - - class _FakeEEGeometry: + def test_fit_geometry_ee_geometry_invalid_payload_raises(self): + """Test that an ee.Geometry with an invalid payload raises TypeError.""" - def getInfo(self): - return {'type': 'not a geojson geometry'} + ee_geometry = mock.MagicMock(spec=ee.Geometry) + ee_geometry.getInfo.return_value = {'type': 'not a geojson geometry'} with self.assertRaisesRegex( TypeError, r'shapely\.geometry\.shape\(ee_geom\.getInfo\(\)\)' ): helpers.fit_geometry( - geometry=_FakeEEGeometry(), + geometry=ee_geometry, grid_crs='EPSG:4326', grid_scale=(0.5, -0.5), ) def test_fit_geometry_unsupported_type_raises(self): """Test that an unrelated input type raises a clear TypeError with guidance.""" - with self.assertRaisesRegex( - TypeError, r'shapely\.geometry\.shape\(ee_geom\.getInfo\(\)\)' - ): + with self.assertRaisesRegex(TypeError, r'shapely geometry or ee\.Geometry'): helpers.fit_geometry( geometry=42, grid_crs='EPSG:4326', From 3d34e63952805daf209b613f3987769975d3a194 Mon Sep 17 00:00:00 2001 From: Abidan Brito <43681148+abidanBrito@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:39:07 +0100 Subject: [PATCH 8/8] chore: update docs --- docs/faq.md | 11 ++++++++++- docs/guide.md | 19 +++++++++++++++++-- docs/quickstart.md | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 603554f..4a1ce51 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,7 +29,16 @@ To align with CF conventions (`[time, y, x]`) and reduce the need for transposes Your AOI may fall outside the dataset extent or the CRS mismatch caused an unexpected reprojection. Try matching source grid first to confirm availability. ## Do I need shapely geometries? -Helpers accept shapely for convenience, as it makes reprojection and area reasoning simpler client-side. However, `fit_geometry` also accepts an Earth Engine geometry (e.g. `ee.Geometry`) directly and converts it for you via `shapely.geometry.shape(ee_geom.getInfo())`. That said, if you do need a shapely geometry elsewhere, you may want to handle this conversion explicitly. +`fit_geometry` accepts **shapely geometries** or **ee.Geometry** only. Shapely is convenient for client-side reprojection and area reasoning. If you have an `ee.Geometry`, it is auto-converted for you via `shapely.geometry.shape(ee_geom.getInfo())`. + +If you have an `ee.Feature` or other `ee.ComputedObject`, call `.geometry()` first: +```python +# Correct +fit_geometry(geometry=ee_feature.geometry(), ...) + +# Incorrect +fit_geometry(geometry=ee_feature, ...) +``` ## `ds.to_netcdf()` fails with `ValueError: could not safely cast array from int64 to int32` Xee time coordinates are stored as `int64` (nanoseconds since epoch). The `scipy` netCDF writer only supports netCDF3, which is limited to `int32`, so the write fails when `scipy` is the only available backend. diff --git a/docs/guide.md b/docs/guide.md index adc202f..2a2634e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -59,7 +59,7 @@ ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid_pa ## Using an Earth Engine Geometry as the AOI -`fit_geometry` accepts an `ee.Geometry` directly. It is auto-converted to shapely via `shapely.geometry.shape(geometry.getInfo())`, so you can stay in an Earth Engine-native workflow without converting by hand. +`fit_geometry` accepts `ee.Geometry` directly and auto-converts it to shapely via `shapely.geometry.shape(geometry.getInfo())`. ```python import ee @@ -76,7 +76,22 @@ grid_params = helpers.fit_geometry( ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid_params) ``` -If you prefer to convert explicitly or need the shapely geometry elsewhere, the equivalent end-to-end conversion is as follows: +**Important:** If you have an `ee.Feature` or other `ee.ComputedObject`, extract the geometry first: + +```python +aoi_feature = ee.Feature(...) +# Correct - Extracting the geometry +grid_params = helpers.fit_geometry( + geometry=aoi_feature.geometry(), + grid_crs='EPSG:4326', + grid_shape=(256, 256), +) + +# Incorrect - Passing the feature directly +grid_params = helpers.fit_geometry(geometry=aoi_feature, ...) +``` + +If you prefer to convert to shapely explicitly or need the shapely geometry elsewhere: ```python import shapely diff --git a/docs/quickstart.md b/docs/quickstart.md index 2890293..946005f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -126,7 +126,7 @@ ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid) ```{admonition} Already have an Earth Engine geometry? :class: tip -`helpers.fit_geometry` accepts an `ee.Geometry` directly. See the [User Guide](guide.md) for an end-to-end example. +`helpers.fit_geometry` accepts `shapely.geometry` or `ee.Geometry` directly. If you have an `ee.Feature`, call `.geometry()` first. See the [User Guide](guide.md) for examples. ``` ## 7. Having trouble?