Repository: geopandas/geopandas
Pull request #2992 (2023-09-01 07:12:46)

CI: test pandas future options (CoW and strings) in dev build

jorisvandenbossche: With the one fix to `iterfeatures`, all tests seem to be passing: all failures are known ones happening on main as well (https://github.com/geopandas/geopandas/issues/2998), and the one remaining failure `test_rebuild_on_multiple_col_selection` just needs an update (it's specifically testing that a multi-column getitem is copying the underlying arrays)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 98223468..7a4e770f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ API changes:
 
 New methods:
 
+- Added ``remove_repeated_points`` method from shapely to GeoSeries/GeoDataframe (#2940).
 - Added ``frechet_distance()`` method from shapely to GeoSeries/GeoDataframe (#2929).
 - Added ``segmentize`` method from shapely to GeoSeries/GeoDataFrame (#2910).
 - Added ``extract_unique_points`` method from shapely to GeoSeries/GeoDataframe (#2915).
@@ -22,6 +23,7 @@ New methods:
 - Added ``offset_curve`` method from shapely to GeoSeries/GeoDataframe (#2902).
 - Added ``shortest_line`` method from shapely to GeoSeries/GeoDataframe (#2960).
 - Added ``minimum_rotated_rectangle`` method from shapely to GeoSeries/GeoDataframe (#2541).
+- Added ``reverse`` method from shapely to GeoSeries/GeoDataframe (#2988).
 
 New features and improvements:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index b76c60d6..c33dd575 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -98,6 +98,8 @@ Constructive methods and attributes
    GeoSeries.minimum_bounding_circle
    GeoSeries.minimum_rotated_rectangle
    GeoSeries.normalize
+   GeoSeries.remove_repeated_points
+   GeoSeries.reverse
    GeoSeries.sample_points
    GeoSeries.segmentize
    GeoSeries.shortest_line
diff --git a/doc/source/docs/user_guide/geometric_manipulations.rst b/doc/source/docs/user_guide/geometric_manipulations.rst
index 4d49e1ed..0d5985e5 100644
--- a/doc/source/docs/user_guide/geometric_manipulations.rst
+++ b/doc/source/docs/user_guide/geometric_manipulations.rst
@@ -60,6 +60,11 @@ Constructive methods
   Returns a :class:`~geopandas.GeoSeries` containing a `Linestring` or `MultiLineString`
   geometry at a distance from the object on its right or its left side.
 
+.. method:: GeoSeries.remove_repeated_points
+
+   Returns a :class:`~geopandas.GeoSeries` containing a copy of the input geometry 
+   with repeated points removed.
+
 .. method:: GeoSeries.simplify(tolerance, preserve_topology=True)
 
   Returns a :class:`~geopandas.GeoSeries` containing a simplified representation of
diff --git a/doc/source/docs/user_guide/io.rst b/doc/source/docs/user_guide/io.rst
index 5ea0422a..dc3792be 100644
--- a/doc/source/docs/user_guide/io.rst
+++ b/doc/source/docs/user_guide/io.rst
@@ -7,22 +7,35 @@ Reading spatial data
 ---------------------
 
 GeoPandas can read almost any vector-based spatial data format including ESRI
-shapefile, GeoJSON files and more using the command::
+shapefile, GeoJSON files and more using the :func:`geopandas.read_file` command::
 
-    geopandas.read_file()
+    geopandas.read_file(...)
 
 which returns a GeoDataFrame object. This is possible because GeoPandas makes
-use of the great `Fiona <http://fiona.readthedocs.io/en/latest/manual.html>`_
-library, which in turn makes use of a massive open-source program called
+use of the massive open-source program called
 `GDAL/OGR <http://www.gdal.org/>`_ designed to facilitate spatial data
-transformations.
+transformations, through the Python packages `Fiona <http://fiona.readthedocs.io/en/latest/manual.html>`_
+or `pyogrio <https://pyogrio.readthedocs.io/en/stable/>`_, which both provide bindings to GDAL.
+
+.. note::
+
+    GeoPandas currently defaults to use Fiona as the engine in ``read_file``. However,
+    GeoPandas 1.0 will switch to use pyogrio as the default engine, since pyogrio can
+    provide a significant speedup compared to Fiona. We recommend to already install
+    pyogrio and specify the engine by using the ``engine`` keyword
+    (``geopandas.read_file(..., engine="pyogrio")``), or by setting the default for the
+    ``engine`` keyword globally with::
+
+        geopandas.options.io_engine = "pyogrio"
 
 Any arguments passed to :func:`geopandas.read_file` after the file name will be
-passed directly to :func:`fiona.open`, which does the actual data importation. In
-general, :func:`geopandas.read_file` is pretty smart and should do what you want
+passed directly to :func:`fiona.open` or :func:`pyogrio.read_dataframe`, which
+does the actual data importation.
+In general, :func:`geopandas.read_file` is pretty smart and should do what you want
 without extra arguments, but for more help, type::
 
     import fiona; help(fiona.open)
+    import pyogrio; help(pyogrio.read_dataframe)
 
 Among other things, one can explicitly set the driver (shapefile, GeoJSON) with
 the ``driver`` keyword, or pick a single layer from a multi-layered file with
@@ -30,17 +43,7 @@ the ``layer`` keyword::
 
     countries_gdf = geopandas.read_file("package.gpkg", layer='countries')
 
-Currently fiona only exposes the default drivers. To display those, type::
-
-    import fiona; fiona.supported_drivers
-
-There is a `list of available drivers <https://github.com/Toblerity/Fiona/blob/master/fiona/drvsupport.py>`_
-which are unexposed but supported (depending on the GDAL-build). You can activate
-these on runtime by updating the `supported_drivers` dictionary like::
-
-    fiona.supported_drivers["NAS"] = "raw"
-
-Where supported in :mod:`Fiona`, GeoPandas can also load resources directly from
+GeoPandas can also load resources directly from
 a web URL, for example for GeoJSON files from `geojson.xyz <http://geojson.xyz/>`_::
 
     url = "http://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_110m_land.geojson"
@@ -60,7 +63,7 @@ specify the filename::
 
     zipfile = "zip:///Users/name/Downloads/gadm36_AFG_shp.zip!data/gadm36_AFG_1.shp"
 
-It is also possible to read any file-like objects with a :func:`os.read` method, such
+It is also possible to read any file-like objects with a :func:`~os.read` method, such
 as a file handler (e.g. via built-in :func:`open` function) or :class:`~io.StringIO`::
 
     filename = "test.geojson"
@@ -84,15 +87,13 @@ You can also read path objects::
 Reading subsets of the data
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Since geopandas is powered by Fiona, which is powered by GDAL, you can take advantage of
-pre-filtering when loading in larger datasets. This can be done geospatially with a geometry
-or bounding box. You can also filter rows loaded with a slice. Read more at :func:`geopandas.read_file`.
+Since geopandas is powered by GDAL, you can take advantage of pre-filtering when loading
+in larger datasets. This can be done geospatially with a geometry or bounding box. You
+can also filter rows loaded with a slice. Read more at :func:`geopandas.read_file`.
 
 Geometry filter
 ^^^^^^^^^^^^^^^
 
-.. versionadded:: 0.7.0
-
 The geometry filter only loads data that intersects with the geometry.
 
 .. code-block:: python
@@ -110,8 +111,6 @@ The geometry filter only loads data that intersects with the geometry.
 Bounding box filter
 ^^^^^^^^^^^^^^^^^^^
 
-.. versionadded:: 0.1.0
-
 The bounding box filter only loads data that intersects with the bounding box.
 
 .. code-block:: python
@@ -127,8 +126,6 @@ The bounding box filter only loads data that intersects with the bounding box.
 Row filter
 ^^^^^^^^^^
 
-.. versionadded:: 0.7.0
-
 Filter the rows loaded in from the file using an integer (for the first n rows)
 or a slice object.
 
@@ -195,6 +192,24 @@ Load in a subset of data with a `SQL WHERE clause <https://gdal.org/user/ogr_sql
         where="subborough='Coney Island'",
     )
 
+Supported drivers
+~~~~~~~~~~~~~~~~~
+
+Currently fiona only exposes the default drivers. To display those, type::
+
+    import fiona; fiona.supported_drivers
+
+There is a `list of available drivers <https://github.com/Toblerity/Fiona/blob/master/fiona/drvsupport.py>`_
+which are unexposed but supported (depending on the GDAL-build). You can activate
+these on runtime by updating the `supported_drivers` dictionary like::
+
+    fiona.supported_drivers["NAS"] = "raw"
+
+When using pyogrio, all drivers supported by the GDAL installation are enabled,
+and you can check those with::
+
+    import pyogrio; pyogrio.list_drivers()
+
 
 Writing spatial data
 ---------------------
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 08bd0781..ed51688a 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -144,7 +144,7 @@ Required dependencies:
 
 Further, optional dependencies are:
 
-- `pyogrio`_ (optional; experimental alternative for fiona)
+- `pyogrio`_ (optional; faster alternative for fiona, will become the default in GeoPandas 1.0)
 - `rtree`_ (optional; spatial index to improve performance and required for
   overlay operations; interface to `libspatialindex`_)
 - `psycopg2`_ (optional; for PostGIS connection)
diff --git a/geopandas/_config.py b/geopandas/_config.py
index cdfa5578..6f29933a 100644
--- a/geopandas/_config.py
+++ b/geopandas/_config.py
@@ -117,4 +117,28 @@ use_pygeos = Option(
 )
 
 
-options = Options({"display_precision": display_precision, "use_pygeos": use_pygeos})
+def _validate_io_engine(value):
+    if value is not None:
+        if value not in ("pyogrio", "fiona"):
+            raise ValueError(f"Expected 'pyogrio' or 'fiona', got '{value}'")
+
+
+io_engine = Option(
+    key="io_engine",
+    default_value=None,
+    doc=(
+        "The default engine for ``read_file`` and ``to_file``. "
+        "Options are 'pyogrio' and 'fiona'."
+    ),
+    validator=_validate_io_engine,
+    callback=None,
+)
+
+
+options = Options(
+    {
+        "display_precision": display_precision,
+        "use_pygeos": use_pygeos,
+        "io_engine": io_engine,
+    }
+)
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index 79994418..5e4a0649 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -8,15 +8,13 @@ import warnings
 
 import numpy as np
 import pandas as pd
-
 import shapely
 import shapely.geometry
 import shapely.geos
 import shapely.ops
+import shapely.validation
 import shapely.wkb
 import shapely.wkt
-import shapely.validation
-
 from shapely.geometry.base import BaseGeometry
 
 from . import _compat as compat
@@ -731,6 +729,25 @@ def interiors(data):
     return data
 
 
+def remove_repeated_points(data, tolerance=0.0):
+    if compat.USE_SHAPELY_20:
+        return shapely.remove_repeated_points(data, tolerance=tolerance)
+    if compat.USE_PYGEOS and compat.SHAPELY_GE_20:
+        warnings.warn(
+            "PyGEOS does not support remove_repeated_points, and Shapely >= 2 is "
+            "installed, thus using Shapely and not PyGEOS to remove repeated points.",
+            stacklevel=4,
+        )
+        return pygeos.from_shapely(
+            shapely.remove_repeated_points(to_shapely(data), tolerance=tolerance)
+        )
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def representative_point(data):
     if compat.USE_PYGEOS:
         return pygeos.point_on_surface(data)
@@ -1093,9 +1110,10 @@ def _shapely_normalize(geom):
     """
     Small helper function for now because it is not yet available in Shapely.
     """
-    from shapely.geos import lgeos
+    from ctypes import c_int, c_void_p
+
     from shapely.geometry.base import geom_factory
-    from ctypes import c_void_p, c_int
+    from shapely.geos import lgeos
 
     lgeos._lgeos.GEOSNormalize_r.restype = c_int
     lgeos._lgeos.GEOSNormalize_r.argtypes = [c_void_p, c_void_p]
@@ -1132,6 +1150,18 @@ def make_valid(data):
     return out
 
 
+def reverse(data):
+    if compat.USE_SHAPELY_20:
+        return shapely.reverse(data)
+    elif compat.USE_PYGEOS:
+        return pygeos.reverse(data)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def project(data, other, normalized=False):
     if compat.USE_SHAPELY_20:
         return shapely.line_locate_point(data, other, normalized=normalized)
diff --git a/geopandas/array.py b/geopandas/array.py
index 0977a2d4..0903fdba 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -560,6 +560,12 @@ class GeometryArray(ExtensionArray):
         # no GeometryArray as result
         return vectorized.interiors(self._data)
 
+    def remove_repeated_points(self, tolerance=0.0):
+        return GeometryArray(
+            vectorized.remove_repeated_points(self._data, tolerance=tolerance),
+            crs=self.crs,
+        )
+
     def representative_point(self):
         return GeometryArray(vectorized.representative_point(self._data), crs=self.crs)
 
@@ -577,6 +583,9 @@ class GeometryArray(ExtensionArray):
     def make_valid(self):
         return GeometryArray(vectorized.make_valid(self._data), crs=self.crs)
 
+    def reverse(self):
+        return GeometryArray(vectorized.reverse(self._data), crs=self.crs)
+
     def segmentize(self, max_segment_length):
         return GeometryArray(
             vectorized.segmentize(self._data, max_segment_length),
@@ -1178,7 +1187,7 @@ class GeometryArray(ExtensionArray):
         # note ExtensionArray usage of value_counts only specifies dropna,
         # so sort, normalize and bins are not arguments
         values = to_wkb(self)
-        from pandas import Series, Index
+        from pandas import Index, Series
 
         result = Series(values).value_counts(dropna=dropna)
         # value_counts converts None to nan, need to convert back for from_wkb to work
diff --git a/geopandas/base.py b/geopandas/base.py
index eca40ed8..63a0a6eb 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -899,6 +899,45 @@ GeometryCollection
         """
         return _delegate_property("interiors", self)
 
+    def remove_repeated_points(self, tolerance=0.0):
+        """Returns a ``GeoSeries`` containing a copy of the input geometry
+        with repeated points removed.
+
+        From the start of the coordinate sequence, each next point within the
+        tolerance is removed.
+
+        Removing repeated points with a non-zero tolerance may result in an invalid
+        geometry being returned.
+
+        Parameters
+        ----------
+        tolerance : float, default 0.0
+            Remove all points within this distance of each other. Use 0.0
+            to remove only exactly repeated points (the default).
+
+        Examples
+        --------
+
+        >>> from shapely import LineString, Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...        LineString([(0, 0), (0, 0), (1, 0)]),
+        ...        Polygon([(0, 0), (0, 0.5), (0, 1), (0.5, 1), (0,0)]),
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0    LINESTRING (0.000 0.000, 0.000 0.000, 1.000 0....
+        1    POLYGON ((0.000 0.000, 0.000 0.500, 0.000 1.00...
+        dtype: geometry
+
+        >>> s.remove_repeated_points(tolerance=0.0)
+        0                LINESTRING (0.000 0.000, 1.000 0.000)
+        1    POLYGON ((0.000 0.000, 0.000 0.500, 0.000 1.00...
+        dtype: geometry
+        """
+        return _delegate_geo_method("remove_repeated_points", self, tolerance=tolerance)
+
     def representative_point(self):
         """Returns a ``GeoSeries`` of (cheaply computed) points that are
         guaranteed to be within each geometry.
@@ -1070,6 +1109,38 @@ GeometryCollection
         """
         return _delegate_geo_method("make_valid", self)
 
+    def reverse(self):
+        """Returns a ``GeoSeries`` with the order of coordinates reversed.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         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.reverse()
+        0    POLYGON ((0.00000 0.00000, 0.00000 1.00000, 1....
+        1    LINESTRING (1.00000 0.00000, 1.00000 1.00000, ...
+        2    POINT (0.00000 0.00000)
+        dtype: geometry
+
+        See also
+        --------
+        GeoSeries.normalize : normalize order of coordinates
+        """
+        return _delegate_geo_method("reverse", self)
+
     def segmentize(self, max_segment_length):
         """Returns a ``GeoSeries`` with vertices added to line segments based on
         maximum segment length.
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 9f533a9e..ef456d4a 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -922,13 +922,13 @@ individually so that features may have different properties
         if len(properties_cols) > 0:
             # convert to object to get python scalars.
             properties_cols = self[properties_cols]
-            properties = properties_cols.astype(object).values
+            properties = properties_cols.astype(object)
             na_mask = pd.isna(properties_cols).values
 
             if na == "null":
                 properties[na_mask] = None
 
-            for i, row in enumerate(properties):
+            for i, row in enumerate(properties.values):
                 geom = geometries[i]
 
                 if na == "drop":
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 8b1572b8..c6d5f9f8 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -94,7 +94,13 @@ def _check_pyogrio(func):
 
 
 def _check_engine(engine, func):
-    # default to "fiona" if installed, otherwise try pyogrio
+    # if not specified through keyword or option, then default to "fiona" if
+    # installed, otherwise try pyogrio
+    if engine is None:
+        import geopandas
+
+        engine = geopandas.options.io_engine
+
     if engine is None:
         _import_fiona()
         if fiona:
@@ -176,7 +182,16 @@ def _read_file(filename, bbox=None, mask=None, rows=None, engine=None, **kwargs)
     """
     Returns a GeoDataFrame from a file or URL.
 
-    .. versionadded:: 0.7.0 mask, rows
+    .. note::
+
+        GeoPandas currently defaults to use Fiona as the engine in ``read_file``.
+        However, GeoPandas 1.0 will switch to use pyogrio as the default engine, since
+        pyogrio can provide a significant speedup compared to Fiona. We recommend to
+        already install pyogrio and specify the engine by using the ``engine`` keyword
+        (``geopandas.read_file(..., engine="pyogrio")``), or by setting the default for
+        the ``engine`` keyword globally with::
+
+            geopandas.options.io_engine = "pyogrio"
 
     Parameters
     ----------
@@ -487,6 +502,17 @@ def _to_file(
     >>> import fiona
     >>> fiona.supported_drivers  # doctest: +SKIP
 
+    .. note::
+
+        GeoPandas currently defaults to use Fiona as the engine in ``to_file``.
+        However, GeoPandas 1.0 will switch to use pyogrio as the default engine, since
+        pyogrio can provide a significant speedup compared to Fiona. We recommend to
+        already install pyogrio and specify the engine by using the ``engine`` keyword
+        (``df.to_file(..., engine="pyogrio")``), or by setting the default for
+        the ``engine`` keyword globally with::
+
+            geopandas.options.io_engine = "pyogrio"
+
     Parameters
     ----------
     df : GeoDataFrame to be written


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 90ccb6c9..82906d6c 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -64,6 +64,7 @@ jobs:
           - env: ci/envs/311-dev.yaml
             os: ubuntu-latest
             dev: true
+            pandas_copy_on_write: "1"
 
     steps:
       - uses: actions/checkout@v4
@@ -93,6 +94,7 @@ jobs:
       - name: Test without PyGEOS
         env:
           USE_PYGEOS: 0
+          PANDAS_COPY_ON_WRITE: ${{ matrix.pandas_copy_on_write || '0' }}
         run: |
           pytest -v -r a -n auto --color=yes --cov=geopandas --cov-append --cov-report term-missing --cov-report xml geopandas/
 
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index acc5e448..14cb71c0 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -1150,3 +1150,23 @@ def test_multiple_geom_cols_error(tmpdir, df_nybb):
     df_nybb["geom2"] = df_nybb.geometry
     with pytest.raises(ValueError, match="GeoDataFrame contains multiple geometry"):
         df_nybb.to_file(os.path.join(str(tmpdir), "boros.gpkg"))
+
+
+@PYOGRIO_MARK
+@FIONA_MARK
+def test_option_io_engine():
+    try:
+        geopandas.options.io_engine = "pyogrio"
+
+        # disallowing to read a Shapefile with fiona should ensure we are
+        # actually reading with pyogrio
+        import fiona
+
+        orig = fiona.supported_drivers["ESRI Shapefile"]
+        fiona.supported_drivers["ESRI Shapefile"] = "w"
+
+        nybb_filename = geopandas.datasets.get_path("nybb")
+        _ = geopandas.read_file(nybb_filename)
+    finally:
+        fiona.supported_drivers["ESRI Shapefile"] = orig
+        geopandas.options.io_engine = None
diff --git a/geopandas/tests/test_config.py b/geopandas/tests/test_config.py
index 35b6aac8..cbadbc5e 100644
--- a/geopandas/tests/test_config.py
+++ b/geopandas/tests/test_config.py
@@ -6,7 +6,11 @@ import pytest
 def test_options():
     assert "display_precision: " in repr(geopandas.options)
 
-    assert dir(geopandas.options) == ["display_precision", "use_pygeos"]
+    assert set(dir(geopandas.options)) == {
+        "display_precision",
+        "use_pygeos",
+        "io_engine",
+    }
 
     with pytest.raises(AttributeError):
         geopandas.options.non_existing_option
@@ -27,3 +31,17 @@ def test_options_display_precision():
         geopandas.options.display_precision = -1
 
     geopandas.options.display_precision = None
+
+
+def test_options_io_engine():
+    assert geopandas.options.io_engine is None
+    geopandas.options.io_engine = "pyogrio"
+    assert geopandas.options.io_engine == "pyogrio"
+
+    with pytest.raises(ValueError):
+        geopandas.options.io_engine = "abc"
+
+    with pytest.raises(ValueError):
+        geopandas.options.io_engine = -1
+
+    geopandas.options.io_engine = None
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 5127a91e..61220086 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -899,6 +899,29 @@ class TestGeomMethods:
         assert_geoseries_equal(result, expected)
         assert result.is_valid.all()
 
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason=("reverse is only implemented for pygeos and shapely >= 2.0"),
+    )
+    def test_reverse(self):
+        expected = GeoSeries(
+            [
+                LineString([(0, 0), (0, 1), (1, 1)]),
+                LineString([(0, 0), (1, 0), (1, 1), (0, 1)]),
+            ]
+        )
+        assert_geoseries_equal(expected, self.g5.reverse())
+
+    @pytest.mark.skipif(
+        (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="reverse not implemented for shapely<2",
+    )
+    def test_reverse_not_implemented(self):
+        with pytest.raises(
+            NotImplementedError, match="shapely >= 2.0 or PyGEOS is required"
+        ):
+            self.g5.reverse()
+
     @pytest.mark.skipif(
         (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
         reason="segmentize keyword introduced in shapely 2.0",
@@ -1874,3 +1897,49 @@ class TestGeomMethods:
         expected = GeoSeries([LineString([[-1, 0], [-1, 2], [1, 2]])])
         assert_geoseries_equal(expected, oc)
         assert isinstance(oc, GeoSeries)
+
+    @pytest.mark.skipif(
+        compat.SHAPELY_GE_20,
+        reason="remove_repeated_points is implemented for shapely >= 2.0",
+    )
+    def test_remove_repeated_points_not_implemented_shapely_pre2(self):
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 2.0 is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            self.squares.remove_repeated_points()
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS and compat.SHAPELY_GE_20),
+        reason="remove_repeated_points is only implemented for shapely >= 2.0",
+    )
+    def test_remove_repeated_points_pygeos_set_shapely_installed(self):
+        with pytest.warns(
+            UserWarning,
+            match=(
+                "PyGEOS does not support remove_repeated_points, "
+                "and Shapely >= 2 is installed"
+            ),
+        ):
+            self.g1.remove_repeated_points()
+
+    @pytest.mark.skipif(
+        not compat.USE_SHAPELY_20,
+        reason="remove_repeated_points is only implemented for shapely >= 2.0",
+    )
+    @pytest.mark.parametrize(
+        "geom,expected",
+        [
+            (
+                GeoSeries(LinearRing([(0, 0), (1, 2), (1, 2), (1, 3), (0, 0)])),
+                GeoSeries(LinearRing([(0, 0), (1, 2), (1, 3), (0, 0)])),
+            ),
+            (
+                GeoSeries(Polygon([(0, 0), (0, 0), (1, 0), (1, 1), (1, 0), (0, 0)])),
+                GeoSeries(Polygon([(0, 0), (1, 0), (1, 1), (1, 0), (0, 0)])),
+            ),
+        ],
+    )
+    def test_remove_repeated_points(self, geom, expected):
+        assert_geoseries_equal(expected, geom.remove_repeated_points(tolerance=0.0))
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index bc909a33..eea85370 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -16,6 +16,7 @@ from geopandas import GeoDataFrame, GeoSeries, read_file, datasets
 
 import pytest
 import numpy as np
+import pandas as pd
 
 if compat.USE_SHAPELY_20:
     import shapely as mod
@@ -173,12 +174,12 @@ class TestFrameSindex:
         # with pandas 2.0, the column is now copied, losing the index (although
         # with Copy-on-Write, this will again be preserved)
         subset1 = self.df[["geom", "A"]]
-        if compat.PANDAS_GE_20:
+        if compat.PANDAS_GE_20 and not pd.options.mode.copy_on_write:
             assert subset1.sindex is not original_index
         else:
             assert subset1.sindex is original_index
         subset2 = self.df[["A", "geom"]]
-        if compat.PANDAS_GE_20:
+        if compat.PANDAS_GE_20 and not pd.options.mode.copy_on_write:
             assert subset2.sindex is not original_index
         else:
             assert subset2.sindex is original_index