Repository: geopandas/geopandas
Pull request #2027 (2021-07-26 16:58:11)

Added type hints - geoseries.py
Added type hints.
See #1991

m-richards: Hey @steveberdy I've just merged this back up to date with 0.11.1. Hopefully these PRs will get a bit more attention shortly for the 0.12 milestone.

Apologies, looks like I've main an error doing so on your fork, I'll fix that up.
steveberdy: @m-richards thanks for doing this!
steveberdy: Thank you @m-richards! I super appreciate this!
martinfleis: I gave it another look and can say that all these changes are correct. 

What I am less sure about is whether we want to merge them now and include in 0.13 or merge right after and try to include more type hints in 0.14. It is currently very patchy even within the GeoSeries context and I am not sure how I feel about that being part of a release. 
m-richards: > I gave it another look and can say that all these changes are correct. 
> 
> What I am less sure about is whether we want to merge them now and include in 0.13 or merge right after and try to include more type hints in 0.14. It is currently very patchy even within the GeoSeries context and I am not sure how I feel about that being part of a release. 

When I merged it up to main I saw it was more patchy than I first realised. There are certainly some typing overloads we should add for things with inplace kwargs (but not necessarily as part of this PR). Given that, happy to defer to 0.14, as much as I think we should have type hints sooner rather than later. 
m-richards: This was discussed on the community call today, agreed to move forward including this. Noted future things to deal with such as 
* typing overloads for e.g. inplace keyword
* arguments which take a GeoSeries / np.array of geometry / a list of geometries
will be deferred to future PRs

Patch:
diff --git a/.gitignore b/.gitignore
index be513b86..b2145e86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,6 @@ geopandas/datasets/ne_110m_admin_0_countries.zip
 
 .asv
 doc/source/getting_started/my_file.geojson
+
+.ruff_cache
+.env
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2f204cae..2151b6f0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,8 +9,7 @@ repos:
       hooks:
           - id: black
             language_version: python3
-    - repo: https://github.com/pycqa/flake8
-      rev: 5.0.4
+    - repo: https://github.com/astral-sh/ruff-pre-commit
+      rev: "v0.0.271"
       hooks:
-          - id: flake8
-            language: python_venv
+        - id: ruff
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f6fc4a0..8bf10b95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,39 @@
 
 ## Development version
 
