Repository: geopandas/geopandas

Issue:
DOC/API: clarify set_geometry handling of (name of) passed values
This PR started originally to fix a case of the GeoDataFrame constructor to preserve the name of a GeoSeries. So when doing `GeoDataFrame(..data.., geometry=GeoSeries(geoms, name='my_geom'))`, to result in a GeoDataFrame with a 'my_geom' geometry column (on master this currently gives 'geometry' ignoring the name of the GeoSeries).

But to fix this, I started looking at the `set_geometry` method (which is being used by the GeoDataFrame constructor). And also noticed that the method is not fully well defined / documented.

- When specifying the name of an existing column, the original geometry column is kept but the "active geometry" column is swapped to the specified column
  - However, with `drop=True`, the old geometry column is replaced with the new (so dropping one column). But, now, we don't use the specified name as the geometry column name, but fall back to the default 'geometry' -> should we preserve the specified name here?

- When specifying actual geometry values, the original geometry column gets replaced with the new values
  - Currently, we don't preserve the name of the passed values (if they have a name, i.e. a (Geo)Series). This is what this PR started with fixing.
  - But if the values have a name, it can also be an option to keep the original geometry column intact (not necessarily the default, but people could do `drop=False` to keep the original one)

See also https://nbviewer.jupyter.org/gist/jorisvandenbossche/61ba4b240a2060ec7dafdead84e13371 for a notebook exploring those options of `set_geometry`.

Thoughts?


Discussion:
> However, with drop=True, the old geometry column is replaced with the new (so dropping one column). But, now, we don't use the specified name as the geometry column name, but fall back to the default 'geometry' -> should we preserve the specified name here?

I think so. If drop=False uses specified name, it sounds more reasonable to keep in for drop=True as well. `drop` itself does not feel like it should do 'drop and rename'. And if you specifically set a column as geometry you should not be confused why it is not called 'geometry'.

> But if the values have a name, it can also be an option to keep the original geometry column intact (not necessarily the default, but people could do drop=False to keep the original one)

This is reasonable as well, as an option. I can imagine situations when it would be useful. You just have to check that the name of geoseries is not the same as existing geometry column while having drop=False.
> I think so. If drop=False uses specified name, it sounds more reasonable to keep in for drop=True as well.

Yes, I think I agree. 
The question then is: do we simply change this, or is there a way we can do this with a deprecation period. So people are for this case already passing `drop=False`, so we can't use this existing keyword any more to silence the warning + get the future behaviour. 
We *could* add a `geometry_name` argument to override the name we infer, so people could then still specify their desired name. But that seems a bit ugly in general.

Thinking about this (but independent issue), maybe we should have a `rename_geometry()` method, because currently renaming the geometry column needs something like `.rename(columns={'my_geom': 'geometry'}).set_geometry('geometry')`
> do we simply change this, or is there a way we can do this with a deprecation period

Do we see it as a bug? Or change of behaviour? If it is the bug, I would change it as a bug fix. If it is the change of intended behaviour, we should include a deprecation period.

I am not a fan of `geometry_name` model...

> maybe we should have a `rename_geometry()` method

Agree.
@jorisvandenbossche can we revive this? We have `rename_geometry` now so we don't have to deal with the `geometry_name` argument to make it easier.

> do we simply change this, or is there a way we can do this with a deprecation period

I believe that this is a bug and as such should be fixed. But it may break things, so if there's a simple way of spitting out deprecation warning in affected situations, let's do that.

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -243,7 +243,7 @@ def _set_geometry(self, col):
         fget=_get_geometry, fset=_set_geometry, doc="Geometry data for GeoDataFrame"
     )
 
