Repository: shapely/shapely
Pull request #2133 (2024-09-03 22:34:36)

TST: minor bump to geos-3.13.0 and numpy-2.1.1
Test latest deps, released today.

Holding until geos-3.13 is released, then I'll modify this PR.

coveralls: ## Pull Request Test Coverage Report for [Build 10691698518](https://coveralls.io/builds/69589282)


### Details

* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.3%**) to **88.055%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/69589282/badge)](https://coveralls.io/builds/69589282) |
| :-- | --: |
| Change from base [Build 10679792502](https://coveralls.io/builds/69572366): |  0.3% |
| Covered Lines: | 2617 |
| Relevant Lines: | 2972 |

---
##### 💛  - [Coveralls](https://coveralls.io)

mwtoews: From the mail-list, [GEOS 3.13.0 Released](https://lists.osgeo.org/pipermail/geos-devel/2024-September/011081.html).

This PR does not yet switch wheel-building/packaging in `main` with GEOS 3.13 yet. Should it?

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 9aa1599..2d98943 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -27,6 +27,7 @@ Improvements:
 - The ``voronoi_polygons`` now accepts the ``ordered`` keyword, optionally forcing the
   order of polygons within the GeometryCollection to follow the order of input
   coordinates. Requires at least GEOS 3.12. (#1968)
+- Add option on ``invalid="fix"`` to ``from_wkb`` and ``from_wkt`` (#2094)
 - Add function ``minimum_clearance_line`` (#2106)
 
 Breaking changes in GEOS 3.12:
diff --git a/setup.py b/setup.py
index 6995f84..870c18a 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,5 @@
-import builtins
+"""Setuptools build."""
+
 import logging
 import os
 import subprocess
@@ -6,7 +7,6 @@ import sys
 from pathlib import Path
 
 from setuptools import Extension, setup
-from setuptools.command.build_ext import build_ext as _build_ext
 
 # ensure the current directory is on sys.path so versioneer can be imported
 # when pip uses PEP 517/518 build rules.
@@ -33,7 +33,7 @@ if "all" in sys.warnoptions:
 
 
 def get_geos_config(option):
-    """Get configuration option from the `geos-config` development utility
+    """Get configuration option from the `geos-config` development utility.
 
     The PATH environment variable should include the path where geos-config is
     located, or the GEOS_CONFIG environment variable should point to the
@@ -41,7 +41,7 @@ def get_geos_config(option):
     """
     cmd = os.environ.get("GEOS_CONFIG", "geos-config")
     try:
-        proc = subprocess.run([cmd, option], capture_output=True, text=True)
+        proc = subprocess.run([cmd, option], capture_output=True, text=True, check=True)
     except OSError:
         return
     if proc.stderr and not proc.stdout:
@@ -52,8 +52,8 @@ def get_geos_config(option):
     return result
 
 
-def get_geos_paths():
-    """Obtain the paths for compiling and linking with the GEOS C-API
+def get_ext_options():
+    """Get Extension options to build using GEOS and NumPy C API.
 
     First the presence of the GEOS_INCLUDE_PATH and GEOS_INCLUDE_PATH environment
     variables is checked. If they are both present, these are taken.
@@ -61,24 +61,44 @@ def get_geos_paths():
     If one of the two paths was not present, geos-config is called (it should be on the
     PATH variable). geos-config provides all the paths.
 
-    If geos-config was not found, no additional paths are provided to the extension. It is
-    still possible to compile in this case using custom arguments to setup.py.
+    If geos-config was not found, no additional paths are provided to the extension.
+    It is still possible to compile in this case using custom arguments to setup.py.
     """
+    import numpy
+
+    opts = {
+        "define_macros": [
+            # avoid accidental use of non-reentrant functions
+            ("GEOS_USE_ONLY_R_API", None),
+            # silence warnings
+            ("NPY_NO_DEPRECATED_API", "0"),
+            # minimum numpy version
+            ("NPY_TARGET_VERSION", "NPY_1_20_API_VERSION"),
+        ],
+        "include_dirs": ["./src"],
+        "library_dirs": [],
+        "extra_link_args": [],
+        "libraries": [],
+    }
+
+    if include_dir := numpy.get_include():
+        opts["include_dirs"].append(include_dir)
+
+    # Without geos-config (e.g. MSVC), specify these two vars
     include_dir = os.environ.get("GEOS_INCLUDE_PATH")
     library_dir = os.environ.get("GEOS_LIBRARY_PATH")
     if include_dir and library_dir:
-        return {
-            "include_dirs": ["./src", include_dir],
-            "library_dirs": [library_dir],
-            "libraries": ["geos_c"],
-        }
+        opts["include_dirs"].append(include_dir)
+        opts["library_dirs"].append(library_dir)
+        opts["libraries"].append("geos_c")
+        return opts
 
     geos_version = get_geos_config("--version")
     if not geos_version:
         log.warning(
-            "Could not find geos-config executable. Either append the path to geos-config"
-            " to PATH or manually provide the include_dirs, library_dirs, libraries and "
-            "other link args for compiling against a GEOS version >=%s.",
+            "Could not find geos-config executable. Either append the path to "
+            "geos-config to PATH or manually provide the include_dirs, library_dirs, "
+            "libraries and other link args for compiling against a GEOS version >=%s.",
             MIN_GEOS_VERSION,
         )
         return {}
@@ -91,47 +111,19 @@ def get_geos_paths():
             f"GEOS version should be >={MIN_GEOS_VERSION}, found {geos_version}"
         )
 
-    libraries = []
-    library_dirs = []
-    include_dirs = ["./src"]
-    extra_link_args = []
     for item in get_geos_config("--cflags").split():
         if item.startswith("-I"):
-            include_dirs.extend(item[2:].split(":"))
+            opts["include_dirs"].extend(item[2:].split(":"))
 
     for item in get_geos_config("--clibs").split():
         if item.startswith("-L"):
-            library_dirs.extend(item[2:].split(":"))
+            opts["library_dirs"].extend(item[2:].split(":"))
         elif item.startswith("-l"):
-            libraries.append(item[2:])
+            opts["libraries"].append(item[2:])
         else:
-            extra_link_args.append(item)
-
-    return {
-        "include_dirs": include_dirs,
-        "library_dirs": library_dirs,
-        "libraries": libraries,
-        "extra_link_args": extra_link_args,
-    }
-
+            opts["extra_link_args"].append(item)
 
-class build_ext(_build_ext):
-    def finalize_options(self):
-        _build_ext.finalize_options(self)
-
-        # Add numpy include dirs without importing numpy on module level.
-        # derived from scikit-hep:
-        # https://github.com/scikit-hep/root_numpy/pull/292
-
-        # Prevent numpy from thinking it is still in its setup process:
-        try:
-            del builtins.__NUMPY_SETUP__
-        except AttributeError:
-            pass
-
-        import numpy
-
-        self.include_dirs.insert(0, numpy.get_include())
+    return opts
 
 
 ext_modules = []
@@ -151,10 +143,11 @@ if "clean" in sys.argv:
 elif "sdist" in sys.argv:
     if Path("LICENSE_GEOS").exists() or Path("LICENSE_win32").exists():
         raise FileExistsError(
-            "Source distributions should not pack LICENSE_GEOS or LICENSE_win32. Please remove the files."
+            "Source distributions should not pack LICENSE_GEOS or LICENSE_win32. "
+            "Please remove the files."
         )
 else:
-    ext_options = get_geos_paths()
+    ext_options = get_ext_options()
 
     ext_modules = [
         Extension(
@@ -181,16 +174,12 @@ else:
     cython_modules = [
         Extension(
             "shapely._geometry_helpers",
-            [
-                "shapely/_geometry_helpers.pyx",
-            ],
+            ["shapely/_geometry_helpers.pyx"],
             **ext_options,
         ),
         Extension(
             "shapely._geos",
-            [
-                "shapely/_geos.pyx",
-            ],
+            ["shapely/_geos.pyx"],
             **ext_options,
         ),
     ]
@@ -198,13 +187,10 @@ else:
     ext_modules += cythonize(
         cython_modules,
         compiler_directives={"language_level": "3"},
-        # enable once Cython >= 0.3 is released
-        # define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")],
     )
 
 
 cmdclass = versioneer.get_cmdclass()
-cmdclass["build_ext"] = build_ext
 
 
 # see pyproject.toml for static project metadata
diff --git a/shapely/_geometry.py b/shapely/_geometry.py
index dd7e6d2..bc7e686 100644
--- a/shapely/_geometry.py
+++ b/shapely/_geometry.py
@@ -57,6 +57,7 @@ def get_type_id(geometry, **kwargs):
     """Return the type ID of a geometry.
 
     Possible values are:
+
     - None (missing) is -1
     - POINT is 0
     - LINESTRING is 1
@@ -492,6 +493,8 @@ def get_exterior_ring(geometry, **kwargs):
 def get_interior_ring(geometry, index, **kwargs):
     """Return the nth interior ring of a polygon.
 
+    The number of interior rings in non-polygons equals zero.
+
     Parameters
     ----------
     geometry : Geometry or array_like
diff --git a/shapely/_geometry_helpers.pyx b/shapely/_geometry_helpers.pyx
index 8302f42..1eda913 100644
--- a/shapely/_geometry_helpers.pyx
+++ b/shapely/_geometry_helpers.pyx
@@ -1,5 +1,3 @@
-# distutils: define_macros=GEOS_USE_ONLY_R_API
-
 cimport cython
 from cpython cimport PyObject
 from cython cimport view
diff --git a/shapely/_geos.pyx b/shapely/_geos.pyx
index 148d3e1..6c465de 100644
--- a/shapely/_geos.pyx
+++ b/shapely/_geos.pyx
@@ -1,4 +1,3 @@
-# distutils: define_macros=GEOS_USE_ONLY_R_API
 #from shapely import GEOSException
 from libc.stdio cimport snprintf
 from libc.stdlib cimport free, malloc
diff --git a/shapely/_ragged_array.py b/shapely/_ragged_array.py
index 1d1eebf..a6d7776 100644
--- a/shapely/_ragged_array.py
+++ b/shapely/_ragged_array.py
@@ -1,13 +1,12 @@
 """Provides a conversion to / from a ragged array representation of geometries.
 
 A ragged (or "jagged") array is an irregular array of arrays of which each
-element can havea  different length. As a result, such an array cannot be
+element can have a different length. As a result, such an array cannot be
 represented as a standard, rectangular nD array.
 The coordinates of geometries can be represented as arrays of arrays of
 coordinate pairs (possibly multiple levels of nesting, depending on the
 geometry type).
 
-
 Geometries, as a ragged array of coordinates, can be efficiently represented
 as contiguous arrays of coordinates provided that there is another data
 structure that keeps track of which range of coordinate values corresponds
diff --git a/shapely/geometry/base.py b/shapely/geometry/base.py
index d131c14..d76929a 100644
--- a/shapely/geometry/base.py
+++ b/shapely/geometry/base.py
@@ -493,7 +493,8 @@ class BaseGeometry(shapely.Geometry):
         quadsegs : int, optional
             Deprecated alias for `quad_segs`.
         **kwargs : dict, optional
-            For backwards compatibility, will be ignored.
+            For backwards compatibility of renamed parameters. If an unsupported
+            kwarg is passed, a `ValueError` will be raised.
 
         Returns
         -------
diff --git a/shapely/io.py b/shapely/io.py
index d7f1693..72683fb 100644
--- a/shapely/io.py
+++ b/shapely/io.py
@@ -25,7 +25,7 @@ __all__ = [
 # Allowed options for handling WKB/WKT decoding errors
 # Note: cannot use standard constructor since "raise" is a keyword
 DecodingErrorOptions = ParamEnum(
-    "DecodingErrorOptions", {"ignore": 0, "warn": 1, "raise": 2}
+    "DecodingErrorOptions", {"ignore": 0, "warn": 1, "raise": 2, "fix": 3}
 )
 
 WKBFlavorOptions = ParamEnum("WKBFlavorOptions", {"extended": 1, "iso": 2})
@@ -267,11 +267,21 @@ def from_wkt(geometry, on_invalid="raise", **kwargs):
     ----------
     geometry : str or array_like
         The WKT string(s) to convert.
-    on_invalid : {"raise", "warn", "ignore"}, default "raise"
-        - raise: an exception will be raised if WKT input geometries are invalid.
+    on_invalid : {"raise", "warn", "ignore", "fix"}, default "raise"
+        Indicates what to do when an invalid WKT string is encountered. Note
+        that the validations involved are very basic, e.g. the minimum number of
+        points for the geometry type. For a thorough check, use
+        :func:`is_valid` after conversion to geometries. Valid options are:
+
+        - raise: an exception will be raised if any 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.
+        - ignore: invalid geometries will be returned as ``None`` without a
+          warning.
+        - fix: an effort is made to fix invalid input geometries (currently just
+          unclosed rings). If this is not possible, they are returned as
+          ``None`` without a warning. Requires GEOS >= 3.11.
+
     **kwargs
         See :ref:`NumPy ufunc docs <ufuncs.kwargs>` for other keyword arguments.
 
@@ -299,11 +309,21 @@ def from_wkb(geometry, on_invalid="raise", **kwargs):
     ----------
     geometry : str or array_like
         The WKB byte object(s) to convert.
-    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
+    on_invalid : {"raise", "warn", "ignore", "fix"}, default "raise"
+        Indicates what to do when an invalid WKB is encountered. Note that the
+        validations involved are very basic, e.g. the minimum number of points
+        for the geometry type. For a thorough check, use :func:`is_valid` after
+        conversion to geometries. Valid options are:
+
+        - raise: an exception will be raised if any input geometry is invalid.
+        - warn: a warning will be raised and invalid WKT geometries will be
           returned as ``None``.
-        - ignore: invalid WKB geometries will be returned as ``None`` without a warning.
+        - ignore: invalid geometries will be returned as ``None`` without a
+          warning.
+        - fix: an effort is made to fix invalid input geometries (currently just
+          unclosed rings). If this is not possible, they are returned as
+          ``None`` without a warning. Requires GEOS >= 3.11.
+
     **kwargs
         See :ref:`NumPy ufunc docs <ufuncs.kwargs>` for other keyword arguments.
 
diff --git a/shapely/predicates.py b/shapely/predicates.py
index 316fe15..477ceb3 100644
--- a/shapely/predicates.py
+++ b/shapely/predicates.py
@@ -596,9 +596,8 @@ def contains(a, b, **kwargs):
 
 @multithreading_enabled
 def contains_properly(a, b, **kwargs):
-    """Return True if geometry B is completely inside geometry A.
-
-    They should have no common boundary points.
+    """Return True if geometry B is completely inside geometry A, with no common
+    boundary points.
 
     A contains B properly if B intersects the interior of A but not the
     boundary (or exterior). This means that a geometry A does not
@@ -642,7 +641,7 @@ def contains_properly(a, b, **kwargs):
     >>> contains_properly(area1, area3)
     True
 
-    """
+    """  # noqa: D205
     return lib.contains_properly(a, b, **kwargs)
 
 
@@ -1011,7 +1010,8 @@ def within(a, b, **kwargs):
 
 @multithreading_enabled
 def equals_exact(a, b, tolerance=0.0, normalize=False, **kwargs):
-    """Return True if the geometries are equivalent within a given tolerance.
+    """Return True if the geometries are structurally equivalent within a given
+    tolerance.
 
     This method uses exact coordinate equality, which requires coordinates
     to be equal (within specified tolerance) and in the same order for
@@ -1067,7 +1067,7 @@ def equals_exact(a, b, tolerance=0.0, normalize=False, **kwargs):
     >>> equals(polygon1, polygon2)
     True
 
-    """
+    """  # noqa: D205
     if normalize:
         a = lib.normalize(a)
         b = lib.normalize(b)
diff --git a/src/c_api.c b/src/c_api.c
index 9193946..9091b0a 100644
--- a/src/c_api.c
+++ b/src/c_api.c
@@ -7,7 +7,6 @@
  *
  ***********************************************************************/
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 #define PyGEOS_API_Module
diff --git a/src/coords.c b/src/coords.c
index ddc0e82..050e20e 100644
--- a/src/coords.c
+++ b/src/coords.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 #include <math.h>
diff --git a/src/geos.c b/src/geos.c
index 7a338a7..fb94394 100644
--- a/src/geos.c
+++ b/src/geos.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include "geos.h"
 
@@ -1059,8 +1058,7 @@ enum ShapelyErrorCode coordseq_from_buffer(GEOSContextHandle_t ctx, const double
                                            unsigned int size, unsigned int dims,
                                            char is_ring, int handle_nan, npy_intp cs1,
                                            npy_intp cs2, GEOSCoordSequence** coord_seq) {
-  char* cp1;
-  unsigned int i, j, first_i, last_i, actual_size;
+  unsigned int first_i, last_i, actual_size;
   double coord;
   char errstate;
   char ring_closure = 0;
@@ -1102,7 +1100,7 @@ enum ShapelyErrorCode coordseq_from_buffer(GEOSContextHandle_t ctx, const double
 #if GEOS_SINCE_3_10_0
   if ((!ring_closure) && ((last_i - first_i + 1) == actual_size)) {
     /* Initialize cp1 so that it points to the first coordinate (possibly skipping NaN)*/
-    cp1 = (char*)buf + cs1 * first_i;
+    char* cp1 = (char*)buf + cs1 * first_i;
     if ((cs1 == dims * 8) && (cs2 == 8)) {
       /* C-contiguous memory */
       int hasZ = dims == 3;
@@ -1137,7 +1135,7 @@ enum ShapelyErrorCode coordseq_from_buffer(GEOSContextHandle_t ctx, const double
   }
   /* add the closing coordinate if necessary */
   if (ring_closure) {
-    for (j = 0; j < dims; j++) {
+    for (unsigned int j = 0; j < dims; j++) {
       coord = *(double*)((char*)buf + first_i * cs1 + j * cs2);
       if (!GEOSCoordSeq_setOrdinate_r(ctx, *coord_seq, actual_size, j, coord)) {
         GEOSCoordSeq_destroy_r(ctx, *coord_seq);
diff --git a/src/geos.h b/src/geos.h
index 9615a6a..9dbd58c 100644
--- a/src/geos.h
+++ b/src/geos.h
@@ -10,10 +10,16 @@
 #endif
 
 // wrap geos.h import to silence geos gcc warnings
+#ifdef __GNUC__
 #pragma GCC diagnostic push
 #pragma GCC diagnostic ignored "-Wstrict-prototypes"
+#endif
+
 #include <geos_c.h>
+
+#ifdef __GNUC__
 #pragma GCC diagnostic pop
+#endif
 
 /* Macros to setup GEOS Context and error handlers
 
diff --git a/src/lib.c b/src/lib.c
index c38b1dc..c713efd 100644
--- a/src/lib.c
+++ b/src/lib.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 
diff --git a/src/pygeom.c b/src/pygeom.c
index 812c8ac..dbfa591 100644
--- a/src/pygeom.c
+++ b/src/pygeom.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include "pygeom.h"
 
diff --git a/src/pygeos.c b/src/pygeos.c
index f6adefe..0d043e1 100644
--- a/src/pygeos.c
+++ b/src/pygeos.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 
diff --git a/src/strtree.c b/src/strtree.c
index 675f79a..a7ee9f0 100644
--- a/src/strtree.c
+++ b/src/strtree.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 #include <float.h>
diff --git a/src/ufuncs.c b/src/ufuncs.c
index 5ea07b7..d0d94d6 100644
--- a/src/ufuncs.c
+++ b/src/ufuncs.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 #include <math.h>
@@ -400,7 +399,6 @@ static void Ydd_b_p_func(char** args, const npy_intp* dimensions, const npy_intp
 #endif
   GEOSGeometry* in1 = NULL;
   GEOSPreparedGeometry* in1_prepared = NULL;
-  GEOSGeometry *geom = NULL;
   const GEOSPreparedGeometry* prepared_geom_tmp = NULL;
   char ret;
 
@@ -438,6 +436,7 @@ static void Ydd_b_p_func(char** args, const npy_intp* dimensions, const npy_intp
 #if GEOS_SINCE_3_12_0
       ret = func(ctx, prepared_geom_tmp, in2, in3);
 #else
+      GEOSGeometry *geom = NULL;
       errstate = create_point(ctx, in2, in3, NULL, SHAPELY_HANDLE_NAN_ALLOW, &geom);
       if (errstate != PGERR_SUCCESS) {
         if (destroy_prepared) {
@@ -2542,7 +2541,6 @@ static PyUFuncGenericFunction set_precision_funcs[1] = {&set_precision_func};
 static char points_dtypes[3] = {NPY_DOUBLE, NPY_INT, NPY_OBJECT};
 static void points_func(char** args, const npy_intp* dimensions, const npy_intp* steps,
                         void* data) {
-  GEOSCoordSequence* coord_seq = NULL;
   GEOSGeometry** geom_arr;
 
   // check the ordinate dimension before calling GEOSCoordSeq_create_r
@@ -2959,9 +2957,6 @@ static PyUFuncGenericFunction create_collection_funcs[1] = {&create_collection_f
 static char bounds_dtypes[2] = {NPY_OBJECT, NPY_DOUBLE};
 static void bounds_func(char** args, const npy_intp* dimensions, const npy_intp* steps, void* data) {
   GEOSGeometry *envelope = NULL, *in1;
-  const GEOSGeometry* ring;
-  const GEOSCoordSequence* coord_seq;
-  int size;
   char *ip1 = args[0], *op1 = args[1];
   double *x1, *y1, *x2, *y2;
 
@@ -3063,6 +3058,16 @@ static void from_wkb_func(char** args, const npy_intp* dimensions, const npy_int
     goto finish;
   }
 
+  /* If on_invalid is 3, try to fix invalid geometries */
+  if (on_invalid == 3) {
+    #if GEOS_SINCE_3_11_0
+      GEOSWKBReader_setFixStructure_r(ctx, reader, 1);
+    #else
+      PyErr_Format(PyExc_ValueError, "on_invalid='fix' only supported for GEOS >= 3.11");
+      goto finish;
+    #endif
+  }
+
   for (i = 0; i < n; i++, ip1 += is1, op1 += os1) {
     CHECK_SIGNALS(i);
     if (errstate == PGERR_PYSIGNAL) {
@@ -3156,6 +3161,16 @@ static void from_wkt_func(char** args, const npy_intp* dimensions, const npy_int
     goto finish;
   }
 
+  /* If on_invalid is 3, try to fix invalid geometries */
+  if (on_invalid == 3) {
+    #if GEOS_SINCE_3_11_0
+      GEOSWKTReader_setFixStructure_r(ctx, reader, 1);
+    #else
+      PyErr_Format(PyExc_ValueError, "on_invalid='fix' only supported for GEOS >= 3.11");
+      goto finish;
+    #endif
+  }
+
   for (i = 0; i < n; i++, ip1 += is1, op1 += os1) {
     CHECK_SIGNALS(i);
     if (errstate == PGERR_PYSIGNAL) {
@@ -3214,8 +3229,10 @@ static char to_wkb_dtypes[7] = {NPY_OBJECT, NPY_BOOL, NPY_INT,
                                 NPY_INT,    NPY_BOOL, NPY_INT,
                                 NPY_OBJECT};
 static void to_wkb_func(char** args, const npy_intp* dimensions, const npy_intp* steps, void* data) {
-  char *ip1 = args[0], *ip2 = args[1], *ip3 = args[2], *ip4 = args[3], *ip5 = args[4],
-       *ip6 = args[5], *op1 = args[6];
+  char *ip1 = args[0], *ip2 = args[1], *ip3 = args[2], *ip4 = args[3], *ip5 = args[4], *op1 = args[6];
+#if GEOS_SINCE_3_10_0
+  char *ip6 = args[5];
+#endif
   npy_intp is1 = steps[0], is2 = steps[1], is3 = steps[2], is4 = steps[3], is5 = steps[4],
            is6 = steps[5], os1 = steps[6];
   npy_intp n = dimensions[0];
diff --git a/src/vector.c b/src/vector.c
index 82d2cb2..91dab3a 100644
--- a/src/vector.c
+++ b/src/vector.c
@@ -1,5 +1,4 @@
 #define PY_SSIZE_T_CLEAN
-#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
 
 #include <Python.h>
 #include <structmember.h>


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 938649f..7d14bba 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,7 +14,7 @@ jobs:
       matrix:
         os: [ubuntu-latest, macos-13, windows-2019]
         architecture: [x64]
-        geos: [3.9.5, 3.10.6, 3.11.4, 3.12.2, 3.13.0beta1, main]
+        geos: [3.9.5, 3.10.6, 3.11.4, 3.12.2, 3.13.0, main]
         include:
           # 2020
           - python: 3.9
@@ -37,8 +37,8 @@ jobs:
             extra_pytest_args: "-W error"  # error on warnings
           # 2024
           - python: "3.13"
-            geos: 3.13.0beta1
-            numpy: 2.1.0
+            geos: 3.13.0
+            numpy: 2.1.1
           # apple silicon
           - os: macos-14
             python: "3.12"
@@ -167,9 +167,9 @@ jobs:
         # ensure we build against numpy nightly (numpy 2.0 compat)
         run: |
           if [ -z "${{ matrix.numpy }}" ]; then
-            pip install -e . --no-build-isolation
+            pip install -v -e . --no-build-isolation
           else
-            pip install -e .
+            pip install -v -e .
           fi
 
       - name: Overview of the Python environment (pip list)
diff --git a/shapely/tests/test_io.py b/shapely/tests/test_io.py
index 1cbaa7a..31b7e5b 100644
--- a/shapely/tests/test_io.py
+++ b/shapely/tests/test_io.py
@@ -2,6 +2,7 @@ import json
 import pickle
 import struct
 import warnings
+from contextlib import nullcontext
 
 import numpy as np
 import pytest
@@ -9,6 +10,7 @@ import pytest
 import shapely
 from shapely import (
     GeometryCollection,
+    GEOSException,
     LinearRing,
     LineString,
     MultiLineString,
@@ -144,40 +146,72 @@ def test_from_wkt_none():
     assert shapely.from_wkt(None) is None
 
 
-def test_from_wkt_exceptions():
-    with pytest.raises(TypeError, match="Expected bytes or string, got int"):
-        shapely.from_wkt(1)
-
-    with pytest.raises(
-        shapely.GEOSException, match="Expected word but encountered end of stream"
-    ):
-        shapely.from_wkt("")
-
-    with pytest.raises(shapely.GEOSException, match="Unknown type: 'NOT'"):
-        shapely.from_wkt("NOT A WKT STRING")
-
-
-def test_from_wkt_warn_on_invalid():
-    with pytest.warns(Warning, match="Invalid WKT"):
-        shapely.from_wkt("", on_invalid="warn")
+@pytest.mark.parametrize(
+    "wkt, on_invalid, error, message",
+    [
+        (1, "raise", TypeError, "Expected bytes or string, got int"),
+        ("", "ignore", None, None),
+        ("", "warn", Warning, "Expected word but encountered end of stream"),
+        ("", "raise", GEOSException, "Expected word but encountered end of stream"),
+        ("", "unsupported_option", ValueError, "not a valid option"),
+        ("LINESTRING (0 0)", "ignore", None, None),
+        ("LINESTRING (0 0)", "raise", GEOSException, "must contain 0 or >1 elements"),
+        ("LINESTRING (0 0)", "warn", Warning, "must contain 0 or >1 elements"),
+        ("NOT A WKT STRING", "ignore", None, None),
+        ("NOT A WKT STRING", "warn", Warning, "Unknown type: 'NOT'"),
+        ("POLYGON ((0 0, 0 0))", "ignore", None, None),
+        ("POLYGON ((0 0, 0 0))", "raise", GEOSException, "Invalid number of points"),
+        ("POLYGON ((0 0, 0 0))", "warn", Warning, "Invalid number of points"),
+    ],
+)
+def test_from_wkt_on_invalid(wkt, on_invalid, error, message):
+    if on_invalid == "warn":
+        handler = pytest.warns(error, match=message)
+    elif on_invalid == "raise":
+        handler = pytest.raises(error, match=message)
+    elif on_invalid == "ignore":
+        handler = nullcontext()
+    else:
+        handler = pytest.raises(error, match=message)
 
-    with pytest.warns(Warning, match="Invalid WKT"):
-        shapely.from_wkt("NOT A WKT STRING", on_invalid="warn")
+    with handler:
+        result = shapely.from_wkt(wkt, on_invalid=on_invalid)
+        assert result is None
 
 
-def test_from_wkb_ignore_on_invalid():
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")
-        shapely.from_wkt("", on_invalid="ignore")
+@pytest.mark.skipif(
+    shapely.geos_version < (3, 11, 0),
+    reason="on_invalid='fix' not supported with GEOS < 3.11",
+)
+@pytest.mark.parametrize(
+    "wkt, expected_wkt",
+    [
+        ("", None),
+        ("LINESTRING (0 0)", None),
+        ("NOT A WKT STRING", None),
+        ("POLYGON ((0 0, 0 0))", None),
+        ("POLYGON ((0 0, 1 1))", "POLYGON ((0 0, 1 1, 0 0))"),
+    ],
+)
+def test_from_wkt_on_invalid_fix(wkt, expected_wkt):
+    """Tests for on_invalid="fix".
 
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")
-        shapely.from_wkt("NOT A WKT STRING", on_invalid="ignore")
+    Geometries that cannot be fixed are returned as None.
+    """
+    geom = shapely.from_wkt(wkt, on_invalid="fix")
+    assert shapely.to_wkt(geom) == expected_wkt
 
 
-def test_from_wkt_on_invalid_unsupported_option():
-    with pytest.raises(ValueError, match="not a valid option"):
-        shapely.from_wkt("NOT A WKT STRING", on_invalid="unsupported_option")
+@pytest.mark.skipif(
+    shapely.geos_version >= (3, 11, 0),
+    reason="on_invalid='fix' is supported with GEOS >= 3.11",
+)
+def test_from_wkt_on_invalid_fix_unsupported_geos():
+    """on_invalid="fix" not supported with GEOS < 3.11"""
+    with pytest.raises(
+        ValueError, match="on_invalid='fix' only supported for GEOS >= 3.11"
+    ):
+        _ = shapely.from_wkt("", on_invalid="fix")
 
 
 @pytest.mark.parametrize("geom", all_types)
@@ -225,59 +259,81 @@ def test_from_wkb_none():
     assert shapely.from_wkb(None) is None
 
 
-def test_from_wkb_exceptions():
-    with pytest.raises(TypeError, match="Expected bytes or string, got int"):
-        shapely.from_wkb(1)
-
-    # invalid WKB
-    with pytest.raises(
-        shapely.GEOSException,
-        match=(
-            "Unexpected EOF parsing WKB|"
-            "ParseException: Input buffer is smaller than requested object size"
+@pytest.mark.parametrize(
+    "wkb, on_invalid, error, message",
+    [
+        (1, "raise", TypeError, "Expected bytes or string, got int"),
+        ("", "ignore", None, None),
+        ("", "raise", GEOSException, "Unexpected EOF parsing WKB"),
+        ("", "warn", Warning, "Unexpected EOF parsing WKB"),
+        ("", "unsupported_option", ValueError, "not a valid option"),
+        (b"\x01\x01\x00\x00\x00\x00", "ignore", None, None),
+        (b"\x01\x01\x00\x00\x00\x00", "raise", GEOSException, "ParseException"),
+        (b"\x01\x01\x00\x00\x00\x00", "warn", Warning, "ParseException"),
+        (INVALID_WKB, "ignore", None, None),
+        (
+            INVALID_WKB,
+            "raise",
+            GEOSException,
+            "Points of LinearRing do not form a closed linestring",
         ),
-    ):
-        result = shapely.from_wkb(b"\x01\x01\x00\x00\x00\x00")
-        assert result is None
-
-    # invalid ring in WKB
-    with pytest.raises(
-        shapely.GEOSException,
-        match="Points of LinearRing do not form a closed linestring",
-    ):
-        result = shapely.from_wkb(INVALID_WKB)
-        assert result is None
-
-
-def test_from_wkb_warn_on_invalid_warn():
-    # invalid WKB
-    with pytest.warns(Warning, match="Invalid WKB"):
-        result = shapely.from_wkb(b"\x01\x01\x00\x00\x00\x00", on_invalid="warn")
-        assert result is None
+        (
+            INVALID_WKB,
+            "warn",
+            Warning,
+            "Points of LinearRing do not form a closed linestring",
+        ),
+    ],
+)
+def test_from_wkb_on_invalid(wkb, on_invalid, error, message):
+    if on_invalid == "warn":
+        handler = pytest.warns(error, match=message)
+    elif on_invalid == "raise":
+        handler = pytest.raises(error, match=message)
+    elif on_invalid == "ignore":
+        handler = nullcontext()
+    else:
+        handler = pytest.raises(error, match=message)
 
-    # invalid ring in WKB
-    with pytest.warns(Warning, match="Invalid WKB"):
-        result = shapely.from_wkb(INVALID_WKB, on_invalid="warn")
+    with handler:
+        result = shapely.from_wkb(wkb, on_invalid=on_invalid)
         assert result is None
 
 
-def test_from_wkb_ignore_on_invalid_ignore():
-    # invalid WKB
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")  # no warning
-        result = shapely.from_wkb(b"\x01\x01\x00\x00\x00\x00", on_invalid="ignore")
-        assert result is None
+@pytest.mark.skipif(
+    shapely.geos_version < (3, 11, 0),
+    reason="on_invalid='fix' not supported with GEOS < 3.11",
+)
+@pytest.mark.parametrize(
+    "wkb, expected_wkt",
+    [
+        (b"", None),
+        (b"\x01\x01\x00\x00\x00\x00", None),
+        (
+            INVALID_WKB,
+            "POLYGON ((1421568.7761 1924750.2852, 1421564.1314 1924752.2408, 1421568.7761 1924750.2852))",  # noqa: E501
+        ),
+    ],
+)
+def test_from_wkb_on_invalid_fix(wkb, expected_wkt):
+    """Tests for on_invalid="fix".
 
-    # invalid ring in WKB
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")  # no warning
-        result = shapely.from_wkb(INVALID_WKB, on_invalid="ignore")
-        assert result is None
+    Geometries that cannot be fixed are returned as None.
+    """
+    geom = shapely.from_wkb(wkb, on_invalid="fix")
+    assert shapely.to_wkt(geom) == expected_wkt
 
 
-def test_from_wkb_on_invalid_unsupported_option():
-    with pytest.raises(ValueError, match="not a valid option"):
-        shapely.from_wkb(b"\x01\x01\x00\x00\x00\x00", on_invalid="unsupported_option")
+@pytest.mark.skipif(
+    shapely.geos_version >= (3, 11, 0),
+    reason="on_invalid='fix' is supported with GEOS >= 3.11",
+)
+def test_from_wkb_on_invalid_fix_unsupported_geos():
+    """on_invalid="fix" not supported with GEOS < 3.11"""
+    with pytest.raises(
+        ValueError, match="on_invalid='fix' only supported for GEOS >= 3.11"
+    ):
+        _ = shapely.from_wkb(b"", on_invalid="fix")
 
 
 @pytest.mark.parametrize("geom", all_types)