Repository: geopandas/geopandas

Issue:
BUG: Clipping with empty shape produces cryptic error
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of geopandas.

---

**Note**: Please read [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug.

#### Code Sample, a copy-pastable example

```python
# Your code here
import geopandas as gpd
import numpy as np
from shapely.geometry import Point

gs = gpd.GeoSeries.from_xy(np.arange(4), np.arange(4)).buffer(1)
mask = Point(np.nan, np.nan).buffer(1)
gpd.clip(gs, mask)
```

#### Problem description

Passing an empty mask shape into `clip` produces a cryptic `IndexError`.

The above code results in the following error:

```python
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/USER/anaconda3/envs/rstools/lib/python3.8/site-packages/geopandas/tools/clip.py", line 174, in clip
    ((box_mask[0] <= box_gdf[2]) and (box_gdf[0] <= box_mask[2]))
IndexError: tuple index out of range
```

An error should be raised here, but the error that is raised is confusing and doesn't help the user understand what has happened. I think bad input data has been allowed to get too far into the machinery. 

#### Expected Output
I think that `clip` should raise a `ValueError` (or whatever error is most appropriate) indicating that the provided mask was empty. This is easy to check for with the `.is_empty` property.

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.8.13 | packaged by conda-forge | (default, Mar 25 2022, 06:04:10)  [GCC 10.3.0]
executable : /home/USER/anaconda3/envs/rstools/bin/python
machine    : Linux-5.15.0-47-generic-x86_64-with-glibc2.10

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.11.0
GEOS lib   : /home/USER/anaconda3/envs/rstools/lib/libgeos_c.so
GDAL       : 3.5.2
GDAL data dir: /home/USER/anaconda3/envs/rstools/share/gdal
PROJ       : 9.0.1
PROJ data dir: /home/USER/anaconda3/envs/rstools/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.11.1
numpy      : 1.23.3
pandas     : 1.5.0
pyproj     : 3.4.0
shapely    : 1.8.4
fiona      : 1.8.21
geoalchemy2: None
geopy      : None
matplotlib : 3.6.0
mapclassify: 2.4.3
pygeos     : 0.13
pyogrio    : v0.4.2
psycopg2   : None
pyarrow    : None
rtree      : 1.0.0

</details>



Discussion:
Hi, thanks for the report! I agree that we should catch this situation and raise an error. Would you be willing to try a making a fix?
@martinfleis yeah, I can work on a fix. Is ValueError appropriate? 
Yes, I would say that a ValueError is the best option here. Note that the error that shows up now is coming from the bounds check, so there may be some other cases to cover than a simple empty geom.
Looking at the code, I noticed a few things:

* All possible types for the input mask have the potential to be invalid. An invalid shapely geom is the only one that causes issues, however, because then `box_mask` becomes an empty tuple rather than a 4-tuple.
* Invalid or all-empty mask inputs (other than an invalid shapely geom) work fine and simply produce an empty series/dataframe.
* There are no checks to see if the mask is valid.
* The docs don't specify regarding invalid or all-empty masks and there are no tests dealing with them in test_clip.py.

What should the contract be, here? I see two ways to deal with this:
* I could simply check if the shapely geom is empty and set `box_mask = (np.nan, np.nan, np.nan, np.nan)` which is basically what happens for a series/dataframe that is all empty or all invalid.
* I could raise an error if `box_mask` is not valid (i.e. empty or contains NaNs).

The first option fixes this error case and makes it behave like the other input types. I suspect that the second option will break some things downstream.  
The first option also doesn't warn or alert the user that something potentially erroneous is happening.
I think that consistency is good. If you clip with a single empty geometry, it should behave the same as if you clip with an array of empty geometries.

The question is what should be the behaviour in that case. If we should raise or return an empty result. But giver we don't raise when doing things like intersection with an empty geom, I would probably go with the empty result. So the only change required here is to catch the empty shapely geom case as you suggest above (option 1).
Can do. Thanks!

Resolving patch:
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -7,6 +7,7 @@
 """
 import warnings
 
+import numpy as np
 import pandas.api.types
 from shapely.geometry import Polygon, MultiPolygon, box
 
@@ -168,7 +169,11 @@ def clip(gdf, mask, keep_geom_type=False):
     elif mask_is_list_like:
         box_mask = mask
     else:
-        box_mask = mask.bounds
+        # Avoid empty tuple returned by .bounds when geometry is empty. A tuple of
+        # all nan values is consistent with the behavior of
+        # {GeoSeries, GeoDataFrame}.total_bounds for empty geometries.
+        # TODO(shapely) can simpely use mask.bounds once relying on Shapely 2.0
+        box_mask = mask.bounds if not mask.is_empty else (np.nan,) * 4
     box_gdf = gdf.total_bounds
     if not (
         ((box_mask[0] <= box_gdf[2]) and (box_gdf[0] <= box_mask[2]))


Test patch:
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -450,3 +450,28 @@ def test_clip_single_multipoly_no_extra_geoms(
     multi = buffered_locations.dissolve(by="type").reset_index()
     clipped = clip(multi, masks)
     assert clipped.geom_type[0] == "Polygon"
+
+
+@pytest.mark.filterwarnings("ignore:All-NaN slice encountered")
+@pytest.mark.parametrize(
+    "mask",
+    [
+        Polygon(),
+        (np.nan,) * 4,
+        (np.nan, 0, np.nan, 1),
+        GeoSeries([Polygon(), Polygon()], crs="EPSG:3857"),
+        GeoSeries([Polygon(), Polygon()], crs="EPSG:3857").to_frame(),
+        GeoSeries([], crs="EPSG:3857"),
+        GeoSeries([], crs="EPSG:3857").to_frame(),
+    ],
+)
+def test_clip_empty_mask(buffered_locations, mask):
+    """Test that clipping with empty mask returns an empty result."""
+    clipped = clip(buffered_locations, mask)
+    assert_geodataframe_equal(
+        clipped,
+        GeoDataFrame([], columns=["geometry", "type"], crs="EPSG:3857"),
+        check_index_type=False,
+    )
+    clipped = clip(buffered_locations.geometry, mask)
+    assert_geoseries_equal(clipped, GeoSeries([], crs="EPSG:3857"))