Repository: geopandas/geopandas
Pull request #2578 (2022-10-02 12:58:42)

DEPR: remove `_crs` attribute
This is almost entirely based on the TODOs in https://github.com/geopandas/geopandas/pull/2373.

The only deviation I've made is in the getter for `crs`, I re-raise an AttributeError for trying to access `gdf.crs` when the active geometry column is not set. I think this is clearer for a user - rather than getting an error about `gdf.geometry` not being defined propagating from inside `gdf.crs` - and there is also the current case of where `gdf.geometry` isn't defined but `"geometry"` is in `gdf.columns` which gives pd.Series has no attribute crs right now - but that would change after #2575.
 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index adfde9c5..a32a4734 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,8 +10,11 @@ New features and improvements:
 - Add where filter to ``read_file`` (#2552)
 
 Deprecations and compatibility notes:
+- Accessing the `crs` of a `GeoDataFrame` without active geometry column was deprecated and this now raises an AttributeError (#2578).
 
 Bug fixes:
+- Accessing `gdf.geometry` where the active geometry column is missing, and a column named `"geometry"` is present 
+  will now raise an `AttributeError`, rather than returning `gdf["geometry"]` (#2575) 
 
 Notes on (optional) dependencies:
 
diff --git a/doc/environment.yml b/doc/environment.yml
index 79e3287f..301d10a9 100644
--- a/doc/environment.yml
+++ b/doc/environment.yml
@@ -12,7 +12,7 @@ dependencies:
   - matplotlib
   - mapclassify
   - sphinx
-  - pydata-sphinx-theme=0.9
+  - pydata-sphinx-theme
   - numpydoc
   - ipython
   - pillow
diff --git a/doc/source/gallery/spatial_joins.ipynb b/doc/source/gallery/spatial_joins.ipynb
index 81f79a00..cfb80b72 100644
--- a/doc/source/gallery/spatial_joins.ipynb
+++ b/doc/source/gallery/spatial_joins.ipynb
@@ -202,7 +202,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "We're not limited to using the `intersection` binary predicate. Any of the `Shapely` geometry methods that return a Boolean can be used by specifying the `op` kwarg."
+    "We're not limited to using the `intersection` binary predicate. Any of the `Shapely` geometry methods that return a Boolean can be used by specifying the `predicate` kwarg."
    ]
   },
   {
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index f7673fa4..01f6fb6c 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -79,6 +79,8 @@ def set_use_pygeos(val=None):
     global USE_SHAPELY_20
     global PYGEOS_SHAPELY_COMPAT
 
+    env_use_pygeos = os.getenv("USE_PYGEOS", None)
+
     if val is not None:
         USE_PYGEOS = bool(val)
     else:
@@ -86,7 +88,6 @@ def set_use_pygeos(val=None):
 
             USE_PYGEOS = HAS_PYGEOS
 
-            env_use_pygeos = os.getenv("USE_PYGEOS", None)
             if env_use_pygeos is not None:
                 USE_PYGEOS = bool(int(env_use_pygeos))
 
@@ -133,6 +134,22 @@ def set_use_pygeos(val=None):
         except ImportError:
             raise ImportError(INSTALL_PYGEOS_ERROR)
 
+    if USE_PYGEOS and env_use_pygeos is None and SHAPELY_GE_20:
+        warnings.warn(
+            "Shapely 2.0 is installed, but because PyGEOS is also installed, "
+            "GeoPandas will still use PyGEOS by default for now. To force to use and "
+            "test Shapely 2.0, you have to set the environment variable USE_PYGEOS=0. "
+            "You can do this before starting the Python process, or in your code "
+            "before importing geopandas:"
+            "\n\nimport os\nos.environ['USE_PYGEOS'] = '0'\nimport geopandas\n\n"
+            "In a future release, GeoPandas will switch to using Shapely by default. "
+            "If you are using PyGEOS directly (calling PyGEOS functions on geometries "
+            "from GeoPandas), this will then stop working and you are encouraged to "
+            "migrate from PyGEOS to Shapely 2.0 "
+            "(https://shapely.readthedocs.io/en/latest/migration_pygeos.html).",
+            stacklevel=6,
+        )
+
     USE_SHAPELY_20 = (not USE_PYGEOS) and SHAPELY_GE_20
 
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index a563c905..bf531fb1 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -123,8 +123,10 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
     GeoSeries : Series object designed to store shapely geometry objects
     """
 
-    # TODO: remove "_crs" in 0.12
-    _metadata = ["_crs", "_geometry_column_name"]
+    _metadata = ["_geometry_column_name"]
+
+    _internal_names = DataFrame._internal_names + ["geometry"]
+    _internal_names_set = set(_internal_names)
 
     _geometry_column_name = DEFAULT_GEO_COLUMN_NAME
 
@@ -132,9 +134,6 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         with compat.ignore_shapely2_warnings():
             super().__init__(data, *args, **kwargs)
 
-        # TODO: to be removed in 0.12
-        self._crs = None
-
         # set_geometry ensures the geometry data have the proper dtype,
         # but is not called if `geometry=None` ('geometry' column present
         # in the data), so therefore need to ensure it here manually
@@ -347,9 +346,6 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         level = _ensure_geometry(level, crs=crs)
         frame[geo_column_name] = level
         frame._geometry_column_name = geo_column_name
-
-        # TODO: to be removed in 0.12
-        frame._crs = level.crs
         if not inplace:
             return frame
 
@@ -431,19 +427,14 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         GeoDataFrame.to_crs : re-project to another CRS
 
         """
-        # TODO: remove try/except in 0.12
         try:
             return self.geometry.crs
         except AttributeError:
-            # the active geometry column might not be set
-            warnings.warn(
-                "Accessing CRS of a GeoDataFrame without a geometry column is "
-                "deprecated and will be removed in GeoPandas 0.12. "
-                "Use GeoDataFrame.set_geometry to set the active geometry column.",
-                FutureWarning,
-                stacklevel=2,
+            raise AttributeError(
+                "The CRS attribute of a GeoDataFrame without an active "
+                "geometry column is not defined. Use GeoDataFrame.set_geometry "
+                "to set the active geometry column."
             )
-            return self._crs
 
     @crs.setter
     def crs(self, value):
@@ -459,15 +450,11 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                 self.geometry.values.crs = value
             else:
                 # column called 'geometry' without geometry
-                self._crs = None if not value else CRS.from_user_input(value)
-
-                # TODO: raise this error in 0.12. This already raises a FutureWarning
-                # TODO: defined in the crs property above
-                # raise ValueError(
-                #     "Assigning CRS to a GeoDataFrame without an active geometry "
-                #     "column is not supported. Use GeoDataFrame.set_geometry to set "
-                #     "the active geometry column.",
-                # )
+                raise ValueError(
+                    "Assigning CRS to a GeoDataFrame without an active geometry "
+                    "column is not supported. Use GeoDataFrame.set_geometry to set "
+                    "the active geometry column.",
+                )
 
     def __setstate__(self, state):
         # overriding DataFrame method for compat with older pickles (CRS handling)
@@ -1449,24 +1436,8 @@ individually so that features may have different properties
             if pd.api.types.is_scalar(value) or isinstance(value, BaseGeometry):
                 value = [value] * self.shape[0]
             try:
-                # TODO: remove this use of _crs in 0.12
-                warn = False
-                if not (hasattr(self, "geometry") and hasattr(self.geometry, "crs")):
-                    crs = self._crs
-                    warn = True
-                else:
-                    crs = getattr(self, "crs", None)
+                crs = getattr(self, "crs", None)
                 value = _ensure_geometry(value, crs=crs)
-                if warn and crs is not None:
-                    warnings.warn(
-                        "Setting geometries to a GeoDataFrame without a geometry "
-                        "column will currently preserve the CRS, if present. "
-                        "This is deprecated, and in the future the CRS will be lost "
-                        "in this case. You can use set_crs(..) on the result to "
-                        "set the CRS manually.",
-                        FutureWarning,
-                        stacklevel=2,
-                    )
             except TypeError:
                 warnings.warn("Geometry column does not contain geometry.")
         super().__setitem__(key, value)


Test patch:
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index a587e5ae..1d9200a2 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -297,12 +297,14 @@ class TestGeometryArrayCRS:
 
         # geometry column without geometry
         df = GeoDataFrame({"geometry": [0, 1]})
-        with pytest.warns(
-            FutureWarning, match="Accessing CRS of a GeoDataFrame without a geometry"
+        with pytest.raises(
+            ValueError,
+            match="Assigning CRS to a GeoDataFrame without an active geometry",
         ):
             df.crs = 27700
-        with pytest.warns(
-            FutureWarning, match="Accessing CRS of a GeoDataFrame without a geometry"
+        with pytest.raises(
+            AttributeError,
+            match="The CRS attribute of a GeoDataFrame without an active",
         ):
             assert df.crs == self.osgb
 
@@ -310,8 +312,9 @@ class TestGeometryArrayCRS:
         df = GeoDataFrame({"col": range(10)}, geometry=self.arr)
         df["geom2"] = df.geometry.centroid
         subset = df[["col", "geom2"]]
-        with pytest.warns(
-            FutureWarning, match="Accessing CRS of a GeoDataFrame without a geometry"
+        with pytest.raises(
+            AttributeError,
+            match="The CRS attribute of a GeoDataFrame without an active",
         ):
             assert subset.crs == self.osgb
 
@@ -355,17 +358,13 @@ class TestGeometryArrayCRS:
         arr = from_shapely(self.geoms)
         df = GeoDataFrame({"col1": [1, 2], "geometry": arr}, crs=4326)
 
-        # create a dataframe without geometry column, but currently has cached _crs
+        # override geometry with non geometry
         with pytest.warns(UserWarning):
             df["geometry"] = 1
 
-        # assigning a list of geometry object will currently use _crs
-        with pytest.warns(
-            FutureWarning,
-            match="Setting geometries to a GeoDataFrame without a geometry",
-        ):
-            df["geometry"] = self.geoms
-        assert df.crs == self.wgs
+        # assigning a list of geometry object doesn't have cached access to 4326
+        df["geometry"] = self.geoms
+        assert df.crs is None
 
     @pytest.mark.parametrize(
         "scalar", [None, Point(0, 0), LineString([(0, 0), (1, 1)])]
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index db639447..9e3112f6 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -361,6 +361,17 @@ class TestDataFrame:
         with pytest.raises(AttributeError, match=msg_no_other_geo_cols):
             GeoDataFrame().geometry
 
+    def test_get_geometry_geometry_inactive(self):
+        # https://github.com/geopandas/geopandas/issues/2574
+        df = self.df.assign(geom2=self.df.geometry).set_geometry("geom2")
+        df = df.loc[:, ["BoroName", "geometry"]]
+        assert df._geometry_column_name == "geom2"
+        msg_geo_col_missing = "is not present. "
+        # Check that df.geometry raises if active geometry column is missing,
+        # it should not fall back to column named "geometry"
+        with pytest.raises(AttributeError, match=msg_geo_col_missing):
+            df.geometry
+
     def test_align(self):
         df = self.df2
 
diff --git a/geopandas/tests/test_merge.py b/geopandas/tests/test_merge.py
index 0de01f34..5794b6ca 100644
--- a/geopandas/tests/test_merge.py
+++ b/geopandas/tests/test_merge.py
@@ -112,11 +112,11 @@ class TestMerging:
         # Note this is not consistent with concat([gdf, gdf], axis=1) where the
         # left metadata is set on the result. This is deliberate for now.
         assert type(result) is GeoDataFrame
-        self._check_metadata(result, geometry_column_name=None, crs=None)
+        assert result._geometry_column_name is None
         assert_index_equal(pd.Index([0, 1]), result.columns)
 
         gseries2.name = "foo"
         result2 = pd.concat([gseries2, self.gseries], axis=1)
         assert type(result2) is GeoDataFrame
-        self._check_metadata(result2, geometry_column_name=None, crs=None)
+        assert result._geometry_column_name is None
         assert_index_equal(pd.Index(["foo", 0]), result2.columns)