From 7d170f82926d69a9c66088d843fbf0f3cf1c922b Mon Sep 17 00:00:00 2001 From: Chris Lyons Date: Mon, 20 Apr 2026 08:56:01 -0400 Subject: [PATCH 1/2] test(integration): migrate to SearchResult API and fix Phase 1 LAZ bbox The v2.0.0 refactor changed `abovepy.search()` to return a SearchResult workflow object instead of a bare GeoDataFrame, but the integration suite still accessed `.columns` and `.iloc` directly. Route those through SearchResult.tiles (the underlying GeoDataFrame). Also switch test_laz_products to pike_county_bbox. Phase 1 LiDAR was flown county-by-county 2010-2017 and Franklin County (Frankfort) was not in that batch, so the Frankfort fixture cannot return Phase 1 LAZ tiles. Pike County has coverage for all three phases. Fixes 10 of 11 failures in the weekly scheduled integration run on main. --- tests/test_integration.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index b75271f..9bb4e4e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -36,8 +36,8 @@ def test_search_dem_phase3_frankfort(self, frankfort_bbox): """Search returns DEM tiles for the Frankfort area.""" tiles = abovepy.search(bbox=frankfort_bbox, product="dem_phase3", max_items=10) assert len(tiles) > 0 - assert "tile_id" in tiles.columns - assert "asset_url" in tiles.columns + assert "tile_id" in tiles.tiles.columns + assert "asset_url" in tiles.tiles.columns def test_search_by_county(self): """County-based search returns results.""" @@ -59,7 +59,7 @@ def test_asset_urls_are_accessible(self, frankfort_bbox): import httpx tiles = abovepy.search(bbox=frankfort_bbox, product="dem_phase3", max_items=1) - url = tiles.iloc[0]["asset_url"] + url = tiles.tiles.iloc[0]["asset_url"] resp = httpx.head(url, follow_redirects=True, timeout=30) assert resp.status_code == 200 @@ -90,7 +90,7 @@ def test_dem_products(self, frankfort_bbox, product): """DEM products return tiles for Frankfort area.""" tiles = abovepy.search(bbox=frankfort_bbox, product=product, max_items=3) assert len(tiles) > 0 - assert tiles.iloc[0]["product"] == product + assert tiles.tiles.iloc[0]["product"] == product @pytest.mark.parametrize( "product", @@ -113,9 +113,11 @@ def test_ortho_products(self, frankfort_bbox, product): "laz_phase3", ], ) - def test_laz_products(self, frankfort_bbox, product): - """LiDAR products return tiles for Frankfort area.""" - tiles = abovepy.search(bbox=frankfort_bbox, product=product, max_items=3) + def test_laz_products(self, pike_county_bbox, product): + """LiDAR products return tiles. Uses Pike County because Phase 1 LAZ + does not cover Franklin County (Frankfort); Phase 1 was flown + county-by-county 2010-2017 and Franklin was not in that batch.""" + tiles = abovepy.search(bbox=pike_county_bbox, product=product, max_items=3) assert len(tiles) > 0 @@ -198,7 +200,7 @@ class TestLiveRead: def test_read_cog_windowed(self, frankfort_bbox): """Read a real tile with a windowed bbox.""" tiles = abovepy.search(bbox=frankfort_bbox, product="dem_phase3", max_items=1) - url = tiles.iloc[0]["asset_url"] + url = tiles.tiles.iloc[0]["asset_url"] data, profile = abovepy.read(url, bbox=frankfort_bbox) assert data.shape[0] >= 1 assert profile["crs"] is not None @@ -207,7 +209,7 @@ def test_read_cog_windowed(self, frankfort_bbox): def test_read_full_tile(self, frankfort_bbox): """Read a full tile without bbox clipping.""" tiles = abovepy.search(bbox=frankfort_bbox, product="dem_phase3", max_items=1) - url = tiles.iloc[0]["asset_url"] + url = tiles.tiles.iloc[0]["asset_url"] data, profile = abovepy.read(url) assert data.shape[1] > 0 assert data.shape[2] > 0 @@ -216,7 +218,7 @@ def test_read_full_tile(self, frankfort_bbox): def test_read_returns_epsg3089(self, frankfort_bbox): """Read tile CRS should be EPSG:3089.""" tiles = abovepy.search(bbox=frankfort_bbox, product="dem_phase3", max_items=1) - url = tiles.iloc[0]["asset_url"] + url = tiles.tiles.iloc[0]["asset_url"] _, profile = abovepy.read(url) crs_str = str(profile["crs"]) assert "3089" in crs_str @@ -253,6 +255,10 @@ def test_download_skip_existing(self, frankfort_bbox): @pytest.mark.slow def test_mosaic_vrt(self, frankfort_bbox): """Download 2 tiles, mosaic to VRT, verify it's readable.""" + pytest.importorskip( + "osgeo", + reason="VRT construction requires GDAL Python bindings (not a default runtime dep).", + ) tiles = abovepy.search(bbox=frankfort_bbox, product="dem_phase3", max_items=2) if len(tiles) < 2: pytest.skip("Need at least 2 tiles for mosaic test") @@ -308,6 +314,6 @@ def test_laz_tile_url_accessible(self, frankfort_bbox): tiles = abovepy.search(bbox=frankfort_bbox, product="laz_phase2", max_items=1) if tiles.empty: pytest.skip("No COPC tiles found in Frankfort area") - url = tiles.iloc[0]["asset_url"] + url = tiles.tiles.iloc[0]["asset_url"] resp = httpx.head(url, follow_redirects=True, timeout=30) assert resp.status_code == 200 From f8c72f6d8def44605bfbe60c28d046a57764c234 Mon Sep 17 00:00:00 2001 From: Chris Lyons Date: Mon, 20 Apr 2026 08:56:08 -0400 Subject: [PATCH 2/2] fix(mosaic): raise actionable MosaicError when GDAL bindings missing _build_vrt imports `from osgeo import gdal`, which is not bundled with rasterio and not in any optional-dependency group. On systems without the GDAL Python bindings (including the CI integration runner) the import would fail with a bare ModuleNotFoundError. Wrap the import and raise MosaicError with install guidance instead. The `.tif` path through `_merge_tiles` does not need GDAL bindings, so the message points users there as a fallback. --- src/abovepy/_mosaic.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/abovepy/_mosaic.py b/src/abovepy/_mosaic.py index 2a451ed..e0c9bd2 100644 --- a/src/abovepy/_mosaic.py +++ b/src/abovepy/_mosaic.py @@ -103,7 +103,17 @@ def _build_vrt( Path Path to the created VRT file. """ - from osgeo import gdal + try: + from osgeo import gdal + except ImportError as exc: + from abovepy._exceptions import MosaicError + + raise MosaicError( + "VRT construction requires the GDAL Python bindings, which are not " + "installed by default. Install with `conda install -c conda-forge gdal` " + "(recommended) or `pip install gdal` (requires matching system GDAL headers). " + "Alternatively, pass an output path ending in `.tif` to merge via rasterio." + ) from exc gdal.UseExceptions()