Repository: geopandas/geopandas
Pull request #3290 (2024-05-17 14:02:29)

ENH: support limit keyword in GeoSeries.fillna
Following the implementation of https://github.com/pandas-dev/pandas/pull/58249

This should fix the dev CI build, because pandas enabled this feature for EAs recently and added tests for it, and those tests were now failing.

jorisvandenbossche: At least the dev CI is green now .. ;)
jorisvandenbossche: And now all builds are green!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b93420ed..b419352d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,6 +62,7 @@ New features and improvements:
   preservation of the original order of observations. (#3233)
 - Added `show_bbox`, `drop_id` and `to_wgs84` arguments to allow further customization of
   `GeoSeries.to_json` (#3226)
+- The `GeoSeries.fillna` method now supports the `limit` keyword (#3290).
 
 Backwards incompatible API changes:
 
diff --git a/geopandas/array.py b/geopandas/array.py
index c63d714e..d221af00 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -1208,29 +1208,13 @@ class GeometryArray(ExtensionArray):
             result[~shapely.is_valid_input(result)] = None
         return GeometryArray(result, crs=self.crs)
 
-    def _fill(self, idx, value):
-        """
-        Fill index locations with ``value``.
-
-        ``value`` should be a BaseGeometry or a GeometryArray.
-        """
-        if isna(value):
-            value = [None]
-        elif _is_scalar_geometry(value):
-            value = [value]
-        elif isinstance(value, GeometryArray):
-            value = value[idx]
-        else:
-            raise TypeError(
-                "'value' parameter must be None, a scalar geometry, or a GeoSeries, "
-                f"but you passed a {type(value).__name__!r}"
-            )
-
-        value_arr = np.empty(len(value), dtype=object)
-        value_arr[:] = value
-
-        self._data[idx] = value_arr
-        return self
+    # compat for pandas < 3.0
+    def _pad_or_backfill(
+        self, method, limit=None, limit_area=None, copy=True, **kwargs
+    ):
+        return super()._pad_or_backfill(
+            method=method, limit=limit, limit_area=limit_area, copy=copy, **kwargs
+        )
 
     def fillna(self, value=None, method=None, limit=None, copy=True):
         """
@@ -1249,12 +1233,7 @@ class GeometryArray(ExtensionArray):
             backfill / bfill: use NEXT valid observation to fill gap
 
         limit : int, default None
-            If method is specified, this is the maximum number of consecutive
-            NaN values to forward/backward fill. In other words, if there is
-            a gap with more than this number of consecutive NaNs, it will only
-            be partially filled. If method is not specified, this is the
-            maximum number of entries along the entire axis where NaNs will be
-            filled.
+            The maximum number of entries where NA values will be filled.
 
         copy : bool, default True
             Whether to make a copy of the data before filling. If False, then
@@ -1272,7 +1251,30 @@ class GeometryArray(ExtensionArray):
             new_values = self.copy()
         else:
             new_values = self
-        return new_values._fill(mask, value) if mask.any() else new_values
+
+        if not mask.any():
+            return new_values
+
+        if limit is not None and limit < len(self):
+            modify = mask.cumsum() > limit
+            if modify.any():
+                mask[modify] = False
+
+        if isna(value):
+            value = [None]
+        elif _is_scalar_geometry(value):
+            value = [value]
+        elif isinstance(value, GeometryArray):
+            value = value[mask]
+        else:
+            raise TypeError(
+                "'value' parameter must be None, a scalar geometry, or a GeoSeries, "
+                f"but you passed a {type(value).__name__!r}"
+            )
+        value_arr = np.asarray(value, dtype=object)
+
+        new_values._data[mask] = value_arr
+        return new_values
 
     def astype(self, dtype, copy=True):
         """
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 60de6990..21dce50e 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -808,7 +808,7 @@ class GeoSeries(GeoPandasBase, Series):
         """Alias for `notna` method. See `notna` for more detail."""
         return self.notna()
 
-    def fillna(self, value=None, inplace: bool = False, **kwargs):
+    def fillna(self, value=None, inplace: bool = False, limit=None, **kwargs):
         """
         Fill NA values with geometry (or geometries).
 
@@ -821,6 +821,9 @@ class GeoSeries(GeoPandasBase, Series):
             are passed, missing values will be filled based on the corresponding index
             locations. If pd.NA or np.nan are passed, values will be filled with
             ``None`` (not GEOMETRYCOLLECTION EMPTY).
+        limit : int, default None
+            This is the maximum number of entries along the entire axis
+            where NaNs will be filled. Must be greater than 0 if not None.
 
         Returns
         -------
@@ -880,7 +883,7 @@ class GeoSeries(GeoPandasBase, Series):
         """
         if value is None:
             value = GeometryCollection()
-        return super().fillna(value=value, inplace=inplace, **kwargs)
+        return super().fillna(value=value, limit=limit, inplace=inplace, **kwargs)
 
     def __contains__(self, other) -> bool:
         """Allow tests of the form "geom in s"


Test patch:
diff --git a/geopandas/tests/test_extension_array.py b/geopandas/tests/test_extension_array.py
index 4f709c79..c199b04a 100644
--- a/geopandas/tests/test_extension_array.py
+++ b/geopandas/tests/test_extension_array.py
@@ -26,7 +26,7 @@ import shapely.geometry
 from shapely.geometry import Point
 
 from geopandas.array import GeometryArray, GeometryDtype, from_shapely
-from geopandas._compat import PANDAS_GE_15, PANDAS_GE_22
+from geopandas._compat import PANDAS_GE_15, PANDAS_GE_21, PANDAS_GE_22
 
 import pytest
 
@@ -453,27 +453,29 @@ class TestMissing(extension_tests.BaseMissingTests):
         # `geopandas\tests\test_pandas_methods.py::test_fillna_scalar`
         # and `geopandas\tests\test_pandas_methods.py::test_fillna_series`.
 
-    @pytest.mark.skip("fillna method not supported")
+    @pytest.mark.skipif(
+        not PANDAS_GE_21, reason="fillna method not supported with older pandas"
+    )
     def test_fillna_limit_pad(self, data_missing):
-        pass
+        super().test_fillna_limit_pad(data_missing)
 
-    @pytest.mark.skip("fillna method not supported")
+    @pytest.mark.skipif(
+        not PANDAS_GE_21, reason="fillna method not supported with older pandas"
+    )
     def test_fillna_limit_backfill(self, data_missing):
-        pass
+        super().test_fillna_limit_backfill(data_missing)
 
-    @pytest.mark.skip("fillna method not supported")
-    def test_fillna_series_method(self, data_missing, method):
-        pass
+    @pytest.mark.skipif(
+        not PANDAS_GE_21, reason="fillna method not supported with older pandas"
+    )
+    def test_fillna_series_method(self, data_missing, fillna_method):
+        super().test_fillna_series_method(data_missing, fillna_method)
 
-    @pytest.mark.skip("fillna method not supported")
+    @pytest.mark.skipif(
+        not PANDAS_GE_21, reason="fillna method not supported with older pandas"
+    )
     def test_fillna_no_op_returns_copy(self, data):
-        pass
-
-    @pytest.mark.skip("fillna method not supported")
-    def test_ffill_limit_area(
-        self, data_missing, limit_area, input_ilocs, expected_ilocs
-    ):
-        pass
+        super().test_fillna_no_op_returns_copy(data)
 
 
 if PANDAS_GE_22: