Repository: geopandas/geopandas
Pull request #2685 (2022-12-24 05:56:29)

COMPAT: Pandas 1.5.2 / Pandas 2.0 dissolve numeric only
To keep these merge requests well defined, will have to incrementally get ci to pass. This solves the dissolve related issues.
There are also separately pandas 2.0 representation of utc timezones which has changed (#2686), an interaction issue between matplotlib 3.3.2 and the new numpy 1.2.4 and some doctests seem to have changed as well.

edit: checking the tests on CI now, I think all these failures are expected, these are unrelated to the dissolve stuff.

m-richards: The last test failure is codecov - for the line which is passing through non captured warnings. This doesn't seem particularly straightforward to try and test.
jorisvandenbossche: > This doesn't seem particularly straightforward to try and test.

Not tested, but maybe you can create a small custom aggregation function that raises a warning:

```
def my_func(group):
    warnings.warn(...)
    return group.mean()

gdf.dissolve(.., aggfunc=my_func)
```
m-richards: > 

thanks, looks like you're right, we'll see if this is all green now.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8122253..8bd3c4e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
 # Changelog
 
+## Development version
+
+New features and improvements:
+
+- Added ``minimum_bounding_circle()`` method from shapely to GeoSeries/GeoDataframe (#2621).
+
+Deprecations and compatibility notes:
+
+- Added warning that ``unary_union`` will return ``'GEOMETRYCOLLECTION EMPTY'`` instead
+  of None for all-None GeoSeries. (#2618)
+
+Bug fixes:
+
+- Ensure that GeoDataFrame created from DataFrame is a copy, not a view (#2667)
+
 ## Version 0.12.2 (December 10, 2022)
 
 Bug fixes:
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index bf7f4ec2..7d7317b9 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -86,6 +86,7 @@ Constructive methods and attributes
    GeoSeries.centroid
    GeoSeries.convex_hull
    GeoSeries.envelope
+   GeoSeries.minimum_bounding_circle
    GeoSeries.simplify
    GeoSeries.normalize
 
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index d1f132b4..266da762 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -20,6 +20,7 @@ PANDAS_GE_115 = Version(pd.__version__) >= Version("1.1.5")
 PANDAS_GE_12 = Version(pd.__version__) >= Version("1.2.0")
 PANDAS_GE_13 = Version(pd.__version__) >= Version("1.3.0")
 PANDAS_GE_14 = Version(pd.__version__) >= Version("1.4.0rc0")
+PANDAS_GE_15 = Version(pd.__version__) >= Version("1.5.0")
 PANDAS_GE_20 = Version(pd.__version__) >= Version("2.0.0.dev0")
 
 
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index 20aa7332..36408804 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -677,6 +677,18 @@ def representative_point(data):
         return out
 
 
+def minimum_bounding_circle(data):
+    if compat.USE_SHAPELY_20:
+        return shapely.minimum_bounding_circle(data)
+    elif compat.USE_PYGEOS:
+        return pygeos.minimum_bounding_circle(data)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 #
 # Binary predicates
 #
@@ -1021,21 +1033,41 @@ def relate(data, other):
 
 
 def unary_union(data):
+    warning_msg = (
+        "`unary_union` returned None due to all-None GeoSeries. In future, "
+        "`unary_union` will return 'GEOMETRYCOLLECTION EMPTY' instead."
+    )
+
     if compat.USE_SHAPELY_20:
         data = shapely.union_all(data)
-        if data is None:  # shapely 2.0a1
-            return None
-        if data.is_empty:  # shapely 2.0
+        if data is None or data.is_empty:  # shapely 2.0a1 and 2.0
+            warnings.warn(
+                warning_msg,
+                FutureWarning,
+                stacklevel=4,
+            )
             return None
         else:
             return data
     elif compat.USE_PYGEOS:
-        return _pygeos_to_shapely(pygeos.union_all(data))
+        result = _pygeos_to_shapely(pygeos.union_all(data))
+        if result is None:
+            warnings.warn(
+                warning_msg,
+                FutureWarning,
+                stacklevel=4,
+            )
+        return result
     else:
         data = [g for g in data if g is not None]
         if data:
             return shapely.ops.unary_union(data)
         else:
+            warnings.warn(
+                warning_msg,
+                FutureWarning,
+                stacklevel=4,
+            )
             return None
 
 
diff --git a/geopandas/array.py b/geopandas/array.py
index 5f472333..22fceb36 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -511,6 +511,11 @@ class GeometryArray(ExtensionArray):
     def representative_point(self):
         return GeometryArray(vectorized.representative_point(self.data), crs=self.crs)
 
+    def minimum_bounding_circle(self):
+        return GeometryArray(
+            vectorized.minimum_bounding_circle(self.data), crs=self.crs
+        )
+
     def normalize(self):
         return GeometryArray(vectorized.normalize(self.data), crs=self.crs)
 
@@ -1329,9 +1334,8 @@ class GeometryArray(ExtensionArray):
             # TODO(pygeos)
             return getattr(to_shapely(self), name)()
         raise TypeError(
-            "cannot perform {name} with type {dtype}".format(
-                name=name, dtype=self.dtype
-            )
+            f"'{type(self).__name__}' with dtype {self.dtype} "
+            f"does not support reduction '{name}'"
         )
 
     def __array__(self, dtype=None):
diff --git a/geopandas/base.py b/geopandas/base.py
index ada4c79d..b4c6a908 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -692,6 +692,39 @@ GeometryCollection
         """
         return _delegate_geo_method("representative_point", self)
 
+    def minimum_bounding_circle(self):
+        """Returns a ``GeoSeries`` of geometries representing the minimum bounding
+        circle that encloses each geometry.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(0, 0), (1, 1), (1, 0)]),
+        ...         Point(0, 0),
+        ...     ]
+        ... )
+        >>> s
+        0    POLYGON ((0.00000 0.00000, 1.00000 1.00000, 0....
+        1    LINESTRING (0.00000 0.00000, 1.00000 1.00000, ...
+        2                              POINT (0.00000 0.00000)
+        dtype: geometry
+
+        >>> s.minimum_bounding_circle()
+        0    POLYGON ((1.20711 0.50000, 1.19352 0.36205, 1....
+        1    POLYGON ((1.20711 0.50000, 1.19352 0.36205, 1....
+        2                              POINT (0.00000 0.00000)
+        dtype: geometry
+
+        See also
+        --------
+        GeoSeries.convex_hull : convex hull geometry
+        """
+        return _delegate_geo_method("minimum_bounding_circle", self)
+
     def normalize(self):
         """Returns a ``GeoSeries`` of normalized
         geometries to normal form (or canonical form).
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 1498c67c..6b5eb009 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -132,6 +132,12 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
 
     def __init__(self, data=None, *args, geometry=None, crs=None, **kwargs):
         with compat.ignore_shapely2_warnings():
+            if (
+                kwargs.get("copy") is None
+                and isinstance(data, DataFrame)
+                and not isinstance(data, GeoDataFrame)
+            ):
+                kwargs.update(copy=True)
             super().__init__(data, *args, **kwargs)
 
         # set_geometry ensures the geometry data have the proper dtype,
@@ -1575,6 +1581,7 @@ individually so that features may have different properties
         sort=True,
         observed=False,
         dropna=True,
+        **kwargs,
     ):
         """
         Dissolve geometries within `groupby` into single observation.
@@ -1627,7 +1634,12 @@ individually so that features may have different properties
             if a non-default value is given for this parameter.
 
             .. versionadded:: 0.9.0
+        **kwargs :
+            Keyword arguments to be passed to the pandas `DataFrameGroupby.agg` method
+            which is used by `dissolve`. In particular, `numeric_only` may be
+            supplied, which will be required in pandas 2.0 for certain aggfuncs.
 
+            .. versionadded:: 0.13.0
         Returns
         -------
         GeoDataFrame
@@ -1673,7 +1685,26 @@ individually so that features may have different properties
 
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
-        aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc)
+        with warnings.catch_warnings(record=True) as record:
+            aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc, **kwargs)
+        for w in record:
+            if str(w.message).startswith("The default value of numeric_only"):
+                msg = (
+                    f"The default value of numeric_only in aggfunc='{aggfunc}' "
+                    "within pandas.DataFrameGroupBy.agg used in dissolve is "
+                    "deprecated. In pandas 2.0, numeric_only will default to False. "
+                    "Either specify numeric_only as additional argument in dissolve() "
+                    "or select only columns which should be valid for the function."
+                )
+                warnings.warn(msg, FutureWarning, stacklevel=2)
+            else:
+                # Only want to capture specific warning,
+                # other warnings from pandas should be passed through
+                # TODO this is not an ideal approach
+                warnings.showwarning(
+                    w.message, w.category, w.filename, w.lineno, w.file, w.line
+                )
+
         aggregated_data.columns = aggregated_data.columns.to_flat_index()
 
         # Process spatial component


Test patch:
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index dd0eafc9..6d9b740e 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -10,6 +10,7 @@ from geopandas import _compat as compat
 from pandas.testing import assert_frame_equal
 import pytest
 
+from geopandas._compat import PANDAS_GE_15, PANDAS_GE_20
 from geopandas.testing import assert_geodataframe_equal
 
 
@@ -82,11 +83,52 @@ def test_first_dissolve(nybb_polydf, first):
 
 
 def test_mean_dissolve(nybb_polydf, first, expected_mean):
-    test = nybb_polydf.dissolve("manhattan_bronx", aggfunc="mean")
-    assert_frame_equal(expected_mean, test, check_column_type=False)
+    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=np.mean
+        )
 
-    test = nybb_polydf.dissolve("manhattan_bronx", aggfunc=np.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
+    def sum_and_warn(group):
+        warnings.warn("foo")
+        if PANDAS_GE_20:
+            return group.sum(numeric_only=False)
+        else:
+            return group.sum()
+
+    with pytest.warns(UserWarning, match="foo"):
+        nybb_polydf.dissolve("manhattan_bronx", aggfunc=sum_and_warn)
 
 
 def test_multicolumn_dissolve(nybb_polydf, first):
@@ -123,7 +165,7 @@ def test_dissolve_none(nybb_polydf):
 
 
 def test_dissolve_none_mean(nybb_polydf):
-    test = nybb_polydf.dissolve(aggfunc="mean")
+    test = nybb_polydf.dissolve(aggfunc="mean", numeric_only=True)
     expected = GeoDataFrame(
         {
             nybb_polydf.geometry.name: [nybb_polydf.geometry.unary_union],
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index 3ccd49ed..7e4a8544 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -14,6 +14,7 @@ import matplotlib.colors as colors  # noqa
 from branca.colormap import StepColormap  # noqa
 
 BRANCA_05 = Version(branca.__version__) > Version("0.4.2")
+FOLIUM_G_014 = Version(folium.__version__) > Version("0.14.0")
 
 
 class TestExplore:
@@ -524,8 +525,14 @@ class TestExplore:
         df2["values"] = df2["BoroCode"] * 10.0
         m = df2[df2["values"] >= 30].explore("values", vmin=0)
         out_str = self._fetch_map_string(m)
-        assert 'case"1":return{"color":"#7ad151","fillColor":"#7ad151"' in out_str
-        assert 'case"2":return{"color":"#22a884","fillColor":"#22a884"' in out_str
+        if FOLIUM_G_014:
+            assert 'case"0":return{"color":"#fde725","fillColor":"#fde725"' in out_str
+            assert 'case"1":return{"color":"#7ad151","fillColor":"#7ad151"' in out_str
+            assert 'default:return{"color":"#22a884","fillColor":"#22a884"' in out_str
+        else:
+            assert 'case"1":return{"color":"#7ad151","fillColor":"#7ad151"' in out_str
+            assert 'case"2":return{"color":"#22a884","fillColor":"#22a884"' in out_str
+            assert 'default:return{"color":"#fde725","fillColor":"#fde725"' in out_str
 
         df2["values_negative"] = df2["BoroCode"] * -10.0
         m = df2[df2["values_negative"] <= 30].explore("values_negative", vmax=0)
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 9e3112f6..878403dd 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -13,7 +13,7 @@ from shapely.geometry import Point, Polygon
 
 import geopandas
 import geopandas._compat as compat
-from geopandas import GeoDataFrame, GeoSeries, read_file
+from geopandas import GeoDataFrame, GeoSeries, points_from_xy, read_file
 from geopandas.array import GeometryArray, GeometryDtype, from_shapely
 from geopandas._compat import ignore_shapely2_warnings
 
@@ -772,6 +772,33 @@ class TestDataFrame:
         with pytest.raises(ValueError):
             df.set_geometry("location", inplace=True)
 
+    def test_dataframe_not_manipulated(self):
+        df = pd.DataFrame(
+            {
+                "A": range(len(self.df)),
+                "latitude": self.df.geometry.centroid.y,
+                "longitude": self.df.geometry.centroid.x,
+            },
+            index=self.df.index,
+        )
+        df_copy = df.copy()
+        gf = GeoDataFrame(
+            df,
+            geometry=points_from_xy(df["longitude"], df["latitude"]),
+            crs=self.df.crs,
+        )
+        assert type(df) == pd.DataFrame
+        assert "geometry" not in df
+        assert_frame_equal(df, df_copy)
+        assert isinstance(gf, GeoDataFrame)
+        assert hasattr(gf, "geometry")
+
+        # ensure mutating columns in gf doesn't update df
+        gf.loc[0, "A"] = 7
+        assert_frame_equal(df, df_copy)
+        gf["A"] = 3
+        assert_frame_equal(df, df_copy)
+
     def test_geodataframe_geointerface(self):
         assert self.df.__geo_interface__["type"] == "FeatureCollection"
         assert len(self.df.__geo_interface__["features"]) == self.df.shape[0]
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 606102e3..eccd5c00 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -385,8 +385,9 @@ class TestGeomMethods:
         g2 = GeoSeries([p1, None])
         self._test_unary_topological("unary_union", p1, g2)
 
-        g3 = GeoSeries([None, None])
-        assert g3.unary_union is None
+        with pytest.warns(FutureWarning, match="`unary_union` returned None"):
+            g3 = GeoSeries([None, None])
+            assert g3.unary_union is None
 
     def test_cascaded_union_deprecated(self):
         p1 = self.t1
@@ -927,6 +928,21 @@ class TestGeomMethods:
         assert isinstance(e, GeoSeries)
         assert self.g3.crs == e.crs
 
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="minimum_bounding_circle is only implemented for pygeos, not shapely",
+    )
+    def test_minimum_bounding_circle(self):
+        mbc = self.g1.minimum_bounding_circle()
+        centers = GeoSeries([Point(0.5, 0.5)] * 2)
+        assert np.all(mbc.centroid.geom_equals_exact(centers, 0.001))
+        assert_series_equal(
+            mbc.area,
+            Series([1.560723, 1.560723]),
+        )
+        assert isinstance(mbc, GeoSeries)
+        assert self.g1.crs == mbc.crs
+
     def test_total_bounds(self):
         bbox = self.sol.x, self.sol.y, self.esb.x, self.esb.y
         assert isinstance(self.landmarks.total_bounds, np.ndarray)
diff --git a/geopandas/tests/test_op_output_types.py b/geopandas/tests/test_op_output_types.py
index 5a447f39..789982b9 100644
--- a/geopandas/tests/test_op_output_types.py
+++ b/geopandas/tests/test_op_output_types.py
@@ -348,5 +348,5 @@ def test_constructor_sliced_in_pandas_methods(df2):
     # drop the secondary geometry columns as not hashable
     hashable_test_df = df2.drop(columns=["geometry2", "geometry3"])
     assert type(hashable_test_df.duplicated()) == pd.Series
-    assert type(df2.quantile()) == pd.Series
+    assert type(df2.quantile(numeric_only=True)) == pd.Series
     assert type(df2.memory_usage()) == pd.Series
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index 69f34071..e92f04c7 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -9,6 +9,7 @@ from shapely.geometry import Point, Polygon, LineString, GeometryCollection, box
 import geopandas
 from geopandas import GeoDataFrame, GeoSeries, overlay, read_file
 from geopandas import _compat
+from geopandas._compat import PANDAS_GE_20
 
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 import pytest
@@ -755,13 +756,18 @@ def test_non_overlapping(how):
     result = overlay(df1, df2, how=how)
 
     if how == "intersection":
+        if PANDAS_GE_20:
+            index = None
+        else:
+            index = pd.Index([], dtype="object")
+
         expected = GeoDataFrame(
             {
                 "col1": np.array([], dtype="int64"),
                 "col2": np.array([], dtype="int64"),
                 "geometry": [],
             },
-            index=pd.Index([], dtype="object"),
+            index=index,
         )
     elif how == "union":
         expected = GeoDataFrame(
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 27d45f1d..3875adaf 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -326,7 +326,11 @@ def test_to_csv(df):
 def test_numerical_operations(s, df):
     # df methods ignore the geometry column
     exp = pd.Series([3, 4], index=["value1", "value2"])
-    assert_series_equal(df.sum(), exp)
+    if not compat.PANDAS_GE_20:
+        res = df.sum()
+    else:
+        res = df.sum(numeric_only=True)
+    assert_series_equal(res, exp)
 
     # series methods raise error (not supported for geometry)
     with pytest.raises(TypeError):
@@ -343,8 +347,7 @@ def test_numerical_operations(s, df):
     with pytest.raises(TypeError):
         df + 1
 
-    with pytest.raises((TypeError, AssertionError)):
-        # TODO(pandas 0.23) remove AssertionError -> raised in 0.23
+    with pytest.raises(TypeError):
         s + 1
 
     # boolean comparisons work
@@ -536,7 +539,10 @@ def test_groupby(df):
     assert_frame_equal(res, exp)
 
     # reductions ignore geometry column
-    res = df.groupby("value2").sum()
+    if not compat.PANDAS_GE_20:
+        res = df.groupby("value2").sum()
+    else:
+        res = df.groupby("value2").sum(numeric_only=True)
     exp = pd.DataFrame({"value1": [2, 1], "value2": [1, 2]}, dtype="int64").set_index(
         "value2"
     )