Repository: geopandas/geopandas

Issue:
ENH: Add on_invalid parameter to from_wkb
WKB geometries may contain invalid geometries that cannot be properly extracted, which by default raises exceptions.  This can prevent reading geometries from a file (e.g., [#2791]) which are not under control of the user and easily fixed.

We can expose the  [`on_invalid` parameter](https://shapely.readthedocs.io/en/stable/reference/shapely.from_wkb.html#shapely-from-wkb) (also available for pygeos) and then readers (e.g., pyogrio) can also support this parameter to skip or warn on reading invalid geometries.


Discussion:

Resolving patch:
diff --git a/geopandas/array.py b/geopandas/array.py
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -191,7 +191,7 @@ def to_shapely(geoms):
     return geoms._data
 
 
-def from_wkb(data, crs=None):
+def from_wkb(data, crs=None, on_invalid="raise"):
     """
     Convert a list or array of WKB objects to a GeometryArray.
 
@@ -203,9 +203,14 @@ def from_wkb(data, crs=None):
         Coordinate Reference System of the geometry objects. Can be anything accepted by
         :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
         such as an authority string (eg "EPSG:4326") or a WKT string.
+    on_invalid: {"raise", "warn", "ignore"}, default "raise"
+        - raise: an exception will be raised if a WKB input geometry is invalid.
+        - warn: a warning will be raised and invalid WKB geometries will be returned as
+          None.
+        - ignore: invalid WKB geometries will be returned as None without a warning.
 
     """
-    return GeometryArray(shapely.from_wkb(data), crs=crs)
+    return GeometryArray(shapely.from_wkb(data, on_invalid=on_invalid), crs=crs)
 
 
 def to_wkb(geoms, hex=False, **kwargs):
@@ -217,7 +222,7 @@ def to_wkb(geoms, hex=False, **kwargs):
     return shapely.to_wkb(geoms, hex=hex, **kwargs)
 
 
-def from_wkt(data, crs=None):
+def from_wkt(data, crs=None, on_invalid="raise"):
     """
     Convert a list or array of WKT objects to a GeometryArray.
 
@@ -229,9 +234,14 @@ def from_wkt(data, crs=None):
         Coordinate Reference System of the geometry objects. Can be anything accepted by
         :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
         such as an authority string (eg "EPSG:4326") or a WKT string.
+    on_invalid : {"raise", "warn", "ignore"}, default "raise"
+        - raise: an exception will be raised if a WKT input geometry is invalid.
+        - warn: a warning will be raised and invalid WKT geometries will be
+          returned as ``None``.
+        - ignore: invalid WKT geometries will be returned as ``None`` without a warning.
 
     """
-    return GeometryArray(shapely.from_wkt(data), crs=crs)
+    return GeometryArray(shapely.from_wkt(data, on_invalid=on_invalid), crs=crs)
 
 
 def to_wkt(geoms, **kwargs):
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -359,7 +359,7 @@ def from_file(cls, filename: os.PathLike | typing.IO, **kwargs) -> GeoSeries:
 
     @classmethod
     def from_wkb(
-        cls, data, index=None, crs: Optional[Any] = None, **kwargs
+        cls, data, index=None, crs: Optional[Any] = None, on_invalid="raise", **kwargs
     ) -> GeoSeries:
         """
         Alternate constructor to create a ``GeoSeries``
@@ -376,6 +376,12 @@ def from_wkb(
             accepted by
             :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
             such as an authority string (eg "EPSG:4326") or a WKT string.
+        on_invalid: {"raise", "warn", "ignore"}, default "raise"
+            - raise: an exception will be raised if a WKB input geometry is invalid.
+            - warn: a warning will be raised and invalid WKB geometries will be returned
+              as None.
+            - ignore: invalid WKB geometries will be returned as None without a warning.
+
         kwargs
             Additional arguments passed to the Series constructor,
             e.g. ``name``.
@@ -389,11 +395,13 @@ def from_wkb(
         GeoSeries.from_wkt
 
         """
-        return cls._from_wkb_or_wkb(from_wkb, data, index=index, crs=crs, **kwargs)
+        return cls._from_wkb_or_wkt(
+            from_wkb, data, index=index, crs=crs, on_invalid=on_invalid, **kwargs
+        )
 
     @classmethod
     def from_wkt(
-        cls, data, index=None, crs: Optional[Any] = None, **kwargs
+        cls, data, index=None, crs: Optional[Any] = None, on_invalid="raise", **kwargs
     ) -> GeoSeries:
         """
         Alternate constructor to create a ``GeoSeries``
@@ -410,6 +418,13 @@ def from_wkt(
             accepted by
             :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
             such as an authority string (eg "EPSG:4326") or a WKT string.
+        on_invalid : {"raise", "warn", "ignore"}, default "raise"
+            - raise: an exception will be raised if a WKT input geometry is invalid.
+            - warn: a warning will be raised and invalid WKT geometries will be
+              returned as ``None``.
+            - ignore: invalid WKT geometries will be returned as ``None`` without a
+              warning.
+
         kwargs
             Additional arguments passed to the Series constructor,
             e.g. ``name``.
@@ -437,7 +452,9 @@ def from_wkt(
         2    POINT (3 3)
         dtype: geometry
         """
-        return cls._from_wkb_or_wkb(from_wkt, data, index=index, crs=crs, **kwargs)
+        return cls._from_wkb_or_wkt(
+            from_wkt, data, index=index, crs=crs, on_invalid=on_invalid, **kwargs
+        )
 
     @classmethod
     def from_xy(cls, x, y, z=None, index=None, crs=None, **kwargs) -> GeoSeries:
@@ -495,12 +512,13 @@ def from_xy(cls, x, y, z=None, index=None, crs=None, **kwargs) -> GeoSeries:
         return cls(points_from_xy(x, y, z, crs=crs), index=index, crs=crs, **kwargs)
 
     @classmethod
-    def _from_wkb_or_wkb(
+    def _from_wkb_or_wkt(
         cls,
         from_wkb_or_wkt_function: Callable,
         data,
         index=None,
         crs: Optional[Any] = None,
+        on_invalid: str = "raise",
         **kwargs,
     ) -> GeoSeries:
         """Create a GeoSeries from either WKT or WKB values"""
@@ -510,7 +528,11 @@ def _from_wkb_or_wkb(
             else:
                 index = data.index
             data = data.values
-        return cls(from_wkb_or_wkt_function(data, crs=crs), index=index, **kwargs)
+        return cls(
+            from_wkb_or_wkt_function(data, crs=crs, on_invalid=on_invalid),
+            index=index,
+            **kwargs,
+        )
 
     @property
     def __geo_interface__(self) -> Dict:


Test patch:
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -1,4 +1,5 @@
 import random
+import warnings
 
 import numpy as np
 import pandas as pd
@@ -162,6 +163,24 @@ def test_from_wkb_hex():
     assert isinstance(res, GeometryArray)
 
 
+def test_from_wkb_on_invalid():
+    # Single point LineString hex WKB: invalid
+    invalid_wkb_hex = "01020000000100000000000000000008400000000000000840"
+    message = "point array must contain 0 or >1 elements"
+
+    with pytest.raises(Exception, match=message):
+        from_wkb([invalid_wkb_hex], on_invalid="raise")
+
+    with pytest.warns(Warning, match=message):
+        res = from_wkb([invalid_wkb_hex], on_invalid="warn")
+    assert res == [None]
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        res = from_wkb([invalid_wkb_hex], on_invalid="ignore")
+    assert res == [None]
+
+
 def test_to_wkb():
     P = from_shapely(points_no_missing)
     res = to_wkb(P)
@@ -217,6 +236,24 @@ def f(x):
     assert res[0] == multi_poly
 
 
+def test_from_wkt_on_invalid():
+    # Single point LineString WKT: invalid
+    invalid_wkt = "LINESTRING(0 0)"
+    message = "point array must contain 0 or >1 elements"
+
+    with pytest.raises(Exception, match=message):
+        from_wkt([invalid_wkt], on_invalid="raise")
+
+    with pytest.warns(Warning, match=message):
+        res = from_wkt([invalid_wkt], on_invalid="warn")
+    assert res == [None]
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        res = from_wkt([invalid_wkt], on_invalid="ignore")
+    assert res == [None]
+
+
 def test_to_wkt():
     P = from_shapely(points_no_missing)
     res = to_wkt(P, rounding_precision=-1)
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -283,6 +283,23 @@ def test_proj4strings(self):
     def test_from_wkb(self):
         assert_geoseries_equal(self.g1, GeoSeries.from_wkb([self.t1.wkb, self.sq.wkb]))
 
+    def test_from_wkb_on_invalid(self):
+        # Single point LineString hex WKB: invalid
+        invalid_wkb_hex = "01020000000100000000000000000008400000000000000840"
+        message = "point array must contain 0 or >1 elements"
+
+        with pytest.raises(Exception, match=message):
+            GeoSeries.from_wkb([invalid_wkb_hex], on_invalid="raise")
+
+        with pytest.warns(Warning, match=message):
+            res = GeoSeries.from_wkb([invalid_wkb_hex], on_invalid="warn")
+        assert res[0] is None
+
+        with warnings.catch_warnings():
+            warnings.simplefilter("error")
+            res = GeoSeries.from_wkb([invalid_wkb_hex], on_invalid="ignore")
+        assert res[0] is None
+
     def test_from_wkb_series(self):
         s = pd.Series([self.t1.wkb, self.sq.wkb], index=[1, 2])
         expected = self.g1.copy()
@@ -298,6 +315,23 @@ def test_from_wkb_series_with_index(self):
     def test_from_wkt(self):
         assert_geoseries_equal(self.g1, GeoSeries.from_wkt([self.t1.wkt, self.sq.wkt]))
 
+    def test_from_wkt_on_invalid(self):
+        # Single point LineString WKT: invalid
+        invalid_wkt = "LINESTRING(0 0)"
+        message = "point array must contain 0 or >1 elements"
+
+        with pytest.raises(Exception, match=message):
+            GeoSeries.from_wkt([invalid_wkt], on_invalid="raise")
+
+        with pytest.warns(Warning, match=message):
+            res = GeoSeries.from_wkt([invalid_wkt], on_invalid="warn")
+        assert res[0] is None
+
+        with warnings.catch_warnings():
+            warnings.simplefilter("error")
+            res = GeoSeries.from_wkt([invalid_wkt], on_invalid="ignore")
+        assert res[0] is None
+
     def test_from_wkt_series(self):
         s = pd.Series([self.t1.wkt, self.sq.wkt], index=[1, 2])
         expected = self.g1.copy()