From 866276e565e4b2904257506662fe7d8e87c74bde Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 24 Aug 2026 00:43:37 -0400 Subject: [PATCH 1/6] feat: add Earth Engine layers to Python API Expose a geemap-style add_ee_layer method that turns authenticated Earth Engine objects into restorable raster tile layers. Document supported objects and cover image, collection, vector, validation, and error paths. --- python/README.md | 5 + python/src/geolibre/geolibre.py | 111 +++++++++++++++++++++++ python/tests/test_map.py | 109 ++++++++++++++++++++++ skills/geolibre/references/python-api.md | 7 ++ 4 files changed, 232 insertions(+) diff --git a/python/README.md b/python/README.md index 42df24a57..2630dc313 100644 --- a/python/README.md +++ b/python/README.md @@ -44,6 +44,10 @@ m.add_tile_layer( attribution="(c) OpenStreetMap contributors", ) m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain") + +import ee +ee.Initialize(project="your-google-cloud-project") +m.add_ee_layer(ee.Image("USGS/SRTMGL1_003"), {"min": 0, "max": 3000}, name="SRTM") m.add_basemap("dark") m.set_center(-120, 47, zoom=8) ``` @@ -74,6 +78,7 @@ m.to_project()["mapView"]["center"] | `add_vector_tiles(url, name=, source_layers=, source_layer=, **style)` | Add vector tiles from a TileJSON endpoint. | | `add_pmtiles(url, name=, tile_type=, source_layers=, **style)` | Add a PMTiles archive (vector or raster). | | `add_tile_layer(url, name=, tile_size=, attribution=)` | Add a raster XYZ tile layer. | +| `add_ee_layer(ee_object, vis_params=, name=, shown=, opacity=)` | Add an authenticated Google Earth Engine object as raster tiles. | | `add_wms(endpoint, layers, name=, styles=, image_format=, transparent=, tile_size=, **style)` | Add a WMS (GetMap) tiled raster layer. | | `add_wmts(url, name=, tile_size=, **style)` | Add a WMTS tile URL template. | | `add_wfs(endpoint, type_name, name=, version=, output_format=, srs_name=, max_features=, **style)` | Add a WFS layer (GeoJSON, fetched and inlined). | diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index dcc7a8e59..7a60322fd 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -1632,6 +1632,117 @@ def add_tile_layer( ) ) + def add_ee_layer( + self, + ee_object: Any, + vis_params: dict[str, Any] | None = None, + name: str = "Earth Engine", + shown: bool = True, + opacity: float = 1.0, + ) -> str: + """Add a Google Earth Engine object as a raster tile layer. + + This follows the ``geemap``/``leafmap`` convention: Earth Engine is + evaluated in the Python kernel to obtain a map tile URL, while the + GeoLibre app renders that URL as a normal raster layer. Earth Engine + must already be authenticated and initialized (usually with + ``ee.Authenticate()`` and ``ee.Initialize(project=...)``). + + Args: + ee_object: An ``ee.Image``, ``ee.ImageCollection``, + ``ee.FeatureCollection``, ``ee.Feature``, or ``ee.Geometry``. + A compatible object exposing ``getMapId`` is also accepted. + vis_params: Earth Engine visualization parameters, such as + ``bands``, ``min``, ``max``, and ``palette``. + name: Layer display name. + shown: Whether the layer is initially visible. + opacity: Initial opacity between 0 and 1. + + Returns: + The id of the added layer. + + Raises: + ImportError: If conversion requires the optional Earth Engine + Python package and it is not installed. + TypeError: If ``ee_object`` is not a supported Earth Engine object. + ValueError: If Earth Engine returns no usable tile URL or opacity + is outside the range 0--1. + + Note: + The generated tile URL is tied to the Earth Engine map ID. A saved + project may need the layer to be regenerated after that map ID + expires. + """ + try: + opacity_value = float(opacity) + except (TypeError, ValueError) as exc: + raise ValueError("opacity must be a finite number between 0 and 1") from exc + if not math.isfinite(opacity_value) or not 0 <= opacity_value <= 1: + raise ValueError("opacity must be a finite number between 0 and 1") + + params = dict(vis_params or {}) + map_object = ee_object + map_params = params + + if not callable(getattr(map_object, "getMapId", None)): + try: + import ee + except ImportError as exc: + raise ImportError( + "Adding this Earth Engine object requires the `earthengine-api` " + "package. Install it with `pip install earthengine-api`." + ) from exc + + if isinstance(map_object, ee.ImageCollection): + map_object = map_object.mosaic() + elif isinstance(map_object, (ee.FeatureCollection, ee.Feature, ee.Geometry)): + if isinstance(map_object, ee.Geometry): + map_object = ee.Feature(map_object) + if isinstance(map_object, ee.Feature): + map_object = ee.FeatureCollection([map_object]) + vector_style = { + "color": "000000", + "fillColor": "00000000", + "width": 2, + "pointSize": 3, + "pointShape": "circle", + **params, + } + map_object = map_object.style(**vector_style) + map_params = {} + elif not isinstance(map_object, ee.Image): + raise TypeError( + "ee_object must be an Earth Engine Image, ImageCollection, " + "FeatureCollection, Feature, or Geometry" + ) + + try: + map_id = map_object.getMapId(map_params) + except Exception as exc: + raise RuntimeError( + "Earth Engine could not create map tiles. Authenticate and initialize " + "Earth Engine before calling add_ee_layer()." + ) from exc + + tile_fetcher = map_id.get("tile_fetcher") if isinstance(map_id, dict) else None + tile_url = getattr(tile_fetcher, "url_format", None) + if not tile_url and isinstance(map_id, dict): + tile_url = map_id.get("tile_url") or map_id.get("url_format") + if not isinstance(tile_url, str) or not tile_url: + raise ValueError("Earth Engine returned a map ID without a tile URL") + + layer = _project.tile_layer( + name, + tile_url, + attribution="Google Earth Engine", + ) + layer["visible"] = bool(shown) + layer["opacity"] = opacity_value + layer["metadata"]["provider"] = "earth-engine" + if isinstance(map_id, dict) and map_id.get("mapid"): + layer["metadata"]["earthEngineMapId"] = map_id["mapid"] + return self._add_layer(layer) + @staticmethod def _resolve_raster_source(source: Any) -> str: """Resolve a raster source to a URL the in-iframe app can fetch. diff --git a/python/tests/test_map.py b/python/tests/test_map.py index 9b17ff1f5..3d76a53a1 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -70,6 +70,115 @@ def test_add_wmts(m): assert _last_layer(m)["type"] == "wmts" +def test_add_ee_layer_from_map_id(m): + class TileFetcher: + url_format = "https://earthengine.googleapis.com/maps/test/tiles/{z}/{x}/{y}" + + class Image: + def getMapId(self, vis_params): + assert vis_params == {"min": 0, "max": 3000, "palette": ["blue", "green"]} + return {"mapid": "test-map", "tile_fetcher": TileFetcher()} + + layer_id = m.add_ee_layer( + Image(), + {"min": 0, "max": 3000, "palette": ["blue", "green"]}, + name="Elevation", + shown=False, + opacity=0.4, + ) + layer = _last_layer(m) + assert layer["id"] == layer_id + assert layer["name"] == "Elevation" + assert layer["type"] == "xyz" + assert layer["visible"] is False + assert layer["opacity"] == 0.4 + assert layer["source"]["tiles"] == [TileFetcher.url_format] + assert layer["source"]["attribution"] == "Google Earth Engine" + assert layer["metadata"]["sourceKind"] == "xyz-url" + assert layer["metadata"]["provider"] == "earth-engine" + assert layer["metadata"]["earthEngineMapId"] == "test-map" + + +@pytest.mark.parametrize("opacity", [-0.1, 1.1, float("nan"), "bad"]) +def test_add_ee_layer_rejects_invalid_opacity(m, opacity): + with pytest.raises(ValueError, match="opacity must"): + m.add_ee_layer(object(), opacity=opacity) + + +def test_add_ee_layer_requires_tile_url(m): + class Image: + def getMapId(self, _vis_params): + return {"mapid": "missing-fetcher"} + + with pytest.raises(ValueError, match="without a tile URL"): + m.add_ee_layer(Image()) + + +def test_add_ee_layer_wraps_earth_engine_errors(m): + class Image: + def getMapId(self, _vis_params): + raise RuntimeError("not initialized") + + with pytest.raises(RuntimeError, match="Authenticate and initialize"): + m.add_ee_layer(Image()) + + +def test_add_ee_layer_mosaics_image_collection(monkeypatch, m): + class TileFetcher: + url_format = "https://earthengine.googleapis.com/maps/collection/tiles/{z}/{x}/{y}" + + class Image: + def getMapId(self, vis_params): + assert vis_params == {"bands": ["B4", "B3", "B2"]} + return {"tile_fetcher": TileFetcher()} + + class ImageCollection: + def mosaic(self): + return Image() + + fake_ee = types.SimpleNamespace( + Image=Image, + ImageCollection=ImageCollection, + FeatureCollection=type("FeatureCollection", (), {}), + Feature=type("Feature", (), {}), + Geometry=type("Geometry", (), {}), + ) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + m.add_ee_layer(ImageCollection(), {"bands": ["B4", "B3", "B2"]}) + assert _last_layer(m)["source"]["tiles"] == [TileFetcher.url_format] + + +def test_add_ee_layer_styles_feature_collection(monkeypatch, m): + captured = {} + + class TileFetcher: + url_format = "https://earthengine.googleapis.com/maps/features/tiles/{z}/{x}/{y}" + + class Image: + def getMapId(self, vis_params): + captured["map_params"] = vis_params + return {"tile_fetcher": TileFetcher()} + + class FeatureCollection: + def style(self, **style): + captured["style"] = style + return Image() + + fake_ee = types.SimpleNamespace( + Image=Image, + ImageCollection=type("ImageCollection", (), {}), + FeatureCollection=FeatureCollection, + Feature=type("Feature", (), {}), + Geometry=type("Geometry", (), {}), + ) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + m.add_ee_layer(FeatureCollection(), {"color": "ff0000", "width": 4}) + assert captured["style"]["color"] == "ff0000" + assert captured["style"]["width"] == 4 + assert captured["style"]["fillColor"] == "00000000" + assert captured["map_params"] == {} + + def test_add_raster_is_cog(m): m.add_raster("https://e/dem.tif", bands=[1, 2, 3]) layer = _last_layer(m) diff --git a/skills/geolibre/references/python-api.md b/skills/geolibre/references/python-api.md index 388b3d7c7..2356d029a 100644 --- a/skills/geolibre/references/python-api.md +++ b/skills/geolibre/references/python-api.md @@ -48,6 +48,8 @@ m.add_csv(data, x="longitude", y="latitude", name="CSV") m.add_cog(url, name="COG", bands=None, colormap=None, rescale=None) m.add_raster(...) # same, incl. a local GeoTIFF m.add_tile_layer(url, name, tile_size=256, attribution=None) +m.add_ee_layer(ee_object, vis_params=None, name="Earth Engine", shown=True, + opacity=1.0) m.add_pmtiles(url, name, tile_type="vector", source_layers=None) m.add_vector_tiles(url, name, source_layers=None) m.add_wms(endpoint, layers, name, version="1.1.1") @@ -61,6 +63,11 @@ Every `add_*` returns the new layer's **id** and accepts style keyword arguments inline (`m.add_geojson(url, name="Roads", strokeColor="#ef4444", strokeWidth=3)`). +`add_ee_layer` accepts an authenticated Earth Engine Image, ImageCollection, +FeatureCollection, Feature, or Geometry. Initialize the Earth Engine Python API +before calling it; ImageCollections are mosaicked and vector objects are styled +into raster tiles using `vis_params`. + ### Symbology without precomputing ```python From 87f36d17f3f0161a9f53fd267f9afd6df9b17823 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 24 Aug 2026 07:19:59 -0400 Subject: [PATCH 2/6] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add_ee_layer: classify Earth Engine types before the duck-typed `getMapId` fallback. `ee.ImageCollection`, `ee.FeatureCollection` and `ee.Feature` all expose `getMapId`, so real objects skipped the mosaic/style step entirely — collections were never mosaicked and vector styling (`width`, `fillColor`, `pointSize`) was dropped, since `FeatureCollection.getMapId` honours only `color`. The `getMapId` duck-type is now the fallback for non-`ee` objects. - Update the ImageCollection/FeatureCollection test fakes to expose `getMapId` (asserting it is never called) so the dispatch order is guarded, and add a test for the unsupported-type TypeError. - Include the original error text in the RuntimeError raised when `getMapId()` fails, so quota/vis_params failures are not reported as auth problems, and document `RuntimeError` in the `Raises` section. - python/README.md: note that `add_ee_layer` needs `earthengine-api`, call `ee.Authenticate()` in the quickstart, and mention map-id expiry. - docs/python.md: sync the docs-site copy — add the `add_ee_layer` row and the Earth Engine quickstart example with the map-id expiry caveat. - skills/geolibre/references/python-api.md: document that the stored tile URL is tied to an expiring Earth Engine map id. --- docs/python.md | 15 ++++++++ python/README.md | 8 +++++ python/src/geolibre/geolibre.py | 46 ++++++++++++++++-------- python/tests/test_map.py | 23 ++++++++++++ skills/geolibre/references/python-api.md | 4 ++- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/docs/python.md b/docs/python.md index 00f151cdb..1dd6ada9e 100644 --- a/docs/python.md +++ b/docs/python.md @@ -57,10 +57,24 @@ m.add_tile_layer( attribution="(c) OpenStreetMap contributors", ) m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain") + +# Earth Engine layers need `pip install earthengine-api` and credentials +# (run `ee.Authenticate()` once, if you have not already). +import ee +ee.Authenticate() +ee.Initialize(project="your-google-cloud-project") +m.add_ee_layer(ee.Image("USGS/SRTMGL1_003"), {"min": 0, "max": 3000}, name="SRTM") + m.add_basemap("dark") m.set_center(-120, 47, zoom=8) ``` +`add_ee_layer` evaluates the Earth Engine object in the kernel and adds the +resulting tile URL as a raster layer (ImageCollections are mosaicked, vector +objects are styled into raster tiles with `vis_params`). That URL is tied to an +Earth Engine map id that expires, so a saved project may need the Earth Engine +layer regenerated when it is reopened. + `add_raster` / `add_cog` also accept a **local** GeoTIFF path on the kernel host: the file is served by the bundled localhost server so the app can read it. This only works where the **browser can reach the kernel's localhost** (local Jupyter, @@ -240,6 +254,7 @@ m.on_layer_change(lambda e: print("layers", e["layerIds"])) | `add_vector_tiles(url, name=, source_layers=, source_layer=, **style)` | Add a vector tile layer from a TileJSON endpoint. | | `add_pmtiles(url, name=, tile_type=, source_layers=, **style)` | Add a PMTiles archive (vector or raster). | | `add_tile_layer(url, name=, tile_size=, attribution=)` | Add a raster XYZ tile layer. | +| `add_ee_layer(ee_object, vis_params=, name=, shown=, opacity=)` | Add an authenticated Google Earth Engine object as raster tiles (needs `earthengine-api`). | | `add_wms(endpoint, layers, name=, styles=, image_format=, transparent=, tile_size=, **style)` | Add a WMS layer (GetMap, tiled raster). | | `add_wmts(url, name=, tile_size=, **style)` | Add a WMTS layer from a tile URL template. | | `add_wfs(endpoint, type_name, name=, version=, output_format=, srs_name=, max_features=, **style)` | Add a WFS layer (GetFeature GeoJSON, fetched and inlined). | diff --git a/python/README.md b/python/README.md index 2630dc313..55a041163 100644 --- a/python/README.md +++ b/python/README.md @@ -45,7 +45,10 @@ m.add_tile_layer( ) m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain") +# Earth Engine layers need `pip install earthengine-api` and credentials +# (run `ee.Authenticate()` once, if you have not already). import ee +ee.Authenticate() ee.Initialize(project="your-google-cloud-project") m.add_ee_layer(ee.Image("USGS/SRTMGL1_003"), {"min": 0, "max": 3000}, name="SRTM") m.add_basemap("dark") @@ -188,6 +191,11 @@ anywhere untrusted, or use `Map.save_project`, which redacts by default. (works in the running server with no restart where it is installed). On other remote servers (Binder, remote JupyterLab), pass `Map(server_proxy=True)` to use that same remote path; `Map(server_proxy=False)` forces the direct path. +- `add_ee_layer` needs the Earth Engine Python API, which is **not** a + dependency of this package: `pip install earthengine-api`. Authenticate once + with `ee.Authenticate()` and initialize with `ee.Initialize(project=...)` + before adding a layer. The generated tile URL is tied to an Earth Engine map + id that expires, so a saved project may need the layer regenerated later. - Optional extras: `pip install "geolibre[all]"` adds GeoPandas/Shapely support for `add_geojson(geodataframe)` and for reading **local** vector files (`add_vector`/`add_geoparquet`/`add_flatgeobuf`/`add_shp`/`add_kml`/`add_gpkg`), diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 7a60322fd..9db040b79 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -1667,6 +1667,9 @@ def add_ee_layer( TypeError: If ``ee_object`` is not a supported Earth Engine object. ValueError: If Earth Engine returns no usable tile URL or opacity is outside the range 0--1. + RuntimeError: If Earth Engine fails to create map tiles (for + example when it is not initialized, or the request is + rejected). Note: The generated tile URL is tied to the Earth Engine map ID. A saved @@ -1684,15 +1687,22 @@ def add_ee_layer( map_object = ee_object map_params = params - if not callable(getattr(map_object, "getMapId", None)): - try: - import ee - except ImportError as exc: - raise ImportError( - "Adding this Earth Engine object requires the `earthengine-api` " - "package. Install it with `pip install earthengine-api`." - ) from exc - + try: + import ee + except ImportError: + ee = None + + # Earth Engine types are classified *before* the duck-typed + # ``getMapId`` fallback: ``ee.ImageCollection``, ``ee.FeatureCollection`` + # and ``ee.Feature`` all expose ``getMapId`` themselves, so a + # ``getMapId``-first check would silently skip the mosaic/style step and + # drop every vector option except ``color``. + ee_types = ( + (ee.Image, ee.ImageCollection, ee.FeatureCollection, ee.Feature, ee.Geometry) + if ee is not None + else () + ) + if ee is not None and isinstance(map_object, ee_types): if isinstance(map_object, ee.ImageCollection): map_object = map_object.mosaic() elif isinstance(map_object, (ee.FeatureCollection, ee.Feature, ee.Geometry)): @@ -1710,18 +1720,24 @@ def add_ee_layer( } map_object = map_object.style(**vector_style) map_params = {} - elif not isinstance(map_object, ee.Image): - raise TypeError( - "ee_object must be an Earth Engine Image, ImageCollection, " - "FeatureCollection, Feature, or Geometry" + elif not callable(getattr(map_object, "getMapId", None)): + if ee is None: + raise ImportError( + "Adding this Earth Engine object requires the `earthengine-api` " + "package. Install it with `pip install earthengine-api`." ) + raise TypeError( + "ee_object must be an Earth Engine Image, ImageCollection, " + "FeatureCollection, Feature, or Geometry" + ) try: map_id = map_object.getMapId(map_params) except Exception as exc: raise RuntimeError( - "Earth Engine could not create map tiles. Authenticate and initialize " - "Earth Engine before calling add_ee_layer()." + f"Earth Engine could not create map tiles: {exc}. Authenticate and " + "initialize Earth Engine before calling add_ee_layer(), and check " + "that vis_params are valid for this object." ) from exc tile_fetcher = map_id.get("tile_fetcher") if isinstance(map_id, dict) else None diff --git a/python/tests/test_map.py b/python/tests/test_map.py index 3d76a53a1..a5dc2a431 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -133,6 +133,11 @@ def getMapId(self, vis_params): return {"tile_fetcher": TileFetcher()} class ImageCollection: + # The real ee.ImageCollection exposes getMapId, so dispatch must not + # take a duck-typed shortcut past mosaic(). + def getMapId(self, _vis_params): # pragma: no cover - must not be called + raise AssertionError("ImageCollection.getMapId must not be called") + def mosaic(self): return Image() @@ -148,6 +153,19 @@ def mosaic(self): assert _last_layer(m)["source"]["tiles"] == [TileFetcher.url_format] +def test_add_ee_layer_rejects_unsupported_object(monkeypatch, m): + fake_ee = types.SimpleNamespace( + Image=type("Image", (), {}), + ImageCollection=type("ImageCollection", (), {}), + FeatureCollection=type("FeatureCollection", (), {}), + Feature=type("Feature", (), {}), + Geometry=type("Geometry", (), {}), + ) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + with pytest.raises(TypeError, match="ee_object must be"): + m.add_ee_layer(object()) + + def test_add_ee_layer_styles_feature_collection(monkeypatch, m): captured = {} @@ -160,6 +178,11 @@ def getMapId(self, vis_params): return {"tile_fetcher": TileFetcher()} class FeatureCollection: + # The real ee.FeatureCollection exposes getMapId, but it honours only + # `color`; styling must run so width/fillColor/pointSize survive. + def getMapId(self, _vis_params): # pragma: no cover - must not be called + raise AssertionError("FeatureCollection.getMapId must not be called") + def style(self, **style): captured["style"] = style return Image() diff --git a/skills/geolibre/references/python-api.md b/skills/geolibre/references/python-api.md index 2356d029a..ff215b291 100644 --- a/skills/geolibre/references/python-api.md +++ b/skills/geolibre/references/python-api.md @@ -66,7 +66,9 @@ strokeWidth=3)`). `add_ee_layer` accepts an authenticated Earth Engine Image, ImageCollection, FeatureCollection, Feature, or Geometry. Initialize the Earth Engine Python API before calling it; ImageCollections are mosaicked and vector objects are styled -into raster tiles using `vis_params`. +into raster tiles using `vis_params`. The stored tile URL is tied to an Earth +Engine map id that expires, so a project loaded later may need the Earth Engine +layer regenerated. ### Symbology without precomputing From baefaacd41150900221e5c551af0b0cb91690461 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 24 Aug 2026 07:25:39 -0400 Subject: [PATCH 3/6] Address Claude review feedback - Validate vector `vis_params` against the keys `ee.FeatureCollection.style()` actually declares (it takes explicit keywords, not `**kwargs`), so an image-shaped `{"min": ..., "max": ...}` on a FeatureCollection raises a ValueError naming the accepted keys instead of a raw `TypeError: style() got an unexpected keyword argument 'min'` that collides with the documented meaning of TypeError. - Wrap `mosaic()`/`style()` in the same failure handling as `getMapId()`, so a preparation failure surfaces as the documented RuntimeError; document the vector key set on `vis_params` and widen the Raises entries. - Move the Earth Engine snippet out of the primary quickstart block in python/README.md and docs/python.md into its own clearly-optional example, so copy-pasting the quickstart no longer runs a blocking `ee.Authenticate()` or requires earthengine-api. - Note the vector-only style keys in the agent skill reference. - Tests for both new paths (rejected image vis_params, wrapped mosaic failure). --- docs/python.md | 23 ++++--- python/README.md | 14 ++-- python/src/geolibre/geolibre.py | 83 +++++++++++++++++------- python/tests/test_map.py | 34 ++++++++++ skills/geolibre/references/python-api.md | 9 ++- 5 files changed, 123 insertions(+), 40 deletions(-) diff --git a/docs/python.md b/docs/python.md index 1dd6ada9e..1785144b7 100644 --- a/docs/python.md +++ b/docs/python.md @@ -57,23 +57,28 @@ m.add_tile_layer( attribution="(c) OpenStreetMap contributors", ) m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain") +m.add_basemap("dark") +m.set_center(-120, 47, zoom=8) +``` -# Earth Engine layers need `pip install earthengine-api` and credentials -# (run `ee.Authenticate()` once, if you have not already). +Google Earth Engine layers are optional and need `pip install earthengine-api` +plus credentials (`ee.Authenticate()` once, then a Google Cloud project): + +```python import ee -ee.Authenticate() + +ee.Authenticate() # once per machine ee.Initialize(project="your-google-cloud-project") m.add_ee_layer(ee.Image("USGS/SRTMGL1_003"), {"min": 0, "max": 3000}, name="SRTM") - -m.add_basemap("dark") -m.set_center(-120, 47, zoom=8) ``` `add_ee_layer` evaluates the Earth Engine object in the kernel and adds the resulting tile URL as a raster layer (ImageCollections are mosaicked, vector -objects are styled into raster tiles with `vis_params`). That URL is tied to an -Earth Engine map id that expires, so a saved project may need the Earth Engine -layer regenerated when it is reopened. +objects are styled into raster tiles — for those, `vis_params` takes +`ee.FeatureCollection.style()` keys such as `color`, `fillColor`, `width`, and +`pointSize`, not image keys). That URL is tied to an Earth Engine map id that +expires, so a saved project may need the Earth Engine layer regenerated when it +is reopened. `add_raster` / `add_cog` also accept a **local** GeoTIFF path on the kernel host: the file is served by the bundled localhost server so the app can read it. This diff --git a/python/README.md b/python/README.md index 55a041163..3e8765131 100644 --- a/python/README.md +++ b/python/README.md @@ -44,15 +44,19 @@ m.add_tile_layer( attribution="(c) OpenStreetMap contributors", ) m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain") +m.add_basemap("dark") +m.set_center(-120, 47, zoom=8) +``` + +Google Earth Engine layers are optional and need `pip install earthengine-api` +plus credentials (`ee.Authenticate()` once, then a Google Cloud project): -# Earth Engine layers need `pip install earthengine-api` and credentials -# (run `ee.Authenticate()` once, if you have not already). +```python import ee -ee.Authenticate() + +ee.Authenticate() # once per machine ee.Initialize(project="your-google-cloud-project") m.add_ee_layer(ee.Image("USGS/SRTMGL1_003"), {"min": 0, "max": 3000}, name="SRTM") -m.add_basemap("dark") -m.set_center(-120, 47, zoom=8) ``` Round-trip the project: diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 9db040b79..ad4f7310a 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -41,6 +41,24 @@ # same 50 MB ceiling applies to a fetched response or a local file. _MAX_TABULAR_BYTES = _project._MAX_GEOJSON_BYTES +# ``ee.FeatureCollection.style()`` is declared with explicit keyword parameters, +# not ``**kwargs``, so an image-shaped ``vis_params`` (``min``/``max``/``palette``) +# would reach it as ``TypeError: style() got an unexpected keyword argument`` -- +# indistinguishable, to the caller, from the ``TypeError`` add_ee_layer raises for +# an unsupported object. Validate against the accepted keys instead. +_EE_VECTOR_STYLE_KEYS = frozenset( + { + "color", + "pointSize", + "pointShape", + "width", + "fillColor", + "styleProperty", + "neighborhood", + "lineType", + } +) + # Column name for CSV fields beyond the header row. csv.DictReader's default # restkey is ``None``, which would put a non-string key in the feature # properties and break JSON serialization on the way to the widget. @@ -1653,7 +1671,11 @@ def add_ee_layer( ``ee.FeatureCollection``, ``ee.Feature``, or ``ee.Geometry``. A compatible object exposing ``getMapId`` is also accepted. vis_params: Earth Engine visualization parameters, such as - ``bands``, ``min``, ``max``, and ``palette``. + ``bands``, ``min``, ``max``, and ``palette``. For vector + objects these are ``ee.FeatureCollection.style()`` keys + instead (``color``, ``fillColor``, ``width``, ``pointSize``, + ``pointShape``, ``lineType``, ``styleProperty``, + ``neighborhood``). name: Layer display name. shown: Whether the layer is initially visible. opacity: Initial opacity between 0 and 1. @@ -1665,11 +1687,12 @@ def add_ee_layer( ImportError: If conversion requires the optional Earth Engine Python package and it is not installed. TypeError: If ``ee_object`` is not a supported Earth Engine object. - ValueError: If Earth Engine returns no usable tile URL or opacity - is outside the range 0--1. - RuntimeError: If Earth Engine fails to create map tiles (for - example when it is not initialized, or the request is - rejected). + ValueError: If Earth Engine returns no usable tile URL, opacity is + outside the range 0--1, or ``vis_params`` carries a key + ``ee.FeatureCollection.style()`` does not accept. + RuntimeError: If Earth Engine fails to prepare the object or to + create map tiles (for example when it is not initialized, or + the request is rejected). Note: The generated tile URL is tied to the Earth Engine map ID. A saved @@ -1703,23 +1726,37 @@ def add_ee_layer( else () ) if ee is not None and isinstance(map_object, ee_types): - if isinstance(map_object, ee.ImageCollection): - map_object = map_object.mosaic() - elif isinstance(map_object, (ee.FeatureCollection, ee.Feature, ee.Geometry)): - if isinstance(map_object, ee.Geometry): - map_object = ee.Feature(map_object) - if isinstance(map_object, ee.Feature): - map_object = ee.FeatureCollection([map_object]) - vector_style = { - "color": "000000", - "fillColor": "00000000", - "width": 2, - "pointSize": 3, - "pointShape": "circle", - **params, - } - map_object = map_object.style(**vector_style) - map_params = {} + is_vector = isinstance(map_object, (ee.FeatureCollection, ee.Feature, ee.Geometry)) + if is_vector: + unsupported = sorted(set(params) - _EE_VECTOR_STYLE_KEYS) + if unsupported: + raise ValueError( + "vis_params for an Earth Engine FeatureCollection, Feature, or " + f"Geometry may only contain {sorted(_EE_VECTOR_STYLE_KEYS)}; got " + f"{unsupported}" + ) + try: + if isinstance(map_object, ee.ImageCollection): + map_object = map_object.mosaic() + elif is_vector: + if isinstance(map_object, ee.Geometry): + map_object = ee.Feature(map_object) + if isinstance(map_object, ee.Feature): + map_object = ee.FeatureCollection([map_object]) + vector_style = { + "color": "000000", + "fillColor": "00000000", + "width": 2, + "pointSize": 3, + "pointShape": "circle", + **params, + } + map_object = map_object.style(**vector_style) + map_params = {} + except Exception as exc: + raise RuntimeError( + f"Earth Engine could not prepare this object for display: {exc}" + ) from exc elif not callable(getattr(map_object, "getMapId", None)): if ee is None: raise ImportError( diff --git a/python/tests/test_map.py b/python/tests/test_map.py index a5dc2a431..5d7b643c8 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -202,6 +202,40 @@ def style(self, **style): assert captured["map_params"] == {} +def test_add_ee_layer_rejects_image_vis_params_on_vector(monkeypatch, m): + class FeatureCollection: + def style(self, **_style): # pragma: no cover - must not be reached + raise AssertionError("style() must not be called with bad vis_params") + + fake_ee = types.SimpleNamespace( + Image=type("Image", (), {}), + ImageCollection=type("ImageCollection", (), {}), + FeatureCollection=FeatureCollection, + Feature=type("Feature", (), {}), + Geometry=type("Geometry", (), {}), + ) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + with pytest.raises(ValueError, match="may only contain"): + m.add_ee_layer(FeatureCollection(), {"min": 0, "max": 3000}) + + +def test_add_ee_layer_wraps_preparation_errors(monkeypatch, m): + class ImageCollection: + def mosaic(self): + raise RuntimeError("collection is empty") + + fake_ee = types.SimpleNamespace( + Image=type("Image", (), {}), + ImageCollection=ImageCollection, + FeatureCollection=type("FeatureCollection", (), {}), + Feature=type("Feature", (), {}), + Geometry=type("Geometry", (), {}), + ) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + with pytest.raises(RuntimeError, match="could not prepare this object"): + m.add_ee_layer(ImageCollection()) + + def test_add_raster_is_cog(m): m.add_raster("https://e/dem.tif", bands=[1, 2, 3]) layer = _last_layer(m) diff --git a/skills/geolibre/references/python-api.md b/skills/geolibre/references/python-api.md index ff215b291..32f7de49e 100644 --- a/skills/geolibre/references/python-api.md +++ b/skills/geolibre/references/python-api.md @@ -66,9 +66,12 @@ strokeWidth=3)`). `add_ee_layer` accepts an authenticated Earth Engine Image, ImageCollection, FeatureCollection, Feature, or Geometry. Initialize the Earth Engine Python API before calling it; ImageCollections are mosaicked and vector objects are styled -into raster tiles using `vis_params`. The stored tile URL is tied to an Earth -Engine map id that expires, so a project loaded later may need the Earth Engine -layer regenerated. +into raster tiles — for a FeatureCollection/Feature/Geometry, `vis_params` takes +`ee.FeatureCollection.style()` keys (`color`, `fillColor`, `width`, `pointSize`, +`pointShape`, `lineType`, `styleProperty`, `neighborhood`), not image keys like +`min`/`max`/`palette`. The stored tile URL is tied to an Earth Engine map id +that expires, so a project loaded later may need the Earth Engine layer +regenerated. ### Symbology without precomputing From bc5f435fee438c5cac2f0a2b803aebe4e988ba89 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 24 Aug 2026 07:26:53 -0400 Subject: [PATCH 4/6] Address review feedback - Test the ee.Feature and ee.Geometry conversion chain in add_ee_layer: a Geometry is wrapped into a Feature and a Feature into a single-element FeatureCollection before style() runs, and the styled image is fetched with empty map params. Those two branches were previously unexercised. --- python/tests/test_map.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/python/tests/test_map.py b/python/tests/test_map.py index 5d7b643c8..88bcc5f03 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -202,6 +202,73 @@ def style(self, **style): assert captured["map_params"] == {} +def _fake_vector_ee(captured): + """Fake `ee` module whose vector types record the conversion chain.""" + + class TileFetcher: + url_format = "https://earthengine.googleapis.com/maps/vector/tiles/{z}/{x}/{y}" + + class Image: + def getMapId(self, vis_params): + captured["map_params"] = vis_params + return {"tile_fetcher": TileFetcher()} + + class Geometry: + pass + + class Feature: + def __init__(self, geometry=None): + captured["feature_from"] = geometry + + class FeatureCollection: + def __init__(self, features=None): + captured["collection_from"] = features + + def style(self, **style): + captured["style"] = style + return Image() + + fake_ee = types.SimpleNamespace( + Image=Image, + ImageCollection=type("ImageCollection", (), {}), + FeatureCollection=FeatureCollection, + Feature=Feature, + Geometry=Geometry, + ) + return fake_ee, TileFetcher.url_format + + +def test_add_ee_layer_wraps_feature_in_collection(monkeypatch, m): + captured = {} + fake_ee, url = _fake_vector_ee(captured) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + + feature = fake_ee.Feature() + m.add_ee_layer(feature, {"color": "00ff00"}) + + assert captured["collection_from"] == [feature] + assert captured["style"]["color"] == "00ff00" + assert captured["style"]["pointSize"] == 3 + assert captured["map_params"] == {} + assert _last_layer(m)["source"]["tiles"] == [url] + + +def test_add_ee_layer_wraps_geometry_in_feature_and_collection(monkeypatch, m): + captured = {} + fake_ee, url = _fake_vector_ee(captured) + monkeypatch.setitem(sys.modules, "ee", fake_ee) + + geometry = fake_ee.Geometry() + m.add_ee_layer(geometry, {"width": 5}) + + assert captured["feature_from"] is geometry + assert isinstance(captured["collection_from"][0], fake_ee.Feature) + assert captured["style"]["width"] == 5 + assert captured["style"]["fillColor"] == "00000000" + assert captured["map_params"] == {} + assert _last_layer(m)["source"]["tiles"] == [url] + + def test_add_ee_layer_rejects_image_vis_params_on_vector(monkeypatch, m): class FeatureCollection: def style(self, **_style): # pragma: no cover - must not be reached From aa4491b703e1c276905f177dedae089541c54d71 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 24 Aug 2026 07:33:03 -0400 Subject: [PATCH 5/6] Address Claude review feedback - Validate that `vis_params` is a mapping, so a list or string raises the method's own TypeError instead of a raw `dict()` error. - Document that `add_ee_layer` produces a plain raster tile layer, not one of the live layers the app's Earth Engine panel manages (that panel matches on `metadata.sourceKind === "earth-engine-raster"`). This is intentional: the Python path evaluates the object kernel-side into a static tile URL and has no browser-side control to sync with. --- docs/python.md | 3 ++- python/src/geolibre/geolibre.py | 12 ++++++++++-- python/tests/test_map.py | 5 +++++ skills/geolibre/references/python-api.md | 3 ++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/python.md b/docs/python.md index 1785144b7..efd697658 100644 --- a/docs/python.md +++ b/docs/python.md @@ -78,7 +78,8 @@ objects are styled into raster tiles — for those, `vis_params` takes `ee.FeatureCollection.style()` keys such as `color`, `fillColor`, `width`, and `pointSize`, not image keys). That URL is tied to an Earth Engine map id that expires, so a saved project may need the Earth Engine layer regenerated when it -is reopened. +is reopened. The result is a plain raster tile layer, not one of the live layers +the app's own Earth Engine panel manages. `add_raster` / `add_cog` also accept a **local** GeoTIFF path on the kernel host: the file is served by the bundled localhost server so the app can read it. This diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index ad4f7310a..9bdb358b9 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -16,7 +16,7 @@ import urllib.parse import uuid import warnings -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any from urllib.error import URLError @@ -1686,7 +1686,8 @@ def add_ee_layer( Raises: ImportError: If conversion requires the optional Earth Engine Python package and it is not installed. - TypeError: If ``ee_object`` is not a supported Earth Engine object. + TypeError: If ``ee_object`` is not a supported Earth Engine object, + or ``vis_params`` is not a mapping. ValueError: If Earth Engine returns no usable tile URL, opacity is outside the range 0--1, or ``vis_params`` carries a key ``ee.FeatureCollection.style()`` does not accept. @@ -1698,6 +1699,11 @@ def add_ee_layer( The generated tile URL is tied to the Earth Engine map ID. A saved project may need the layer to be regenerated after that map ID expires. + + The layer is a plain raster tile layer, not one of the live layers + the app's own Earth Engine panel manages, so it is listed and + styled like any other tile layer rather than appearing in that + panel. """ try: opacity_value = float(opacity) @@ -1706,6 +1712,8 @@ def add_ee_layer( if not math.isfinite(opacity_value) or not 0 <= opacity_value <= 1: raise ValueError("opacity must be a finite number between 0 and 1") + if vis_params is not None and not isinstance(vis_params, Mapping): + raise TypeError("vis_params must be a mapping of Earth Engine visualization keys") params = dict(vis_params or {}) map_object = ee_object map_params = params diff --git a/python/tests/test_map.py b/python/tests/test_map.py index 88bcc5f03..45f3183d2 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -202,6 +202,11 @@ def style(self, **style): assert captured["map_params"] == {} +def test_add_ee_layer_rejects_non_mapping_vis_params(m): + with pytest.raises(TypeError, match="vis_params must be a mapping"): + m.add_ee_layer(object(), ["min", "max"]) + + def _fake_vector_ee(captured): """Fake `ee` module whose vector types record the conversion chain.""" diff --git a/skills/geolibre/references/python-api.md b/skills/geolibre/references/python-api.md index 32f7de49e..33e143fc5 100644 --- a/skills/geolibre/references/python-api.md +++ b/skills/geolibre/references/python-api.md @@ -71,7 +71,8 @@ into raster tiles — for a FeatureCollection/Feature/Geometry, `vis_params` tak `pointShape`, `lineType`, `styleProperty`, `neighborhood`), not image keys like `min`/`max`/`palette`. The stored tile URL is tied to an Earth Engine map id that expires, so a project loaded later may need the Earth Engine layer -regenerated. +regenerated. The result is a plain raster tile layer, not one of the live +layers the app's own Earth Engine panel manages. ### Symbology without precomputing From 6baf72c87cc53cb0cf6371bbb4e9deba31bdf306 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 24 Aug 2026 08:21:01 -0400 Subject: [PATCH 6/6] Address CodeRabbit review feedback - Parameterize the non-mapping `vis_params` test over a list, a string, and an int, so every shape the documented TypeError covers is exercised. --- python/tests/test_map.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/tests/test_map.py b/python/tests/test_map.py index 45f3183d2..e37902679 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -202,9 +202,10 @@ def style(self, **style): assert captured["map_params"] == {} -def test_add_ee_layer_rejects_non_mapping_vis_params(m): +@pytest.mark.parametrize("vis_params", [["min", "max"], "min", 3]) +def test_add_ee_layer_rejects_non_mapping_vis_params(m, vis_params): with pytest.raises(TypeError, match="vis_params must be a mapping"): - m.add_ee_layer(object(), ["min", "max"]) + m.add_ee_layer(object(), vis_params) def _fake_vector_ee(captured):