Repository: geopandas/geopandas

Issue:
ENH: Implement `mask` argument for `read_file` with pyogrio backend
#### Is your feature request related to a problem?

I wish I could use geopandas to open a file using the pyogrio backend using a mask to filter the incoming data.

#### Describe the solution you'd like

Currently, the `read_file` method raises an error when supplying the pyogrio backend with a mask. This mask functionality has recently been implemented there.

#### API breaking implications

This does not break the API.

#### Describe alternatives you've considered

I have considered using `pyogrio.read_dataframe` directly. This requires me to restructure my own code in a serious way, so I'd like to access this functionality via geopandas.


Discussion:
brendan-ward: I also renamed some of the test cases of bbox / mask for clarity

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9405275e..71c888cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,9 @@ Potentially breaking changes:
   now returns a Pandas DataFrame instead of a GeoDataFrame with an empty
   ``geometry`` column.
 
+API changes:
+- Added support for ``mask`` keyword for pyogrio engine for pyogrio >= 0.7.0 (#3062).
+
 Bug fixes:
 
 - Fix `GeoDataFrame.merge()` incorrectly returning a `DataFrame` instead of a
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 98fe7f8a..8b34965e 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -1,3 +1,4 @@
+from io import IOBase
 import os
 from packaging.version import Version
 from pathlib import Path
@@ -8,6 +9,7 @@ import pandas as pd
 from pandas.api.types import is_integer_dtype
 
 import pyproj
+import shapely
 from shapely.geometry import mapping
 from shapely.geometry.base import BaseGeometry
 
@@ -63,15 +65,20 @@ def _import_fiona():
 
 pyogrio = None
 pyogrio_import_error = None
+PYOGRIO_GE_07 = False
 
 
 def _import_pyogrio():
     global pyogrio
     global pyogrio_import_error
+    global PYOGRIO_GE_07
 
     if pyogrio is None:
         try:
             import pyogrio
+
+            PYOGRIO_GE_07 = Version(pyogrio.__version__) >= Version("0.7.0")
+
         except ImportError as err:
             pyogrio = False
             pyogrio_import_error = str(err)
@@ -210,7 +217,8 @@ def _read_file(filename, bbox=None, mask=None, rows=None, engine=None, **kwargs)
         Filter for features that intersect with the given dict-like geojson
         geometry, GeoSeries, GeoDataFrame or shapely geometry.
         CRS mis-matches are resolved if given a GeoSeries or GeoDataFrame.
-        Cannot be used with bbox.
+        Cannot be used with bbox. If multiple geometries are passed, this will
+        first union all geometries, which may be computationally expensive.
     rows : int or slice, default None
         Load in specific rows by passing an integer (first `n` rows) or a
         slice() object.
@@ -435,22 +443,47 @@ def _read_file_pyogrio(path_or_bytes, bbox=None, mask=None, rows=None, **kwargs)
                 raise ValueError("slice with step is not supported")
         else:
             raise TypeError("'rows' must be an integer or a slice.")
+
+    if bbox is not None and mask is not None:
+        # match error message from Fiona
+        raise ValueError("mask and bbox can not be set together")
+
     if bbox is not None:
         if isinstance(bbox, (GeoDataFrame, GeoSeries)):
-            bbox = tuple(bbox.total_bounds)
+            crs = pyogrio.read_info(path_or_bytes).get("crs")
+            if isinstance(path_or_bytes, IOBase):
+                path_or_bytes.seek(0)
+
+            bbox = tuple(bbox.to_crs(crs).total_bounds)
         elif isinstance(bbox, BaseGeometry):
             bbox = bbox.bounds
         if len(bbox) != 4:
             raise ValueError("'bbox' should be a length-4 tuple.")
+
     if mask is not None:
-        raise ValueError(
-            "The 'mask' keyword is not supported with the 'pyogrio' engine. "
-            "You can use 'bbox' instead."
-        )
+        # NOTE: mask cannot be used at same time as bbox keyword
+        if not PYOGRIO_GE_07:
+            raise ValueError(
+                "The 'mask' keyword requires pyogrio >= 0.7.0.  "
+                "You can use 'bbox' instead."
+            )
+        if isinstance(mask, (GeoDataFrame, GeoSeries)):
+            crs = pyogrio.read_info(path_or_bytes).get("crs")
+            if isinstance(path_or_bytes, IOBase):
+                path_or_bytes.seek(0)
+
+            mask = shapely.unary_union(mask.to_crs(crs).geometry.values)
+        elif isinstance(mask, BaseGeometry):
+            mask = shapely.unary_union(mask)
+        elif isinstance(mask, dict) or hasattr(mask, "__geo_interface__"):
+            # convert GeoJSON to shapely geometry
+            mask = shapely.geometry.shape(mask)
+
+        kwargs["mask"] = mask
+
     if kwargs.pop("ignore_geometry", False):
         kwargs["read_geometry"] = False
 
-    # TODO: if bbox is not None, check its CRS vs the CRS of the file
     return pyogrio.read_dataframe(path_or_bytes, bbox=bbox, **kwargs)
 
 


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index ccd72bf6..9ca44ae4 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -12,7 +12,7 @@ import pytz
 from packaging.version import Version
 from pandas.api.types import is_datetime64_any_dtype
 from pandas.testing import assert_series_equal, assert_frame_equal
-from shapely.geometry import Point, Polygon, box
+from shapely.geometry import Point, Polygon, box, mapping
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
@@ -64,6 +64,11 @@ def skip_pyogrio_not_supported(engine):
         pytest.skip("not supported for the pyogrio engine")
 
 
+def skip_pyogrio_lt_07(engine):
+    if engine == "pyogrio" and not PYOGRIO_GE_07:
+        pytest.skip("requires pyogrio >= 0.7.0")
+
+
 @pytest.fixture
 def df_nybb(engine, nybb_filename):
     df = read_file(nybb_filename, engine=engine)
@@ -701,7 +706,7 @@ def test_allow_legacy_gdal_path(engine, nybb_filename):
     assert isinstance(gdf, geopandas.GeoDataFrame)
 
 
-def test_read_file_filtered__bbox(df_nybb, engine, nybb_filename):
+def test_read_file_bbox_tuple(df_nybb, engine, nybb_filename):
     bbox = (
         1031051.7879884212,
         224272.49231459625,
@@ -713,7 +718,7 @@ def test_read_file_filtered__bbox(df_nybb, engine, nybb_filename):
     assert_geodataframe_equal(filtered_df, expected.reset_index(drop=True))
 
 
-def test_read_file_filtered__bbox__polygon(df_nybb, engine, nybb_filename):
+def test_read_file_bbox_polygon(df_nybb, engine, nybb_filename):
     bbox = box(
         1031051.7879884212, 224272.49231459625, 1047224.3104931959, 244317.30894023244
     )
@@ -866,7 +871,8 @@ def test_read_file__columns(naturalearth_lowres):
     assert gdf.columns.tolist() == ["name", "pop_est", "geometry"]
 
 
-def test_read_file_filtered_with_gdf_boundary(df_nybb, engine, nybb_filename):
+@pytest.mark.parametrize("file_like", [False, True])
+def test_read_file_bbox_gdf(df_nybb, engine, nybb_filename, file_like):
     full_df_shape = df_nybb.shape
     bbox = geopandas.GeoDataFrame(
         geometry=[
@@ -879,30 +885,41 @@ def test_read_file_filtered_with_gdf_boundary(df_nybb, engine, nybb_filename):
         ],
         crs=NYBB_CRS,
     )
-    filtered_df = read_file(nybb_filename, bbox=bbox, engine=engine)
+    infile = (
+        open(nybb_filename.replace("zip://", ""), "rb") if file_like else nybb_filename
+    )
+    filtered_df = read_file(infile, bbox=bbox, engine=engine)
     filtered_df_shape = filtered_df.shape
     assert full_df_shape != filtered_df_shape
     assert filtered_df_shape == (2, 5)
 
 
-def test_read_file_filtered_with_gdf_boundary__mask(
-    df_nybb, engine, naturalearth_lowres, naturalearth_cities
-):
-    skip_pyogrio_not_supported(engine)
-    gdf_mask = geopandas.read_file(naturalearth_lowres)
-    gdf = geopandas.read_file(
-        naturalearth_cities,
-        mask=gdf_mask[gdf_mask.continent == "Africa"],
-        engine=engine,
+@pytest.mark.parametrize("file_like", [False, True])
+def test_read_file_mask_gdf(df_nybb, engine, nybb_filename, file_like):
+    skip_pyogrio_lt_07(engine)
+    full_df_shape = df_nybb.shape
+    mask = geopandas.GeoDataFrame(
+        geometry=[
+            box(
+                1031051.7879884212,
+                224272.49231459625,
+                1047224.3104931959,
+                244317.30894023244,
+            )
+        ],
+        crs=NYBB_CRS,
+    )
+    infile = (
+        open(nybb_filename.replace("zip://", ""), "rb") if file_like else nybb_filename
     )
-    filtered_df_shape = gdf.shape
-    assert filtered_df_shape == (57, 2)
+    filtered_df = read_file(infile, mask=mask, engine=engine)
+    filtered_df_shape = filtered_df.shape
+    assert full_df_shape != filtered_df_shape
+    assert filtered_df_shape == (2, 5)
 
 
-def test_read_file_filtered_with_gdf_boundary__mask__polygon(
-    df_nybb, engine, nybb_filename
-):
-    skip_pyogrio_not_supported(engine)
+def test_read_file_mask_polygon(df_nybb, engine, nybb_filename):
+    skip_pyogrio_lt_07(engine)
     full_df_shape = df_nybb.shape
     mask = box(
         1031051.7879884212, 224272.49231459625, 1047224.3104931959, 244317.30894023244
@@ -913,10 +930,25 @@ def test_read_file_filtered_with_gdf_boundary__mask__polygon(
     assert filtered_df_shape == (2, 5)
 
 
-def test_read_file_filtered_with_gdf_boundary_mismatched_crs(
-    df_nybb, engine, nybb_filename
-):
-    skip_pyogrio_not_supported(engine)
+def test_read_file_mask_geojson(df_nybb, nybb_filename, engine):
+    skip_pyogrio_lt_07(engine)
+    full_df_shape = df_nybb.shape
+    mask = mapping(
+        box(
+            1031051.7879884212,
+            224272.49231459625,
+            1047224.3104931959,
+            244317.30894023244,
+        )
+    )
+    filtered_df = read_file(nybb_filename, mask=mask, engine=engine)
+    filtered_df_shape = filtered_df.shape
+    assert full_df_shape != filtered_df_shape
+    assert filtered_df_shape == (2, 5)
+
+
+def test_read_file_bbox_gdf_mismatched_crs(df_nybb, engine, nybb_filename):
+    skip_pyogrio_lt_07(engine)
     full_df_shape = df_nybb.shape
     bbox = geopandas.GeoDataFrame(
         geometry=[
@@ -936,10 +968,8 @@ def test_read_file_filtered_with_gdf_boundary_mismatched_crs(
     assert filtered_df_shape == (2, 5)
 
 
-def test_read_file_filtered_with_gdf_boundary_mismatched_crs__mask(
-    df_nybb, engine, nybb_filename
-):
-    skip_pyogrio_not_supported(engine)
+def test_read_file_mask_gdf_mismatched_crs(df_nybb, engine, nybb_filename):
+    skip_pyogrio_lt_07(engine)
     full_df_shape = df_nybb.shape
     mask = geopandas.GeoDataFrame(
         geometry=[
@@ -959,6 +989,22 @@ def test_read_file_filtered_with_gdf_boundary_mismatched_crs__mask(
     assert filtered_df_shape == (2, 5)
 
 
+def test_read_file_bbox_mask_not_allowed(engine, nybb_filename):
+    skip_pyogrio_lt_07(engine)
+
+    bbox = (
+        1031051.7879884212,
+        224272.49231459625,
+        1047224.3104931959,
+        244317.30894023244,
+    )
+
+    mask = box(*bbox)
+
+    with pytest.raises(ValueError, match="mask and bbox can not be set together"):
+        read_file(nybb_filename, bbox=bbox, mask=mask)
+
+
 @pytest.mark.filterwarnings(
     "ignore:Layer 'b'test_empty'' does not have any features:UserWarning"
 )