Repository: geopandas/geopandas
Pull request #3316 (2024-05-31 19:08:18)

ENH: allow None in set_crs to remove CRS
Allows passing `None` to `set_crs` to remove CRS information from GeometryArray.

xref https://github.com/geopandas/geopandas/pull/3088#issuecomment-2142278823

Will push one more commit using this across our tests to eliminate warnings from #3088.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2adfdc0b..16221d31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,6 +62,8 @@ New features and improvements:
 files based on a bounding box, and write out a bounding box column to parquet files (#3282)
 - `align` keyword in binary methods now defaults to `None`, treated as True. Explicit True
   will silence the warning about mismachted indices. (#3212)
+- `GeoSeries.set_crs` can now be used to remove CRS information by passing
+  `crs=None, allow_override=True`. (#3316)
 
 Backwards incompatible API changes:
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 6544b64d..2a179b39 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1477,16 +1477,20 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
         If there are multiple geometry columns within the GeoDataFrame, only
         the CRS of the active geometry column is set.
 
-        NOTE: The underlying geometries are not transformed to this CRS. To
+        Pass ``None`` to remove CRS from the active geometry column.
+
+        Notes
+        -----
+        The underlying geometries are not transformed to this CRS. To
         transform the geometries to a new CRS, use the ``to_crs`` method.
 
         Parameters
         ----------
-        crs : pyproj.CRS, optional if `epsg` is specified
+        crs : pyproj.CRS | None, optional
             The value can be anything accepted
             by :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
             such as an authority string (eg "EPSG:4326") or a WKT string.
-        epsg : int, optional if `crs` is specified
+        epsg : int, optional
             EPSG code specifying the projection.
         inplace : bool, default False
             If True, the CRS of the GeoDataFrame will be changed in place
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 8bcabb99..505304e7 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -654,7 +654,6 @@ class GeoSeries(GeoPandasBase, Series):
         from geopandas import GeoDataFrame
 
         data = GeoDataFrame({"geometry": self}, index=self.index)
-        data.crs = self.crs
         data.to_file(filename, driver, index=index, **kwargs)
 
     #
@@ -999,12 +998,16 @@ class GeoSeries(GeoPandasBase, Series):
         """
         Set the Coordinate Reference System (CRS) of a ``GeoSeries``.
 
-        NOTE: The underlying geometries are not transformed to this CRS. To
+        Pass ``None`` to remove CRS from the ``GeoSeries``.
+
+        Notes
+        -----
+        The underlying geometries are not transformed to this CRS. To
         transform the geometries to a new CRS, use the ``to_crs`` method.
 
         Parameters
         ----------
-        crs : pyproj.CRS, optional if `epsg` is specified
+        crs : pyproj.CRS | None, optional
             The value can be anything accepted
             by :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
             such as an authority string (eg "EPSG:4326") or a WKT string.
@@ -1072,8 +1075,6 @@ class GeoSeries(GeoPandasBase, Series):
             crs = CRS.from_user_input(crs)
         elif epsg is not None:
             crs = CRS.from_epsg(epsg)
-        else:
-            raise ValueError("Must pass either crs or epsg.")
 
         if not allow_override and self.crs is not None and not self.crs == crs:
             raise ValueError(
@@ -1086,7 +1087,7 @@ class GeoSeries(GeoPandasBase, Series):
             result = self.copy()
         else:
             result = self
-        result.crs = crs
+        result.array.crs = crs
         return result
 
     def to_crs(


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 86cc3078..161c18c9 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -636,7 +636,7 @@ def test_missing_crs(tmpdir, file_format, naturalearth_lowres):
     reader, writer = file_format
 
     df = read_file(naturalearth_lowres)
-    df.crs = None
+    df.geometry.array.crs = None
 
     filename = os.path.join(str(tmpdir), "test.pq")
     writer(df, filename)
@@ -981,7 +981,6 @@ def test_parquet_read_partitioned_dataset_fsspec(tmpdir, naturalearth_lowres):
     ["point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon"],
 )
 def test_read_parquet_geoarrow(geometry_type):
-
     result = geopandas.read_parquet(
         DATA_PATH
         / "arrow"
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index d17f28a0..4d79e16a 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -461,8 +461,7 @@ def test_to_file_crs(tmpdir, engine, nybb_filename):
     assert result.crs == "epsg:3857"
 
     # specify CRS for gdf without one
-    df2 = df.copy()
-    df2.crs = None
+    df2 = df.set_crs(None, allow_override=True)
     df2.to_file(tempfilename, crs=2263, engine=engine)
     df = GeoDataFrame.from_file(tempfilename, engine=engine)
     assert df.crs == "epsg:2263"
@@ -555,7 +554,7 @@ def test_empty_crs(tmpdir, driver, ext, engine):
 
     if ext == ".geojson":
         # geojson by default assumes epsg:4326
-        df.crs = "EPSG:4326"
+        df.geometry.array.crs = "EPSG:4326"
 
     assert_geodataframe_equal(result, df)
 
diff --git a/geopandas/io/tests/test_geoarrow.py b/geopandas/io/tests/test_geoarrow.py
index 68881b6f..c9502264 100644
--- a/geopandas/io/tests/test_geoarrow.py
+++ b/geopandas/io/tests/test_geoarrow.py
@@ -36,7 +36,6 @@ def pa_array(array):
 
 
 def assert_table_equal(left, right, check_metadata=True):
-
     geom_type = left["geometry"].type
     # in case of Points (directly the inner fixed_size_list or struct type)
     # -> there are NaNs for empties -> we need to compare them separately
@@ -154,7 +153,7 @@ def test_geoarrow_export(geometry_type, dim, geometry_encoding, interleaved):
     df["geometry"] = GeoSeries.from_wkb(df["geometry"])
     df["row_number"] = df["row_number"].astype("int32")
     df = GeoDataFrame(df)
-    df.geometry.crs = None
+    df.geometry.array.crs = None
 
     # Read the expected data
     if geometry_encoding == "WKB":
@@ -349,7 +348,7 @@ def test_geoarrow_export_with_extension_types(geometry_type, dim):
     df["geometry"] = GeoSeries.from_wkb(df["geometry"])
     df["row_number"] = df["row_number"].astype("int32")
     df = GeoDataFrame(df)
-    df.geometry.crs = None
+    df.geometry.array.crs = None
 
     pytest.importorskip("geoarrow.pyarrow")
 
diff --git a/geopandas/io/tests/test_sql.py b/geopandas/io/tests/test_sql.py
index 10712d61..524e7e49 100644
--- a/geopandas/io/tests/test_sql.py
+++ b/geopandas/io/tests/test_sql.py
@@ -490,7 +490,7 @@ class TestIO:
         table = "nybb"
 
         # Write to db
-        df_nybb.crs = None
+        df_nybb.geometry.array.crs = None
         with pytest.warns(UserWarning, match="Could not parse CRS from the GeoDataF"):
             write_postgis(df_nybb, con=engine, name=table, if_exists="replace")
         # Validate that srid is -1
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index 610b4b1e..476fed14 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -213,7 +213,7 @@ class TestGeometryArrayCRS:
         assert s.values.crs == self.osgb
 
         # manually change CRS
-        s.crs = 4326
+        s = s.set_crs(4326, allow_override=True)
         assert s.crs == self.wgs
         assert s.values.crs == self.wgs
 
@@ -418,7 +418,7 @@ class TestGeometryArrayCRS:
             FutureWarning, match="You are adding a column named 'geometry'"
         ):
             df["geometry"] = scalar
-        df.crs = 4326
+        df = df.set_crs(4326)
         assert df.crs == self.wgs
         assert df.geometry.crs == self.wgs
         assert df.geometry.values.crs == self.wgs
@@ -741,6 +741,7 @@ class TestSetCRS:
         assert non_naive.crs == "EPSG:3857"
         assert result.crs == "EPSG:3857"
 
-        # raise error when no crs is passed
-        with pytest.raises(ValueError):
-            naive.set_crs(crs=None, epsg=None)
+        # set CRS to None
+        result = non_naive.set_crs(crs=None, allow_override=True)
+        assert result.crs is None
+        assert non_naive.crs == "EPSG:3857"
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index 6cf16c35..de0ae17b 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -70,7 +70,7 @@ def test_dissolve_retains_existing_crs(nybb_polydf):
 
 
 def test_dissolve_retains_nonexisting_crs(nybb_polydf):
-    nybb_polydf.crs = None
+    nybb_polydf.geometry.array.crs = None
     test = nybb_polydf.dissolve("manhattan_bronx")
     assert test.crs is None
 
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index 2a872249..f1b15d65 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -378,7 +378,7 @@ class TestExplore:
     def test_no_crs(self):
         """Naive geometry get no tiles"""
         df = self.world.copy()
-        df.crs = None
+        df.geometry.array.crs = None
         m = df.explore()
         assert "openstreetmap" not in m.to_dict()["children"].keys()
 
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 9c0d0023..8efb81ab 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -415,8 +415,7 @@ class TestDataFrame:
 
         if compat.HAS_PYPROJ:
             # assert crs is / is not preserved on mixed dataframes
-            df_nocrs = df.copy()
-            df_nocrs.crs = None
+            df_nocrs = df.copy().set_crs(None, allow_override=True)
             res1, res2 = df.align(df_nocrs)
             assert_geodataframe_equal(res1, df)
             assert res1.crs is not None
@@ -443,10 +442,8 @@ class TestDataFrame:
         assert_geodataframe_equal(res2, exp2)
 
         if compat.HAS_PYPROJ:
-            df2_nocrs = df2.copy()
-            df2_nocrs.crs = None
-            exp2_nocrs = exp2.copy()
-            exp2_nocrs.crs = None
+            df2_nocrs = df2.copy().set_crs(None, allow_override=True)
+            exp2_nocrs = exp2.copy().set_crs(None, allow_override=True)
             res1, res2 = df1.align(df2_nocrs)
             assert_geodataframe_equal(res1, exp1)
             assert res1.crs is not None
@@ -480,7 +477,7 @@ class TestDataFrame:
         assert coord == [970217.0223999023, 145643.33221435547]
 
     def test_to_json_no_crs(self):
-        self.df.crs = None
+        self.df.geometry.array.crs = None
         with pytest.raises(ValueError, match="CRS is not set"):
             self.df.to_json(to_wgs84=True)
 
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index d0314bed..dc428a53 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -318,14 +318,12 @@ class TestGeomMethods:
             result = getattr(gdf, op)
         fcmp(result, expected)
 
-    # TODO re-enable for all operations once we use pyproj > 2
-    # def test_crs_warning(self):
-    #     # operations on geometries should warn for different CRS
-    #     no_crs_g3 = self.g3.copy()
-    #     no_crs_g3.crs = None
-    #     with pytest.warns(UserWarning):
-    #         self._test_binary_topological('intersection', self.g3,
-    #                                       self.g3, no_crs_g3)
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
+    def test_crs_warning(self):
+        # operations on geometries should warn for different CRS
+        no_crs_g3 = self.g3.copy().set_crs(None, allow_override=True)
+        with pytest.warns(UserWarning):
+            self._test_binary_topological("intersection", self.g3, self.g3, no_crs_g3)
 
     def test_alignment_warning(self):
         with pytest.warns(
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
index aed4eb1e..39590176 100644
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -38,8 +38,7 @@ class TestSeries:
         self.sq = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
         self.g1 = GeoSeries([self.t1, self.sq])
         self.g2 = GeoSeries([self.sq, self.t1])
-        self.g3 = GeoSeries([self.t1, self.t2])
-        self.g3.crs = "epsg:4326"
+        self.g3 = GeoSeries([self.t1, self.t2], crs="epsg:4326")
         self.g4 = GeoSeries([self.t2, self.t1])
         self.na = GeoSeries([self.t1, self.t2, Polygon()])
         self.na_none = GeoSeries([self.t1, self.t2, None])
@@ -84,17 +83,14 @@ class TestSeries:
 
     @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
     def test_align_crs(self):
-        a1 = self.a1
-        a1.crs = "epsg:4326"
-        a2 = self.a2
-        a2.crs = "epsg:31370"
+        a1 = self.a1.set_crs("epsg:4326")
+        a2 = self.a2.set_crs("epsg:31370")
 
         res1, res2 = a1.align(a2)
         assert res1.crs == "epsg:4326"
         assert res2.crs == "epsg:31370"
 
-        a2.crs = None
-        res1, res2 = a1.align(a2)
+        res1, res2 = a1.align(a2.set_crs(None, allow_override=True))
         assert res1.crs == "epsg:4326"
         assert res2.crs is None
 
@@ -341,8 +337,7 @@ class TestSeries:
         assert geom_almost_equals(self.g3, reprojected_back)
 
         # Set to equivalent string, convert, compare to original
-        copy = self.g3.copy()
-        copy.crs = "epsg:4326"
+        copy = self.g3.copy().set_crs("epsg:4326", allow_override=True)
         reprojected = copy.to_crs({"proj": "utm", "zone": "30"})
         reprojected_back = reprojected.to_crs(epsg=4326)
         assert geom_almost_equals(self.g3, reprojected_back)
@@ -542,8 +537,7 @@ def test_isna_empty_geoseries():
 
 @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
 def test_geoseries_crs():
-    gs = GeoSeries()
-    gs.crs = "IGNF:ETRS89UTM28"
+    gs = GeoSeries().set_crs("IGNF:ETRS89UTM28")
     assert gs.crs.to_authority() == ("IGNF", "ETRS89UTM28")
 
 
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index 3defe9cc..d810e22a 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -81,7 +81,7 @@ def test_overlay(dfs_index, how):
         expected = read_file(
             os.path.join(DATA, "polys", "df1_df2-{0}.geojson".format(name))
         )
-        expected.crs = None
+        expected.geometry.array.crs = None
         for col in expected.columns[expected.dtypes == "int32"]:
             expected[col] = expected[col].astype("int64")
         return expected
diff --git a/geopandas/tests/test_testing.py b/geopandas/tests/test_testing.py
index 6ff207cf..cb9d0b68 100644
--- a/geopandas/tests/test_testing.py
+++ b/geopandas/tests/test_testing.py
@@ -47,9 +47,9 @@ df1 = GeoDataFrame({"col1": [1, 2], "geometry": s1})
 df2 = GeoDataFrame({"col1": [1, 2], "geometry": s2})
 
 s4 = s1.copy()
-s4.crs = 4326
+s4.array.crs = 4326
 s5 = s2.copy()
-s5.crs = 27700
+s5.array.crs = 27700
 
 s6 = GeoSeries(
     [
@@ -104,7 +104,7 @@ def test_geodataframe():
 
     assert_geodataframe_equal(df5, df4, check_like=True)
     if HAS_PYPROJ:
-        df5.geom2.crs = 3857
+        df5["geom2"] = df5.geom2.set_crs(3857, allow_override=True)
         with pytest.raises(AssertionError):
             assert_geodataframe_equal(df5, df4, check_like=True)