Repository: geopandas/geopandas
Pull request #3435 (2024-10-05 11:24:12)

DOC: update docstring examples to match GEOS 3.13 outputs


Patch:
diff --git a/.github/workflows/versioneer.yml b/.github/workflows/versioneer.yml
index 26858177..d67e7bed 100644
--- a/.github/workflows/versioneer.yml
+++ b/.github/workflows/versioneer.yml
@@ -33,7 +33,7 @@ jobs:
           git reset -- geopandas/__init__.py
           git checkout -- geopandas/__init__.py
       - name: Create PR
-        uses: peter-evans/create-pull-request@v6
+        uses: peter-evans/create-pull-request@v7
         with:
           title: "Update Versioneer"
           branch: update-versioneer
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dce450c6..66d9621b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,7 +20,11 @@ Bug fixes:
 - Support GeoDataFrame constructor receiving arguments to `geometry` which are not
   (Geo)Series, but instead should be interpreted as column names, like Enums (#3384).
 - Fix regression where constructing a GeoSeries from a pd.Series with GeometryDtype values 
-  failed when `crs` was provided(#3383).
+  failed when `crs` was provided (#3383).
+- Fix regression where `overlay` with `keep_geom_type` returns wrong results if the
+  input contains invalid geometries (#3395).
+- Fix the dtype of the GeometryArray backing data being incorrect for zero length 
+  GeoDataFrames causing errors in `overlay` (3424).
 
 Deprecations and compatibility notes:
 
diff --git a/ci/envs/310-latest-conda-forge_no_pyogrio.yaml b/ci/envs/310-latest-no-pyogrio.yaml
similarity index 100%
rename from ci/envs/310-latest-conda-forge_no_pyogrio.yaml
rename to ci/envs/310-latest-no-pyogrio.yaml
diff --git a/ci/envs/310-pd20-defaults.yaml b/ci/envs/310-pd20-defaults.yaml
deleted file mode 100644
index ebb68f8e..00000000
--- a/ci/envs/310-pd20-defaults.yaml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: test
-channels:
-  - defaults
-dependencies:
-  - python=3.10
-  # required
-  - pandas=2.0
-  - shapely
-  # - pyogrio # comes as 0.5.1
-  - numpy=1.24
-  - pyproj>=3.4.0
-  - geos
-  - packaging
-  # testing
-  - pytest
-  - pytest-cov
-  - pytest-xdist
-  - fsspec
-  - pytz
-  # optional
-  - matplotlib
-  #- geopy
-  - SQLalchemy
-  - libspatialite
-  - pip
-  - pip:
-      - pyogrio
-      - geopy
-      - mapclassify==2.5.0
-      - pyarrow
-      - geodatasets
diff --git a/ci/envs/311-latest-conda-forge.yaml b/ci/envs/311-latest.yaml
similarity index 100%
rename from ci/envs/311-latest-conda-forge.yaml
rename to ci/envs/311-latest.yaml
diff --git a/ci/envs/311-pd22-conda-forge.yaml b/ci/envs/311-pd22.yaml
similarity index 100%
rename from ci/envs/311-pd22-conda-forge.yaml
rename to ci/envs/311-pd22.yaml
diff --git a/ci/envs/312-latest-conda-forge_no_pyproj.yaml b/ci/envs/312-latest-no-pyproj.yaml
similarity index 100%
rename from ci/envs/312-latest-conda-forge_no_pyproj.yaml
rename to ci/envs/312-latest-no-pyproj.yaml
diff --git a/ci/envs/312-latest-conda-forge.yaml b/ci/envs/312-latest.yaml
similarity index 100%
rename from ci/envs/312-latest-conda-forge.yaml
rename to ci/envs/312-latest.yaml
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index c588351c..c449a3e3 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -10,7 +10,6 @@ import shapely.geos
 # pandas compat
 # -----------------------------------------------------------------------------
 
-PANDAS_GE_15 = Version(pd.__version__) >= Version("1.5.0")
 PANDAS_GE_20 = Version(pd.__version__) >= Version("2.0.0")
 PANDAS_GE_202 = Version(pd.__version__) >= Version("2.0.2")
 PANDAS_GE_21 = Version(pd.__version__) >= Version("2.1.0")
diff --git a/geopandas/array.py b/geopandas/array.py
index 7c061179..6bb88ef7 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -40,6 +40,9 @@ _names = {
     "GEOMETRYCOLLECTION": "GeometryCollection",
 }
 
+POLYGON_GEOM_TYPES = {"Polygon", "MultiPolygon"}
+LINE_GEOM_TYPES = {"LineString", "MultiLineString", "LinearRing"}
+POINT_GEOM_TYPES = {"Point", "MultiPoint"}
 
 type_mapping = {p.value: _names[p.name] for p in shapely.GeometryType}
 geometry_type_ids = list(type_mapping.keys())
@@ -159,6 +162,8 @@ def from_shapely(data, crs=None):
     if not isinstance(data, np.ndarray):
         arr = np.empty(len(data), dtype=object)
         arr[:] = data
+    elif len(data) == 0 and data.dtype == "float64":
+        arr = data.astype(object)
     else:
         arr = data
 
diff --git a/geopandas/base.py b/geopandas/base.py
index 97d7e700..6441cd6b 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -1414,7 +1414,7 @@ GeometryCollection
         dtype: geometry
 
         >>> s.offset_curve(1)
-        0    LINESTRING (-1 0, -1 1, -0.981 1.195, -0.924 1...
+        0    LINESTRING (-1 0, -1 1, -1 1.195, -0.9 1.383, ...
         dtype: geometry
         """
         return _delegate_geo_method(
@@ -5698,7 +5698,7 @@ GeometryCollection
 
         >>> s.skew(45, 30, origin=(0, 0))
         0                                    POINT (2 1.57735)
-        1                   LINESTRING (0 -0.42265, 1 0.57735)
+        1         LINESTRING (1.11022e-16 -0.42265, 1 0.57735)
         2    POLYGON ((2 0.73205, 4 2.3094, 4 2.73205, 2 0....
         dtype: geometry
         """
diff --git a/geopandas/explore.py b/geopandas/explore.py
index 155ef5ef..8553d652 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -1,5 +1,4 @@
 import warnings
-from packaging.version import Version
 from statistics import mean
 
 import numpy as np
@@ -293,10 +292,6 @@ def _explore(
         from matplotlib import colormaps as cm
         from matplotlib import colors
 
-        # check for minimum version of folium
-        if Version(folium.__version__) < Version("0.12.0"):
-            raise ImportError
-
     except (ImportError, ModuleNotFoundError):
         raise ImportError(
             "The 'folium>=0.12', 'matplotlib' and 'mapclassify' packages "
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 4e8bc852..e68b2959 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import typing
 import warnings
-from packaging.version import Version
 from typing import Any
 
 import numpy as np
@@ -1445,8 +1444,6 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         ]
 
         """
-        import pyarrow as pa
-
         from geopandas.io._geoarrow import (
             GeoArrowArray,
             construct_geometry_array,
@@ -1456,9 +1453,6 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         field_name = self.name if self.name is not None else ""
 
         if geometry_encoding.lower() == "geoarrow":
-            if Version(pa.__version__) < Version("10.0.0"):
-                raise ValueError("Converting to 'geoarrow' requires pyarrow >= 10.0.")
-
             field, geom_arr = construct_geometry_array(
                 np.array(self.array),
                 include_z=include_z,
diff --git a/geopandas/io/_geoarrow.py b/geopandas/io/_geoarrow.py
index 858bffa9..9b7ec8f2 100644
--- a/geopandas/io/_geoarrow.py
+++ b/geopandas/io/_geoarrow.py
@@ -137,9 +137,6 @@ def geopandas_to_arrow(
     geometry_encoding_dict = {}
 
     if geometry_encoding.lower() == "geoarrow":
-        if Version(pa.__version__) < Version("10.0.0"):
-            raise ValueError("Converting to 'geoarrow' requires pyarrow >= 10.0.")
-
         # Encode all geometry columns to GeoArrow
         for i, col in zip(geometry_indices, geometry_columns):
             field, geom_arr = construct_geometry_array(
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 1dc384c6..f1169cb4 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -481,11 +481,6 @@ def _to_feather(df, path, index=None, compression=None, schema_version=None, **k
     feather = import_optional_dependency(
         "pyarrow.feather", extra="pyarrow is required for Feather support."
     )
-    # TODO move this into `import_optional_dependency`
-    import pyarrow
-
-    if Version(pyarrow.__version__) < Version("0.17.0"):
-        raise ImportError("pyarrow >= 0.17 required for Feather support")
 
     path = _expand_user(path)
     table = _geopandas_to_arrow(df, index=index, schema_version=schema_version)
@@ -568,14 +563,7 @@ def _get_filesystem_path(path, filesystem=None, storage_options=None):
 
     If the filesystem is not None then it's just returned as is.
     """
-    import pyarrow
-
-    if (
-        isinstance(path, str)
-        and storage_options is None
-        and filesystem is None
-        and Version(pyarrow.__version__) >= Version("5.0.0")
-    ):
+    if isinstance(path, str) and storage_options is None and filesystem is None:
         # Use the native pyarrow filesystem if possible.
         try:
             from pyarrow.fs import FileSystem
@@ -841,14 +829,8 @@ def _read_feather(path, columns=None, **kwargs):
     feather = import_optional_dependency(
         "pyarrow.feather", extra="pyarrow is required for Feather support."
     )
-    # TODO move this into `import_optional_dependency`
-    import pyarrow
-
     import geopandas.io._pyarrow_hotfix  # noqa: F401
 
-    if Version(pyarrow.__version__) < Version("0.17.0"):
-        raise ImportError("pyarrow >= 0.17 required for Feather support")
-
     path = _expand_user(path)
 
     table = feather.read_table(path, columns=columns, **kwargs)
diff --git a/geopandas/tools/_random.py b/geopandas/tools/_random.py
index 007d02fa..210c44e3 100644
--- a/geopandas/tools/_random.py
+++ b/geopandas/tools/_random.py
@@ -4,7 +4,12 @@ import numpy
 
 from shapely.geometry import MultiPoint
 
-from geopandas.array import from_shapely, points_from_xy
+from geopandas.array import (
+    LINE_GEOM_TYPES,
+    POLYGON_GEOM_TYPES,
+    from_shapely,
+    points_from_xy,
+)
 from geopandas.geoseries import GeoSeries
 
 
@@ -45,10 +50,10 @@ def uniform(geom, size, rng=None):
     if geom is None or geom.is_empty:
         return MultiPoint()
 
-    if geom.geom_type in ("Polygon", "MultiPolygon"):
+    if geom.geom_type in POLYGON_GEOM_TYPES:
         return _uniform_polygon(geom, size=size, generator=generator)
 
-    if geom.geom_type in ("LineString", "MultiLineString"):
+    if geom.geom_type in LINE_GEOM_TYPES:
         return _uniform_line(geom, size=size, generator=generator)
 
     warn(
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
index 641417a5..d1386f30 100644
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -14,7 +14,13 @@ import pandas.api.types
 from shapely.geometry import MultiPolygon, Polygon, box
 
 from geopandas import GeoDataFrame, GeoSeries
-from geopandas.array import _check_crs, _crs_mismatch_warn
+from geopandas.array import (
+    LINE_GEOM_TYPES,
+    POINT_GEOM_TYPES,
+    POLYGON_GEOM_TYPES,
+    _check_crs,
+    _crs_mismatch_warn,
+)
 
 
 def _mask_is_list_like_rectangle(mask):
@@ -213,25 +219,21 @@ def clip(gdf, mask, keep_geom_type=False, sort=False):
                 stacklevel=2,
             )
         else:
-            polys = ["Polygon", "MultiPolygon"]
-            lines = ["LineString", "MultiLineString", "LinearRing"]
-            points = ["Point", "MultiPoint"]
-
             # Check that the gdf for multiple geom types (points, lines and/or polys)
             orig_types_total = sum(
                 [
-                    gdf.geom_type.isin(polys).any(),
-                    gdf.geom_type.isin(lines).any(),
-                    gdf.geom_type.isin(points).any(),
+                    gdf.geom_type.isin(POLYGON_GEOM_TYPES).any(),
+                    gdf.geom_type.isin(LINE_GEOM_TYPES).any(),
+                    gdf.geom_type.isin(POINT_GEOM_TYPES).any(),
                 ]
             )
 
             # Check how many geometry types are in the clipped GeoDataFrame
             clip_types_total = sum(
                 [
-                    clipped.geom_type.isin(polys).any(),
-                    clipped.geom_type.isin(lines).any(),
-                    clipped.geom_type.isin(points).any(),
+                    clipped.geom_type.isin(POLYGON_GEOM_TYPES).any(),
+                    clipped.geom_type.isin(LINE_GEOM_TYPES).any(),
+                    clipped.geom_type.isin(POINT_GEOM_TYPES).any(),
                 ]
             )
 
@@ -247,9 +249,9 @@ def clip(gdf, mask, keep_geom_type=False, sort=False):
                 orig_type = gdf.geom_type.iloc[0]
                 if new_collection:
                     clipped = clipped.explode(index_parts=False)
-                if orig_type in polys:
-                    clipped = clipped.loc[clipped.geom_type.isin(polys)]
-                elif orig_type in lines:
-                    clipped = clipped.loc[clipped.geom_type.isin(lines)]
+                if orig_type in POLYGON_GEOM_TYPES:
+                    clipped = clipped.loc[clipped.geom_type.isin(POLYGON_GEOM_TYPES)]
+                elif orig_type in LINE_GEOM_TYPES:
+                    clipped = clipped.loc[clipped.geom_type.isin(LINE_GEOM_TYPES)]
 
     return clipped
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index 053ee587..6332d425 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -6,7 +6,13 @@ import pandas as pd
 
 from geopandas import GeoDataFrame, GeoSeries
 from geopandas._compat import PANDAS_GE_30
-from geopandas.array import _check_crs, _crs_mismatch_warn
+from geopandas.array import (
+    LINE_GEOM_TYPES,
+    POINT_GEOM_TYPES,
+    POLYGON_GEOM_TYPES,
+    _check_crs,
+    _crs_mismatch_warn,
+)
 
 
 def _ensure_geometry_column(df):
@@ -39,7 +45,7 @@ def _overlay_intersection(df1, df2):
         right = df2.geometry.take(idx2)
         right.reset_index(drop=True, inplace=True)
         intersections = left.intersection(right)
-        poly_ix = intersections.geom_type.isin(["Polygon", "MultiPolygon"])
+        poly_ix = intersections.geom_type.isin(POLYGON_GEOM_TYPES)
         intersections.loc[poly_ix] = intersections[poly_ix].make_valid()
 
         # only keep actual intersecting geometries
@@ -96,7 +102,7 @@ def _overlay_difference(df1, df2):
         )
         new_g.append(new)
     differences = GeoSeries(new_g, index=df1.index, crs=df1.crs)
-    poly_ix = differences.geom_type.isin(["Polygon", "MultiPolygon"])
+    poly_ix = differences.geom_type.isin(POLYGON_GEOM_TYPES)
     differences.loc[poly_ix] = differences[poly_ix].make_valid()
     geom_diff = differences[~differences.is_empty].copy()
     dfdiff = df1[~differences.is_empty].copy()
@@ -261,13 +267,10 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
     else:
         keep_geom_type_warning = False
 
-    polys = ["Polygon", "MultiPolygon"]
-    lines = ["LineString", "MultiLineString", "LinearRing"]
-    points = ["Point", "MultiPoint"]
     for i, df in enumerate([df1, df2]):
-        poly_check = df.geom_type.isin(polys).any()
-        lines_check = df.geom_type.isin(lines).any()
-        points_check = df.geom_type.isin(points).any()
+        poly_check = df.geom_type.isin(POLYGON_GEOM_TYPES).any()
+        lines_check = df.geom_type.isin(LINE_GEOM_TYPES).any()
+        points_check = df.geom_type.isin(POINT_GEOM_TYPES).any()
         if sum([poly_check, lines_check, points_check]) > 1:
             raise NotImplementedError(f"df{i + 1} contains mixed geometry types.")
 
@@ -292,11 +295,17 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
     # Computations
     def _make_valid(df):
         df = df.copy()
-        if df.geom_type.isin(polys).all():
+        if df.geom_type.isin(POLYGON_GEOM_TYPES).all():
             mask = ~df.geometry.is_valid
             col = df._geometry_column_name
             if make_valid:
                 df.loc[mask, col] = df.loc[mask, col].make_valid()
+                # Extract only the input geometry type, as make_valid may change it
+                if mask.any():
+                    df = _collection_extract(
+                        df, geom_type="Polygon", keep_geom_type_warning=False
+                    )
+
             elif mask.any():
                 raise ValueError(
                     "You have passed make_valid=False along with "
@@ -306,6 +315,10 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
                 )
         return df
 
+    # Determine the geometry type before make_valid, as make_valid may change it
+    if keep_geom_type:
+        geom_type = df1.geom_type.iloc[0]
+
     df1 = _make_valid(df1)
     df2 = _make_valid(df2)
 
@@ -327,67 +340,69 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
             result.drop(["__idx1", "__idx2"], axis=1, inplace=True)
 
     if keep_geom_type:
-        geom_type = df1.geom_type.iloc[0]
+        result = _collection_extract(result, geom_type, keep_geom_type_warning)
 
-        # First we filter the geometry types inside GeometryCollections objects
-        # (e.g. GeometryCollection([polygon, point]) -> polygon)
-        # we do this separately on only the relevant rows, as this is an expensive
-        # operation (an expensive no-op for geometry types other than collections)
-        is_collection = result.geom_type == "GeometryCollection"
-        if is_collection.any():
-            geom_col = result._geometry_column_name
-            collections = result[[geom_col]][is_collection]
-
-            exploded = collections.reset_index(drop=True).explode(index_parts=True)
-            exploded = exploded.reset_index(level=0)
-
-            orig_num_geoms_exploded = exploded.shape[0]
-            if geom_type in polys:
-                exploded.loc[~exploded.geom_type.isin(polys), geom_col] = None
-            elif geom_type in lines:
-                exploded.loc[~exploded.geom_type.isin(lines), geom_col] = None
-            elif geom_type in points:
-                exploded.loc[~exploded.geom_type.isin(points), geom_col] = None
-            else:
-                raise TypeError(f"`keep_geom_type` does not support {geom_type}.")
-            num_dropped_collection = (
-                orig_num_geoms_exploded - exploded.geometry.isna().sum()
-            )
+    result.reset_index(drop=True, inplace=True)
+    return result
 
-            # level_0 created with above reset_index operation
-            # and represents the original geometry collections
-            # TODO avoiding dissolve to call union_all in this case could further
-            # improve performance (we only need to collect geometries in their
-            # respective Multi version)
-            dissolved = exploded.dissolve(by="level_0")
-            result.loc[is_collection, geom_col] = dissolved[geom_col].values
-        else:
-            num_dropped_collection = 0
-
-        # Now we filter all geometries (in theory we don't need to do this
-        # again for the rows handled above for GeometryCollections, but filtering
-        # them out is probably more expensive as simply including them when this
-        # is typically about only a few rows)
-        orig_num_geoms = result.shape[0]
-        if geom_type in polys:
-            result = result.loc[result.geom_type.isin(polys)]
-        elif geom_type in lines:
-            result = result.loc[result.geom_type.isin(lines)]
-        elif geom_type in points:
-            result = result.loc[result.geom_type.isin(points)]
-        else:
-            raise TypeError(f"`keep_geom_type` does not support {geom_type}.")
-        num_dropped = orig_num_geoms - result.shape[0]
-
-        if (num_dropped > 0 or num_dropped_collection > 0) and keep_geom_type_warning:
-            warnings.warn(
-                "`keep_geom_type=True` in overlay resulted in "
-                f"{num_dropped + num_dropped_collection} dropped geometries of "
-                "different geometry types than df1 has. Set `keep_geom_type=False` to "
-                "retain all geometries",
-                UserWarning,
-                stacklevel=2,
-            )
 
-    result.reset_index(drop=True, inplace=True)
+def _collection_extract(df, geom_type, keep_geom_type_warning):
+    # Check input
+    if geom_type in POLYGON_GEOM_TYPES:
+        geom_types = POLYGON_GEOM_TYPES
+    elif geom_type in LINE_GEOM_TYPES:
+        geom_types = LINE_GEOM_TYPES
+    elif geom_type in POINT_GEOM_TYPES:
+        geom_types = POINT_GEOM_TYPES
+    else:
+        raise TypeError(f"`geom_type` does not support {geom_type}.")
+
+    result = df.copy()
+
+    # First we filter the geometry types inside GeometryCollections objects
+    # (e.g. GeometryCollection([polygon, point]) -> polygon)
+    # we do this separately on only the relevant rows, as this is an expensive
+    # operation (an expensive no-op for geometry types other than collections)
+    is_collection = result.geom_type == "GeometryCollection"
+    if is_collection.any():
+        geom_col = result._geometry_column_name
+        collections = result.loc[is_collection, [geom_col]]
+
+        exploded = collections.reset_index(drop=True).explode(index_parts=True)
+        exploded = exploded.reset_index(level=0)
+
+        orig_num_geoms_exploded = exploded.shape[0]
+        exploded.loc[~exploded.geom_type.isin(geom_types), geom_col] = None
+        num_dropped_collection = (
+            orig_num_geoms_exploded - exploded.geometry.isna().sum()
+        )
+
+        # level_0 created with above reset_index operation
+        # and represents the original geometry collections
+        # TODO avoiding dissolve to call union_all in this case could further
+        # improve performance (we only need to collect geometries in their
+        # respective Multi version)
+        dissolved = exploded.dissolve(by="level_0")
+        result.loc[is_collection, geom_col] = dissolved[geom_col].values
+    else:
+        num_dropped_collection = 0
+
+    # Now we filter all geometries (in theory we don't need to do this
+    # again for the rows handled above for GeometryCollections, but filtering
+    # them out is probably more expensive as simply including them when this
+    # is typically about only a few rows)
+    orig_num_geoms = result.shape[0]
+    result = result.loc[result.geom_type.isin(geom_types)]
+    num_dropped = orig_num_geoms - result.shape[0]
+
+    if (num_dropped > 0 or num_dropped_collection > 0) and keep_geom_type_warning:
+        warnings.warn(
+            "`keep_geom_type=True` in overlay resulted in "
+            f"{num_dropped + num_dropped_collection} dropped geometries of different "
+            "geometry types than df1 has. Set `keep_geom_type=False` to retain all "
+            "geometries",
+            UserWarning,
+            stacklevel=2,
+        )
+
     return result


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 670c7090..36f3a6e6 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -41,28 +41,26 @@ jobs:
         env:
           - ci/envs/310-minimal.yaml
           - ci/envs/310-no-optional-deps.yaml
-          - ci/envs/310-pd20-defaults.yaml
-          - ci/envs/311-latest-defaults.yaml
-          - ci/envs/310-pd21-conda-forge.yaml
-          - ci/envs/310-latest-conda-forge_no_pyogrio.yaml
-          - ci/envs/311-pd22-conda-forge.yaml
-          - ci/envs/311-latest-conda-forge.yaml
-          - ci/envs/312-latest-conda-forge.yaml
-          - ci/envs/312-latest-conda-forge_no_pyproj.yaml
+          - ci/envs/310-pd21.yaml
+          - ci/envs/310-latest-no-pyogrio.yaml
+          - ci/envs/311-pd22.yaml
+          - ci/envs/311-latest.yaml
+          - ci/envs/312-latest.yaml
+          - ci/envs/312-latest-no-pyproj.yaml
         include:
-          - env: ci/envs/310-latest-conda-forge_no_pyogrio.yaml
+          - env: ci/envs/310-latest-no-pyogrio.yaml
             os: macos-latest
             postgis: false
             dev: false
-          - env: ci/envs/311-latest-conda-forge.yaml
+          - env: ci/envs/311-latest.yaml
             os: macos-latest
             postgis: false
             dev: false
-          - env: ci/envs/310-latest-conda-forge_no_pyogrio.yaml
+          - env: ci/envs/310-latest-no-pyogrio.yaml
             os: windows-latest
             postgis: false
             dev: false
-          - env: ci/envs/311-latest-conda-forge.yaml
+          - env: ci/envs/311-latest.yaml
             os: windows-latest
             postgis: false
             dev: false
@@ -74,7 +72,7 @@ jobs:
       - uses: actions/checkout@v4
 
       - name: Install Conda environment with Micromamba
-        uses: mamba-org/setup-micromamba@v1
+        uses: mamba-org/setup-micromamba@v2
         with:
           environment-file: ${{ matrix.env }}
 
@@ -90,7 +88,7 @@ jobs:
           pytest -v -r a -n auto --color=yes --cov=geopandas --cov-append --cov-report term-missing --cov-report xml geopandas/
 
       - name: Test with PostGIS
-        if: (contains(matrix.env, '311-pd22-conda-forge.yaml') || contains(matrix.env, '312-latest-conda-forge.yaml'))
+        if: (contains(matrix.env, '311-pd22.yaml') || contains(matrix.env, '312-latest.yaml'))
           && contains(matrix.os, 'ubuntu')
         env:
           PGUSER: postgres
@@ -104,7 +102,7 @@ jobs:
           pytest -v -r a --color=yes --cov=geopandas --cov-append --cov-report term-missing --cov-report xml geopandas/io/tests/test_sql.py | tee /dev/stderr | if grep SKIPPED >/dev/null;then echo "TESTS SKIPPED, FAILING" && exit 1;fi
 
       - name: Test docstrings
-        if: contains(matrix.env, '311-pd22-conda-forge.yaml') && contains(matrix.os, 'ubuntu')
+        if: contains(matrix.env, '311-pd22.yaml') && contains(matrix.os, 'ubuntu')
         run: |
           pytest -v --color=yes --doctest-only geopandas --ignore=geopandas/datasets
 
diff --git a/ci/envs/310-pd21-conda-forge.yaml b/ci/envs/310-pd21.yaml
similarity index 100%
rename from ci/envs/310-pd21-conda-forge.yaml
rename to ci/envs/310-pd21.yaml
diff --git a/ci/envs/311-latest-defaults.yaml b/ci/envs/311-latest-defaults.yaml
deleted file mode 100644
index aafd2744..00000000
--- a/ci/envs/311-latest-defaults.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: test
-channels:
-  - defaults
-dependencies:
-  - python=3.11
-  # required
-  - numpy
-  - pandas
-  - shapely
-  # - pyogrio # comes as 0.5.1
-  - pyproj>=3.4.0
-  - geos
-  - packaging
-  # testing
-  - pytest
-  - pytest-cov
-  - pytest-xdist
-  - fsspec
-  - pytz
-  # optional
-  - matplotlib
-  #- geopy
-  - SQLalchemy
-  - libspatialite
-  - pip
-  - pip:
-      - pyogrio
-      - geopy
-      - mapclassify
-      - pyarrow
-      - folium
-      - xyzservices
-      - geodatasets
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 00254e36..8c73a4a9 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -45,18 +45,7 @@ import pyarrow.parquet as pq
 from pyarrow import feather
 
 
-@pytest.fixture(
-    params=[
-        "parquet",
-        pytest.param(
-            "feather",
-            marks=pytest.mark.skipif(
-                Version(pyarrow.__version__) < Version("0.17.0"),
-                reason="needs pyarrow >= 0.17",
-            ),
-        ),
-    ]
-)
+@pytest.fixture(params=["parquet", pytest.param("feather")])
 def file_format(request):
     if request.param == "parquet":
         return read_parquet, GeoDataFrame.to_parquet
@@ -487,10 +476,6 @@ def test_parquet_compression(compression, tmpdir, naturalearth_lowres):
     assert_geodataframe_equal(df, pq_df)
 
 
-@pytest.mark.skipif(
-    Version(pyarrow.__version__) < Version("0.17.0"),
-    reason="Feather only supported for pyarrow >= 0.17",
-)
 @pytest.mark.parametrize("compression", ["uncompressed", "lz4", "zstd"])
 def test_feather_compression(compression, tmpdir, naturalearth_lowres):
     """Using compression options should not raise errors, and should
@@ -705,20 +690,6 @@ def test_default_geo_col_writes(tmp_path):
     assert_frame_equal(df, pq_df)
 
 
-@pytest.mark.skipif(
-    Version(pyarrow.__version__) >= Version("0.17.0"),
-    reason="Feather only supported for pyarrow >= 0.17",
-)
-def test_feather_arrow_version(tmpdir, naturalearth_lowres):
-    df = read_file(naturalearth_lowres)
-    filename = os.path.join(str(tmpdir), "test.feather")
-
-    with pytest.raises(
-        ImportError, match="pyarrow >= 0.17 required for Feather support"
-    ):
-        df.to_feather(filename)
-
-
 def test_fsspec_url(naturalearth_lowres):
     _ = pytest.importorskip("fsspec")
     import fsspec.implementations.memory
@@ -755,10 +726,6 @@ def test_non_fsspec_url_with_storage_options_raises(naturalearth_lowres):
         read_parquet(naturalearth_lowres, storage_options={"foo": "bar"})
 
 
-@pytest.mark.skipif(
-    Version(pyarrow.__version__) < Version("5.0.0"),
-    reason="pyarrow.fs requires pyarrow>=5.0.0",
-)
 def test_prefers_pyarrow_fs():
     filesystem, _ = _get_filesystem_path("file:///data.parquet")
     assert isinstance(filesystem, pyarrow.fs.LocalFileSystem)
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index bc95e3e4..fa02fab6 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -5,7 +5,7 @@ import pandas as pd
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_15, PANDAS_GE_20, PANDAS_GE_30
+from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20, PANDAS_GE_30
 
 import pytest
 from geopandas.testing import assert_geodataframe_equal, geom_almost_equals
@@ -81,40 +81,16 @@ def test_first_dissolve(nybb_polydf, first):
 
 
 def test_mean_dissolve(nybb_polydf, first, expected_mean):
-    if not PANDAS_GE_15:
-        test = nybb_polydf.dissolve("manhattan_bronx", aggfunc="mean")
-        test2 = nybb_polydf.dissolve("manhattan_bronx", aggfunc=np.mean)
-    elif PANDAS_GE_15 and not PANDAS_GE_20:
-        with pytest.warns(FutureWarning, match=".*used in dissolve is deprecated.*"):
-            test = nybb_polydf.dissolve("manhattan_bronx", aggfunc="mean")
-            test2 = nybb_polydf.dissolve("manhattan_bronx", aggfunc=np.mean)
-    else:  # pandas 2.0
-        test = nybb_polydf.dissolve(
-            "manhattan_bronx", aggfunc="mean", numeric_only=True
-        )
-        # for non pandas "mean", numeric only cannot be applied. Drop columns manually
-        test2 = nybb_polydf.drop(columns=["BoroName"]).dissolve(
-            "manhattan_bronx", aggfunc="mean"
-        )
+    test = nybb_polydf.dissolve("manhattan_bronx", aggfunc="mean", numeric_only=True)
+    # for non pandas "mean", numeric only cannot be applied. Drop columns manually
+    test2 = nybb_polydf.drop(columns=["BoroName"]).dissolve(
+        "manhattan_bronx", aggfunc="mean"
+    )
 
     assert_frame_equal(expected_mean, test, check_column_type=False)
     assert_frame_equal(expected_mean, test2, check_column_type=False)
 
 
-@pytest.mark.skipif(not PANDAS_GE_15 or PANDAS_GE_20, reason="warning for pandas 1.5.x")
-def test_mean_dissolve_warning_capture(nybb_polydf, first, expected_mean):
-    with pytest.warns(
-        FutureWarning,
-        match=".*used in dissolve is deprecated.*",
-    ):
-        nybb_polydf.dissolve("manhattan_bronx", aggfunc="mean")
-
-    # test no warning for aggfunc first which doesn't have numeric only semantics
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")
-        nybb_polydf.dissolve("manhattan_bronx", aggfunc="first")
-
-
 def test_dissolve_emits_other_warnings(nybb_polydf):
     # we only do something special for pandas 1.5.x, but expect this
     # test to be true on any version
diff --git a/geopandas/tests/test_extension_array.py b/geopandas/tests/test_extension_array.py
index 74a0ddf9..6b31166a 100644
--- a/geopandas/tests/test_extension_array.py
+++ b/geopandas/tests/test_extension_array.py
@@ -25,7 +25,7 @@ from pandas.tests.extension import base as extension_tests
 import shapely.geometry
 from shapely.geometry import Point
 
-from geopandas._compat import PANDAS_GE_15, PANDAS_GE_21, PANDAS_GE_22
+from geopandas._compat import PANDAS_GE_21, PANDAS_GE_22
 from geopandas.array import GeometryArray, GeometryDtype, from_shapely
 
 import pytest
@@ -557,16 +557,10 @@ class TestComparisonOps(extension_tests.BaseComparisonOpsTests):
 
 
 class TestMethods(extension_tests.BaseMethodsTests):
-    @pytest.mark.skipif(
-        not PANDAS_GE_15, reason="sorting index not yet working with older pandas"
-    )
     @pytest.mark.parametrize("dropna", [True, False])
     def test_value_counts(self, all_data, dropna):
         pass
 
-    @pytest.mark.skipif(
-        not PANDAS_GE_15, reason="sorting index not yet working with older pandas"
-    )
     def test_value_counts_with_normalize(self, data):
         pass
 
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index 4514f0ea..ed2f4c1d 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -110,6 +110,22 @@ def test_overlay(dfs_index, how):
         assert_geodataframe_equal(result, expected, check_column_type=False)
 
 
+@pytest.mark.parametrize("keep_geom_type", [True, False])
+def test_overlay_invalid_input(keep_geom_type):
+    invalid_polygon = Polygon([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)])
+    square = Polygon(((0, 0), (1, 0), (1, 1), (0, 1), (0, 0)))
+    df1 = geopandas.GeoDataFrame({"A": [1, 2]}, geometry=[invalid_polygon, square])
+    df2 = geopandas.GeoDataFrame({"B": [5, 6]}, geometry=[square, square])
+
+    df1_df2 = df1.overlay(df2, keep_geom_type=keep_geom_type)
+    # As overlay only supports inputs with uniform geometry types, `make_valid` of
+    # `invalid_polygon` should always result in POLYGON EMPTY, so the number of results
+    # will should always be 2.
+    assert len(df1_df2) == 2
+    if keep_geom_type:
+        assert df1_df2.geom_type.isin(["Polygon"]).all()
+
+
 @pytest.mark.filterwarnings("ignore:GeoSeries crs mismatch:UserWarning")
 def test_overlay_nybb(how, nybb_filename):
     polydf = read_file(nybb_filename)
@@ -811,6 +827,26 @@ def test_no_intersection():
     assert_geodataframe_equal(result, expected, check_index_type=False)
 
 
+def test_zero_len():
+    # https://github.com/geopandas/geopandas/issues/3422
+    gdf1 = GeoDataFrame(
+        {
+            "geometry": [
+                Polygon([[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]])
+            ]
+        },
+        crs=4326,
+    )
+    # overlay with empty geodataframe shouldn't throw
+    gdf2 = GeoDataFrame({"geometry": []}, crs=4326)
+    res = gdf1.overlay(gdf2, how="union")
+    assert_geodataframe_equal(res, gdf1)
+
+    gdf2 = GeoDataFrame(geometry=[], crs=4326)
+    res = gdf1.overlay(gdf2, how="union")
+    assert_geodataframe_equal(res, gdf1)
+
+
 class TestOverlayWikiExample:
     def setup_method(self):
         self.layer_a = GeoDataFrame(geometry=[box(0, 2, 6, 6)])
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
index dbdbfe6e..bf465e30 100644
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -17,6 +17,7 @@ from shapely.geometry import (
 import geopandas
 from geopandas import GeoDataFrame, GeoSeries, clip
 from geopandas._compat import HAS_PYPROJ
+from geopandas.array import POLYGON_GEOM_TYPES
 from geopandas.tools.clip import _mask_is_list_like_rectangle
 
 import pytest
@@ -304,7 +305,7 @@ class TestClipWithSingleRectangleGdf:
         )
         assert np.array_equal(clipped.total_bounds, expected_bounds)
         # Assert returned data is a not geometry collection
-        assert (clipped.geom_type.isin(["Polygon", "MultiPolygon"])).all()
+        assert (clipped.geom_type.isin(POLYGON_GEOM_TYPES)).all()
 
     def test_clip_multiline(self, multi_line, mask):
         """Test that clipping a multiline feature with a poly returns expected