Repository: shapely/shapely
Pull request #2030 (2024-03-26 11:32:26)

DOC: update user manual for functions that ~moved from shapely.ops to shapely
From shapely 2.0, many functions that were in `shapely.ops` before, became available directly in the `shapely` namespace, I suppose in the spirit of [Flat is better than nested](https://peps.python.org/pep-0423/#avoid-deep-nesting). Some functions were also renamed to be more consistent with GEOS/PostGIS.

However, the user manual wasn't updated to reflect this. This PR tries to incorporate these changes.

I expected this to be a mainly cosmetic change, but while making the changes I encountered many more differences between the functions than I was expecting, so to be discussed how to deal with this:
- line-like input versus explicit iterables of Linestrings: line oriented functions in `shapely.ops` support line-like input, but the equivalent functions in `shapely` don't . I changed the doc to reflect that, but maybe this is not wanted? Relevant functions:
    - shapely.ops.polygonize` vs `shapely.polygonize`
    - `shapely.ops.polygonize_full` vs `shapely.polygonize_full`
- `shapely.line_merge` is a vectorized function and expects a (arraylike of) MultiLineStrings as input. Hence, many merge operations can be done in one call. `shapely.ops.linemerge` expects an iterable of line-line elements as input, and will merge those, so only one merge operation can be done per call.
- some functions return slightly different results, e.g.:
    - `shapely.shortest_line` returns a LineString vs `shapely.ops.nearest_points` returns a Tuple of 2 Points

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


### 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/69665055/badge)](https://coveralls.io/builds/69665055) |
| :-- | --: |
| Change from base [Build 10748293051](https://coveralls.io/builds/69664377): |  0.3% |
| Covered Lines: | 2617 |
| Relevant Lines: | 2972 |

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


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 79f9282..e6fdf9a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,6 +16,10 @@ on:
   schedule:
     # in addition run weekly for nightly upload in case no other commits happened
     - cron: '34 2 * * 0'
+  workflow_dispatch:
+
+env:
+  GEOS_VERSION: "3.12.2"
 
 jobs:
   build_sdist:
@@ -31,7 +35,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.10"
+          python-version: "3.x"
 
       - name: Build a source tarball
         run: |
@@ -45,39 +49,109 @@ jobs:
           name: release-sdist
           path: ./dist/*.tar.gz
           retention-days: 30
+          compression-level: 0
+
+  build_wheels_linux:
+    name: Build ${{ matrix.baseimage }} wheels
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - baseimage: manylinux2014_x86_64
+            cibw_build: "*manylinux_x86_64"
+            arch: x86_64
+          - baseimage: musllinux_1_1_x86_64
+            cibw_build: "*musllinux_x86_64"
+            arch: x86_64
+          # Wait until native arm64 runners are available
+          # - baseimage: manylinux2014_aarch64
+          #   cibw_build: "*manylinux_aarch64"
+          #   arch: aarch64
+          # - baseimage: musllinux_1_2_aarch64
+          #   cibw_build: "*musllinux_aarch64"
+          #   arch: aarch64
+    env:
+      basetag: 2024.08.12-1
+
+    steps:
+      - name: Checkout source
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      # - name: Set up QEMU
+      #   uses: docker/setup-qemu-action@v3
+      #   with:
+      #     platforms: arm64
+      #   if: ${{ matrix.arch == 'aarch64' }}
+
+      - name: Set up Docker Buildx
+        id: buildx
+        uses: docker/setup-buildx-action@v3
+        with:
+          install: true
+          buildkitd-flags: --debug
+
+      - name: Build Docker image with GEOS
+        # using build-push-action (without push) to make use of cache arguments
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          file: ci/Dockerfile
+          platforms: linux/${{ matrix.arch }}
+          tags: ${{ matrix.baseimage }}_geos:${{ env.GEOS_VERSION }}
+          build-args: |
+            BASEIMAGE=quay.io/pypa/${{ matrix.baseimage }}:${{ env.basetag }}
+            GEOS_VERSION=${{ env.GEOS_VERSION }}
+          push: false
+          load: true
+          cache-from: type=gha,scope=${{ matrix.baseimage }}
+          cache-to: type=gha,mode=max,scope=${{ matrix.baseimage }}
+        env:
+          BUILDKIT_PROGRESS: plain
+
+      - name: Add GEOS LICENSE
+        run: cp ci/wheelbuilder/LICENSE_GEOS .
+        shell: bash
+
+      - name: Build wheels
+        uses: pypa/cibuildwheel@v2.20.0
+        env:
+          CIBW_ARCHS: ${{ matrix.arch }}
+          CIBW_BUILD: ${{ matrix.cibw_build }}
+          CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.baseimage }}_geos:${{ env.GEOS_VERSION }}
+          CIBW_MUSLLINUX_X86_64_IMAGE: ${{ matrix.baseimage }}_geos:${{ env.GEOS_VERSION }}
+          # CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.baseimage }}_geos:${{ env.GEOS_VERSION }}
+          # CIBW_MUSLLINUX_AARCH64_IMAGE: ${{ matrix.baseimage }}_geos:${{ env.GEOS_VERSION }}
+
+      - name: Upload artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: release-linux-${{ matrix.baseimage }}
+          path: ./wheelhouse/*.whl
+          retention-days: 30
+          compression-level: 0
 
-  build_wheels:
+  build_wheels_mac_win:
     name: Build ${{ matrix.arch }} wheels on ${{ matrix.os }}
     runs-on: ${{ matrix.os }}
-    env:
-      GEOS_VERSION: "3.12.2"
     strategy:
       fail-fast: false
       matrix:
         include:
-        - os: ubuntu-20.04
-          arch: x86_64
-        # Numpy no longer builds i686 packages
-        # - os: ubuntu-20.04
-        #   arch: i686
-        # The aarch64 build has been transferred to Travis
-        # - os: ubuntu-20.04
-        #   arch: aarch64
-        #   qemu_platform: arm64
-        # Note: Numpy doesn't have ppc64le & s390x wheels
-        # Also, some GEOS tests fail on s390x.
-        - os: windows-2019
-          arch: x86
-          msvc_arch: x86
-        - os: windows-2019
-          arch: AMD64
-          msvc_arch: x64
-        - os: macos-13
-          arch: x86_64
-          cmake_osx_architectures: x86_64
-        - os: macos-14
-          arch: arm64
-          cmake_osx_architectures: arm64
+          - os: windows-2019
+            arch: x86
+            msvc_arch: x86
+          - os: windows-2019
+            arch: AMD64
+            msvc_arch: x64
+          - os: macos-13
+            arch: x86_64
+            cmake_osx_architectures: x86_64
+          - os: macos-14
+            arch: arm64
+            cmake_osx_architectures: arm64
 
     steps:
       - name: Checkout source
@@ -92,22 +166,14 @@ jobs:
           key: ${{ matrix.os }}-${{ matrix.arch }}-${{ env.GEOS_VERSION }}-${{ hashFiles('ci/*') }}
 
       - name: Add GEOS LICENSE
-        run: |
-          cp ci/wheelbuilder/LICENSE_GEOS .
+        run: cp ci/wheelbuilder/LICENSE_GEOS .
         shell: bash
 
       - name: Add MSVC LICENSE
-        run: |
-          cp ci/wheelbuilder/LICENSE_win32 .
+        run: cp ci/wheelbuilder/LICENSE_win32 .
         shell: bash
         if: ${{ matrix.os == 'windows-2019' }}
 
-      # - name: Set up QEMU
-      #   uses: docker/setup-qemu-action@v1
-      #   with:
-      #     platforms: ${{ matrix.qemu_platform }}
-      #   if: ${{ matrix.qemu_platform }}
-
       - name: Activate MSVC
         uses: ilammy/msvc-dev-cmd@v1
         with:
@@ -118,13 +184,6 @@ jobs:
         uses: pypa/cibuildwheel@v2.20.0
         env:
           CIBW_ARCHS: ${{ matrix.arch }}
-          CIBW_SKIP: pp* *musllinux* *-manylinux_i686
-          CIBW_TEST_SKIP: "cp38-macosx_arm64"
-          CIBW_ENVIRONMENT_LINUX:
-            GEOS_VERSION=${{ env.GEOS_VERSION }}
-            GEOS_INSTALL=/host${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}
-            GEOS_CONFIG=/host${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}/bin/geos-config
-            LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/host${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}/lib
           CIBW_ENVIRONMENT_MACOS:
             GEOS_INSTALL=${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}
             GEOS_CONFIG=${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}/bin/geos-config
@@ -135,12 +194,10 @@ jobs:
             GEOS_INSTALL='${{ runner.temp }}\geos-${{ env.GEOS_VERSION }}'
             GEOS_LIBRARY_PATH='${{ runner.temp }}\geos-${{ env.GEOS_VERSION }}\lib'
             GEOS_INCLUDE_PATH='${{ runner.temp }}\geos-${{ env.GEOS_VERSION }}\include'
-          CIBW_BEFORE_ALL: ./ci/install_geos.sh
+          CIBW_BEFORE_ALL_MACOS: ./ci/install_geos.sh
           CIBW_BEFORE_ALL_WINDOWS: ci\install_geos.cmd
           CIBW_BEFORE_BUILD_WINDOWS: pip install delvewheel
           CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: delvewheel repair --add-path ${{ runner.temp }}\geos-${{ env.GEOS_VERSION }}\bin -w {dest_dir} {wheel}
-          CIBW_TEST_REQUIRES: pytest
-          CIBW_TEST_COMMAND: pytest --pyargs shapely.tests
 
       - name: Upload artifacts
         uses: actions/upload-artifact@v4
@@ -148,10 +205,11 @@ jobs:
           name: release-${{ matrix.os }}-${{ matrix.arch }}
           path: ./wheelhouse/*.whl
           retention-days: 5
+          compression-level: 0
 
   nightly_upload:
     name: Upload nightly wheels
-    needs: [build_wheels]
+    needs: [build_sdist, build_wheels_linux, build_wheels_mac_win]
     runs-on: ubuntu-latest
     if: github.repository == 'shapely/shapely' && github.ref == 'refs/heads/main'
     steps:
@@ -168,7 +226,7 @@ jobs:
 
   publish:
     name: Publish on GitHub and PyPI
-    needs: [build_wheels, build_sdist]
+    needs: [build_sdist, build_wheels_linux, build_wheels_mac_win]
     runs-on: ubuntu-latest
     # release on every tag
     if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/')
@@ -212,5 +270,5 @@ jobs:
         with:
           user: __token__
           password: ${{ secrets.pypi_password }}
-          skip_existing: true
+          skip-existing: true
           # To test: repository_url: https://test.pypi.org/legacy/
diff --git a/CHANGES.txt b/CHANGES.txt
index 9aa1599..c14f04e 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:
@@ -41,6 +42,10 @@ Breaking changes in GEOS 3.12:
 - The WKT representation of a MULTIPOINT changed from for example "MULTIPOINT (0 0, 1 1)"
   to "MULTIPOINT ((0 0), (1 1))". (#1885)
 
+Packaging:
+
+- Binary wheels are now built for musllinux (Alpine) x86_64 platforms (#1996).
+
 
 2.0.6 (2024-08-19)
 ------------------
diff --git a/ci/Dockerfile b/ci/Dockerfile
new file mode 100644
index 0000000..10865c2
--- /dev/null
+++ b/ci/Dockerfile
@@ -0,0 +1,13 @@
+ARG BASEIMAGE=quay.io/pypa/manylinux2014_x86_64:latest
+ARG GEOS_VERSION=3.12.2
+
+FROM $BASEIMAGE
+ARG GEOS_VERSION
+ENV GEOS_VERSION=${GEOS_VERSION}
+RUN curl -OL --retry 5 https://download.osgeo.org/geos/geos-$GEOS_VERSION.tar.bz2
+RUN tar xfj geos-$GEOS_VERSION.tar.bz2 && rm geos-$GEOS_VERSION.tar.bz2
+RUN cmake -DCMAKE_BUILD_TYPE=Release -S geos-$GEOS_VERSION -B build
+RUN cmake --build build -j 4
+RUN cmake --install build
+RUN cd build && ctest --output-on-failure
+RUN rm -rf build geos-$GEOS_VERSION
diff --git a/docs/manual.rst b/docs/manual.rst
index c24d255..c0746d4 100644
--- a/docs/manual.rst
+++ b/docs/manual.rst
@@ -2129,36 +2129,34 @@ Other Operations
 Merging Linear Features
 -----------------------
 
-Sequences of touching lines can be merged into `MultiLineStrings` or `Polygons`
-using functions in the :mod:`shapely.ops` module.
+Sequences of touching lines can be merged into `MultiLineStrings` or
+`Polygons`.
 
-.. function:: shapely.ops.polygonize(lines)
+.. function:: shapely.polygonize(lines)
 
   Returns an iterator over polygons constructed from the input `lines`.
 
-  As with the :class:`MultiLineString` constructor, the input elements may be
-  any line-like object.
+  The source should be a sequence of LineString objects.
 
   .. code-block:: pycon
 
-    >>> from shapely.ops import polygonize
+    >>> from shapely import polygonize
     >>> lines = [
-    ...     ((0, 0), (1, 1)),
-    ...     ((0, 0), (0, 1)),
-    ...     ((0, 1), (1, 1)),
-    ...     ((1, 1), (1, 0)),
-    ...     ((1, 0), (0, 0))
+    ...     LineString([(0, 0), (1, 1)]),
+    ...     LineString([(0, 0), (0, 1)]),
+    ...     LineString([(0, 1), (1, 1)]),
+    ...     LineString([(1, 1), (1, 0)]),
+    ...     LineString([(1, 0), (0, 0)]),
     ...     ]
-    >>> list(polygonize(lines))
+    >>> list(polygonize(lines).geoms)
     [<POLYGON ((0 0, 1 1, 1 0, 0 0))>, <POLYGON ((1 1, 0 0, 0 1, 1 1))>]
 
-.. function:: shapely.ops.polygonize_full(lines)
+.. function:: shapely.polygonize_full(lines)
 
   Creates polygons from a source of lines, returning the polygons
   and leftover geometries.
 
-  The source may be a MultiLineString, a sequence of LineString objects,
-  or a sequence of objects than can be adapted to LineStrings.
+  The source should be a sequence of LineString objects.
 
   Returns a tuple of objects: (polygons, cut edges, dangles, invalid ring
   lines). Each are a geometry collection.
@@ -2172,15 +2170,15 @@ using functions in the :mod:`shapely.ops` module.
 
   .. code-block:: pycon
 
-    >>> from shapely.ops import polygonize_full
+    >>> from shapely import polygonize_full
     >>> lines = [
-    ...     ((0, 0), (1, 1)),
-    ...     ((0, 0), (0, 1)),
-    ...     ((0, 1), (1, 1)),
-    ...     ((1, 1), (1, 0)),
-    ...     ((1, 0), (0, 0)),
-    ...     ((5, 5), (6, 6)),
-    ...     ((1, 1), (100, 100)),
+    ...     LineString([(0, 0), (1, 1)]),
+    ...     LineString([(0, 0), (0, 1)]),
+    ...     LineString([(0, 1), (1, 1)]),
+    ...     LineString([(1, 1), (1, 0)]),
+    ...     LineString([(1, 0), (0, 0)]),
+    ...     LineString([(5, 5), (6, 6)]),
+    ...     LineString([(1, 1), (100, 100)]),
     ...     ]
     >>> result, cuts, dangles, invalids = polygonize_full(lines)
     >>> len(result.geoms)
@@ -2190,20 +2188,17 @@ using functions in the :mod:`shapely.ops` module.
     >>> list(dangles.geoms)
     [<LINESTRING (1 1, 100 100)>, <LINESTRING (5 5, 6 6)>]
 
-.. function:: shapely.ops.linemerge(lines)
+.. function:: shapely.line_merge(multilinestring)
 
-  Returns a `LineString` or `MultiLineString` representing the merger of all
-  contiguous elements of `lines`.
-
-  As with :func:`shapely.ops.polygonize`, the input elements may be any
-  line-like object.
+  Returns `LineString(s)` or `MultiLineString(s)` representing the merger of
+  all contiguous elements of the input `MultiLineString(s)`.
 
 .. code-block:: python
 
-    >>> from shapely.ops import linemerge
-    >>> linemerge(lines)
+    >>> from shapely import line_merge
+    >>> line_merge(MultiLineString(lines))
     <MULTILINESTRING ((1 1, 1 0, 0 0), (0 0, 1 1), (0 0, 0 1, 1 1), (1 1, 100 10...>
-    >>> list(linemerge(lines).geoms)
+    >>> list(line_merge(MultiLineString(lines)).geoms)
     [<LINESTRING (1 1, 1 0, 0 0)>,
      <LINESTRING (0 0, 1 1)>,
      <LINESTRING (0 0, 0 1, 1 1)>,
@@ -2212,10 +2207,10 @@ using functions in the :mod:`shapely.ops` module.
 
 Efficient Rectangle Clipping
 ----------------------------
-The :func:`~shapely.ops.clip_by_rect` function in `shapely.ops` returns the
-portion of a geometry within a rectangle.
+The :func:`~shapely.clip_by_rect` function returns the portion of a geometry
+within a rectangle.
 
-.. function:: shapely.ops.clip_by_rect(geom, xmin, ymin, xmax, ymax)
+.. function:: shapely.clip_by_rect(geom, xmin, ymin, xmax, ymax)
 
     The geometry is clipped in a fast but possibly dirty way. The output is
     not guaranteed to be valid. No exceptions will be raised for topological
@@ -2225,7 +2220,7 @@ portion of a geometry within a rectangle.
 
 .. code-block:: python
 
-  >>> from shapely.ops import clip_by_rect
+  >>> from shapely import clip_by_rect
   >>> polygon = Polygon(
   ...     shell=[(0, 0), (0, 30), (30, 30), (30, 0), (0, 0)],
   ...     holes=[[(10, 10), (20, 10), (20, 20), (10, 20), (10, 10)]],
@@ -2237,12 +2232,12 @@ portion of a geometry within a rectangle.
 Efficient Unions
 ----------------
 
-The :func:`~shapely.ops.unary_union` function in `shapely.ops` is more
-efficient than accumulating with :meth:`~object.union`.
+The :func:`~shapely.unary_union` function is more efficient than accumulating
+with :meth:`~object.union`.
 
 .. plot:: code/unary_union.py
 
-.. function:: shapely.ops.unary_union(geoms)
+.. function:: shapely.unary_union(geoms)
 
   Returns a representation of the union of the given geometric objects.
 
@@ -2251,7 +2246,7 @@ efficient than accumulating with :meth:`~object.union`.
 
   .. code-block:: pycon
 
-    >>> from shapely.ops import unary_union
+    >>> from shapely import unary_union
     >>> polygons = [Point(i, 0).buffer(0.7) for i in range(5)]
     >>> unary_union(polygons)
     <POLYGON ((0.444 -0.541, 0.389 -0.582, 0.33 -0.617, 0.268 -0.647, 0.203 -0.6...>
@@ -2272,24 +2267,15 @@ efficient than accumulating with :meth:`~object.union`.
     >>> unary_union(m).is_valid
     True
 
-.. function:: shapely.ops.cascaded_union(geoms)
-
-  Returns a representation of the union of the given geometric objects.
-
-  .. note::
-
-     In 1.8.0 :func:`shapely.ops.cascaded_union` is deprecated, as it was
-     superseded by :func:`shapely.ops.unary_union`.
-
 Delaunay triangulation
 ----------------------
 
-The :func:`~shapely.ops.triangulate` function in `shapely.ops` calculates a
-Delaunay triangulation from a collection of points.
+The :func:`~shapely.delaunay_triangles` function calculates a Delaunay
+triangulation from a collection of points.
 
 .. plot:: code/triangulate.py
 
-.. function:: shapely.ops.triangulate(geom, tolerance=0.0, edges=False)
+.. function:: shapely.delaunay_triangles(geom, tolerance=0.0, edges=False)
 
    Returns a Delaunay triangulation of the vertices of the input geometry.
 
@@ -2297,8 +2283,8 @@ Delaunay triangulation from a collection of points.
    used as the points of the triangulation.
 
    The `tolerance` keyword argument sets the snapping tolerance used to improve
-   the robustness of the triangulation computation. A tolerance of 0.0 specifies
-   that no snapping will take place.
+   the robustness of the triangulation computation. A tolerance of 0.0
+   specifies that no snapping will take place.
 
    If the `edges` keyword argument is `False` a list of `Polygon` triangles
    will be returned. Otherwise a list of `LineString` edges is returned.
@@ -2307,9 +2293,9 @@ Delaunay triangulation from a collection of points.
 
 .. code-block:: pycon
 
-  >>> from shapely.ops import triangulate
+  >>> from shapely import delaunay_triangles
   >>> points = MultiPoint([(0, 0), (1, 1), (0, 2), (2, 2), (3, 1), (1, 0)])
-  >>> triangulate(points)
+  >>> list(delaunay_triangles(points).geoms)
   [<POLYGON ((0 2, 0 0, 1 1, 0 2))>,
    <POLYGON ((0 2, 1 1, 2 2, 0 2))>,
    <POLYGON ((2 2, 1 1, 3 1, 2 2))>,
@@ -2320,12 +2306,12 @@ Delaunay triangulation from a collection of points.
 Voronoi Diagram
 ---------------
 
-The :func:`~shapely.ops.voronoi_diagram` function in `shapely.ops` constructs a
-Voronoi diagram from a collection points, or the vertices of any geometry.
+The :func:`~shapely.voronoi_polygons` function constructs a Voronoi diagram
+from a collection points, or the vertices of any geometry.
 
 .. plot:: code/voronoi_diagram.py
 
-.. function:: shapely.ops.voronoi_diagram(geom, envelope=None, tolerance=0.0, edges=False)
+.. function:: shapely.voronoi_polygons(geom, envelope=None, tolerance=0.0, edges=False)
 
    Constructs a Voronoi diagram from the vertices of the input geometry.
 
@@ -2350,9 +2336,9 @@ Voronoi diagram from a collection points, or the vertices of any geometry.
 
 .. code-block:: pycon
 
-  >>> from shapely.ops import voronoi_diagram
+  >>> from shapely import voronoi_polygons
   >>> points = MultiPoint([(0, 0), (1, 1), (0, 2), (2, 2), (3, 1), (1, 0)])
-  >>> regions = voronoi_diagram(points)
+  >>> regions = voronoi_polygons(points)
   >>> list(regions.geoms)
   [<POLYGON ((2 1, 2 0.5, 0.5 0.5, 0 1, 1 2, 2 1))>,
    <POLYGON ((6 -3, 3.75 -3, 2 0.5, 2 1, 6 5, 6 -3))>,
@@ -2362,37 +2348,38 @@ Voronoi diagram from a collection points, or the vertices of any geometry.
    <POLYGON ((6 5, 2 1, 1 2, 1 5, 6 5))>]
 
 
-Nearest points
---------------
+Nearest points/shortest line
+----------------------------
 
-The :func:`~shapely.ops.nearest_points` function in `shapely.ops` calculates
-the nearest points in a pair of geometries.
+The :func:`~shapely.shortest_line` function calculates the shortest line
+between a pair of geometries.
 
-.. function:: shapely.ops.nearest_points(geom1, geom2)
+.. function:: shapely.shortest_line(geom1, geom2)
 
-   Returns a tuple of the nearest points in the input geometries. The points are
-   returned in the same order as the input geometries.
+   Returns a tuple of the shortest line between the input geometries. The
+   points are returned in the same order as the input geometries.
 
-   `New in version 1.4.0`.
+   `New in version 2.0`.
 
 .. code-block:: pycon
 
-  >>> from shapely.ops import nearest_points
+  >>> from shapely import shortest_line
   >>> triangle = Polygon([(0, 0), (1, 0), (0.5, 1), (0, 0)])
   >>> square = Polygon([(0, 2), (1, 2), (1, 3), (0, 3), (0, 2)])
-  >>> list(nearest_points(triangle, square))
-  [<POINT (0.5 1)>, <POINT (0.5 2)>]
+  >>> shortest_line(triangle, square)
+  <LINESTRING (0.5 1, 0.5 2)>
 
 
-Note that the nearest points may not be existing vertices in the geometries.
+Note that the shortest line may not connect to vertices of the input
+geometries.
 
 Snapping
 --------
 
-The :func:`~shapely.ops.snap` function in `shapely.ops` snaps the vertices in
-one geometry to the vertices in a second geometry with a given tolerance.
+The :func:`~shapely.snap` snaps the vertices in one geometry to the vertices in
+a second geometry with a given tolerance.
 
-.. function:: shapely.ops.snap(geom1, geom2, tolerance)
+.. function:: shapely.snap(geom1, geom2, tolerance)
 
    Snaps vertices in `geom1` to vertices in the `geom2`. A copy of the snapped
    geometry is returned. The input geometries are not modified.
@@ -2404,7 +2391,7 @@ one geometry to the vertices in a second geometry with a given tolerance.
 
 .. code-block:: pycon
 
-  >>> from shapely.ops import snap
+  >>> from shapely import snap
   >>> square = Polygon([(1,1), (2, 1), (2, 2), (1, 2), (1, 1)])
   >>> line = LineString([(0,0), (0.8, 0.8), (1.8, 0.95), (2.6, 0.5)])
   >>> result = snap(line, square, 0.5)
@@ -2414,10 +2401,10 @@ one geometry to the vertices in a second geometry with a given tolerance.
 Shared paths
 ------------
 
-The :func:`~shapely.ops.shared_paths` function in `shapely.ops` finds the shared
-paths between two linear geometries.
+The :func:`~shapely.shared_paths` function finds the shared paths between two
+linear geometries.
 
-.. function:: shapely.ops.shared_paths(geom1, geom2)
+.. function:: shapely.shared_paths(geom1, geom2)
 
    Finds the shared paths between `geom1` and `geom2`, where both geometries
    are `LineStrings`.
@@ -2431,7 +2418,7 @@ paths between two linear geometries.
 
 .. code-block:: pycon
 
-  >>> from shapely.ops import shared_paths
+  >>> from shapely import shared_paths
   >>> g1 = LineString([(0, 0), (10, 0), (10, 5), (20, 5)])
   >>> g2 = LineString([(5, 0), (30, 0), (30, 5), (0, 5)])
   >>> forward, backward = shared_paths(g1, g2).geoms
@@ -2443,24 +2430,29 @@ paths between two linear geometries.
 Splitting
 ---------
 
-The :func:`~shapely.ops.split` function in `shapely.ops` splits a geometry by another geometry.
+The :func:`~shapely.ops.split` function in `shapely.ops` splits a geometry by
+another geometry.
 
 .. function:: shapely.ops.split(geom, splitter)
 
-   Splits a geometry by another geometry and returns a collection of geometries. This function is the theoretical
-   opposite of the union of the split geometry parts. If the splitter does not split the geometry, a  collection with a single geometry equal to the input geometry is returned.
+   Splits a geometry by another geometry and returns a collection of
+   geometries. This function is the theoretical opposite of the union of the
+   split geometry parts. If the splitter does not split the geometry, a
+   collection with a single geometry equal to the input geometry is returned.
 
    The function supports:
 
-   * Splitting a (Multi)LineString by a (Multi)Point or (Multi)LineString or (Multi)Polygon boundary
+   * Splitting a (Multi)LineString by a (Multi)Point or (Multi)LineString or 
+     (Multi)Polygon boundary
 
    * Splitting a (Multi)Polygon by a LineString
 
-   It may be convenient to snap the splitter with low tolerance to the geometry. For example in the case of splitting a line by a point, the point must be exactly on the line, for the line to be correctly split.
-   When splitting a line by a polygon, the boundary of the polygon is used for the operation.
-   When splitting a line by another line, a ValueError is raised if the two overlap at some segment.
-
-   `New in version 1.6.0`
+   It may be convenient to snap the splitter with low tolerance to the
+   geometry. For example in the case of splitting a line by a point, the point
+   must be exactly on the line, for the line to be correctly split. When
+   splitting a line by a polygon, the boundary of the polygon is used for the
+   operation. When splitting a line by another line, a ValueError is raised if
+   the two overlap at some segment.
 
 .. code-block:: pycon
 
@@ -2474,8 +2466,8 @@ The :func:`~shapely.ops.split` function in `shapely.ops` splits a geometry by an
 Substring
 ---------
 
-The :func:`~shapely.ops.substring` function in :mod:`shapely.ops` returns a line segment
-between specified distances along a `LineString`.
+The :func:`~shapely.ops.substring` function in :mod:`shapely.ops` returns a
+line segment between specified distances along a `LineString`.
 
 .. function:: shapely.ops.substring(geom, start_dist, end_dist[, normalized=False])
 
diff --git a/pyproject.toml b/pyproject.toml
index c19cc78..f09fc79 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -69,6 +69,12 @@ include = ["shapely", "shapely.*"]
 [tool.setuptools.package-data]
 "shapely" = ["*.pxd"]
 
+[tool.cibuildwheel]
+skip = ["pp*", "*_i686", "*_ppc64le", "*_s390x"]
+build-verbosity = 1
+test-requires = "pytest"
+test-command = "pytest --pyargs shapely.tests"
+
 [tool.coverage.run]
 source = ["shapely"]
 omit = ["shapely/tests/*"]
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/src/ufuncs.c b/src/ufuncs.c
index 77fc2da..d0d94d6 100644
--- a/src/ufuncs.c
+++ b/src/ufuncs.c
@@ -3058,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) {
@@ -3151,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) {


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8098840..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"
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)