Repository: geopandas/geopandas

Issue:
BUG: InvalidVersion error when writing a file on disk with engine=fiona
- [x] I have checked that this issue has not already been reported.
- [x] I have confirmed this bug exists on the latest version of geopandas.

Issue alrerady reported in [esri forum](https://community.esri.com/t5/arcgis-pro-questions/arcgispro-py39-gdal-version-3-7-0e-is-recognized/m-p/1364021) and [stack overflow](https://stackoverflow.com/questions/75994612/invalidversion-error-returned-when-writting-an-esri-shp-file-geopandas-geodata).

---



#### Code Sample, a copy-pastable example

```python
import geopandas as gpd
from shapely.geometry import Point

p = Point(0, 1)
gdf = gpd.GeoDataFrame(geometry=[p], crs=4326)
gdf.to_file("path/to/file.shp")
```

or

```python
from packaging.version import Version, InvalidVersion
import fiona
from fiona import Env as fiona_env
with fiona_env():
    gdal_version = fiona.env.get_gdal_release_name()
    Version(gdal_version)
```

#### Problem description

Impossible to write a vector on disk with `fiona`'s engine.
![297467541-64b4f5ee-c0e9-4fc2-8d04-9ea856659cc6](https://github.com/geopandas/geopandas/assets/67311115/6680b4c9-d16d-4086-b17a-bec371e76a07)

1. When writing a file on disk, `geopandas` checks `GDAL` versions through `fiona` using the `Version` package
2. `fiona` outputs a `GDAL` version not handled by `Version` (`3.7.0e` instead of `3.7.0`)
3. `geopandas` doesn't handle such exception (`InvalidVersion`) and fails


#### Expected Output

Handle the `InvalidVersion` error in this part of the code.

#### Output of ``geopandas.show_versions()``

<details>

```
import geopandas
geopandas.show_versions()
SYSTEM INFO
-----------
python     : 3.9.18 [MSC v.1931 64 bit (AMD64)]
executable : C:\Users\rbraun\Anaconda3\envs\arcgispro-eo\python.exe
machine    : Windows-10-10.0.19045-SP0
GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.11.2
GEOS lib   : None
GDAL       : 3.7.0e
GDAL data dir: None
PROJ       : 9.2.0
PROJ data dir: C:\Users\rbraun\Anaconda3\envs\arcgispro-eo\Library\share\proj
PYTHON DEPENDENCIES
-------------------
geopandas  : 0.14.2
numpy      : 1.22.4
pandas     : 2.0.2
pyproj     : 3.6.0
shapely    : 2.0.1
fiona      : 1.8.21
geoalchemy2: None
geopy      : 2.4.1
matplotlib : 3.6.3
mapclassify: 2.5.0
pygeos     : None
pyogrio    : 0.6.0
psycopg2   : None
pyarrow    : 14.0.2
rtree      : 1.1.0
```

</details>

#### Workaround

The workaround is to write the file on disk using pyogrio as stated [here](https://geopandas.org/en/stable/docs/user_guide/io.html#reading-spatial-data):

```python
geopandas.options.io_engine = "pyogrio"
```

Discussion:
remi-braun: This seems to work 💪 

Thank your for doing support and maintenance for Esri 🙄 

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2151b6f0..9d3e00f2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,6 +10,6 @@ repos:
           - id: black
             language_version: python3
     - repo: https://github.com/astral-sh/ruff-pre-commit
-      rev: "v0.0.271"
+      rev: "v0.1.13"
       hooks:
         - id: ruff
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fadd1dd8..71c888cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ New methods:
 - Added `contains_properly` method from shapely to GeoSeries/GeoDataframe (#3105).
 - Added `snap` method from shapely to GeoSeries/GeoDataframe (#3086).
 - Added `transform` method from shapely to GeoSeries/GeoDataFrame (#3075).
+- Added `to_geo_dict` method to generate GeoJSON-like dictionary from a GeoDataFrame (#3132).
 
 New features and improvements:
 
@@ -41,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
@@ -51,9 +55,9 @@ Bug fixes:
 
 Deprecations and compatibility notes:
 
-- The deprecation of `geopandas.datasets` has been enforced and the module has been 
-  removed. New sample datasets are now available in the 
-  [geodatasets](https://geodatasets.readthedocs.io/en/latest/) package (#3084). 
+- The deprecation of `geopandas.datasets` has been enforced and the module has been
+  removed. New sample datasets are now available in the
+  [geodatasets](https://geodatasets.readthedocs.io/en/latest/) package (#3084).
 
 ## Version 0.14.2 (Jan 4, 2024)
 
diff --git a/doc/source/docs/reference/geodataframe.rst b/doc/source/docs/reference/geodataframe.rst
index d6a8090a..8a7c33d2 100644
--- a/doc/source/docs/reference/geodataframe.rst
+++ b/doc/source/docs/reference/geodataframe.rst
@@ -24,6 +24,7 @@ Serialization / IO / conversion
    GeoDataFrame.from_postgis
    GeoDataFrame.to_file
    GeoDataFrame.to_json
+   GeoDataFrame.to_geo_dict
    GeoDataFrame.to_parquet
    GeoDataFrame.to_feather
    GeoDataFrame.to_postgis
diff --git a/geopandas/array.py b/geopandas/array.py
index ef726feb..7486b85d 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -1578,7 +1578,7 @@ class GeometryArray(ExtensionArray):
     def _reduce(self, name, skipna=True, **kwargs):
         # including the base class version here (that raises by default)
         # because this was not yet defined in pandas 0.23
-        if name == "any" or name == "all":
+        if name in ("any", "all"):
             return getattr(to_shapely(self), name)()
         raise TypeError(
             f"'{type(self).__name__}' with dtype {self.dtype} "
diff --git a/geopandas/explore.py b/geopandas/explore.py
index c01d313a..9457dce2 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -624,7 +624,7 @@ def _explore(
     for feature in feature_collection["features"]:
         for k in feature["properties"]:
             # escape the curly braces in values
-            if type(feature["properties"][k]) == str:
+            if isinstance(feature["properties"][k], str):
                 feature["properties"][k] = re.sub(
                     r"\{{2,}",
                     lambda x: "{% raw %}" + x.group(0) + "{% endraw %}",
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 751d4814..285499d3 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -825,7 +825,7 @@ es": {"name": "urn:ogc:def:crs:EPSG::3857"}}}'
         else:
             df = self
 
-        geo = df._to_geo(na=na, show_bbox=show_bbox, drop_id=drop_id)
+        geo = df.to_geo_dict(na=na, show_bbox=show_bbox, drop_id=drop_id)
 
         # if the geometry is not in WGS84, include CRS in the JSON
         if df.crs is not None and not df.crs.equals("epsg:4326"):
@@ -853,7 +853,7 @@ es": {"name": "urn:ogc:def:crs:EPSG::3857"}}}'
         represents the ``GeoDataFrame`` as a GeoJSON-like
         ``FeatureCollection``.
 
-        This differs from `_to_geo()` only in that it is a property with
+        This differs from :meth:`to_geo_dict` only in that it is a property with
         default args instead of a method.
 
         CRS of the dataframe is not passed on to the output, unlike
@@ -877,7 +877,7 @@ es": {"name": "urn:ogc:def:crs:EPSG::3857"}}}'
 ': {'col1': 'name2'}, 'geometry': {'type': 'Point', 'coordinates': (2.0, 1.0)}, 'b\
 box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
         """
-        return self._to_geo(na="null", show_bbox=True, drop_id=False)
+        return self.to_geo_dict(na="null", show_bbox=True, drop_id=False)
 
     def iterfeatures(self, na="null", show_bbox=False, drop_id=False):
         """
@@ -923,8 +923,8 @@ individually so that features may have different properties
 
         if self._geometry_column_name not in self:
             raise AttributeError(
-                "No geometry data set (expected in"
-                " column '%s')." % self._geometry_column_name
+                "No geometry data set (expected in column '%s')."
+                % self._geometry_column_name
             )
 
         ids = np.array(self.index, copy=False)
@@ -987,18 +987,60 @@ individually so that features may have different properties
 
                 yield feature
 
-    def _to_geo(self, **kwargs):
+    def to_geo_dict(self, na="null", show_bbox=False, drop_id=False):
         """
-        Returns a python feature collection (i.e. the geointerface)
-        representation of the GeoDataFrame.
+        Returns a python feature collection representation of the GeoDataFrame
+        as a dictionary with a list of features based on the ``__geo_interface__``
+        GeoJSON-like specification.
+
+        Parameters
+        ----------
+        na : str, optional
+            Options are {'null', 'drop', 'keep'}, default 'null'.
+            Indicates how to output missing (NaN) values in the GeoDataFrame
+
+            - null: output the missing entries as JSON null
+            - drop: remove the property from the feature. This applies to each feature \
+individually so that features may have different properties
+            - keep: output the missing entries as NaN
+
+        show_bbox : bool, optional
+            Include bbox (bounds) in the geojson. Default False.
+        drop_id : bool, default: False
+            Whether to retain the index of the GeoDataFrame as the id property
+            in the generated dictionary. Default is False, but may want True
+            if the index is just arbitrary row numbers.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Point
+        >>> d = {'col1': ['name1', 'name2'], 'geometry': [Point(1, 2), Point(2, 1)]}
+        >>> gdf = geopandas.GeoDataFrame(d)
+        >>> gdf
+            col1     geometry
+        0  name1  POINT (1 2)
+        1  name2  POINT (2 1)
+
+        >>> gdf.to_geo_dict()
+        {'type': 'FeatureCollection', 'features': [{'id': '0', 'type': 'Feature', '\
+properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.0, \
+2.0)}}, {'id': '1', 'type': 'Feature', 'properties': {'col1': 'name2'}, 'geometry':\
+ {'type': 'Point', 'coordinates': (2.0, 1.0)}}]}
+
+        See also
+        --------
+        GeoDataFrame.to_json : return a GeoDataFrame as a GeoJSON string
 
         """
         geo = {
             "type": "FeatureCollection",
-            "features": list(self.iterfeatures(**kwargs)),
+            "features": list(
+                self.iterfeatures(na=na, show_bbox=show_bbox, drop_id=drop_id)
+            ),
         }
 
-        if kwargs.get("show_bbox", False):
+        if show_bbox:
             geo["bbox"] = tuple(self.total_bounds)
 
         return geo
@@ -1098,7 +1140,7 @@ individually so that features may have different properties
         engine = kwargs.pop("engine", "auto")
         if engine not in ("auto", "pyarrow"):
             raise ValueError(
-                f"GeoPandas only supports using pyarrow as the engine for "
+                "GeoPandas only supports using pyarrow as the engine for "
                 f"to_parquet: {engine!r} passed instead."
             )
 
@@ -1641,8 +1683,8 @@ individually so that features may have different properties
                 raise ValueError(
                     "Concat operation has resulted in multiple columns using "
                     f"the geometry column name '{self._geometry_column_name}'.\n"
-                    f"Please ensure this column from the first DataFrame is not "
-                    f"repeated."
+                    "Please ensure this column from the first DataFrame is not "
+                    "repeated."
                 )
         elif method == "unstack":
             # unstack adds multiindex columns and reshapes data.
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 961107c2..c4634882 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -682,7 +682,7 @@ class GeoSeries(GeoPandasBase, Series):
         return self._wrapped_pandas_method("select", *args, **kwargs)
 
     @doc(pd.Series)
-    def apply(self, func, convert_dtype: bool = None, args=(), **kwargs):
+    def apply(self, func, convert_dtype: Optional[bool] = None, args=(), **kwargs):
         if convert_dtype is not None:
             kwargs["convert_dtype"] = convert_dtype
         else:
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 98fe7f8a..eaf44ce1 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.
@@ -400,15 +408,35 @@ def _read_file_fiona(
                     f_filt, crs=crs, columns=columns + ["geometry"]
                 )
             for k in datetime_fields:
-                as_dt = pd.to_datetime(df[k], errors="ignore")
-                # if to_datetime failed, try again for mixed timezone offsets
-                if as_dt.dtype == "object":
+                as_dt = None
+                # plain try catch for when pandas will raise in the future
+                # TODO we can tighten the exception type in future when it does
+                try:
+                    with warnings.catch_warnings():
+                        # pandas 2.x does not yet enforce this behaviour but raises a
+                        # warning  -> we want to to suppress this warning for our users,
+                        # and do this by turning it into an error so we take the
+                        # `except` code path to try again with utc=True
+                        warnings.filterwarnings(
+                            "error",
+                            "In a future version of pandas, parsing datetimes with "
+                            "mixed time zones will raise an error",
+                            FutureWarning,
+                        )
+                        as_dt = pd.to_datetime(df[k])
+                except Exception:
+                    pass
+                if as_dt is None or as_dt.dtype == "object":
+                    # if to_datetime failed, try again for mixed timezone offsets
                     # This can still fail if there are invalid datetimes
-                    as_dt = pd.to_datetime(df[k], errors="ignore", utc=True)
+                    try:
+                        as_dt = pd.to_datetime(df[k], utc=True)
+                    except Exception:
+                        pass
                 # if to_datetime succeeded, round datetimes as
                 # fiona only supports up to ms precision (any microseconds are
                 # floating point rounding error)
-                if not (as_dt.dtype == "object"):
+                if as_dt is not None and not (as_dt.dtype == "object"):
                     if PANDAS_GE_20:
                         df[k] = as_dt.dt.as_unit("ms")
                     else:
@@ -435,22 +463,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)
 
 
@@ -635,10 +688,12 @@ def _to_file_fiona(df, filename, driver, schema, crs, mode, **kwargs):
     with fiona_env():
         crs_wkt = None
         try:
-            gdal_version = fiona.env.get_gdal_release_name()
-        except AttributeError:
-            gdal_version = "2.0.0"  # just assume it is not the latest
-        if Version(gdal_version) >= Version("3.0.0") and crs:
+            gdal_version = Version(
+                fiona.env.get_gdal_release_name().strip("e")
+            )  # GH3147
+        except (AttributeError, ValueError):
+            gdal_version = Version("2.0.0")  # just assume it is not the latest
+        if gdal_version >= Version("3.0.0") and crs:
             crs_wkt = crs.to_wkt()
         elif crs:
             crs_wkt = crs.to_wkt("WKT1_GDAL")
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
index f717f47f..71c9f495 100644
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -104,7 +104,7 @@ def sjoin(
             " in a future release. Please use the `predicate` parameter"
             " instead."
         )
-        if predicate != "intersects" and op != predicate:
+        if predicate not in ("intersects", op):
             override_message = (
                 "A non-default value for `predicate` was passed"
                 f' (got `predicate="{predicate}"`'
diff --git a/pyproject.toml b/pyproject.toml
index af2cefc2..4a46fe0e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -165,6 +165,8 @@ ignore = [ # space before : (needed for how black formats slicing)
     "PLW0603",
     # Docstrings should not be included in stubs
     "PYI021",
+    # Use `typing.NamedTuple` instead of `collections.namedtuple`
+    "PYI024",
     # No builtin `eval()` allowed
     "PGH001",
     # compare-to-empty-string
@@ -197,6 +199,8 @@ ignore = [ # space before : (needed for how black formats slicing)
     "RUF007",
     # explicit-f-string-type-conversion
     "RUF010",
+    # Mutable class attributes should be annotated with `typing.ClassVar`,
+    "RUF012"
 ]
 extend-exclude = ["doc/*", "benchmarks/*", "versioneer.py", "geopandas/_version.py"]
 


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"
 )
diff --git a/geopandas/io/tests/test_sql.py b/geopandas/io/tests/test_sql.py
index 8a5a7fc5..0d48f5f1 100644
--- a/geopandas/io/tests/test_sql.py
+++ b/geopandas/io/tests/test_sql.py
@@ -444,13 +444,15 @@ class TestIO:
 
         # There should be twice as many rows in the new table
         assert new_rows == orig_rows * 2, (
-            "There should be {target} rows,"
-            "found: {current}".format(target=orig_rows * 2, current=new_rows),
+            "There should be {target} rows,found: {current}".format(
+                target=orig_rows * 2, current=new_rows
+            ),
         )
         # Number of columns should stay the same
         assert new_cols == orig_cols, (
-            "There should be {target} columns,"
-            "found: {current}".format(target=orig_cols, current=new_cols),
+            "There should be {target} columns,found: {current}".format(
+                target=orig_cols, current=new_cols
+            ),
         )
 
     def test_write_postgis_without_crs(self, engine_postgis, df_nybb):
@@ -462,7 +464,6 @@ class TestIO:
         table = "nybb"
 
         # Write to db
-        df_nybb = df_nybb
         df_nybb.crs = None
         with pytest.warns(UserWarning, match="Could not parse CRS from the GeoDataF"):
             write_postgis(df_nybb, con=engine, name=table, if_exists="replace")
diff --git a/geopandas/testing.py b/geopandas/testing.py
index a0874ef5..f3cb7db9 100644
--- a/geopandas/testing.py
+++ b/geopandas/testing.py
@@ -321,7 +321,7 @@ def assert_geodataframe_equal(
     )
 
     if check_like:
-        left, right = left.reindex_like(right), right
+        left = left.reindex_like(right)
 
     # column comparison
     assert_index_equal(
diff --git a/geopandas/tests/test_api.py b/geopandas/tests/test_api.py
index fec7f179..4f8c72c5 100644
--- a/geopandas/tests/test_api.py
+++ b/geopandas/tests/test_api.py
@@ -33,5 +33,5 @@ if mods:
         blacklist
     )
     call = [sys.executable, "-c", code]
-    returncode = subprocess.run(call).returncode
+    returncode = subprocess.run(call, check=False).returncode
     assert returncode == 0
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 85cb3c56..2d707894 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -844,40 +844,40 @@ class TestDataFrame:
         df.loc[0, "BoroName"] = np.nan
         # when containing missing values
         # null: output the missing entries as JSON null
-        result = list(df.iterfeatures(na="null"))[0]["properties"]
+        result = next(iter(df.iterfeatures(na="null")))["properties"]
         assert result["BoroName"] is None
         # drop: remove the property from the feature.
-        result = list(df.iterfeatures(na="drop"))[0]["properties"]
+        result = next(iter(df.iterfeatures(na="drop")))["properties"]
         assert "BoroName" not in result.keys()
         # keep: output the missing entries as NaN
-        result = list(df.iterfeatures(na="keep"))[0]["properties"]
+        result = next(iter(df.iterfeatures(na="keep")))["properties"]
         assert np.isnan(result["BoroName"])
 
         # test for checking that the (non-null) features are python scalars and
         # not numpy scalars
         assert type(df.loc[0, "Shape_Leng"]) is np.float64
         # null
-        result = list(df.iterfeatures(na="null"))[0]
-        assert type(result["properties"]["Shape_Leng"]) is float
+        result = next(iter(df.iterfeatures(na="null")))
+        assert isinstance(result["properties"]["Shape_Leng"], float)
         # drop
-        result = list(df.iterfeatures(na="drop"))[0]
-        assert type(result["properties"]["Shape_Leng"]) is float
+        result = next(iter(df.iterfeatures(na="drop")))
+        assert isinstance(result["properties"]["Shape_Leng"], float)
         # keep
-        result = list(df.iterfeatures(na="keep"))[0]
-        assert type(result["properties"]["Shape_Leng"]) is float
+        result = next(iter(df.iterfeatures(na="keep")))
+        assert isinstance(result["properties"]["Shape_Leng"], float)
 
         # when only having numerical columns
         df_only_numerical_cols = df[["Shape_Leng", "Shape_Area", "geometry"]]
         assert type(df_only_numerical_cols.loc[0, "Shape_Leng"]) is np.float64
         # null
-        result = list(df_only_numerical_cols.iterfeatures(na="null"))[0]
-        assert type(result["properties"]["Shape_Leng"]) is float
+        result = next(iter(df_only_numerical_cols.iterfeatures(na="null")))
+        assert isinstance(result["properties"]["Shape_Leng"], float)
         # drop
-        result = list(df_only_numerical_cols.iterfeatures(na="drop"))[0]
-        assert type(result["properties"]["Shape_Leng"]) is float
+        result = next(iter(df_only_numerical_cols.iterfeatures(na="drop")))
+        assert isinstance(result["properties"]["Shape_Leng"], float)
         # keep
-        result = list(df_only_numerical_cols.iterfeatures(na="keep"))[0]
-        assert type(result["properties"]["Shape_Leng"]) is float
+        result = next(iter(df_only_numerical_cols.iterfeatures(na="keep")))
+        assert isinstance(result["properties"]["Shape_Leng"], float)
 
         with pytest.raises(
             ValueError, match="GeoDataFrame cannot contain duplicated column names."
@@ -899,25 +899,25 @@ class TestDataFrame:
         )
         # null
         expected = {"non-scalar": [1, 2], "test_col": None}
-        result = list(df.iterfeatures(na="null"))[0].get("properties")
+        result = next(iter(df.iterfeatures(na="null"))).get("properties")
         assert expected == result
         # drop
         expected = {"non-scalar": [1, 2]}
-        result = list(df.iterfeatures(na="drop"))[0].get("properties")
+        result = next(iter(df.iterfeatures(na="drop"))).get("properties")
         assert expected == result
         # keep
         expected = {"non-scalar": [1, 2], "test_col": None}
-        result = list(df.iterfeatures(na="keep"))[0].get("properties")
+        result = next(iter(df.iterfeatures(na="keep"))).get("properties")
         assert expected == result
 
     def test_geodataframe_geojson_no_bbox(self):
-        geo = self.df._to_geo(na="null", show_bbox=False)
+        geo = self.df.to_geo_dict(na="null", show_bbox=False)
         assert "bbox" not in geo.keys()
         for feature in geo["features"]:
             assert "bbox" not in feature.keys()
 
     def test_geodataframe_geojson_bbox(self):
-        geo = self.df._to_geo(na="null", show_bbox=True)
+        geo = self.df.to_geo_dict(na="null", show_bbox=True)
         assert "bbox" in geo.keys()
         assert len(geo["bbox"]) == 4
         assert isinstance(geo["bbox"], tuple)