Repository: geopandas/geopandas

Issue:
ENH: adapt apply() to handle CRS
Following #1339 and discussion there, we should adapt `apply` to preserve CRS if it is applied on GeometryArrays. Once the CRS on GA level is merged. Currently (within the PR yet) any function applied on any other column than `geometry` drops CRS.

As discussed, it should come with the ability to opt-out/assign different CRS.

See https://github.com/geopandas/geopandas/pull/1339#issuecomment-605295675 and https://github.com/geopandas/geopandas/pull/1339#issuecomment-605676457 for details.


Discussion:

Resolving patch:
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -42,6 +42,24 @@ def _geoseries_constructor_with_fallback(data=None, index=None, crs=None, **kwar
         return Series(data=data, index=index, **kwargs)
 
 
+def inherit_doc(cls):
+    """
+    A decorator adding a docstring from an existing method.
+    """
+
+    def decorator(decorated):
+        original_method = getattr(cls, decorated.__name__, None)
+        if original_method:
+            doc = original_method.__doc__ or ""
+        else:
+            doc = ""
+
+        decorated.__doc__ = doc
+        return decorated
+
+    return decorator
+
+
 class GeoSeries(GeoPandasBase, Series):
     """
     A Series object designed to store shapely geometry objects.
@@ -284,15 +302,26 @@ def _wrapped_pandas_method(self, mtd, *args, **kwargs):
     def __getitem__(self, key):
         return self._wrapped_pandas_method("__getitem__", key)
 
+    @inherit_doc(pd.Series)
     def sort_index(self, *args, **kwargs):
         return self._wrapped_pandas_method("sort_index", *args, **kwargs)
 
+    @inherit_doc(pd.Series)
     def take(self, *args, **kwargs):
         return self._wrapped_pandas_method("take", *args, **kwargs)
 
+    @inherit_doc(pd.Series)
     def select(self, *args, **kwargs):
         return self._wrapped_pandas_method("select", *args, **kwargs)
 
+    @inherit_doc(pd.Series)
+    def apply(self, func, args=(), **kwargs):
+        result = super().apply(func, args=args, **kwargs)
+        if isinstance(result, GeoSeries):
+            if self.crs is not None:
+                result.set_crs(self.crs, inplace=True)
+        return result
+
     def __finalize__(self, other, method=None, **kwargs):
         """ propagate metadata from other to self """
         # NOTE: backported from pandas master (upcoming v0.13)


Test patch:
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -553,6 +553,14 @@ def test_astype(self):
         df2 = df.astype({"col1": str})
         assert df2.crs == self.osgb
 
+    def test_apply(self):
+        s = GeoSeries(self.arr)
+        assert s.crs == 27700
+
+        # apply preserves the CRS if the result is a GeoSeries
+        result = s.apply(lambda x: x.centroid)
+        assert result.crs == 27700
+
 
 class TestSetCRS:
     @pytest.mark.parametrize(
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -489,6 +489,26 @@ def test_groupby_groups(df):
     assert_frame_equal(res, exp)
 
 
+def test_apply(s):
+    # function that returns geometry preserves GeoSeries class
+    def geom_func(geom):
+        assert isinstance(geom, Point)
+        return geom
+
+    result = s.apply(geom_func)
+    assert isinstance(result, GeoSeries)
+    assert_geoseries_equal(result, s)
+
+    # function that returns non-geometry results in Series
+    def numeric_func(geom):
+        assert isinstance(geom, Point)
+        return geom.x
+
+    result = s.apply(numeric_func)
+    assert not isinstance(result, GeoSeries)
+    assert_series_equal(result, pd.Series([0.0, 1.0, 2.0]))
+
+
 def test_apply_loc_len1(df):
     # subset of len 1 with loc -> bug in pandas with inconsistent Block ndim
     # resulting in bug in apply