+New methods:
+
+- Added ``segmentize`` method from shapely to GeoSeries/GeoDataFrame (#2910).
+- Added ``extract_unique_points`` method from shapely to GeoSeries/GeoDataframe (#2915).
+- Added ``hausdorff_distance`` method from shapely to GeoSeries/GeoDataframe (#2909).
+- Added ``delaunay_triangles`` method from shapely to GeoSeries/GeoDataframe (#2907).
+- Added ``concave_hull`` method from shapely to GeoSeries/GeoDataframe (#2903).
+- Added ``offset_curve`` method from shapely to GeoSeries/GeoDataframe (#2902).
+
 New features and improvements:
 
+- Added ``exclusive`` parameter to ``sjoin_nearest`` method for Shapely >= 2.0 (#2877)
 
 Bug fixes:
 
+- Fix a color assignment in ``explore`` when using ``UserDefined`` bins (#2923)
+- ``assert_geodataframe_equal`` now handles GeoDataFrames with no active geometry (#2498)
+
+## Version 0.13.2 (Jun 6, 2023)
+
+Bug fix:
+
+- Fix a regression in reading from local file URIs (``file://..``) using
+  ``geopandas.read_file`` (#2948).
+
+## Version 0.13.1 (Jun 5, 2023)
+
+Bug fix:
+
+- Fix a regression in reading from URLs using ``geopandas.read_file`` (#2908). This
+  restores the behaviour to download all data up-front before passing it to the
+  underlying engine (fiona or pyogrio), except if the server supports partial requests
+  (to support reading a subset of a large file).
 
 ## Version 0.13 (May 6, 2023)
 
@@ -857,4 +885,4 @@ Bug fixes:
 - Properly handle rows with missing geometries (#139, #193).
 - Fix ``GeoSeries.to_json`` (#263).
 - Correctly serialize metadata when pickling (#199, #206).
-- Fix ``merge`` and ``concat`` to return correct GeoDataFrame (#247, #320, #322).
+- Fix ``merge`` and ``concat`` to return correct GeoDataFrame (#247, #320, #322).
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ec895527..dad159d2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -47,7 +47,7 @@ Style
 - GeoPandas follows [the PEP 8
   standard](http://www.python.org/dev/peps/pep-0008/) and uses
   [Black](https://black.readthedocs.io/en/stable/) and
-  [Flake8](http://flake8.pycqa.org/en/latest/) to ensure a consistent
+  [ruff](https://beta.ruff.rs/docs/) to ensure a consistent
   code format throughout the project.
 
 - Imports should be grouped with standard library imports first,
@@ -57,7 +57,7 @@ Style
   imports when necessary in tests.
 
 - You can set up [pre-commit hooks](https://pre-commit.com/) to
-  automatically run `black` and `flake8` when you make a git
+  automatically run `black` and `ruff` when you make a git
   commit. This can be done by installing `pre-commit`:
 
     $ python -m pip install pre-commit
@@ -67,7 +67,7 @@ Style
 
     $ pre-commit install
 
-  Then `black` and `flake8` will be run automatically each time you
+  Then `black` and `ruff` will be run automatically each time you
   commit changes. You can skip these checks with `git commit
   --no-verify`. You can also configure your local git clone to have
   `git blame` ignore the commits that introduced large formatting-only
diff --git a/ci/envs/310-dev.yaml b/ci/envs/310-dev.yaml
index b6ae56fc..dafa5020 100644
--- a/ci/envs/310-dev.yaml
+++ b/ci/envs/310-dev.yaml
@@ -33,3 +33,4 @@ dependencies:
     - git+https://github.com/pygeos/pygeos.git@master
     - git+https://github.com/python-visualization/folium.git@main
     - git+https://github.com/geopandas/xyzservices.git@main
+    - git+https://github.com/geopandas/geodatasets.git@main
diff --git a/ci/envs/38-minimal.yaml b/ci/envs/38-minimal.yaml
index b7a1436d..3c215bdc 100644
--- a/ci/envs/38-minimal.yaml
+++ b/ci/envs/38-minimal.yaml
@@ -23,3 +23,4 @@ dependencies:
   - SQLalchemy
   - libspatialite
   - pyarrow=8.0.0
+  - geodatasets
diff --git a/ci/envs/38-pd12-defaults.yaml b/ci/envs/38-pd12-defaults.yaml
index 9608a37f..1585017d 100644
--- a/ci/envs/38-pd12-defaults.yaml
+++ b/ci/envs/38-pd12-defaults.yaml
@@ -28,3 +28,4 @@ dependencies:
       - geopy
       - mapclassify==2.4.0
       - pyarrow
+      - geodatasets
diff --git a/ci/envs/39-pd13-conda-forge.yaml b/ci/envs/39-pd13-conda-forge.yaml
index 634882fa..8ffcf488 100644
--- a/ci/envs/39-pd13-conda-forge.yaml
+++ b/ci/envs/39-pd13-conda-forge.yaml
@@ -24,6 +24,7 @@ dependencies:
   - xyzservices
   - scipy
   - geopy
+  - geodatasets
   # installed in tests.yaml, because not available on windows
   # - postgis
   - SQLalchemy<2
@@ -33,6 +34,3 @@ dependencies:
   - pyarrow
   # doctest testing
   - pytest-doctestplus
-  - pip
-  - pip:
-      - geodatasets
diff --git a/doc/source/community/contributing.rst b/doc/source/community/contributing.rst
index bfd951f8..f59ead17 100644
--- a/doc/source/community/contributing.rst
+++ b/doc/source/community/contributing.rst
@@ -33,8 +33,8 @@ In particular, when submitting a pull request:
   return values should be documented explicitly.
 
 - Follow PEP 8 when possible. We use `Black
-  <https://black.readthedocs.io/en/stable/>`_ and `Flake8
-  <http://flake8.pycqa.org/en/latest/>`_ to ensure a consistent code
+  <https://black.readthedocs.io/en/stable/>`_ and `ruff
+  <https://beta.ruff.rs/docs/>`_ to ensure a consistent code
   format throughout the project. For more details see
   :ref:`below <contributing_style>`.
 
@@ -324,7 +324,7 @@ Style Guide & Linting
 
 GeoPandas follows the `PEP8 <http://www.python.org/dev/peps/pep-0008/>`_ standard
 and uses `Black <https://black.readthedocs.io/en/stable/>`_ and
-`Flake8 <http://flake8.pycqa.org/en/latest/>`_ to ensure a consistent code
+`ruff <https://beta.ruff.rs/docs/>`_ to ensure a consistent code
 format throughout the project.
 
 Continuous Integration (GitHub Actions) will run those tools and
@@ -332,13 +332,13 @@ report any stylistic errors in your code. Therefore, it is helpful before
 submitting code to run the check yourself::
 
    black geopandas
-   git diff upstream/main -u -- "*.py" | flake8 --diff
+   git diff upstream/main -u -- "*.py" | ruff .
 
 to auto-format your code. Additionally, many editors have plugins that will
 apply ``black`` as you edit files.
 
 Optionally (but recommended), you can setup `pre-commit hooks <https://pre-commit.com/>`_
-to automatically run ``black`` and ``flake8`` when you make a git commit. If you did not
+to automatically run ``black`` and ``ruff`` when you make a git commit. If you did not
 use the provided development environment in ``environment-dev.yml``, you must first install ``pre-commit``::
 
    $ python -m pip install pre-commit
@@ -348,7 +348,7 @@ From the root of the geopandas repository, you should then install the
 
    $ pre-commit install
 
-Then ``black`` and ``flake8`` will be run automatically
+Then ``black`` and ``ruff`` will be run automatically
 each time you commit changes. You can skip these checks with
 ``git commit --no-verify``.
 
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 826e098c..3b9358b3 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -71,6 +71,7 @@ ipython_exec_lines = [
 
 # Fix issue with warnings from numpydoc (see discussion in PR #534)
 numpydoc_show_class_members = False
+autodoc_typehints = "none"
 
 
 def setup(app):
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index bd05245b..755b56a2 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -22,7 +22,9 @@ General methods and attributes
    GeoSeries.total_bounds
    GeoSeries.length
    GeoSeries.geom_type
+   GeoSeries.offset_curve
    GeoSeries.distance
+   GeoSeries.hausdorff_distance
    GeoSeries.representative_point
    GeoSeries.exterior
    GeoSeries.interiors
@@ -86,12 +88,16 @@ Constructive methods and attributes
    GeoSeries.buffer
    GeoSeries.boundary
    GeoSeries.centroid
+   GeoSeries.concave_hull
    GeoSeries.convex_hull
+   GeoSeries.delaunay_triangles
    GeoSeries.envelope
+   GeoSeries.extract_unique_points
    GeoSeries.make_valid
    GeoSeries.minimum_bounding_circle
    GeoSeries.normalize
    GeoSeries.sample_points
+   GeoSeries.segmentize
    GeoSeries.simplify
 
 Affine transformations
diff --git a/doc/source/docs/user_guide/geometric_manipulations.rst b/doc/source/docs/user_guide/geometric_manipulations.rst
index 61d92b8a..4d49e1ed 100644
--- a/doc/source/docs/user_guide/geometric_manipulations.rst
+++ b/doc/source/docs/user_guide/geometric_manipulations.rst
@@ -24,6 +24,13 @@ Constructive methods
 
   Returns a :class:`~geopandas.GeoSeries` of points for each geometric centroid.
 
+.. attribute:: GeoSeries.concave_hull
+
+  Returns a :class:`~geopandas.GeoSeries` of geometries representing the smallest
+  concave `Polygon` containing all the points in each object unless the
+  number of points in the object is less than three. For two points,
+  the concave hull collapses to a `LineString`; for 1, a `Point`.
+
 .. attribute:: GeoSeries.convex_hull
 
   Returns a :class:`~geopandas.GeoSeries` of geometries representing the smallest
@@ -31,17 +38,38 @@ Constructive methods
   number of points in the object is less than three. For two points,
   the convex hull collapses to a `LineString`; for 1, a `Point`.
 
+.. method:: GeoSeries.delaunay_triangles(tolerance, preserve_topology=True)
+
+  Returns a :class:`~geopandas.GeoSeries` consisting of polygons (default) or linestrings
+  (`only_edges=True`) representing the computed Delaunay triangulation around the vertices
+  of an input geometry.
+
 .. attribute:: GeoSeries.envelope
 
   Returns a :class:`~geopandas.GeoSeries` of geometries representing the point or
   smallest rectangular polygon (with sides parallel to the coordinate
   axes) that contains each object.
 
+.. method:: GeoSeries.extract_unique_points
+
+  Returns a :class:`~geopandas.GeoSeries` of geometries containing all distinct
+  vertices of each input geometry as a multipoint.
+  
+.. method:: GeoSeries.offset_curve(distance, quad_segs=8, join_style="round", mitre_limit=5.0)
+
+  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.simplify(tolerance, preserve_topology=True)
 
   Returns a :class:`~geopandas.GeoSeries` containing a simplified representation of
   each object.
 
+.. method:: GeoSeries.segmentize(max_segment_length)
+
+  Returns a :class:`~geopandas.GeoSeries` with additional vertices added to line
+  segments based on max_segment_length.
+
 .. attribute:: GeoSeries.unary_union
 
   Return a geometry containing the union of all geometries in the :class:`~geopandas.GeoSeries`.
diff --git a/doc/source/gallery/polygon_plotting_with_folium.ipynb b/doc/source/gallery/polygon_plotting_with_folium.ipynb
index 3a71ffb1..1da13782 100644
--- a/doc/source/gallery/polygon_plotting_with_folium.ipynb
+++ b/doc/source/gallery/polygon_plotting_with_folium.ipynb
@@ -64,7 +64,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Notice that the values of the polygon geometries do not directly represent the values of latitude of longitude in a geographic coordinate system.\n",
+    "Notice that the values of the polygon geometries do not directly represent the values of latitude or longitude in a geographic coordinate system.\n",
     "To view the coordinate reference system of the geometry column, access the `crs` attribute:"
    ]
   },
diff --git a/doc/source/getting_started/introduction.ipynb b/doc/source/getting_started/introduction.ipynb
index b97baacf..a23a0946 100644
--- a/doc/source/getting_started/introduction.ipynb
+++ b/doc/source/getting_started/introduction.ipynb
@@ -319,7 +319,7 @@
    "source": [
     "# saving the first plot as an axis and setting alpha (transparency) to 0.5\n",
     "ax = gdf[\"convex_hull\"].plot(alpha=0.5)\n",
-    "# passing the first plot and setting linewitdth to 0.5\n",
+    "# passing the first plot and setting linewidth to 0.5\n",
     "gdf[\"boundary\"].plot(ax=ax, color=\"white\", linewidth=0.5)"
    ]
   },
@@ -355,7 +355,7 @@
     "ax = gdf[\"buffered\"].plot(alpha=0.5)\n",
     "# passing the first plot as an axis to the second\n",
     "gdf[\"buffered_centroid\"].plot(ax=ax, color=\"red\", alpha=0.5)\n",
-    "# passing the first plot and setting linewitdth to 0.5\n",
+    "# passing the first plot and setting linewidth to 0.5\n",
     "gdf[\"boundary\"].plot(ax=ax, color=\"white\", linewidth=0.5)"
    ]
   },
@@ -457,7 +457,7 @@
     "ax = gdf.plot(\n",
     "    \"within\", legend=True, categorical=True, legend_kwds={\"loc\": \"upper left\"}\n",
     ")\n",
-    "# passing the first plot and setting linewitdth to 0.5\n",
+    "# passing the first plot and setting linewidth to 0.5\n",
     "gdf[\"boundary\"].plot(ax=ax, color=\"black\", linewidth=0.5)"
    ]
   },
diff --git a/geopandas/__init__.py b/geopandas/__init__.py
index 0ce113d3..bbe1b445 100644
--- a/geopandas/__init__.py
+++ b/geopandas/__init__.py
@@ -1,27 +1,27 @@
-from geopandas._config import options  # noqa
+from geopandas._config import options
 
-from geopandas.geoseries import GeoSeries  # noqa
-from geopandas.geodataframe import GeoDataFrame  # noqa
-from geopandas.array import points_from_xy  # noqa
+from geopandas.geoseries import GeoSeries
+from geopandas.geodataframe import GeoDataFrame
+from geopandas.array import points_from_xy
 
-from geopandas.io.file import _read_file as read_file  # noqa
-from geopandas.io.arrow import _read_parquet as read_parquet  # noqa
-from geopandas.io.arrow import _read_feather as read_feather  # noqa
-from geopandas.io.sql import _read_postgis as read_postgis  # noqa
-from geopandas.tools import sjoin, sjoin_nearest  # noqa
-from geopandas.tools import overlay  # noqa
-from geopandas.tools._show_versions import show_versions  # noqa
-from geopandas.tools import clip  # noqa
+from geopandas.io.file import _read_file as read_file
+from geopandas.io.arrow import _read_parquet as read_parquet
+from geopandas.io.arrow import _read_feather as read_feather
+from geopandas.io.sql import _read_postgis as read_postgis
+from geopandas.tools import sjoin, sjoin_nearest
+from geopandas.tools import overlay
+from geopandas.tools._show_versions import show_versions
+from geopandas.tools import clip
 
 
-import geopandas.datasets  # noqa
+import geopandas.datasets
 
 
 # make the interactive namespace easier to use
 # for `from geopandas import *` demos.
-import geopandas as gpd  # noqa
-import pandas as pd  # noqa
-import numpy as np  # noqa
+import geopandas as gpd
+import pandas as pd
+import numpy as np
 
 from . import _version
 
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index b7064438..3ccf3490 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -49,7 +49,7 @@ INSTALL_PYGEOS_ERROR = "To use PyGEOS within GeoPandas, you need to install PyGE
 'conda install pygeos' or 'pip install pygeos'"
 
 try:
-    import pygeos  # noqa
+    import pygeos
 
     # only automatically use pygeos if version is high enough
     if Version(pygeos.__version__) >= Version("0.8"):
@@ -61,6 +61,7 @@ try:
             "The installed version of PyGEOS is too old ({0} installed, 0.8 required),"
             " and thus GeoPandas will not use PyGEOS.".format(pygeos.__version__),
             UserWarning,
+            stacklevel=2,
         )
         HAS_PYGEOS = False
 except ImportError:
@@ -95,7 +96,7 @@ def set_use_pygeos(val=None):
     # validate the pygeos version
     if USE_PYGEOS:
         try:
-            import pygeos  # noqa
+            import pygeos
 
             # validate the pygeos version
             if not Version(pygeos.__version__) >= Version("0.8"):
@@ -103,7 +104,8 @@ def set_use_pygeos(val=None):
                     USE_PYGEOS = False
                     warnings.warn(
                         "The PyGEOS version is too old, and Shapely >= 2 is installed, "
-                        "thus using Shapely by default and not PyGEOS."
+                        "thus using Shapely by default and not PyGEOS.",
+                        stacklevel=2,
                     )
                 else:
                     raise ImportError(
@@ -126,7 +128,8 @@ def set_use_pygeos(val=None):
                     "version PyGEOS was compiled with ({}). Conversions between both "
                     "will be slow.".format(
                         shapely_geos_version, geos_capi_version_string
-                    )
+                    ),
+                    stacklevel=2,
                 )
                 PYGEOS_SHAPELY_COMPAT = False
             else:
@@ -248,7 +251,7 @@ def import_optional_dependency(name: str, extra: str = ""):
 HAS_RTREE = None
 RTREE_GE_094 = False
 try:
-    import rtree  # noqa
+    import rtree  # noqa: F401
 
     HAS_RTREE = True
 except ImportError:
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index 97534d52..596df147 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -611,6 +611,23 @@ def centroid(data):
         return _unary_geo("centroid", data)
 
 
+def concave_hull(data, **kwargs):
+    if compat.USE_SHAPELY_20:
+        return shapely.concave_hull(data, **kwargs)
+    if compat.USE_PYGEOS and compat.SHAPELY_GE_20:
+        warnings.warn(
+            "PyGEOS does not support concave_hull, and Shapely >= 2 is installed, "
+            "thus using Shapely and not PyGEOS for calculating the concave_hull.",
+            stacklevel=4,
+        )
+        return shapely.concave_hull(to_shapely(data), **kwargs)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def convex_hull(data):
     if compat.USE_SHAPELY_20:
         return shapely.convex_hull(data)
@@ -620,6 +637,18 @@ def convex_hull(data):
         return _unary_geo("convex_hull", data)
 
 
+def delaunay_triangles(data, tolerance, only_edges):
+    if compat.USE_SHAPELY_20:
+        return shapely.delaunay_triangles(data, tolerance, only_edges)
+    elif compat.USE_PYGEOS:
+        return pygeos.delaunay_triangles(data, tolerance, only_edges)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def envelope(data):
     if compat.USE_SHAPELY_20:
         return shapely.envelope(data)
@@ -638,6 +667,36 @@ def exterior(data):
         return _unary_geo("exterior", data)
 
 
+def extract_unique_points(data):
+    if compat.USE_SHAPELY_20:
+        return shapely.extract_unique_points(data)
+    elif compat.USE_PYGEOS:
+        return pygeos.extract_unique_points(data)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
+def offset_curve(data, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
+    if compat.USE_SHAPELY_20:
+        return shapely.offset_curve(
+            data,
+            distance=distance,
+            quad_segs=quad_segs,
+            join_style=join_style,
+            mitre_limit=mitre_limit,
+        )
+    elif compat.USE_PYGEOS:
+        return pygeos.offset_curve(data, distance, quad_segs, join_style, mitre_limit)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def interiors(data):
     data = to_shapely(data)
     has_non_poly = False
@@ -654,7 +713,8 @@ def interiors(data):
     if has_non_poly:
         warnings.warn(
             "Only Polygon objects have interior rings. For other "
-            "geometry types, None is returned."
+            "geometry types, None is returned.",
+            stacklevel=2,
         )
     data = np.empty(len(data), dtype=object)
     with compat.ignore_shapely2_warnings():
@@ -700,6 +760,18 @@ def minimum_bounding_radius(data):
         )
 
 
+def segmentize(data, max_segment_length):
+    if compat.USE_SHAPELY_20:
+        return shapely.segmentize(data, max_segment_length)
+    elif compat.USE_PYGEOS:
+        return pygeos.segmentize(data, max_segment_length)
+    else:
+        raise NotImplementedError(
+            "shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 #
 # Binary predicates
 #
@@ -883,6 +955,20 @@ def distance(data, other):
         return _binary_op_float("distance", data, other)
 
 
+def hausdorff_distance(data, other, densify=None, **kwargs):
+    if compat.USE_SHAPELY_20:
+        return shapely.hausdorff_distance(data, other, densify=densify, **kwargs)
+    elif compat.USE_PYGEOS:
+        return _binary_method(
+            "hausdorff_distance", data, other, densify=densify, **kwargs
+        )
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def buffer(data, distance, resolution=16, **kwargs):
     if compat.USE_SHAPELY_20:
         if compat.SHAPELY_G_20a1:
diff --git a/geopandas/array.py b/geopandas/array.py
index 40e94e4b..6869b27f 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -29,7 +29,6 @@ from . import _compat as compat
 from . import _vectorized as vectorized
 from .sindex import _get_sindex_class
 
-
 TransformerFromCRS = lru_cache(Transformer.from_crs)
 
 
@@ -513,10 +512,19 @@ class GeometryArray(ExtensionArray):
         self.check_geographic_crs(stacklevel=5)
         return GeometryArray(vectorized.centroid(self._data), crs=self.crs)
 
+    def concave_hull(self, ratio, allow_holes):
+        return vectorized.concave_hull(self._data, ratio=ratio, allow_holes=allow_holes)
+
     @property
     def convex_hull(self):
         return GeometryArray(vectorized.convex_hull(self._data), crs=self.crs)
 
+    def delaunay_triangles(self, tolerance, only_edges):
+        return GeometryArray(
+            vectorized.delaunay_triangles(self._data, tolerance, only_edges),
+            crs=self.crs,
+        )
+
     @property
     def envelope(self):
         return GeometryArray(vectorized.envelope(self._data), crs=self.crs)
@@ -525,6 +533,21 @@ class GeometryArray(ExtensionArray):
     def exterior(self):
         return GeometryArray(vectorized.exterior(self._data), crs=self.crs)
 
+    def extract_unique_points(self):
+        return GeometryArray(vectorized.extract_unique_points(self._data), crs=self.crs)
+
+    def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
+        return GeometryArray(
+            vectorized.offset_curve(
+                self._data,
+                distance,
+                quad_segs=quad_segs,
+                join_style=join_style,
+                mitre_limit=mitre_limit,
+            ),
+            crs=self.crs,
+        )
+
     @property
     def interiors(self):
         # no GeometryArray as result
@@ -547,6 +570,12 @@ class GeometryArray(ExtensionArray):
     def make_valid(self):
         return GeometryArray(vectorized.make_valid(self._data), crs=self.crs)
 
+    def segmentize(self, max_segment_length):
+        return GeometryArray(
+            vectorized.segmentize(self._data, max_segment_length),
+            crs=self.crs,
+        )
+
     #
     # Binary predicates
     #
@@ -637,6 +666,10 @@ class GeometryArray(ExtensionArray):
         self.check_geographic_crs(stacklevel=6)
         return self._binary_method("distance", self, other)
 
+    def hausdorff_distance(self, other, **kwargs):
+        self.check_geographic_crs(stacklevel=6)
+        return self._binary_method("hausdorff_distance", self, other, **kwargs)
+
     def buffer(self, distance, resolution=16, **kwargs):
         if not (isinstance(distance, (int, float)) and distance == 0):
             self.check_geographic_crs(stacklevel=5)
@@ -1486,7 +1519,8 @@ def _get_common_crs(arr_seq):
             warnings.warn(
                 "CRS not set for some of the concatenation inputs. "
                 f"Setting output's CRS as {names[0]} "
-                "(the single non-null crs provided)."
+                "(the single non-null crs provided).",
+                stacklevel=2,
             )
         return crs_not_none[0]
 
diff --git a/geopandas/base.py b/geopandas/base.py
index e05ed55a..1fc7a038 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -28,7 +28,10 @@ def _delegate_binary_method(op, this, other, align, *args, **kwargs):
     this = this.geometry
     if isinstance(other, GeoPandasBase):
         if align and not this.index.equals(other.index):
-            warn("The indices of the two GeoSeries are different.")
+            warn(
+                "The indices of the two GeoSeries are different.",
+                stacklevel=4,
+            )
             this, other = this.align(other.geometry)
         else:
             other = other.geometry
@@ -492,6 +495,70 @@ GeometryCollection
         """
         return _delegate_property("centroid", self)
 
+    def concave_hull(self, ratio=0.0, allow_holes=False):
+        """Returns a ``GeoSeries`` of geometries representing the concave hull
+        of each geometry.
+
+        The concave hull of a geometry is the smallest concave `Polygon`
+        containing all the points in each geometry, unless the number of points
+        in the geometric object is less than three. For two points, the concave
+        hull collapses to a `LineString`; for 1, a `Point`.
+
+        The hull is constructed by removing border triangles of the Delaunay
+        Triangulation of the points as long as their "size" is larger than the
+        maximum edge length ratio and optionally allowing holes. The edge length factor
+        is a fraction of the length difference between the longest and shortest edges
+        in the Delaunay Triangulation of the input points. For further information
+        on the algorithm used, see
+        https://libgeos.org/doxygen/classgeos_1_1algorithm_1_1hull_1_1ConcaveHull.html
+
+        Parameters
+        ----------
+        ratio : float, (optional, default 0.0)
+            Number in the range [0, 1]. Higher numbers will include fewer vertices
+            in the hull.
+        allow_holes : bool, (optional, default False)
+            If set to True, the concave hull may have holes.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Polygon, LineString, Point, MultiPoint
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         LineString([(0, 0), (1, 1), (1, 0)]),
+        ...         MultiPoint([(0, 0), (1, 1), (0, 1), (1, 0), (0.5, 0.5)]),
+        ...         MultiPoint([(0, 0), (1, 1)]),
+        ...         Point(0, 0),
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0    POLYGON ((0.000 0.000, 1.000 1.000, 0.000 1.00...
+        1    LINESTRING (0.000 0.000, 1.000 1.000, 1.000 0....
+        2    MULTIPOINT (0.000 0.000, 1.000 1.000, 0.000 1....
+        3                MULTIPOINT (0.000 0.000, 1.000 1.000)
+        4                                  POINT (0.000 0.000)
+        dtype: geometry
+
+        >>> s.concave_hull()
+        0    POLYGON ((0.000 1.000, 1.000 1.000, 0.000 0.00...
+        1    POLYGON ((0.000 0.000, 1.000 1.000, 1.000 0.00...
+        2    POLYGON ((0.500 0.500, 0.000 1.000, 1.000 1.00...
+        3                LINESTRING (0.000 0.000, 1.000 1.000)
+        4                                  POINT (0.000 0.000)
+        dtype: geometry
+
+        See also
+        --------
+        GeoSeries.convex_hull : convex hull geometry
+
+        """
+        return _delegate_geo_method(
+            "concave_hull", self, ratio=ratio, allow_holes=allow_holes
+        )
+
     @property
     def convex_hull(self):
         """Returns a ``GeoSeries`` of geometries representing the convex hull
@@ -533,11 +600,62 @@ GeometryCollection
 
         See also
         --------
+        GeoSeries.concave_hull : concave hull geometry
         GeoSeries.envelope : bounding rectangle geometry
 
         """
         return _delegate_property("convex_hull", self)
 
+    def delaunay_triangles(self, tolerance=0.0, only_edges=False):
+        """Returns a ``GeoSeries`` consisting of objects representing
+        the computed Delaunay triangulation around the vertices of
+        an input geometry.
+
+        The output is a ``GeometryCollection`` containing polygons
+        (default) or linestrings (see only_edges).
+
+        Returns an empty GeometryCollection if an input geometry
+        contains less than 3 vertices.
+
+        Parameters
+        ----------
+        tolerance : float | array-like, default 0.0
+            Snap input vertices together if their distance is less than this value.
+        only_edges : bool | array_like, (optional, default False)
+            If set to True, the triangulation will return a collection of
+            linestrings instead of polygons.
+
+        Examples
+        --------
+
+        >>> from shapely import LineString, MultiPoint, Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         MultiPoint([(50, 30), (60, 30), (100, 100)]),
+        ...         Polygon([(50, 30), (60, 30), (100, 100), (50, 30)]),
+        ...         LineString([(50, 30), (60, 30), (100, 100)]),
+        ...     ]
+        ... )
+        >>> s
+        0   MULTIPOINT (50.000 30.000, 60.000 30.000, 100....
+        1   POLYGON ((50.000 30.000, 60.000 30.000, 100.00...
+        2   LINESTRING (50.000 30.000, 60.000 30.000, 100....
+        dtype: geometry
+
+        >>> s.delaunay_triangles()
+        0    GEOMETRYCOLLECTION (POLYGON ((50.000 30.000, 6...
+        1    GEOMETRYCOLLECTION (POLYGON ((50.000 30.000, 6...
+        2    GEOMETRYCOLLECTION (POLYGON ((50.000 30.000, 6...
+        dtype: geometry
+
+        >>> s.delaunay_triangles(only_edges=True)
+        0    MULTILINESTRING ((50.000 30.000, 100.000 100.0...
+        1    MULTILINESTRING ((50.000 30.000, 100.000 100.0...
+        2    MULTILINESTRING ((50.000 30.000, 100.000 100.0...
+        dtype: geometry
+        """
+        return _delegate_geo_method("delaunay_triangles", self, tolerance, only_edges)
+
     @property
     def envelope(self):
         """Returns a ``GeoSeries`` of geometries representing the envelope of
@@ -618,6 +736,88 @@ GeometryCollection
         # TODO: return empty geometry for non-polygons
         return _delegate_property("exterior", self)
 
+    def extract_unique_points(self):
+        """Returns a ``GeoSeries`` of MultiPoints representing all
+        distinct vertices of an input geometry.
+
+        Examples
+        --------
+
+        >>> from shapely import LineString, Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (0, 0), (1, 1), (1, 1)]),
+        ...         Polygon([(0, 0), (0, 0), (1, 1), (1, 1)])
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0    LINESTRING (0.000 0.000, 0.000 0.000, 1.000 1....
+        1    POLYGON ((0.000 0.000, 0.000 0.000, 1.000 1.00...
+        dtype: geometry
+
+        >>> s.extract_unique_points()
+        0    MULTIPOINT (0.000 0.000, 1.000 1.000)
+        1    MULTIPOINT (0.000 0.000, 1.000 1.000)
+        dtype: geometry
+
+        See also
+        --------
+
+        GeoSeries.get_coordinates : extract coordinates as a :class:`~pandas.DataFrame`
+        """
+        return _delegate_geo_method("extract_unique_points", self)
+
+    def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
+        """Returns a ``LineString`` or ``MultiLineString`` geometry at a
+        distance from the object on its right or its left side.
+        Parameters
+        ----------
+        distance : float | array-like
+            Specifies the offset distance from the input geometry. Negative
+            for right side offset, positive for left side offset.
+        quad_segs : int (optional, default 8)
+            Specifies the number of linear segments in a quarter circle in the
+            approximation of circular arcs.
+        join_style : {'round', 'bevel', 'mitre'}, (optional, default 'round')
+            Specifies the shape of outside corners. 'round' results in
+            rounded shapes. 'bevel' results in a beveled edge that touches the
+            original vertex. 'mitre' results in a single vertex that is beveled
+            depending on the ``mitre_limit`` parameter.
+        mitre_limit : float (optional, default 5.0)
+            Crops of 'mitre'-style joins if the point is displaced from the
+            buffered vertex by more than this limit.
+
+        See http://shapely.readthedocs.io/en/latest/manual.html#object.offset_curve
+        for details.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import LineString
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (0, 1), (1, 1)]),
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0    LINESTRING (0.000 0.000, 0.000 1.000, 1.000 1....
+        dtype: geometry
+
+        >>> s.offset_curve(1)
+        0    LINESTRING (-1.000 0.000, -1.000 1.000, -0.981...
+        dtype: geometry
+        """
+        return _delegate_geo_method(
+            "offset_curve",
+            self,
+            distance,
+            quad_segs=quad_segs,
+            join_style=join_style,
+            mitre_limit=mitre_limit,
+        )
+
     @property
     def interiors(self):
         """Returns a ``Series`` of List representing the
@@ -830,6 +1030,47 @@ GeometryCollection
         """
         return _delegate_geo_method("make_valid", self)
 
+    def segmentize(self, max_segment_length):
+        """Returns a ``GeoSeries`` with vertices added to line segments based on
+        maximum segment length.
+
+        Additional vertices will be added to every line segment in an input geometry so
+        that segments are no longer than the provided maximum segment length. New
+        vertices will evenly subdivide each segment. Only linear components of input
+        geometries are densified; other geometries are returned unmodified.
+
+        Parameters
+        ----------
+        max_segment_length : float | array-like
+            Additional vertices will be added so that all line segments are no longer
+            than this value. Must be greater than 0.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely.geometry import Polygon, LineString
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (0, 10)]),
+        ...         Polygon([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]),
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0               LINESTRING (0.000 0.000, 0.000 10.000)
+        1    POLYGON ((0.000 0.000, 10.000 0.000, 10.000 10...
+        dtype: geometry
+
+        >>> s.segmentize(max_segment_length=5)
+        0    LINESTRING (0.000 0.000, 0.000 5.000, 0.000 10...
+        1    POLYGON ((0.000 0.000, 5.000 0.000, 10.000 0.0...
+        dtype: geometry
+        """
+        return _delegate_geo_method("segmentize", self, max_segment_length)
+
     #
     # Reduction operations that return a Shapely geometry
     #
@@ -2218,6 +2459,121 @@ GeometryCollection
         """
         return _binary_op("distance", self, other, align)
 
+    def hausdorff_distance(self, other, align=True, densify=None):
+        """Returns a ``Series`` containing the Hausdorff distance to aligned `other`.
+
+        The Hausdorff distance is the largest distance consisting of any point in `self`
+        with the nearest point in `other`.
+
+        The operation works on a 1-to-1 row-wise manner:
+
+        .. image:: ../../../_static/binary_op-01.svg
+           :align: center
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The Geoseries (elementwise) or geometric object to find the
+            distance to.
+        align : bool (default True)
+            If True, automatically aligns GeoSeries based on their indices.
+            If False, the order of elements is preserved.
+        densify : float (default None)
+            A value between 0 and 1, that splits each subsegment of a line string
+            into equal length segments, making the approximation less coarse.
+            A densify value of 0.5 will add a point halfway between each pair of
+            points. A densify value of 0.25 will add a point a quarter of the way
+            between each pair of points.
+
+
+        Returns
+        -------
+        Series (float)
+
+        Examples
+        --------
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 0), (1, 1)]),
+        ...         Polygon([(0, 0), (-1, 0), (-1, 1)]),
+        ...         LineString([(1, 1), (0, 0)]),
+        ...         Point(0, 0),
+        ...     ],
+        ... )
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0.5, 0.5), (1.5, 0.5), (1.5, 1.5), (0.5, 1.5)]),
+        ...         Point(3, 1),
+        ...         LineString([(1, 0), (2, 0)]),
+        ...         Point(0, 1),
+        ...     ],
+        ...     index=range(1, 5),
+        ... )
+
+        >>> s
+        0    POLYGON ((0.00000 0.00000, 1.00000 0.00000, 1....
+        1    POLYGON ((0.00000 0.00000, -1.00000 0.00000, -...
+        2        LINESTRING (1.00000 1.00000, 0.00000 0.00000)
+        3                              POINT (0.00000 0.00000)
+        dtype: geometry
+
+        >>> s2
+        1    POLYGON ((0.50000 0.50000, 1.50000 0.50000, 1....
+        2                              POINT (3.00000 1.00000)
+        3        LINESTRING (1.00000 0.00000, 2.00000 0.00000)
+        4                              POINT (0.00000 1.00000)
+        dtype: geometry
+
+        We can check the hausdorff distance of each geometry of GeoSeries
+        to a single geometry:
+
+        >>> point = Point(-1, 0)
+        >>> s.hausdorff_distance(point)
+        0    2.236068
+        1    1.000000
+        2    2.236068
+        3    1.000000
+        dtype: float64
+
+        We can also check two GeoSeries against each other, row by row.
+        The GeoSeries above have different indices. We can either align both GeoSeries
+        based on index values and use elements with the same index using
+        ``align=True`` or ignore index and use elements based on their matching
+        order using ``align=False``:
+
+        .. image:: ../../../_static/binary_op-02.svg
+
+        >>> s.hausdorff_distance(s2, align=True)
+        0         NaN
+        1    2.121320
+        2    3.162278
+        3    2.000000
+        4         NaN
+        dtype: float64
+
+        >>> s.hausdorff_distance(s2, align=False)
+        0    0.707107
+        1    4.123106
+        2    1.414214
+        3    1.000000
+        dtype: float64
+
+        We can also set a densify value, which is a float between 0 and 1 and
+        signifies the fraction of the distance between each pair of points that will
+        be used as the distance between the points when densifying.
+
+        >>> l1 = geopandas.GeoSeries([LineString([(130, 0), (0, 0), (0, 150)])])
+        >>> l2 = geopandas.GeoSeries([LineString([(10, 10), (10, 150), (130, 10)])])
+        >>> l1.hausdorff_distance(l2)
+        0    14.142136
+        dtype: float64
+        >>> l1.hausdorff_distance(l2, densify=0.25)
+        0    70.0
+        dtype: float64
+        """
+        return _binary_op("hausdorff_distance", self, other, align, densify=densify)
+
     #
     # Binary operations that return a GeoSeries
     #
@@ -3257,7 +3613,7 @@ GeometryCollection
         2    POLYGON ((8.00000 4.00000, 13.00000 10.00000, ...
         dtype: geometry
 
-        """  # noqa (E501 link is longer than max line length)
+        """  # (E501 link is longer than max line length)
         return _delegate_geo_method("affine_transform", self, matrix)
 
     def translate(self, xoff=0.0, yoff=0.0, zoff=0.0):
@@ -3295,7 +3651,7 @@ GeometryCollection
         2    POLYGON ((5.00000 2.00000, 6.00000 3.00000, 5....
         dtype: geometry
 
-        """  # noqa (E501 link is longer than max line length)
+        """  # (E501 link is longer than max line length)
         return _delegate_geo_method("translate", self, xoff, yoff, zoff)
 
     def rotate(self, angle, origin="center", use_radians=False):
@@ -3820,7 +4176,10 @@ class _CoordinateIndexer(object):
             ys = slice(ys, ys)
         # don't know how to handle step; should this raise?
         if xs.step is not None or ys.step is not None:
-            warn("Ignoring step - full interval is used.")
+            warn(
+                "Ignoring step - full interval is used.",
+                stacklevel=2,
+            )
         if xs.start is None or xs.stop is None or ys.start is None or ys.stop is None:
             xmin, ymin, xmax, ymax = obj.total_bounds
         bbox = box(
diff --git a/geopandas/datasets/naturalearth_creation.py b/geopandas/datasets/naturalearth_creation.py
index c8763297..2942deb2 100644
--- a/geopandas/datasets/naturalearth_creation.py
+++ b/geopandas/datasets/naturalearth_creation.py
@@ -4,7 +4,7 @@ and 'naturalearth_cities.shp'.
 
 Raw data: https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/110m/cultural/ne_110m_admin_0_countries.zip
 Current version used: see code
-"""  # noqa (E501 link is longer than max line length)
+"""
 
 import geopandas as gpd
 import requests
diff --git a/geopandas/explore.py b/geopandas/explore.py
index 04855679..18f61a2f 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -282,7 +282,7 @@ def _explore(
         import folium
         import re
         import matplotlib
-        import matplotlib.colors as colors
+        from matplotlib import colors
         import matplotlib.pyplot as plt
         from mapclassify import classify
 
@@ -291,7 +291,7 @@ def _explore(
         if MPL_361:
             from matplotlib import colormaps as cm
         else:
-            import matplotlib.cm as cm
+            from matplotlib import cm
 
     except (ImportError, ModuleNotFoundError):
         raise ImportError(
@@ -449,7 +449,7 @@ def _explore(
 
         elif callable(cmap):
             # List of colors based on Branca colormaps or self-defined functions
-            color = list(map(lambda x: cmap(x), df[column]))
+            color = [cmap(x) for x in df[column]]
 
         else:
             vmin = gdf[column].min() if vmin is None else vmin
@@ -468,7 +468,7 @@ def _explore(
                 color = np.apply_along_axis(
                     colors.to_hex,
                     1,
-                    _colormap_helper(cmap, n_resample=k, idx=binning.yb),
+                    _colormap_helper(cmap, n_resample=binning.k, idx=binning.yb),
                 )
 
             else:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 51bef73e..e7244803 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -938,7 +938,7 @@ individually so that features may have different properties
                         if not na
                     }
                 else:
-                    properties_items = {k: v for k, v in zip(properties_cols, row)}
+                    properties_items = dict(zip(properties_cols, row))
 
                 if drop_id:
                     feature = {}
@@ -1534,7 +1534,10 @@ individually so that features may have different properties
                 if key == "geometry":
                     self._persist_old_default_geometry_colname()
             except TypeError:
-                warnings.warn("Geometry column does not contain geometry.")
+                warnings.warn(
+                    "Geometry column does not contain geometry.",
+                    stacklevel=2,
+                )
         super().__setitem__(key, value)
 
     #
@@ -1770,9 +1773,13 @@ individually so that features may have different properties
         if by is None and level is None:
             by = np.zeros(len(self), dtype="int64")
 
-        groupby_kwargs = dict(
-            by=by, level=level, sort=sort, observed=observed, dropna=dropna
-        )
+        groupby_kwargs = {
+            "by": by,
+            "level": level,
+            "sort": sort,
+            "observed": observed,
+            "dropna": dropna,
+        }
 
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
@@ -2098,7 +2105,6 @@ individually so that features may have different properties
 
     @doc(_explore)
     def explore(self, *args, **kwargs):
-        """Interactive map based on folium/leaflet.js"""
         return _explore(self, *args, **kwargs)
 
     def sjoin(self, df, *args, **kwargs):
@@ -2175,7 +2181,7 @@ individually so that features may have different properties
         GeoDataFrame.sjoin_nearest : nearest neighbor join
         sjoin : equivalent top-level function
         """
-        return geopandas.sjoin(left_df=self, right_df=df, *args, **kwargs)
+        return geopandas.sjoin(left_df=self, right_df=df, *args, **kwargs)  # noqa: B026
 
     def sjoin_nearest(
         self,
@@ -2185,6 +2191,7 @@ individually so that features may have different properties
         lsuffix="left",
         rsuffix="right",
         distance_col=None,
+        exclusive=False,
     ):
         """
         Spatial join of two GeoDataFrames based on the distance between their
@@ -2222,6 +2229,10 @@ individually so that features may have different properties
         distance_col : string, default None
             If set, save the distances computed between matching geometries under a
             column of this name in the joined GeoDataFrame.
+        exclusive : bool, optional, default False
+            If True, the nearest geometries that are equal to the input geometry
+            will not be returned, default False.
+            Requires Shapely >= 2.0
 
         Examples
         --------
@@ -2304,6 +2315,7 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
             lsuffix=lsuffix,
             rsuffix=rsuffix,
             distance_col=distance_col,
+            exclusive=exclusive,
         )
 
     def clip(self, mask, keep_geom_type=False):
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index e644689d..88661748 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -1,4 +1,9 @@
+from __future__ import annotations
+
 import json
+import os
+import typing
+from typing import Optional, Any, Callable, Dict
 import warnings
 
 import numpy as np
@@ -30,7 +35,9 @@ from .array import (
 from .base import is_geometry_type
 
 
-def _geoseries_constructor_with_fallback(data=None, index=None, crs=None, **kwargs):
+def _geoseries_constructor_with_fallback(
+    data=None, index=None, crs: Optional[Any] = None, **kwargs
+):
     """
     A flexible constructor for GeoSeries._constructor, which needs to be able
     to fall back to a Series (if a certain operation does not produce
@@ -151,7 +158,7 @@ class GeoSeries(GeoPandasBase, Series):
 
     _metadata = ["name"]
 
-    def __init__(self, data=None, index=None, crs=None, **kwargs):
+    def __init__(self, data=None, index=None, crs: Optional[Any] = None, **kwargs):
         if hasattr(data, "crs") and crs:
             if not data.crs:
                 # make a copy to avoid setting CRS to passed GeometryArray
@@ -231,15 +238,15 @@ class GeoSeries(GeoPandasBase, Series):
         if not self.crs:
             self.crs = crs
 
-    def append(self, *args, **kwargs):
+    def append(self, *args, **kwargs) -> GeoSeries:
         return self._wrapped_pandas_method("append", *args, **kwargs)
 
     @property
-    def geometry(self):
+    def geometry(self) -> GeoSeries:
         return self
 
     @property
-    def x(self):
+    def x(self) -> Series:
         """Return the x location of point geometries in a GeoSeries
 
         Returns
@@ -267,7 +274,7 @@ class GeoSeries(GeoPandasBase, Series):
         return _delegate_property("x", self)
 
     @property
-    def y(self):
+    def y(self) -> Series:
         """Return the y location of point geometries in a GeoSeries
 
         Returns
@@ -295,7 +302,7 @@ class GeoSeries(GeoPandasBase, Series):
         return _delegate_property("y", self)
 
     @property
-    def z(self):
+    def z(self) -> Series:
         """Return the z location of point geometries in a GeoSeries
 
         Returns
@@ -323,7 +330,7 @@ class GeoSeries(GeoPandasBase, Series):
         return _delegate_property("z", self)
 
     @classmethod
-    def from_file(cls, filename, **kwargs):
+    def from_file(cls, filename: os.PathLike | typing.IO, **kwargs) -> GeoSeries:
         """Alternate constructor to create a ``GeoSeries`` from a file.
 
         Can load a ``GeoSeries`` from a file from any format recognized by
@@ -357,7 +364,7 @@ class GeoSeries(GeoPandasBase, Series):
 
         See Also
         --------
-        read_file : read file to GeoDataFame
+        read_file : read file to GeoDataFrame
         """
         from geopandas import GeoDataFrame
 
@@ -366,7 +373,9 @@ class GeoSeries(GeoPandasBase, Series):
         return GeoSeries(df.geometry, crs=df.crs)
 
     @classmethod
-    def from_wkb(cls, data, index=None, crs=None, **kwargs):
+    def from_wkb(
+        cls, data, index=None, crs: Optional[Any] = None, **kwargs
+    ) -> GeoSeries:
         """
         Alternate constructor to create a ``GeoSeries``
         from a list or array of WKB objects
@@ -398,7 +407,9 @@ class GeoSeries(GeoPandasBase, Series):
         return cls._from_wkb_or_wkb(from_wkb, data, index=index, crs=crs, **kwargs)
 
     @classmethod
-    def from_wkt(cls, data, index=None, crs=None, **kwargs):
+    def from_wkt(
+        cls, data, index=None, crs: Optional[Any] = None, **kwargs
+    ) -> GeoSeries:
         """
         Alternate constructor to create a ``GeoSeries``
         from a list or array of WKT objects
@@ -444,7 +455,7 @@ class GeoSeries(GeoPandasBase, Series):
         return cls._from_wkb_or_wkb(from_wkt, data, index=index, crs=crs, **kwargs)
 
     @classmethod
-    def from_xy(cls, x, y, z=None, index=None, crs=None, **kwargs):
+    def from_xy(cls, x, y, z=None, index=None, crs=None, **kwargs) -> GeoSeries:
         """
         Alternate constructor to create a :class:`~geopandas.GeoSeries` of Point
         geometries from lists or arrays of x, y(, z) coordinates
@@ -500,8 +511,13 @@ class GeoSeries(GeoPandasBase, Series):
 
     @classmethod
     def _from_wkb_or_wkb(
-        cls, from_wkb_or_wkt_function, data, index=None, crs=None, **kwargs
-    ):
+        cls,
+        from_wkb_or_wkt_function: Callable,
+        data,
+        index=None,
+        crs: Optional[Any] = None,
+        **kwargs,
+    ) -> GeoSeries:
         """Create a GeoSeries from either WKT or WKB values"""
         if isinstance(data, Series):
             if index is not None:
@@ -512,7 +528,7 @@ class GeoSeries(GeoPandasBase, Series):
         return cls(from_wkb_or_wkt_function(data, crs=crs), index=index, **kwargs)
 
     @property
-    def __geo_interface__(self):
+    def __geo_interface__(self) -> Dict:
         """Returns a ``GeoSeries`` as a python feature collection.
 
         Implements the `geo_interface`. The returned python data structure
@@ -538,7 +554,13 @@ class GeoSeries(GeoPandasBase, Series):
 
         return GeoDataFrame({"geometry": self}).__geo_interface__
 
-    def to_file(self, filename, driver=None, index=None, **kwargs):
+    def to_file(
+        self,
+        filename: os.PathLike | typing.IO,
+        driver: Optional[str] = None,
+        index: Optional[bool] = None,
+        **kwargs,
+    ):
         """Write the ``GeoSeries`` to a file.
 
         By default, an ESRI shapefile is written, but any OGR data source
@@ -588,7 +610,7 @@ class GeoSeries(GeoPandasBase, Series):
         See Also
         --------
         GeoDataFrame.to_file : write GeoDataFrame to file
-        read_file : read file to GeoDataFame
+        read_file : read file to GeoDataFrame
 
         Examples
         --------
@@ -641,14 +663,14 @@ class GeoSeries(GeoPandasBase, Series):
         return self._wrapped_pandas_method("select", *args, **kwargs)
 
     @doc(pd.Series)
-    def apply(self, func, convert_dtype=True, args=(), **kwargs):
+    def apply(self, func, convert_dtype: bool = True, args=(), **kwargs):
         result = super().apply(func, convert_dtype=convert_dtype, args=args, **kwargs)
         if isinstance(result, GeoSeries):
             if self.crs is not None:
                 result.set_crs(self.crs, inplace=True)
         return result
 
-    def isna(self):
+    def isna(self) -> Series:
         """
         Detect missing values.
 
@@ -688,11 +710,11 @@ class GeoSeries(GeoPandasBase, Series):
         """
         return super().isna()
 
-    def isnull(self):
+    def isnull(self) -> Series:
         """Alias for `isna` method. See `isna` for more detail."""
         return self.isna()
 
-    def notna(self):
+    def notna(self) -> Series:
         """
         Detect non-missing values.
 
@@ -746,11 +768,11 @@ class GeoSeries(GeoPandasBase, Series):
             )
         return super().notna()
 
-    def notnull(self):
+    def notnull(self) -> Series:
         """Alias for `notna` method. See `notna` for more detail."""
         return self.notna()
 
-    def fillna(self, value=None, method=None, inplace=False, **kwargs):
+    def fillna(self, value=None, method=None, inplace: bool = False, **kwargs):
         """
         Fill NA values with geometry (or geometries).
 
@@ -826,7 +848,7 @@ class GeoSeries(GeoPandasBase, Series):
             value = GeometryCollection() if compat.SHAPELY_GE_20 else BaseGeometry()
         return super().fillna(value=value, method=method, inplace=inplace, **kwargs)
 
-    def __contains__(self, other):
+    def __contains__(self, other) -> bool:
         """Allow tests of the form "geom in s"
 
         Tests whether a GeoSeries contains a geometry.
@@ -847,7 +869,7 @@ class GeoSeries(GeoPandasBase, Series):
         """Interactive map based on folium/leaflet.js"""
         return _explore_geoseries(self, *args, **kwargs)
 
-    def explode(self, ignore_index=False, index_parts=None):
+    def explode(self, ignore_index=False, index_parts=None) -> GeoSeries:
         """
         Explode multi-part geometries into multiple single geometries.
 
@@ -915,7 +937,7 @@ class GeoSeries(GeoPandasBase, Series):
                     self.values._data, return_index=True
                 )
             else:
-                import pygeos  # noqa
+                import pygeos
 
                 geometries, outer_idx = pygeos.get_parts(
                     self.values._data, return_index=True
@@ -962,7 +984,13 @@ class GeoSeries(GeoPandasBase, Series):
     # Additional methods
     #
 
-    def set_crs(self, crs=None, epsg=None, inplace=False, allow_override=False):
+    def set_crs(
+        self,
+        crs: Optional[Any] = None,
+        epsg: Optional[int] = None,
+        inplace: bool = False,
+        allow_override: bool = False,
+    ):
         """
         Set the Coordinate Reference System (CRS) of a ``GeoSeries``.
 
@@ -1054,7 +1082,9 @@ class GeoSeries(GeoPandasBase, Series):
         result.crs = crs
         return result
 
-    def to_crs(self, crs=None, epsg=None):
+    def to_crs(
+        self, crs: Optional[Any] = None, epsg: Optional[int] = None
+    ) -> GeoSeries:
         """Returns a ``GeoSeries`` with all geometries transformed to a new
         coordinate reference system.
 
@@ -1134,7 +1164,7 @@ class GeoSeries(GeoPandasBase, Series):
             self.values.to_crs(crs=crs, epsg=epsg), index=self.index, name=self.name
         )
 
-    def estimate_utm_crs(self, datum_name="WGS 84"):
+    def estimate_utm_crs(self, datum_name: str = "WGS 84") -> CRS:
         """Returns the estimated UTM CRS based on the bounds of the dataset.
 
         .. versionadded:: 0.9
@@ -1174,7 +1204,7 @@ class GeoSeries(GeoPandasBase, Series):
         """
         return self.values.estimate_utm_crs(datum_name)
 
-    def to_json(self, **kwargs):
+    def to_json(self, **kwargs) -> str:
         """
         Returns a GeoJSON string representation of the GeoSeries.
 
@@ -1210,7 +1240,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         """
         return json.dumps(self.__geo_interface__, **kwargs)
 
-    def to_wkb(self, hex=False, **kwargs):
+    def to_wkb(self, hex: bool = False, **kwargs) -> Series:
         """
         Convert GeoSeries geometries to WKB
 
@@ -1235,7 +1265,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         """
         return Series(to_wkb(self.array, hex=hex, **kwargs), index=self.index)
 
-    def to_wkt(self, **kwargs):
+    def to_wkt(self, **kwargs) -> Series:
         """
         Convert GeoSeries geometries to WKT
 
@@ -1313,7 +1343,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         )
         return self.difference(other)
 
-    def clip(self, mask, keep_geom_type=False):
+    def clip(self, mask, keep_geom_type: bool = False) -> GeoSeries:
         """Clip points, lines, or polygon geometries to the mask extent.
 
         Both layers must be in the same Coordinate Reference System (CRS).
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index a131c2c3..6a59d4a0 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -259,7 +259,7 @@ def _geopandas_to_arrow(df, index=None, schema_version=None):
 
     kwargs = {}
     if compat.USE_SHAPELY_20:
-        kwargs = dict(flavor="iso")
+        kwargs = {"flavor": "iso"}
     else:
         for col in df.columns[df.dtypes == "geometry"]:
             series = df[col]
@@ -439,7 +439,8 @@ def _arrow_to_geopandas(table, metadata=None):
         if len(geometry_columns) > 1:
             warnings.warn(
                 "Multiple non-primary geometry columns read from Parquet/Feather "
-                "file. The first column read was promoted to the primary geometry."
+                "file. The first column read was promoted to the primary geometry.",
+                stacklevel=3,
             )
 
     # Convert the WKB columns that are present back to geometry.
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 00c5f364..bcf311c1 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -14,11 +14,15 @@ from shapely.geometry.base import BaseGeometry
 from geopandas import GeoDataFrame, GeoSeries
 
 # Adapted from pandas.io.common
+from urllib.parse import urlparse as parse_url
 from urllib.parse import uses_netloc, uses_params, uses_relative
+import urllib.request
 
 
 _VALID_URLS = set(uses_relative + uses_netloc + uses_params)
 _VALID_URLS.discard("")
+# file:// URIs are supported by fiona/pyogrio -> don't already open + read the file here
+_VALID_URLS.discard("file")
 
 
 fiona = None
@@ -149,6 +153,14 @@ def _expand_user(path):
     return path
 
 
+def _is_url(url):
+    """Check to see if *url* has a valid protocol."""
+    try:
+        return parse_url(url).scheme in _VALID_URLS
+    except Exception:
+        return False
+
+
 def _is_zip(path):
     """Check if a given path is a zipfile"""
     parsed = fiona.path.ParsedPath.from_uri(path)
@@ -228,16 +240,37 @@ def _read_file(filename, bbox=None, mask=None, rows=None, engine=None, **kwargs)
     The format drivers will attempt to detect the encoding of your data, but
     may fail. In this case, the proper encoding can be specified explicitly
     by using the encoding keyword parameter, e.g. ``encoding='utf-8'``.
+
+    When specifying a URL, geopandas will check if the server supports reading
+    partial data and in that case pass the URL as is to the underlying engine,
+    which will then use the network file system handler of GDAL to read from
+    the URL. Otherwise geopandas will download the data from the URL and pass
+    all data in-memory to the underlying engine.
+    If you need more control over how the URL is read, you can specify the
+    GDAL virtual filesystem manually (e.g. ``/vsicurl/https://...``). See the
+    GDAL documentation on filesystems for more details
+    (https://gdal.org/user/virtual_file_systems.html#vsicurl-http-https-ftp-files-random-access).
+
     """
     engine = _check_engine(engine, "'read_file' function")
 
     filename = _expand_user(filename)
 
+    from_bytes = False
+    if _is_url(filename):
+        # if it is a url that supports random access -> pass through to
+        # pyogrio/fiona as is (to support downloading only part of the file)
+        # otherwise still download manually because pyogrio/fiona don't support
+        # all types of urls (https://github.com/geopandas/geopandas/issues/2908)
+        with urllib.request.urlopen(filename) as response:
+            if not response.headers.get("Accept-Ranges") == "bytes":
+                filename = response.read()
+                from_bytes = True
+
     if engine == "pyogrio":
         return _read_file_pyogrio(filename, bbox=bbox, mask=mask, rows=rows, **kwargs)
 
     elif engine == "fiona":
-        from_bytes = False
         if pd.api.types.is_file_like(filename):
             data = filename.read()
             path_or_bytes = data.encode("utf-8") if isinstance(data, str) else data
@@ -525,7 +558,7 @@ def _to_file(
     if driver is None:
         driver = _detect_driver(filename)
 
-    if driver == "ESRI Shapefile" and any([len(c) > 10 for c in df.columns.tolist()]):
+    if driver == "ESRI Shapefile" and any(len(c) > 10 for c in df.columns.tolist()):
         warnings.warn(
             "Column names longer than 10 characters will be truncated when saved to "
             "ESRI Shapefile.",
diff --git a/geopandas/io/sql.py b/geopandas/io/sql.py
index 59307528..5427903e 100644
--- a/geopandas/io/sql.py
+++ b/geopandas/io/sql.py
@@ -8,7 +8,7 @@ import shapely.wkb
 
 from geopandas import GeoDataFrame
 
-from .. import _compat as compat
+from geopandas import _compat as compat
 
 
 @contextmanager
@@ -469,5 +469,3 @@ def _write_postgis(
             dtype=dtype,
             method=_psql_insert_copy,
         )
-
-    return
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index e35f795f..bfba7f80 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -371,6 +371,7 @@ def plot_series(
             "'colormap' is deprecated, please use 'cmap' instead "
             "(for consistency with matplotlib)",
             FutureWarning,
+            stacklevel=3,
         )
         cmap = style_kwds.pop("colormap")
     if "axes" in style_kwds:
@@ -378,6 +379,7 @@ def plot_series(
             "'axes' is deprecated, please use 'ax' instead "
             "(for consistency with pandas)",
             FutureWarning,
+            stacklevel=3,
         )
         ax = style_kwds.pop("axes")
 
@@ -410,6 +412,7 @@ def plot_series(
             "The GeoSeries you are attempting to plot is "
             "empty. Nothing has been displayed.",
             UserWarning,
+            stacklevel=3,
         )
         return ax
 
@@ -418,6 +421,7 @@ def plot_series(
             "The GeoSeries you are attempting to plot is "
             "composed of empty geometries. Nothing has been displayed.",
             UserWarning,
+            stacklevel=3,
         )
         return ax
 
@@ -645,6 +649,7 @@ def plot_dataframe(
             "'colormap' is deprecated, please use 'cmap' instead "
             "(for consistency with matplotlib)",
             FutureWarning,
+            stacklevel=3,
         )
         cmap = style_kwds.pop("colormap")
     if "axes" in style_kwds:
@@ -652,11 +657,14 @@ def plot_dataframe(
             "'axes' is deprecated, please use 'ax' instead "
             "(for consistency with pandas)",
             FutureWarning,
+            stacklevel=3,
         )
         ax = style_kwds.pop("axes")
     if column is not None and color is not None:
         warnings.warn(
-            "Only specify one of 'column' or 'color'. Using 'color'.", UserWarning
+            "Only specify one of 'column' or 'color'. Using 'color'.",
+            UserWarning,
+            stacklevel=3,
         )
         column = None
 
@@ -696,6 +704,7 @@ def plot_dataframe(
             "The GeoDataFrame you are attempting to plot is "
             "empty. Nothing has been displayed.",
             UserWarning,
+            stacklevel=3,
         )
         return ax
 
@@ -975,4 +984,4 @@ class GeoplotAccessor(PlotAccessor):
             raise ValueError(f"{kind} is not a valid plot kind")
 
     def geo(self, *args, **kwargs):
-        return self(kind="geo", *args, **kwargs)
+        return self(kind="geo", *args, **kwargs)  # noqa: B026
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
index 7334f4c3..341f74de 100644
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -232,7 +232,12 @@ class BaseSpatialIndex:
         raise NotImplementedError
 
     def nearest(
-        self, geometry, return_all=True, max_distance=None, return_distance=False
+        self,
+        geometry,
+        return_all=True,
+        max_distance=None,
+        return_distance=False,
+        exclusive=False,
     ):
         """
         Return the nearest geometry in the tree for each input geometry in
@@ -281,6 +286,9 @@ geometries}
             Must be greater than 0. By default None, indicating no distance limit.
         return_distance : bool, optional
             If True, will return distances in addition to indexes. By default False
+        exclusive : bool, optional
+            if True, the nearest geometries that are equal to the input geometry
+            will not be returned. By default False.  Requires Shapely >= 2.0.
 
         Returns
         -------
@@ -420,9 +428,9 @@ geometries}
 
 
 if compat.HAS_RTREE:
-    import rtree.index  # noqa
-    from rtree.core import RTreeError  # noqa
-    from shapely.prepared import prep  # noqa
+    import rtree.index
+    from rtree.core import RTreeError
+    from shapely.prepared import prep
 
     class SpatialIndex(rtree.index.Index, BaseSpatialIndex):
         """Original rtree wrapper, kept for backwards compatibility."""
@@ -669,6 +677,7 @@ if compat.HAS_RTREE:
                 "PyGEOSSTRTreeIndex.nearest for details). This behavior will be "
                 "updated in a future release.",
                 FutureWarning,
+                stacklevel=2,
             )
             return super().nearest(
                 coordinates, num_results=num_results, objects=objects
@@ -701,17 +710,17 @@ if compat.HAS_RTREE:
 
 
 if compat.SHAPELY_GE_20 or compat.HAS_PYGEOS:
-    from . import geoseries  # noqa
-    from . import array  # noqa
+    from . import geoseries
+    from . import array
 
     if compat.USE_SHAPELY_20:
-        import shapely as mod  # noqa
+        import shapely as mod
 
-        _PYGEOS_PREDICATES = {p.name for p in mod.strtree.BinaryPredicate} | set([None])
+        _PYGEOS_PREDICATES = {p.name for p in mod.strtree.BinaryPredicate} | {None}
     else:
-        import pygeos as mod  # noqa
+        import pygeos as mod
 
-        _PYGEOS_PREDICATES = {p.name for p in mod.strtree.BinaryPredicate} | set([None])
+        _PYGEOS_PREDICATES = {p.name for p in mod.strtree.BinaryPredicate} | {None}
 
     class PyGEOSSTRTreeIndex(BaseSpatialIndex):
         """A simple wrapper around pygeos's STRTree.
@@ -839,13 +848,23 @@ if compat.SHAPELY_GE_20 or compat.HAS_PYGEOS:
 
         @doc(BaseSpatialIndex.nearest)
         def nearest(
-            self, geometry, return_all=True, max_distance=None, return_distance=False
+            self,
+            geometry,
+            return_all=True,
+            max_distance=None,
+            return_distance=False,
+            exclusive=False,
         ):
             if not (compat.USE_SHAPELY_20 or compat.PYGEOS_GE_010):
                 raise NotImplementedError(
                     "sindex.nearest requires shapely >= 2.0 or pygeos >= 0.10"
                 )
 
+            if exclusive and not compat.USE_SHAPELY_20:
+                raise NotImplementedError(
+                    "sindex.nearest exclusive parameter requires shapely >= 2.0"
+                )
+
             geometry = self._as_geometry_array(geometry)
             if isinstance(geometry, BaseGeometry) or geometry is None:
                 geometry = [geometry]
@@ -856,6 +875,7 @@ if compat.SHAPELY_GE_20 or compat.HAS_PYGEOS:
                     max_distance=max_distance,
                     return_distance=return_distance,
                     all_matches=return_all,
+                    exclusive=exclusive,
                 )
             else:
                 if not return_all and max_distance is None and not return_distance:
diff --git a/geopandas/tools/_random.py b/geopandas/tools/_random.py
index 3d995c11..2b8b5f91 100644
--- a/geopandas/tools/_random.py
+++ b/geopandas/tools/_random.py
@@ -3,8 +3,8 @@ from warnings import warn
 import numpy
 from shapely.geometry import MultiPoint
 
-from ..array import from_shapely, points_from_xy
-from ..geoseries import GeoSeries
+from geopandas.array import from_shapely, points_from_xy
+from geopandas.geoseries import GeoSeries
 
 
 def uniform(geom, size, seed=None):
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
index 481c523a..e41c49a6 100644
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -200,7 +200,8 @@ def clip(gdf, mask, keep_geom_type=False):
         if geomcoll_orig:
             warnings.warn(
                 "keep_geom_type can not be called on a "
-                "GeoDataFrame with GeometryCollection."
+                "GeoDataFrame with GeometryCollection.",
+                stacklevel=2,
             )
         else:
             polys = ["Polygon", "MultiPolygon"]
@@ -230,7 +231,8 @@ def clip(gdf, mask, keep_geom_type=False):
 
             if orig_types_total > 1:
                 warnings.warn(
-                    "keep_geom_type can not be called on a mixed type GeoDataFrame."
+                    "keep_geom_type can not be called on a mixed type GeoDataFrame.",
+                    stacklevel=2,
                 )
             elif new_collection or more_types:
                 orig_type = gdf.geom_type.iloc[0]
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index 5c1dec9a..fb73405c 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -247,7 +247,7 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
 
     if isinstance(df1, GeoSeries) or isinstance(df2, GeoSeries):
         raise NotImplementedError(
-            "overlay currently only implemented for " "GeoDataFrames"
+            "overlay currently only implemented for GeoDataFrames"
         )
 
     if not _check_crs(df1, df2):
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
index 3b7b01eb..8bffdd16 100644
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -356,12 +356,14 @@ def _nearest_query(
     max_distance: float,
     how: str,
     return_distance: bool,
+    exclusive: bool,
 ):
     if not (compat.USE_SHAPELY_20 or (compat.USE_PYGEOS and compat.PYGEOS_GE_010)):
         raise NotImplementedError(
             "Currently, only PyGEOS >= 0.10.0 or Shapely >= 2.0 supports "
             "`nearest_all`. " + compat.INSTALL_PYGEOS_ERROR
         )
+
     # use the opposite of the join direction for the index
     use_left_as_sindex = how == "right"
     if use_left_as_sindex:
@@ -376,6 +378,7 @@ def _nearest_query(
             return_all=True,
             max_distance=max_distance,
             return_distance=return_distance,
+            exclusive=exclusive,
         )
         if return_distance:
             (input_idx, tree_idx), distances = res
@@ -409,6 +412,7 @@ def sjoin_nearest(
     lsuffix: str = "left",
     rsuffix: str = "right",
     distance_col: Optional[str] = None,
+    exclusive: bool = False,
 ) -> GeoDataFrame:
     """Spatial join of two GeoDataFrames based on the distance between their geometries.
 
@@ -446,6 +450,10 @@ def sjoin_nearest(
     distance_col : string, default None
         If set, save the distances computed between matching geometries under a
         column of this name in the joined GeoDataFrame.
+    exclusive : bool, default False
+        If True, the nearest geometries that are equal to the input geometry
+        will not be returned, default False.
+        Requires Shapely >= 2.0.
 
     Examples
     --------
@@ -527,7 +535,9 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
 
     return_distance = distance_col is not None
 
-    join_df = _nearest_query(left_df, right_df, max_distance, how, return_distance)
+    join_df = _nearest_query(
+        left_df, right_df, max_distance, how, return_distance, exclusive
+    )
 
     if return_distance:
         join_df = join_df.rename(columns={"distances": distance_col})
diff --git a/pyproject.toml b/pyproject.toml
index 6cbcc162..82b7d195 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,13 @@
 [build-system]
-requires = [
-    "setuptools>=61.0.0",
-]
+requires = ["setuptools>=61.0.0"]
 build-backend = "setuptools.build_meta"
 
 [project]
 name = "geopandas"
 dynamic = ["version"]
-authors = [
-    {name = "Kelsey Jordahl", email = "kjordahl@alum.mit.edu"},
-]
-maintainers = [
-    {name = "GeoPandas contributors"},
-]
-license = {text = "BSD 3-Clause"}
+authors = [{ name = "Kelsey Jordahl", email = "kjordahl@alum.mit.edu" }]
+maintainers = [{ name = "GeoPandas contributors" }]
+license = { text = "BSD 3-Clause" }
 description = "Geographic pandas extensions"
 keywords = ["GIS", "cartography", "pandas", "shapely"]
 classifiers = [
@@ -56,10 +50,7 @@ Home = "https://geopandas.org"
 Repository = "https://github.com/geopandas/geopandas"
 
 [tool.setuptools.packages.find]
-include = [
-    "geopandas",
-    "geopandas.*",
-]
+include = ["geopandas", "geopandas.*"]
 
 [tool.setuptools.package-data]
 geopandas = [
@@ -70,13 +61,141 @@ geopandas = [
 ]
 
 [tool.pytest.ini_options]
-markers = [
-    "web: tests that need network connectivity"
-]
+markers = ["web: tests that need network connectivity"]
 xfail_strict = true
 
 filterwarnings = [
     "ignore:distutils Version classes are deprecated.*:DeprecationWarning:pandas.*",
     "ignore:distutils Version classes are deprecated.*:DeprecationWarning:numpy.*",
-    "ignore:The geopandas.dataset module is deprecated"
+    "ignore:The geopandas.dataset module is deprecated",
 ]
+
+[tool.black]
+line-length = 88
+
+[tool.ruff]
+line-length = 88
+select = [
+    # pyflakes
+    "F",
+    # pycodestyle
+    "E",
+    "W",
+    # flake8-2020
+    "YTT",
+    # flake8-bugbear
+    "B",
+    # flake8-quotes
+    "Q",
+    # flake8-debugger
+    "T10",
+    # flake8-gettext
+    "INT",
+    # pylint
+    "PLC",
+    "PLE",
+    "PLR",
+    "PLW",
+    # misc lints
+    "PIE",
+    # flake8-pyi
+    "PYI",
+    # tidy imports
+    "TID",
+    # implicit string concatenation
+    "ISC",
+    # type-checking imports
+    "TCH",
+    # comprehensions
+    "C4",
+    # pygrep-hooks
+    "PGH",
+    # Ruff-specific rules
+    "RUF",
+]
+target-version = "py38"
+ignore = [ # space before : (needed for how black formats slicing)
+    # "E203",  # not yet implemented
+    # module level import not at top of file
+    "E402",
+    # do not assign a lambda expression, use a def
+    "E731",
+    # line break before binary operator
+    # "W503",  # not yet implemented
+    # line break after binary operator
+    # "W504",  # not yet implemented
+    # controversial
+    "B006",
+    # controversial
+    "B007",
+    # controversial
+    "B008",
+    # setattr is used to side-step mypy
+    "B009",
+    # getattr is used to side-step mypy
+    "B010",
+    # tests use assert False
+    "B011",
+    # tests use comparisons but not their returned value
+    "B015",
+    # false positives
+    "B019",
+    # Loop control variable overrides iterable it iterates
+    "B020",
+    # Function definition does not bind loop variable
+    "B023",
+    # Functions defined inside a loop must not use variables redefined in the loop
+    # "B301",  # not yet implemented
+    # Only works with python >=3.10
+    "B905",
+    # Too many arguments to function call
+    "PLR0913",
+    # Too many returns
+    "PLR0911",
+    # Too many branches
+    "PLR0912",
+    # Too many statements
+    "PLR0915",
+    # Redefined loop name
+    "PLW2901",
+    # Global statements are discouraged
+    "PLW0603",
+    # Docstrings should not be included in stubs
+    "PYI021",
+    # No builtin `eval()` allowed
+    "PGH001",
+    # compare-to-empty-string
+    "PLC1901",
+    # Use typing_extensions.TypeAlias for type aliases
+    # "PYI026",  # not yet implemented
+    # Use "collections.abc.*" instead of "typing.*" (PEP 585 syntax)
+    # "PYI027",  # not yet implemented
+    # while int | float can be shortened to float, the former is more explicit
+    # "PYI041",  # not yet implemented
+
+    # Additional checks that don't pass yet
+    # Useless statement
+    "B018",
+    # Within an except clause, raise exceptions with ...
+    "B904",
+    # Magic number
+    "PLR2004",
+    # Consider `elif` instead of `else` then `if` to remove indentation level
+    "PLR5501",
+    # ambiguous-unicode-character-string
+    "RUF001",
+    # ambiguous-unicode-character-docstring
+    "RUF002",
+    # ambiguous-unicode-character-comment
+    "RUF003",
+    # collection-literal-concatenation
+    "RUF005",
+    # pairwise-over-zipped (>=PY310 only)
+    "RUF007",
+    # explicit-f-string-type-conversion
+    "RUF010",
+]
+exclude = ["doc/*", "benchmarks/*", "versioneer.py", "geopandas/_version.py"]
+
+[tool.ruff.per-file-ignores]
+"geopandas/__init__.py" = ["F401"]
diff --git a/setup.cfg b/setup.cfg
index 1dc5af17..5f58ab3c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -9,12 +9,3 @@ versionfile_source = geopandas/_version.py
 versionfile_build = geopandas/_version.py
 tag_prefix = v
 parentdir_prefix = geopandas-
-
-[flake8]
-# Black enforces 88 characters line length
-max_line_length = 88
-
-ignore =
-    E203,  # Space before : (needed for black formatting of slices)
-    W503,  # Line break before binary operator (needed for black)
-    F821   # TODO: fix F821 issues (see GH #1120) and remove this line
diff --git a/setup.py b/setup.py
index 35da885a..9aed6a22 100755
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@ from setuptools import setup
 # https://github.com/python-versioneer/python-versioneer/issues/193
 sys.path.append(os.path.dirname(__file__))
 
-import versioneer  # noqa: E402
+import versioneer
 
 # see pyproject.toml for static project metadata
 setup(


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 78867f70..020d0e6c 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -69,7 +69,7 @@ jobs:
       - uses: actions/checkout@v3
 
       - name: Install Conda environment with Micromamba
-        uses: mamba-org/provision-with-micromamba@main
+        uses: mamba-org/setup-micromamba@v1
         with:
           environment-file: ${{ matrix.env }}
 
diff --git a/ci/envs/310-latest-conda-forge.yaml b/ci/envs/310-latest-conda-forge.yaml
index 350bebe9..af5a90ea 100644
--- a/ci/envs/310-latest-conda-forge.yaml
+++ b/ci/envs/310-latest-conda-forge.yaml
@@ -24,6 +24,7 @@ dependencies:
   - scipy
   - geopy
   - pointpats
+  - geodatasets
   # installed in tests.yaml, because not available on windows
   # - postgis
   - SQLalchemy<2
diff --git a/ci/envs/311-latest-conda-forge.yaml b/ci/envs/311-latest-conda-forge.yaml
index 531151bc..7b577934 100644
--- a/ci/envs/311-latest-conda-forge.yaml
+++ b/ci/envs/311-latest-conda-forge.yaml
@@ -25,6 +25,7 @@ dependencies:
   - scipy
   - geopy
   - pointpats
+  - geodatasets
   # installed in tests.yaml, because not available on windows
   # - postgis
   - SQLalchemy>=2
diff --git a/ci/envs/38-latest-conda-forge.yaml b/ci/envs/38-latest-conda-forge.yaml
index a75dc3e6..d498df66 100644
--- a/ci/envs/38-latest-conda-forge.yaml
+++ b/ci/envs/38-latest-conda-forge.yaml
@@ -29,3 +29,4 @@ dependencies:
   - pyarrow
   - pyogrio
   - pointpats
+  - geodatasets
diff --git a/ci/envs/38-latest-defaults.yaml b/ci/envs/38-latest-defaults.yaml
index d995d22a..90d3b5f1 100644
--- a/ci/envs/38-latest-defaults.yaml
+++ b/ci/envs/38-latest-defaults.yaml
@@ -29,3 +29,4 @@ dependencies:
       - pyarrow
       - folium
       - xyzservices
+      - geodatasets
diff --git a/ci/envs/39-latest-conda-forge.yaml b/ci/envs/39-latest-conda-forge.yaml
index 35a1fde0..2a35ef62 100644
--- a/ci/envs/39-latest-conda-forge.yaml
+++ b/ci/envs/39-latest-conda-forge.yaml
@@ -27,6 +27,7 @@ dependencies:
   - scipy
   - geopy
   - pointpats
+  - geodatasets
   # installed in tests.yaml, because not available on windows
   # - postgis
   - SQLalchemy<2
diff --git a/geopandas/io/tests/generate_legacy_storage_files.py b/geopandas/io/tests/generate_legacy_storage_files.py
index 9cf4a0cc..453c5c64 100644
--- a/geopandas/io/tests/generate_legacy_storage_files.py
+++ b/geopandas/io/tests/generate_legacy_storage_files.py
@@ -45,7 +45,7 @@ def create_pickle_data():
         crs="EPSG:4326",
     )
 
-    return dict(gdf_the_geom=gdf_the_geom, gdf_crs=gdf_crs)
+    return {"gdf_the_geom": gdf_the_geom, "gdf_crs": gdf_crs}
 
 
 def platform_name():
@@ -80,7 +80,7 @@ def write_legacy_pickles(output_dir):
 
 def main():
     if len(sys.argv) != 3:
-        exit(
+        sys.exit(
             "Specify output directory and storage type: generate_legacy_"
             "storage_files.py <output_dir> <storage_type> "
         )
@@ -91,7 +91,7 @@ def main():
     if storage_type == "pickle":
         write_legacy_pickles(output_dir=output_dir)
     else:
-        exit("storage_type must be one of {'pickle'}")
+        sys.exit("storage_type must be one of {'pickle'}")
 
 
 if __name__ == "__main__":
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 88cdeca1..266c0cba 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -631,7 +631,7 @@ def test_fsspec_url():
     with memfs.open("data.parquet", "wb") as f:
         df.to_parquet(f)
 
-    result = read_parquet("memory://data.parquet", storage_options=dict(is_set=True))
+    result = read_parquet("memory://data.parquet", storage_options={"is_set": True})
     assert_geodataframe_equal(result, df)
 
     result = read_parquet("memory://data.parquet", filesystem=memfs)
@@ -834,8 +834,8 @@ def test_read_versioned_file(version):
         geometry=[MultiPolygon([box(0, 0, 1, 1), box(2, 2, 3, 3)]), box(4, 4, 5,5)],
         crs="EPSG:4326",
     )
-    df.to_feather(DATA_PATH / 'arrow' / f'test_data_v{METADATA_VERSION}.feather')  # noqa: E501
-    df.to_parquet(DATA_PATH / 'arrow' / f'test_data_v{METADATA_VERSION}.parquet')  # noqa: E501
+    df.to_feather(DATA_PATH / 'arrow' / f'test_data_v{METADATA_VERSION}.feather')
+    df.to_parquet(DATA_PATH / 'arrow' / f'test_data_v{METADATA_VERSION}.parquet')
     """
     expected = geopandas.GeoDataFrame(
         {"col_str": ["a", "b"], "col_int": [1, 2], "col_float": [0.1, 0.2]},
@@ -867,8 +867,8 @@ def test_read_gdal_files():
     df.to_file("test_data.gpkg", GEOMETRY_NAME="geometry")
     and then the gpkg file is converted to Parquet/Arrow with:
     $ ogr2ogr -f Parquet -lco FID= test_data_gdal350.parquet test_data.gpkg
-    $ ogr2ogr -f Arrow -lco FID= -lco GEOMETRY_ENCODING=WKB test_data_gdal350.arrow test_data.gpkg  # noqa: E501
-    """
+    $ ogr2ogr -f Arrow -lco FID= -lco GEOMETRY_ENCODING=WKB test_data_gdal350.arrow test_data.gpkg
+    """  # noqa: E501
     expected = geopandas.GeoDataFrame(
         {"col_str": ["a", "b"], "col_int": [1, 2], "col_float": [0.1, 0.2]},
         geometry=[MultiPolygon([box(0, 0, 1, 1), box(2, 2, 3, 3)]), box(4, 4, 5, 5)],
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index f4f08855..67930b9d 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -354,10 +354,10 @@ def test_to_file_types(tmpdir, df_points, engine):
         np.uint64,
     ]
     geometry = df_points.geometry
-    data = dict(
-        (str(i), np.arange(len(geometry), dtype=dtype))
+    data = {
+        str(i): np.arange(len(geometry), dtype=dtype)
         for i, dtype in enumerate(int_types)
-    )
+    }
     df = GeoDataFrame(data, geometry=geometry)
     df.to_file(tempfilename, engine=engine)
 
@@ -557,22 +557,29 @@ def test_read_file(engine):
 
 
 @pytest.mark.web
-def test_read_file_remote_geojson_url(engine):
-    url = (
+@pytest.mark.parametrize(
+    "url",
+    [
+        # geojson url
         "https://raw.githubusercontent.com/geopandas/geopandas/"
-        "main/geopandas/tests/data/null_geom.geojson"
-    )
+        "main/geopandas/tests/data/null_geom.geojson",
+        # url to zip file
+        "https://raw.githubusercontent.com/geopandas/geopandas/"
+        "main/geopandas/datasets/nybb_16a.zip",
+        # url to zipfile without extension
+        "https://geonode.goosocean.org/download/480",
+        # url to web service
+        "https://demo.pygeoapi.io/stable/collections/obs/items",
+    ],
+)
+def test_read_file_url(engine, url):
     gdf = read_file(url, engine=engine)
     assert isinstance(gdf, geopandas.GeoDataFrame)
 
 
-@pytest.mark.web
-def test_read_file_remote_zipfile_url(engine):
-    url = (
-        "https://raw.githubusercontent.com/geopandas/geopandas/"
-        "main/geopandas/datasets/nybb_16a.zip"
-    )
-    gdf = read_file(url, engine=engine)
+def test_read_file_local_uri(file_path, engine):
+    local_uri = "file://" + file_path
+    gdf = read_file(local_uri, engine=engine)
     assert isinstance(gdf, geopandas.GeoDataFrame)
 
 
@@ -911,7 +918,7 @@ def test_read_file_empty_shapefile(tmpdir, engine):
     fname = str(tmpdir.join("test_empty.shp"))
 
     with fiona_env():
-        with fiona.open(fname, "w", **meta) as _:  # noqa
+        with fiona.open(fname, "w", **meta) as _:
             pass
 
     empty = read_file(fname, engine=engine)
@@ -1021,8 +1028,6 @@ def test_write_index_to_file(tmpdir, df_points, driver, ext, engine):
         df_check = read_file(tempfilename, engine=engine)
         assert list(df_check.columns) == driver_col + ["geometry"]
 
-        return
-
     #
     # Checks where index is not used/saved
     #
diff --git a/geopandas/testing.py b/geopandas/testing.py
index ae89cd0b..54b68686 100644
--- a/geopandas/testing.py
+++ b/geopandas/testing.py
@@ -288,8 +288,19 @@ def assert_geodataframe_equal(
         assert isinstance(left, type(right))
 
         if check_crs:
+            # allow if neither left and right has an active geometry column
+            if (
+                left._geometry_column_name is None
+                and right._geometry_column_name is None
+            ):
+                pass
+            elif (
+                left._geometry_column_name not in left.columns
+                and right._geometry_column_name not in right.columns
+            ):
+                pass
             # no crs can be either None or {}
-            if not left.crs and not right.crs:
+            elif not left.crs and not right.crs:
                 pass
             else:
                 assert left.crs == right.crs
@@ -328,9 +339,12 @@ def assert_geodataframe_equal(
                 check_crs=check_crs,
             )
 
+    # ensure the active geometry column is the same
+    assert left._geometry_column_name == right._geometry_column_name
+
     # drop geometries and check remaining columns
-    left2 = left.drop([left._geometry_column_name], axis=1)
-    right2 = right.drop([right._geometry_column_name], axis=1)
+    left2 = left.select_dtypes(exclude="geometry")
+    right2 = right.select_dtypes(exclude="geometry")
     assert_frame_equal(
         left2,
         right2,
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
index b62e3f0c..d72d2ed0 100644
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -2,7 +2,6 @@ import random
 
 import numpy as np
 import pandas as pd
-import six
 
 from pyproj import CRS
 import shapely
@@ -192,17 +191,11 @@ def test_to_wkb():
 @pytest.mark.parametrize("string_type", ["str", "bytes"])
 def test_from_wkt(string_type):
     if string_type == "str":
-        f = six.text_type
+        f = str
     else:
-        if six.PY3:
 
-            def f(x):
-                return bytes(x, "utf8")
-
-        else:
-
-            def f(x):
-                return x
+        def f(x):
+            return bytes(x, "utf8")
 
     # list
     L_wkt = [f(p.wkt) for p in points_no_missing]
@@ -468,7 +461,7 @@ def test_unary_predicates(attr):
     na_value = False
     if attr == "is_simple" and geos_version < (3, 8) and not compat.USE_PYGEOS:
         # poly.is_simple raises an error for empty polygon for GEOS < 3.8
-        with pytest.raises(Exception):
+        with pytest.raises(Exception):  # noqa: B017
             T.is_simple
         vals = triangle_no_missing
         V = from_shapely(vals)
@@ -627,10 +620,10 @@ def test_binary_project(normalized):
 
     result = L.project(P, normalized=normalized)
     expected = [
-        l.project(p, normalized=normalized)
-        if l is not None and p is not None
+        line.project(p, normalized=normalized)
+        if line is not None and p is not None
         else na_value
-        for p, l in zip(points, lines)
+        for p, line in zip(points, lines)
     ]
     np.testing.assert_allclose(result, expected)
 
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index 5958cb6b..14f78995 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -112,8 +112,8 @@ def test_to_crs_dimension_mixed():
 )
 def epsg4326(request):
     if isinstance(request.param, int):
-        return dict(epsg=request.param)
-    return dict(crs=request.param)
+        return {"epsg": request.param}
+    return {"crs": request.param}
 
 
 @pytest.fixture(
@@ -130,8 +130,8 @@ def epsg4326(request):
 )
 def epsg26918(request):
     if isinstance(request.param, int):
-        return dict(epsg=request.param)
-    return dict(crs=request.param)
+        return {"epsg": request.param}
+    return {"crs": request.param}
 
 
 @pytest.mark.filterwarnings("ignore:'\\+init:DeprecationWarning")
@@ -650,7 +650,7 @@ class TestGeometryArrayCRS:
         arr = from_shapely(self.geoms, crs=27700)
         df = GeoDataFrame({"col1": [0, 1]}, geometry=arr)
 
-        df["geometry"] = [g for g in df.geometry]
+        df["geometry"] = list(df.geometry)
         assert df.geometry.values.crs == self.osgb
 
         df2 = GeoDataFrame({"col1": [0, 1]}, geometry=arr)
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index 23808826..0a93dea4 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -120,7 +120,7 @@ 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")
+        warnings.warn("foo")  # noqa: B028
         if PANDAS_GE_20:
             return group.sum(numeric_only=False)
         else:
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index c9cc8a5f..dba61184 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -8,10 +8,11 @@ folium = pytest.importorskip("folium")
 branca = pytest.importorskip("branca")
 matplotlib = pytest.importorskip("matplotlib")
 mapclassify = pytest.importorskip("mapclassify")
+geodatasets = pytest.importorskip("geodatasets")
 
-import matplotlib.cm as cm  # noqa
-import matplotlib.colors as colors  # noqa
-from branca.colormap import StepColormap  # noqa
+from matplotlib import cm
+from matplotlib import colors
+from branca.colormap import StepColormap
 
 BRANCA_05 = Version(branca.__version__) > Version("0.4.2")
 FOLIUM_G_014 = Version(folium.__version__) > Version("0.14.0")
@@ -22,6 +23,7 @@ class TestExplore:
         self.nybb = gpd.read_file(gpd.datasets.get_path("nybb"))
         self.world = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres"))
         self.cities = gpd.read_file(gpd.datasets.get_path("naturalearth_cities"))
+        self.chicago = gpd.read_file(geodatasets.get_path("geoda.chicago_commpop"))
         self.world["range"] = range(len(self.world))
         self.missing = self.world.copy()
         np.random.seed(42)
@@ -173,6 +175,7 @@ class TestExplore:
         assert '"fillColor":"#5ec962"' in out_str
         assert '"fillColor":"#fde725"' in out_str
         assert '"fillColor":"#440154"' in out_str
+
         # custom k
         m = self.world.explore(column="pop_est", scheme="naturalbreaks", k=3)
         out_str = self._fetch_map_string(m)
@@ -180,6 +183,18 @@ class TestExplore:
         assert '"fillColor":"#fde725"' in out_str
         assert '"fillColor":"#440154"' in out_str
 
+        # UserDefined overriding default k
+        m = self.chicago.explore(
+            column="POP2010",
+            scheme="UserDefined",
+            classification_kwds={"bins": [25000, 50000, 75000, 100000]},
+        )
+        out_str = self._fetch_map_string(m)
+        assert '"fillColor":"#fde725"' in out_str
+        assert '"fillColor":"#35b779"' in out_str
+        assert '"fillColor":"#31688e"' in out_str
+        assert '"fillColor":"#440154"' in out_str
+
     def test_categorical(self):
         """Categorical maps"""
         # auto detection
@@ -325,11 +340,11 @@ class TestExplore:
     def test_style_kwds(self):
         """Style keywords"""
         m = self.world.explore(
-            style_kwds=dict(fillOpacity=0.1, weight=0.5, fillColor="orange")
+            style_kwds={"fillOpacity": 0.1, "weight": 0.5, "fillColor": "orange"}
         )
         out_str = self._fetch_map_string(m)
         assert '"fillColor":"orange","fillOpacity":0.1,"weight":0.5' in out_str
-        m = self.world.explore(column="pop_est", style_kwds=dict(color="black"))
+        m = self.world.explore(column="pop_est", style_kwds={"color": "black"})
         assert '"color":"black"' in self._fetch_map_string(m)
 
         # custom style_function - geopandas/issues/2350
@@ -348,14 +363,12 @@ class TestExplore:
         # two lines with formatting instructions from style_function.
         # make sure each passes test
         assert all(
-            [
-                ('"fillColor":"green"' in t and '"color":"white"' in t)
-                or ('"fillColor":"red"' in t and '"color":"black"' in t)
-                for t in [
-                    "".join(line.split())
-                    for line in m._parent.render().split("\n")
-                    if "return" in line and "color" in line
-                ]
+            ('"fillColor":"green"' in t and '"color":"white"' in t)
+            or ('"fillColor":"red"' in t and '"color":"black"' in t)
+            for t in [
+                "".join(line.split())
+                for line in m._parent.render().split("\n")
+                if "return" in line and "color" in line
             ]
         )
 
@@ -426,7 +439,7 @@ class TestExplore:
         m = self.world.explore(
             tooltip=True,
             popup=False,
-            tooltip_kwds=dict(aliases=[0, 1, 2, 3, 4, 5], sticky=False),
+            tooltip_kwds={"aliases": [0, 1, 2, 3, 4, 5], "sticky": False},
         )
         out_str = self._fetch_map_string(m)
         assert (
@@ -440,7 +453,7 @@ class TestExplore:
         m = self.world.explore(
             tooltip=False,
             popup=True,
-            popup_kwds=dict(aliases=[0, 1, 2, 3, 4, 5]),
+            popup_kwds={"aliases": [0, 1, 2, 3, 4, 5]},
         )
         out_str = self._fetch_map_string(m)
         assert (
@@ -454,8 +467,8 @@ class TestExplore:
         m = self.world.explore(
             tooltip=True,
             popup=True,
-            tooltip_kwds=dict(labels=False),
-            popup_kwds=dict(labels=False),
+            tooltip_kwds={"labels": False},
+            popup_kwds={"labels": False},
         )
         out_str = self._fetch_map_string(m)
         assert "<th>${aliases[i]" not in out_str
@@ -483,7 +496,7 @@ class TestExplore:
         for s in strings:
             assert s in out_str
 
-        m = self.cities.explore(marker_kwds=dict(radius=5, fill=False))
+        m = self.cities.explore(marker_kwds={"radius": 5, "fill": False})
         strings = ['"radius":5', '"fill":false', "CircleMarker(latlng,opts)"]
         out_str = self._fetch_map_string(m)
         for s in strings:
@@ -556,10 +569,10 @@ class TestExplore:
         m = self.missing.explore("pop_est")
         assert '"fillColor":null' in self._fetch_map_string(m)
 
-        m = self.missing.explore("pop_est", missing_kwds=dict(color="red"))
+        m = self.missing.explore("pop_est", missing_kwds={"color": "red"})
         assert '"fillColor":"red"' in self._fetch_map_string(m)
 
-        m = self.missing.explore("continent", missing_kwds=dict(color="red"))
+        m = self.missing.explore("continent", missing_kwds={"color": "red"})
         assert '"fillColor":"red"' in self._fetch_map_string(m)
 
     def test_categorical_legend(self):
@@ -590,13 +603,13 @@ class TestExplore:
         assert quoted_in("text('range')", out_str)
 
         m = self.world.explore(
-            "range", legend=True, legend_kwds=dict(caption="my_caption")
+            "range", legend=True, legend_kwds={"caption": "my_caption"}
         )
         out_str = self._fetch_map_string(m)
         assert "attr(\"id\",'legend')" in out_str
         assert quoted_in("text('my_caption')", out_str)
 
-        m = self.missing.explore("pop_est", legend=True, missing_kwds=dict(color="red"))
+        m = self.missing.explore("pop_est", legend=True, missing_kwds={"color": "red"})
         out_str = self._fetch_map_string(m)
         assert "red'></span>NaN" in out_str
 
@@ -604,7 +617,7 @@ class TestExplore:
         m = self.world.explore(
             "pop_est",
             legend=True,
-            legend_kwds=dict(scale=False),
+            legend_kwds={"scale": False},
             scheme="Headtailbreaks",
         )
         out_str = self._fetch_map_string(m)
@@ -645,7 +658,7 @@ class TestExplore:
         import re
 
         # linear
-        m = self.world.explore("pop_est", legend_kwds=dict(max_labels=3))
+        m = self.world.explore("pop_est", legend_kwds={"max_labels": 3})
         out_str = self._fetch_map_string(m)
         tick_str = re.search(r"tickValues\(\[[\',\,\.,0-9]*\]\)", out_str).group(0)
         assert (
@@ -655,13 +668,13 @@ class TestExplore:
 
         # scheme
         m = self.world.explore(
-            "pop_est", scheme="headtailbreaks", legend_kwds=dict(max_labels=3)
+            "pop_est", scheme="headtailbreaks", legend_kwds={"max_labels": 3}
         )
         out_str = self._fetch_map_string(m)
         assert "tickValues([140.0,'',184117213.1818182,'',1382066377.0,''])" in out_str
 
         # short cmap
-        m = self.world.explore("pop_est", legend_kwds=dict(max_labels=3), cmap="tab10")
+        m = self.world.explore("pop_est", legend_kwds={"max_labels": 3}, cmap="tab10")
         out_str = self._fetch_map_string(m)
 
         tick_str = re.search(r"tickValues\(\[[\',\,\.,0-9]*\]\)", out_str).group(0)
@@ -746,8 +759,8 @@ class TestExplore:
             column="pop_est",
             legend=True,
             scheme="naturalbreaks",
-            missing_kwds=dict(color="red", label="missing"),
-            legend_kwds=dict(colorbar=False, interval=True),
+            missing_kwds={"color": "red", "label": "missing"},
+            legend_kwds={"colorbar": False, "interval": True},
         )
         out_str = self._fetch_map_string(m)
 
@@ -767,8 +780,8 @@ class TestExplore:
             column="pop_est",
             legend=True,
             scheme="naturalbreaks",
-            missing_kwds=dict(color="red", label="missing"),
-            legend_kwds=dict(colorbar=False, interval=False),
+            missing_kwds={"color": "red", "label": "missing"},
+            legend_kwds={"colorbar": False, "interval": False},
         )
         out_str = self._fetch_map_string(m)
 
@@ -789,7 +802,7 @@ class TestExplore:
             legend=True,
             scheme="naturalbreaks",
             k=5,
-            legend_kwds=dict(colorbar=False, labels=["s", "m", "l", "xl", "xxl"]),
+            legend_kwds={"colorbar": False, "labels": ["s", "m", "l", "xl", "xxl"]},
         )
         out_str = self._fetch_map_string(m)
 
@@ -802,8 +815,8 @@ class TestExplore:
             column="pop_est",
             legend=True,
             scheme="naturalbreaks",
-            missing_kwds=dict(color="red", label="missing"),
-            legend_kwds=dict(colorbar=False, fmt="{:.0f}"),
+            missing_kwds={"color": "red", "label": "missing"},
+            legend_kwds={"colorbar": False, "fmt": "{:.0f}"},
         )
         out_str = self._fetch_map_string(m)
 
@@ -836,7 +849,7 @@ class TestExplore:
         assert '"fillOpacity":0.75' in out_str
 
         m = self.nybb.explore(
-            highlight=True, highlight_kwds=dict(fillOpacity=1, color="red")
+            highlight=True, highlight_kwds={"fillOpacity": 1, "color": "red"}
         )
         out_str = self._fetch_map_string(m)
 
@@ -914,12 +927,16 @@ class TestExplore:
 
         # check that folium and leaflet Map() parameters can be passed
         m = self.world.explore(
-            zoom_control=False, map_kwds=dict(dragging=False, scrollWheelZoom=False)
+            zoom_control=False, map_kwds={"dragging": False, "scrollWheelZoom": False}
         )
         check()
         with pytest.raises(
             ValueError, match="'zoom_control' cannot be specified in 'map_kwds'"
         ):
             self.world.explore(
-                map_kwds=dict(dragging=False, scrollWheelZoom=False, zoom_control=False)
+                map_kwds={
+                    "dragging": False,
+                    "scrollWheelZoom": False,
+                    "zoom_control": False,
+                }
             )
diff --git a/geopandas/tests/test_extension_array.py b/geopandas/tests/test_extension_array.py
index dddbcaf3..fd0733c6 100644
--- a/geopandas/tests/test_extension_array.py
+++ b/geopandas/tests/test_extension_array.py
@@ -479,12 +479,12 @@ class TestComparisonOps(extension_tests.BaseComparisonOpsTests):
         expected = s.combine(other, op)
         self.assert_series_equal(result, expected)
 
-    def test_compare_scalar(self, data, all_compare_operators):  # noqa
+    def test_compare_scalar(self, data, all_compare_operators):
         op_name = all_compare_operators
         s = pd.Series(data)
         self._compare_other(s, data, op_name, data[0])
 
-    def test_compare_array(self, data, all_compare_operators):  # noqa
+    def test_compare_array(self, data, all_compare_operators):
         op_name = all_compare_operators
         s = pd.Series(data)
         other = pd.Series([data[0]] * len(data))
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 34412706..009cdbc8 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -976,7 +976,7 @@ class TestDataFrame:
     @pytest.mark.parametrize("how", ["left", "inner", "right"])
     @pytest.mark.parametrize("predicate", ["intersects", "within", "contains"])
     @pytest.mark.skipif(
-        not (compat.USE_PYGEOS and compat.HAS_RTREE and compat.USE_SHAPELY_20),
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20 or compat.HAS_RTREE),
         reason="sjoin needs `rtree` or `pygeos` dependency",
     )
     def test_sjoin(self, how, predicate):
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 5665eae2..b7a753b7 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -11,6 +11,7 @@ import shapely
 from shapely.geometry import (
     LinearRing,
     LineString,
+    MultiLineString,
     MultiPoint,
     Point,
     Polygon,
@@ -26,7 +27,7 @@ from geopandas.base import GeoPandasBase
 from geopandas.testing import assert_geodataframe_equal
 from geopandas.tests.util import assert_geoseries_equal, geom_almost_equals, geom_equals
 from geopandas import _compat as compat
-from pandas.testing import assert_frame_equal, assert_series_equal, assert_index_equal
+from pandas.testing import assert_frame_equal, assert_index_equal, assert_series_equal
 import pytest
 
 
@@ -48,6 +49,7 @@ class TestGeomMethods:
         self.sqz = Polygon([(1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4)])
         self.t4 = Polygon([(0, 0), (3, 0), (3, 3), (0, 2)])
         self.t5 = Polygon([(2, 0), (3, 0), (3, 3), (2, 3)])
+        self.t6 = Polygon([(2, 0), (2, 0), (3, 0), (3, 0)])
         self.inner_sq = Polygon(
             [(0.25, 0.25), (0.75, 0.25), (0.75, 0.75), (0.25, 0.75)]
         )
@@ -169,6 +171,12 @@ class TestGeomMethods:
                 [1.0, 1.0, np.nan],
             ]
         )
+        self.squares = GeoSeries([self.sq for _ in range(3)])
+
+        self.l5 = LineString([(100, 0), (0, 0), (0, 100)])
+        self.l6 = LineString([(5, 5), (5, 100), (100, 5)])
+        self.g12 = GeoSeries([self.l5])
+        self.g13 = GeoSeries([self.l6])
 
     def _test_unary_real(self, op, expected, a):
         """Tests for 'area', 'length', 'is_valid', etc."""
@@ -564,6 +572,53 @@ class TestGeomMethods:
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.distance(self.p0)
 
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="requires hausdorff_distance in shapely 2.0+",
+    )
+    def test_hausdorff_distance(self):
+        # closest point is (0, 0) in self.p1
+        expected = Series(
+            np.array([np.sqrt(5**2 + 5**2), np.nan]), self.na_none.index
+        )
+        assert_array_dtype_equal(expected, self.na_none.hausdorff_distance(self.p0))
+
+        expected = Series(
+            np.array([np.sqrt(5**2 + 5**2), np.nan]), self.na_none.index
+        )
+        assert_array_dtype_equal(expected, self.na_none.hausdorff_distance(self.p0))
+
+        expected = Series(np.array([np.nan, 0, 0, 0, 0, 0, np.nan, np.nan]), range(8))
+        with pytest.warns(UserWarning, match="The indices .+ different"):
+            assert_array_dtype_equal(
+                expected, self.g0.hausdorff_distance(self.g9, align=True)
+            )
+
+        val_1 = self.g0.iloc[0].hausdorff_distance(self.g9.iloc[0])
+        val_2 = self.g0.iloc[2].hausdorff_distance(self.g9.iloc[2])
+        val_3 = self.g0.iloc[4].hausdorff_distance(self.g9.iloc[4])
+        expected = Series(
+            np.array([val_1, val_1, val_2, val_2, val_3, np.nan, np.nan]), self.g0.index
+        )
+        assert_array_dtype_equal(
+            expected, self.g0.hausdorff_distance(self.g9, align=False)
+        )
+
+        expected = Series(np.array([52.5]), self.g12.index)
+        assert_array_dtype_equal(
+            expected, self.g12.hausdorff_distance(self.g13, densify=0.25)
+        )
+
+    @pytest.mark.skipif(
+        (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="hausdorff_distance not implemented for shapely<2",
+    )
+    def test_hausdorff_distance_not(self):
+        with pytest.raises(
+            NotImplementedError, match="shapely >= 2.0 or PyGEOS is required"
+        ):
+            self.g0.hausdorff_distance(self.g9)
+
     def test_intersects(self):
         expected = [True, True, True, True, True, False, False]
         assert_array_dtype_equal(expected, self.g0.intersects(self.t1))
@@ -777,10 +832,168 @@ class TestGeomMethods:
         ):
             s.make_valid()
 
+    @pytest.mark.skipif(
+        (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="segmentize keyword introduced in shapely 2.0",
+    )
+    def test_segmentize_shapely_pre20(self):
+        s = GeoSeries([Point(1, 1)])
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            s.segmentize(max_segment_length=1)
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="segmentize keyword introduced in shapely 2.0",
+    )
+    def test_segmentize_linestrings(self):
+        expected_g1 = GeoSeries(
+            [
+                Polygon(
+                    (
+                        (
+                            (0, 0),
+                            (0.5, 0),
+                            (1, 0),
+                            (1, 0.5),
+                            (1, 1),
+                            (0.6666666666666666, 0.6666666666666666),
+                            (0.3333333333333333, 0.3333333333333333),
+                            (0, 0),
+                        )
+                    )
+                ),
+                Polygon(
+                    (
+                        (
+                            (0, 0),
+                            (0.5, 0),
+                            (1, 0),
+                            (1, 0.5),
+                            (1, 1),
+                            (0.5, 1),
+                            (0, 1),
+                            (0, 0.5),
+                            (0, 0),
+                        )
+                    )
+                ),
+            ]
+        )
+        expected_g5 = GeoSeries(
+            [
+                LineString([(0, 0), (0, 0.5), (0, 1), (0.5, 1), (1, 1)]),
+                LineString(
+                    [(0, 0), (0.5, 0), (1, 0), (1, 0.5), (1, 1), (0.5, 1), (0, 1)]
+                ),
+            ]
+        )
+        result_g1 = self.g1.segmentize(max_segment_length=0.5)
+        result_g5 = self.g5.segmentize(max_segment_length=0.5)
+        assert_geoseries_equal(expected_g1, result_g1)
+        assert_geoseries_equal(expected_g5, result_g5)
+
+    @pytest.mark.skipif(
+        compat.SHAPELY_GE_20,
+        reason="concave_hull is implemented for shapely >= 2.0",
+    )
+    def test_concave_hull_not_implemented_shapely_pre2(self):
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 2.0 is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            self.squares.concave_hull()
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS and compat.SHAPELY_GE_20),
+        reason="concave_hull is only implemented for shapely >= 2.0",
+    )
+    def test_concave_hull_pygeos_set_shapely_installed(self):
+        expected = GeoSeries(
+            [
+                Polygon([(0, 1), (1, 1), (0, 0), (0, 1)]),
+                Polygon([(1, 0), (0, 0), (0, 1), (1, 1), (1, 0)]),
+            ]
+        )
+        with pytest.warns(
+            UserWarning,
+            match="PyGEOS does not support concave_hull, and Shapely >= 2 is installed",
+        ):
+            assert_geoseries_equal(expected, self.g5.concave_hull())
+
+    @pytest.mark.skipif(
+        not compat.USE_SHAPELY_20,
+        reason="concave_hull is only implemented for shapely >= 2.0",
+    )
+    def test_concave_hull(self):
+        assert_geoseries_equal(self.squares, self.squares.concave_hull())
+
+    @pytest.mark.skipif(
+        not compat.USE_SHAPELY_20,
+        reason="concave_hull is only implemented for shapely >= 2.0",
+    )
+    @pytest.mark.parametrize(
+        "expected_series,ratio",
+        [
+            ([(0, 0), (0, 3), (1, 1), (3, 3), (3, 0), (0, 0)], 0.0),
+            ([(0, 0), (0, 3), (3, 3), (3, 0), (0, 0)], 1.0),
+        ],
+    )
+    def test_concave_hull_accepts_kwargs(self, expected_series, ratio):
+        expected = GeoSeries(Polygon(expected_series))
+        s = GeoSeries(MultiPoint([(0, 0), (0, 3), (1, 1), (3, 0), (3, 3)]))
+        assert_geoseries_equal(expected, s.concave_hull(ratio=ratio))
+
     def test_convex_hull(self):
         # the convex hull of a square should be the same as the square
-        squares = GeoSeries([self.sq for i in range(3)])
-        assert_geoseries_equal(squares, squares.convex_hull)
+        assert_geoseries_equal(self.squares, self.squares.convex_hull)
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="delaunay_triangles not implemented for shapely<2",
+    )
+    def test_delaunay_triangles(self):
+        expected = GeoSeries(
+            [
+                GeometryCollection([Polygon([(0, 0), (1, 0), (1, 1), (0, 0)])]),
+                GeometryCollection([Polygon([(0, 1), (0, 0), (1, 1), (0, 1)])]),
+            ]
+        )
+        dlt = self.g3.delaunay_triangles()
+        assert isinstance(dlt, GeoSeries)
+        assert_series_equal(expected, dlt)
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="delaunay_triangles not implemented for shapely<2",
+    )
+    def test_delaunay_triangles_pass_kwargs(self):
+        expected = GeoSeries(
+            [
+                MultiLineString([[(0, 0), (1, 1)], [(0, 0), (1, 0)], [(1, 0), (1, 1)]]),
+                MultiLineString([[(0, 1), (1, 1)], [(0, 0), (0, 1)], [(0, 0), (1, 1)]]),
+            ]
+        )
+        dlt = self.g3.delaunay_triangles(only_edges=True)
+        assert isinstance(dlt, GeoSeries)
+        assert_series_equal(expected, dlt)
+
+    @pytest.mark.skipif(
+        compat.USE_PYGEOS or compat.USE_SHAPELY_20,
+        reason="delaunay_triangles implemented for shapely>2",
+    )
+    def test_delaunay_triangles_shapely_pre20(self):
+        s = GeoSeries([Point(1, 1)])
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            s.delaunay_triangles()
 
     def test_exterior(self):
         exp_exterior = GeoSeries([LinearRing(p.boundary) for p in self.g3])
@@ -918,7 +1131,7 @@ class TestGeomMethods:
         assert geom_almost_equals(expected, calculated)
 
     def test_buffer_args(self):
-        args = dict(cap_style=3, join_style=2, mitre_limit=2.5)
+        args = {"cap_style": 3, "join_style": 2, "mitre_limit": 2.5}
         calculated_series = self.g0.buffer(10, **args)
         for original, calculated in zip(self.g0, calculated_series):
             if original is None:
@@ -976,6 +1189,27 @@ 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=(
+            "extract_unique_points is only implemented for pygeos and shapely >= 2.0"
+        ),
+    )
+    def test_extract_unique_points(self):
+        eup = GeoSeries([self.t6]).extract_unique_points()
+        expected = GeoSeries([MultiPoint([(2, 0), (3, 0)])])
+        assert_series_equal(eup, expected)
+
+    @pytest.mark.skipif(
+        (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="extract_unique_points not implemented for shapely<2",
+    )
+    def test_extract_unique_points_not_implemented(self):
+        with pytest.raises(
+            NotImplementedError, match="shapely >= 2.0 or PyGEOS is required"
+        ):
+            self.g1.extract_unique_points()
+
     @pytest.mark.skipif(
         not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
         reason="minimum_bounding_circle is only implemented for pygeos, not shapely",
@@ -1579,3 +1813,13 @@ class TestGeomMethods:
 
         with pytest.raises(AttributeError, match="pointpats.random module has no"):
             gs.sample_points(10, method="nonexistent")
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="offset_curve is only implemented for pygeos, not shapely",
+    )
+    def test_offset_curve(self):
+        oc = GeoSeries([self.l1]).offset_curve(1, join_style="mitre")
+        expected = GeoSeries([LineString([[-1, 0], [-1, 2], [1, 2]])])
+        assert_geoseries_equal(expected, oc)
+        assert isinstance(oc, GeoSeries)
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index 60a3f85f..13fe9095 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -514,7 +514,7 @@ def test_overlay_strict(how, keep_geom_type, geom_types):
         # so we sort the resultant dataframes to get a consistent order
         # independently of the spatial index implementation
         assert all(expected.columns == result.columns), "Column name mismatch"
-        cols = list(set(result.columns) - set(["geometry"]))
+        cols = list(set(result.columns) - {"geometry"})
         expected = expected.sort_values(cols, axis=0).reset_index(drop=True)
         result = result.sort_values(cols, axis=0).reset_index(drop=True)
 
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 99da2185..c10a4183 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -776,9 +776,7 @@ def test_pivot(df):
     # pivot failing due to creating a MultiIndex
     result = df.pivot(columns="value1")
     expected = GeoDataFrame(pd.DataFrame(df).pivot(columns="value1"))
-    # TODO assert_geodataframe_equal crashes
-    assert isinstance(result, GeoDataFrame)
-    assert_frame_equal(result, expected)
+    assert_geodataframe_equal(result, expected)
 
 
 def test_preserve_attrs(df):
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index 69061d99..836c0c71 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -29,7 +29,7 @@ import pytest
 
 matplotlib = pytest.importorskip("matplotlib")
 matplotlib.use("Agg")
-import matplotlib.pyplot as plt  # noqa
+import matplotlib.pyplot as plt
 
 try:  # skipif and importorskip do not work for decorators
     from matplotlib.testing.decorators import check_figures_equal
@@ -1137,7 +1137,7 @@ class TestMapclassifyPlotting:
     @classmethod
     def setup_class(cls):
         try:
-            import mapclassify  # noqa
+            import mapclassify
         except ImportError:
             pytest.importorskip("mapclassify")
         cls.mc = mapclassify
@@ -1313,10 +1313,8 @@ class TestMapclassifyPlotting:
             ]
         )
         assert all(
-            [
-                (z == expected).all(axis=1).any()
-                for z in ax.collections[0].get_facecolors()
-            ]
+            (z == expected).all(axis=1).any()
+            for z in ax.collections[0].get_facecolors()
         )
         labels = [
             "0.00, 0.10",
@@ -1366,10 +1364,8 @@ class TestMapclassifyPlotting:
             ]
         )
         assert all(
-            [
-                (z == expected).all(axis=1).any()
-                for z in ax2.collections[0].get_facecolors()
-            ]
+            (z == expected).all(axis=1).any()
+            for z in ax2.collections[0].get_facecolors()
         )
 
         labels = [
@@ -1404,10 +1400,8 @@ class TestMapclassifyPlotting:
             ]
         )
         assert all(
-            [
-                (z == expected).all(axis=1).any()
-                for z in ax3.collections[0].get_facecolors()
-            ]
+            (z == expected).all(axis=1).any()
+            for z in ax3.collections[0].get_facecolors()
         )
 
         legend = [t.get_text() for t in ax3.get_legend().get_texts()]
@@ -1434,7 +1428,7 @@ class TestMapclassifyPlotting:
         assert labels == expected
 
         ax2 = self.nybb.plot(
-            "vals", scheme="quantiles", legend=True, legend_kwds=dict(fmt="{:.3f}")
+            "vals", scheme="quantiles", legend=True, legend_kwds={"fmt": "{:.3f}"}
         )
         labels = [t.get_text() for t in ax2.get_legend().get_texts()]
         expected = [
@@ -1746,8 +1740,6 @@ class TestGeoplotAccessor:
         ax_geopandas_2 = fig_ref.subplots()
         getattr(self.gdf.plot, kind)(ax=ax_geopandas_2, **kwargs)
 
-    _pandas_kinds = []
-
     _pandas_kinds = GeoplotAccessor._pandas_kinds
 
     if MPL_DECORATORS:
@@ -1876,7 +1868,7 @@ def _check_colors(N, actual_colors, expected_colors, alpha=None):
         `expected_colors`. (Any transparency on the `collection` is assumed
         to be set in its own facecolor RGBA tuples.)
     """
-    import matplotlib.colors as colors
+    from matplotlib import colors
 
     conv = colors.colorConverter
 
@@ -1884,9 +1876,9 @@ def _check_colors(N, actual_colors, expected_colors, alpha=None):
     actual_colors = map(tuple, actual_colors)
     all_actual_colors = list(itertools.islice(itertools.cycle(actual_colors), N))
 
-    assert len(all_actual_colors) == len(expected_colors), (
-        "Different " "lengths of actual and expected colors!"
-    )
+    assert len(all_actual_colors) == len(
+        expected_colors
+    ), "Different lengths of actual and expected colors!"
 
     for actual, expected in zip(all_actual_colors, expected_colors):
         assert actual == conv.to_rgba(expected, alpha=alpha), "{} != {}".format(
@@ -1920,8 +1912,7 @@ def _get_ax(fig, label):
     for ax in fig.axes:
         if ax.get_label() == label:
             return ax
-    else:
-        raise ValueError("no ax found with label {0}".format(label))
+    raise ValueError("no ax found with label {0}".format(label))
 
 
 def _get_colorbar_ax(fig):
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index e60127cb..bc909a33 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -834,6 +834,66 @@ class TestPygeosInterface:
         else:
             assert_array_equal(res, expected[0])
 
+    @pytest.mark.skipif(
+        not (compat.USE_SHAPELY_20),
+        reason=(
+            "shapely >= 2.0 is required to test sindex.nearest with parameter exclusive"
+        ),
+    )
+    @pytest.mark.parametrize("return_distance", [True, False])
+    @pytest.mark.parametrize(
+        "return_all,max_distance,exclusive,expected",
+        [
+            (False, None, False, ([[0, 1, 2, 3, 4], [0, 1, 2, 3, 4]], 5 * [0])),
+            (False, None, True, ([[0, 1, 2, 3, 4], [1, 0, 1, 2, 3]], 5 * [sqrt(2)])),
+            (True, None, False, ([[0, 1, 2, 3, 4], [0, 1, 2, 3, 4]], 5 * [0])),
+            (
+                True,
+                None,
+                True,
+                ([[0, 1, 1, 2, 2, 3, 3, 4], [1, 0, 2, 1, 3, 2, 4, 3]], 8 * [sqrt(2)]),
+            ),
+            (False, 1.1, True, ([[1, 2, 5], [5, 5, 1]], 3 * [1])),
+            (True, 1.1, True, ([[1, 2, 5, 5], [5, 5, 1, 2]], 4 * [1])),
+        ],
+    )
+    def test_nearest_exclusive(
+        self, expected, max_distance, return_all, return_distance, exclusive
+    ):
+        geoms = mod.points(np.arange(5), np.arange(5))
+        if max_distance:
+            # add a non grid point
+            geoms = np.append(geoms, [Point(1, 2)])
+
+        df = geopandas.GeoDataFrame({"geometry": geoms})
+
+        ps = geoms
+        res = df.sindex.nearest(
+            ps,
+            return_all=return_all,
+            max_distance=max_distance,
+            return_distance=return_distance,
+            exclusive=exclusive,
+        )
+        if return_distance:
+            assert_array_equal(res[0], expected[0])
+            assert_array_equal(res[1], expected[1])
+        else:
+            assert_array_equal(res, expected[0])
+
+    @pytest.mark.skipif(
+        compat.USE_SHAPELY_20 or not (compat.USE_PYGEOS and not compat.PYGEOS_GE_010),
+        reason="sindex.nearest exclusive parameter requires shapely >= 2.0",
+    )
+    def test_nearest_exclusive_unavailable(self):
+        from shapely.geometry import Point
+
+        geoms = [Point((x, y)) for (x, y) in zip(np.arange(5), np.arange(5))]
+        df = geopandas.GeoDataFrame(geometry=geoms)
+
+        with pytest.raises(NotImplementedError, match="requires shapely >= 2.0"):
+            df.sindex.nearest(geoms, exclusive=True)
+
     # --------------------------- misc tests ---------------------------- #
 
     def test_empty_tree_geometries(self):
diff --git a/geopandas/tests/test_testing.py b/geopandas/tests/test_testing.py
index 1783dd23..b119f72b 100644
--- a/geopandas/tests/test_testing.py
+++ b/geopandas/tests/test_testing.py
@@ -3,7 +3,8 @@ import warnings
 import numpy as np
 
 from shapely.geometry import Point, Polygon
-from pandas import Series
+import pandas as pd
+from pandas import DataFrame, Series
 
 from geopandas import GeoDataFrame, GeoSeries
 from geopandas.array import from_shapely
@@ -139,3 +140,44 @@ def test_almost_equal_but_not_equal():
     assert_geoseries_equal(s_origin, s_almost_origin, check_less_precise=True)
     with pytest.raises(AssertionError):
         assert_geoseries_equal(s_origin, s_almost_origin)
+
+
+def test_geodataframe_no_active_geometry_column():
+    def create_dataframe():
+        gdf = GeoDataFrame({"value": [1, 2], "geometry": [Point(1, 1), Point(2, 2)]})
+        gdf["geom2"] = GeoSeries([Point(3, 3), Point(4, 4)])
+        return gdf
+
+    # no active geometry column (None)
+    df1 = create_dataframe()
+    df1._geometry_column_name = None
+    df2 = create_dataframe()
+    df2._geometry_column_name = None
+    assert_geodataframe_equal(df1, df2)
+
+    # active geometry column ("geometry") not present
+    df1 = create_dataframe()[["value", "geom2"]]
+    df2 = create_dataframe()[["value", "geom2"]]
+    assert_geodataframe_equal(df1, df2)
+
+    df1 = GeoDataFrame(create_dataframe()[["value"]])
+    df2 = GeoDataFrame(create_dataframe()[["value"]])
+    assert_geodataframe_equal(df1, df2)
+
+
+def test_geodataframe_multiindex():
+    def create_dataframe():
+        gdf = DataFrame([[Point(0, 0), Point(1, 1)], [Point(2, 2), Point(3, 3)]])
+        gdf = GeoDataFrame(gdf.astype("geometry"))
+        gdf.columns = pd.MultiIndex.from_product([["geometry"], [0, 1]])
+        return gdf
+
+    df1 = create_dataframe()
+    df2 = create_dataframe()
+    assert_geodataframe_equal(df1, df2)
+
+    df1 = create_dataframe()
+    df1._geometry_column_name = None
+    df2 = create_dataframe()
+    df2._geometry_column_name = None
+    assert_geodataframe_equal(df1, df2)
diff --git a/geopandas/tests/util.py b/geopandas/tests/util.py
index a6641371..9e7021ef 100644
--- a/geopandas/tests/util.py
+++ b/geopandas/tests/util.py
@@ -4,7 +4,7 @@ from pandas import Series
 
 from geopandas import GeoDataFrame
 
-from geopandas.testing import (  # noqa
+from geopandas.testing import (  # noqa: F401
     assert_geoseries_equal,
     geom_almost_equals,
     geom_equals,
@@ -16,9 +16,9 @@ PACKAGE_DIR = os.path.dirname(os.path.dirname(HERE))
 
 # mock not used here, but the import from here is used in other modules
 try:
-    import unittest.mock as mock  # noqa
+    from unittest import mock
 except ImportError:
-    import mock  # noqa
+    import mock  # noqa: F401
 
 
 def validate_boro_df(df, case_sensitive=False):
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
index 4ea75080..d999ab63 100644
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -1,6 +1,5 @@
 """Tests for the clip module."""
 
-import warnings
 from packaging.version import Version
 
 import numpy as np
@@ -353,15 +352,6 @@ class TestClipWithSingleRectangleGdf:
             and clipped.geom_type[2] == "LineString"
         )
 
-    def test_clip_warning_no_extra_geoms(self, buffered_locations, mask):
-        """Test a user warning is provided if no new geometry types are found."""
-        with pytest.warns(UserWarning):
-            clip(buffered_locations, mask, True)
-            warnings.warn(
-                "keep_geom_type was called when no extra geometry types existed.",
-                UserWarning,
-            )
-
     def test_clip_with_line_extra_geom(self, sliver_line, mask):
         """When the output of a clipped line returns a geom collection,
         and keep_geom_type is True, no geometry collections should be returned."""
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
index 1b15d21e..dbab2ac6 100644
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -1,18 +1,18 @@
 import math
 from typing import Sequence
-from geopandas.testing import assert_geodataframe_equal
 
 import numpy as np
 import pandas as pd
+import shapely
 
 from shapely.geometry import Point, Polygon, GeometryCollection
 
 import geopandas
 import geopandas._compat as compat
 from geopandas import GeoDataFrame, GeoSeries, read_file, sjoin, sjoin_nearest
-from geopandas.testing import assert_geoseries_equal
+from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 
-from pandas.testing import assert_frame_equal
+from pandas.testing import assert_frame_equal, assert_series_equal
 import pytest
 
 
@@ -111,7 +111,7 @@ class TestSpatialJoin:
         left = GeoDataFrame({"col": [1], "geometry": [Point(0, 0)]})
         right = GeoDataFrame({"col": [1], "geometry": [Point(0, 0)]})
         joined = sjoin(left, right, how=how, lsuffix=lsuffix, rsuffix=rsuffix)
-        assert set(joined.columns) == expected_cols | set(("geometry",))
+        assert set(joined.columns) == expected_cols | {"geometry"}
 
     @pytest.mark.parametrize("dfs", ["default-index", "string-index"], indirect=True)
     def test_crs_mismatch(self, dfs):
@@ -136,7 +136,7 @@ class TestSpatialJoin:
         if op != predicate:
             warntype = UserWarning
             match = (
-                "`predicate` will be overridden by the value of `op`"
+                "`predicate` will be overridden by the value of `op`"  # noqa: ISC003
                 + r"(.|\s)*"
                 + match
             )
@@ -926,3 +926,31 @@ class TestNearest:
         result5 = result5.dropna()
         result5["index_right"] = result5["index_right"].astype("int64")
         assert_geodataframe_equal(result5, result4, check_like=True)
+
+    @pytest.mark.skipif(
+        not (compat.USE_SHAPELY_20),
+        reason=(
+            "shapely >= 2.0 is required to run sjoin_nearest"
+            "with parameter `exclusive` set"
+        ),
+    )
+    @pytest.mark.parametrize(
+        "max_distance,expected", [(None, [1, 1, 3, 3, 2]), (1.1, [3, 3, 1, 2])]
+    )
+    def test_sjoin_nearest_exclusive(self, max_distance, expected):
+        geoms = shapely.points(np.arange(3), np.arange(3))
+        geoms = np.append(geoms, [Point(1, 2)])
+
+        df = geopandas.GeoDataFrame({"geometry": geoms})
+        result = df.sjoin_nearest(
+            df, max_distance=max_distance, distance_col="dist", exclusive=True
+        )
+
+        assert_series_equal(
+            result["index_right"].reset_index(drop=True),
+            pd.Series(expected),
+            check_names=False,
+        )
+
+        if max_distance:
+            assert result["dist"].max() <= max_distance