-    def set_geometry(self, col, drop=False, inplace=False, crs=None):
+    def set_geometry(self, col, drop=None, inplace=False, crs=None):
         """
         Set the GeoDataFrame geometry using either an existing column or
         the specified input. By default yields a new object.
@@ -252,9 +252,20 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
 
         Parameters
         ----------
-        col : column label or array
+        col : column label or array-like
+            An existing column name or values to set as the new geometry column.
+            If values (array-like, (Geo)Series) are passed, then if they are named
+            (Series) the new geometry column will have the corresponding name,
+            otherwise the existing geometry column will be replaced. If there is
+            no existing geometry column, the new geometry column will use the
+            default name "geometry".
         drop : boolean, default False
-            Delete column to be used as the new geometry
+            When specifying a named Series or an existing column name for `col`,
+            controls if the previous geometry column should be dropped from the
+            result. The default of False keeps both the old and new geometry column.
+
+            .. deprecated:: 1.0.0
+
         inplace : boolean, default False
             Modify the GeoDataFrame in place (do not create a new object)
         crs : pyproj.CRS, optional
@@ -305,15 +316,25 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
         else:
             frame = self.copy()
 
-        to_remove = None
         geo_column_name = self._geometry_column_name
+
         if geo_column_name is None:
             geo_column_name = "geometry"
         if isinstance(col, (Series, list, np.ndarray, GeometryArray)):
+            if drop:
+                msg = (
+                    "The `drop` keyword argument is deprecated and has no effect when "
+                    "`col` is an array-like value. You should stop passing `drop` to "
+                    "`set_geometry` when this is the case."
+                )
+                warnings.warn(msg, category=FutureWarning, stacklevel=2)
+            if isinstance(col, Series) and col.name is not None:
+                geo_column_name = col.name
+
             level = col
         elif hasattr(col, "ndim") and col.ndim > 1:
             raise ValueError("Must pass array with one dimension only.")
-        else:
+        else:  # should be a colname
             try:
                 level = frame[col]
             except KeyError:
@@ -324,24 +345,42 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
                     "the column name is shared by multiple columns."
                 )
 
+            given_colname_drop_msg = (
+                "The `drop` keyword argument is deprecated and in future the only "
+                "supported behaviour will match drop=False. To silence this "
+                "warning and adopt the future behaviour, stop providing "
+                "`drop` as a keyword to `set_geometry`. To replicate the "
+                "`drop=True` behaviour you should update "
+                "your code to\n`geo_col_name = gdf.active_geometry_name;"
+                " gdf.set_geometry(new_geo_col).drop("
+                "columns=geo_col_name).rename_geometry(geo_col_name)`."
+            )
+
+            if drop is False:  # specifically False, not falsy i.e. None
+                # User supplied False explicitly, but arg is deprecated
+                warnings.warn(
+                    given_colname_drop_msg,
+                    category=FutureWarning,
+                    stacklevel=2,
+                )
             if drop:
-                to_remove = col
+                del frame[col]
+                warnings.warn(
+                    given_colname_drop_msg,
+                    category=FutureWarning,
+                    stacklevel=2,
+                )
             else:
+                # if not dropping, set the active geometry name to the given col name
                 geo_column_name = col
 
-        if to_remove:
-            del frame[to_remove]
-
         if not crs:
             crs = getattr(level, "crs", None)
 
-        if isinstance(level, (GeoSeries, GeometryArray)) and level.crs != crs:
-            # Avoids caching issues/crs sharing issues
-            level = level.copy()
-            level.crs = crs
-
         # Check that we are using a listlike of geometries
         level = _ensure_geometry(level, crs=crs)
+        # ensure_geometry only sets crs on level if it has crs==None
+        level.crs = crs
         # update _geometry_column_name prior to assignment
         # to avoid default is None warning
         frame._geometry_column_name = geo_column_name


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -267,6 +267,9 @@ def test_set_geometry(self):
         with pytest.raises(ValueError):
             self.df.set_geometry(self.df)
 
+    def test_set_geometry_crs(self):
+        geom = GeoSeries([Point(x, y) for x, y in zip(range(5), range(5))])
+
         # new crs - setting should default to GeoSeries' crs
         gs = GeoSeries(geom, crs="epsg:3857")
         new_df = self.df.set_geometry(gs)
@@ -958,24 +961,24 @@ def test_estimate_utm_crs(self):
 
     def test_to_wkb(self):
         wkbs0 = [
-            (
+            (  # POINT (0 0)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
-            ),  # POINT (0 0)
-            (
+            ),
+            (  # POINT (1 1)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\xf0?\x00\x00\x00\x00\x00\x00\xf0?"
-            ),  # POINT (1 1)
+            ),
         ]
         wkbs1 = [
-            (
+            (  # POINT (2 2)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\x00@\x00\x00\x00\x00\x00\x00\x00@"
-            ),  # POINT (2 2)
-            (
+            ),
+            (  # POINT (3 3)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\x08@\x00\x00\x00\x00\x00\x00\x08@"
-            ),  # POINT (3 3)
+            ),
         ]
         gs0 = GeoSeries.from_wkb(wkbs0)
         gs1 = GeoSeries.from_wkb(wkbs1)
@@ -1284,7 +1287,6 @@ def test_column_ordering(self):
         check_geodataframe(gdf)
         gdf.columns == ["geometry", "a"]
 
-    @pytest.mark.xfail
     def test_preserve_series_name(self):
         geoms = [Point(1, 1), Point(2, 2), Point(3, 3)]
         gs = GeoSeries(geoms)
@@ -1510,3 +1512,74 @@ def test_geodataframe_crs_colname():
     assert gdf.crs is None
     assert gdf["crs"].iloc[0] == 1
     assert getattr(gdf, "crs") is None
+
+
+@pytest.fixture
+def nybb2(nybb_filename):
+    yield read_file(nybb_filename).head(2)
+
+
+@pytest.mark.parametrize("geo_col_name", ["geometry", "polygons"])
+def test_set_geometry_supply_colname(nybb2, geo_col_name):
+    if geo_col_name != "geometry":
+        nybb2 = nybb2.rename_geometry(geo_col_name)
+    nybb2["centroid"] = nybb2.geometry.centroid
+    res = nybb2.set_geometry("centroid")
+    assert res.active_geometry_name == "centroid"
+    assert geo_col_name in res.columns
+
+    # Test that drop=False explicitly warns
+    deprecated = "The `drop` keyword argument is deprecated"
+    with pytest.warns(FutureWarning, match=deprecated):
+        res2 = nybb2.set_geometry("centroid", drop=False)
+    assert_geodataframe_equal(res, res2)
+
+    with pytest.warns(FutureWarning, match=deprecated):
+        res3 = nybb2.set_geometry("centroid", drop=True)
+    # drop=True should preserve previous geometry col name (keep old behaviour)
+    assert res3.active_geometry_name == geo_col_name
+    assert "centroid" not in res3.columns
+
+    # Test that alternative suggested without using drop=True is equivalent
+    assert_geodataframe_equal(
+        res3,
+        nybb2.set_geometry("centroid")
+        .drop(columns=geo_col_name)
+        .rename_geometry(geo_col_name),
+    )
+
+
+@pytest.mark.parametrize("geo_col_name", ["geometry", "polygons"])
+def test_set_geometry_supply_arraylike(nybb2, geo_col_name):
+    if geo_col_name != "geometry":
+        nybb2 = nybb2.rename_geometry(geo_col_name)
+    centroids = nybb2.geometry.centroid
+    res = nybb2.set_geometry(centroids)
+    assert res.active_geometry_name == geo_col_name
+    # drop should do nothing if the column already exists
+    match_str = (
+        "The `drop` keyword argument is deprecated and has no effect when "
+        "`col` is an array-like value"
+    )
+    with pytest.warns(
+        FutureWarning,
+        match=match_str,
+    ):
+        res2 = nybb2.set_geometry(centroids, drop=True)
+    assert res2.active_geometry_name == geo_col_name
+
+    centroids = centroids.rename("centroids")
+    res3 = nybb2.set_geometry(centroids)
+    # Should preserve the geoseries name
+    # (and old geometry column should be kept)
+    assert res3.active_geometry_name == "centroids"
+    assert geo_col_name in res3.columns
+
+    # Drop should not remove previous active geometry colname for arraylike inputs
+    with pytest.warns(
+        FutureWarning,
+        match=match_str,
+    ):
+        res4 = nybb2.set_geometry(centroids, drop=True)
+    assert res4.active_geometry_name == "centroids"
+    assert geo_col_name in res4.columns