From 4a88e88924b2a8c00f74add81188c49e9b63edfb Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 26 Feb 2024 11:35:49 +0100 Subject: [PATCH 01/11] Upload nightly wheels to Scientific Python nightly channel (#1997) --- .github/workflows/release.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 249ace1b0..ca486ca01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ on: - "MANIFEST.in" - "pyproject.toml" - "setup.py" + schedule: + # in addition run weekly for nightly upload in case no other commits happened + - cron: '34 2 * * 0' jobs: build_sdist: @@ -153,6 +156,13 @@ jobs: path: ./wheelhouse/*.whl retention-days: 5 + - name: Upload nightly wheels + if: github.repository_owner == 'shapely' && github.ref == 'refs/heads/main' + uses: scientific-python/upload-nightly-action@b67d7fcc0396e1128a474d1ab2b48aa94680f9fc # 0.5.0 + with: + artifacts_path: wheelhouse + anaconda_nightly_upload_token: ${{secrets.ANACONDA_ORG_UPLOAD_TOKEN}} + publish: name: Publish on GitHub and PyPI needs: [build_wheels, build_sdist] From c3ddf310f108a7f589d763d613d755ac12ab5d4f Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 26 Feb 2024 16:08:56 +0100 Subject: [PATCH 02/11] Fix uploading of nightly wheels (#1998) --- .github/workflows/release.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca486ca01..b792271c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,11 +156,21 @@ jobs: path: ./wheelhouse/*.whl retention-days: 5 - - name: Upload nightly wheels - if: github.repository_owner == 'shapely' && github.ref == 'refs/heads/main' + nightly_upload: + name: Upload nightly wheels + needs: [build_wheels] + runs-on: ubuntu-latest + if: github.repository == 'shapely/shapely' && github.ref == 'refs/heads/main' + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: dist + - name: Upload wheels to Anaconda Cloud uses: scientific-python/upload-nightly-action@b67d7fcc0396e1128a474d1ab2b48aa94680f9fc # 0.5.0 with: - artifacts_path: wheelhouse + artifacts_path: dist anaconda_nightly_upload_token: ${{secrets.ANACONDA_ORG_UPLOAD_TOKEN}} publish: From e15799520838f9ba696cc107bc874f07644a474d Mon Sep 17 00:00:00 2001 From: Mike Taves Date: Tue, 5 Mar 2024 19:56:24 +1300 Subject: [PATCH 03/11] ENH: Enable M and ZM in WKT and WKB outputs (#1808) --- shapely/io.py | 26 ++- shapely/tests/common.py | 102 +++++++++++ shapely/tests/test_io.py | 365 +++++++++++++++++++++++++++++++++++++++ shapely/wkt.py | 15 +- src/pygeom.c | 18 +- 5 files changed, 504 insertions(+), 22 deletions(-) diff --git a/shapely/io.py b/shapely/io.py index 094bfcc4d..5c99ecb59 100644 --- a/shapely/io.py +++ b/shapely/io.py @@ -1,6 +1,6 @@ import numpy as np -from shapely import lib +from shapely import geos_version, lib from shapely._enum import ParamEnum # include ragged array functions here for reference documentation purpose @@ -33,7 +33,7 @@ def to_wkt( geometry, rounding_precision=6, trim=True, - output_dimension=3, + output_dimension=None, old_3d=False, **kwargs, ): @@ -58,8 +58,10 @@ def to_wkt( -1 to indicate the full precision. trim : bool, default True If True, trim unnecessary decimals (trailing zeros). - output_dimension : int, default 3 - The output dimension for the WKT string. Supported values are 2 and 3. + output_dimension : int, default None + The output dimension for the WKT string. Supported values are 2, 3 and + 4 for GEOS 3.12+. Default None will automatically choose 3 or 4, + depending on the version of GEOS. Specifying 3 means that up to 3 dimensions will be written but 2D geometries will still be represented as 2D in the WKT string. old_3d : bool, default False @@ -97,7 +99,9 @@ def to_wkt( raise TypeError("rounding_precision only accepts scalar values") if not np.isscalar(trim): raise TypeError("trim only accepts scalar values") - if not np.isscalar(output_dimension): + if output_dimension is None: + output_dimension = 3 if geos_version < (3, 12, 0) else 4 + elif not np.isscalar(output_dimension): raise TypeError("output_dimension only accepts scalar values") if not np.isscalar(old_3d): raise TypeError("old_3d only accepts scalar values") @@ -115,7 +119,7 @@ def to_wkt( def to_wkb( geometry, hex=False, - output_dimension=3, + output_dimension=None, byte_order=-1, include_srid=False, flavor="extended", @@ -141,8 +145,10 @@ def to_wkb( hex : bool, default False If true, export the WKB as a hexidecimal string. The default is to return a binary bytes object. - output_dimension : int, default 3 - The output dimension for the WKB. Supported values are 2 and 3. + output_dimension : int, default None + The output dimension for the WKB. Supported values are 2, 3 and 4 for + GEOS 3.12+. Default None will automatically choose 3 or 4, depending on + the version of GEOS. Specifying 3 means that up to 3 dimensions will be written but 2D geometries will still be represented as 2D in the WKB represenation. byte_order : int, default -1 @@ -173,7 +179,9 @@ def to_wkb( """ if not np.isscalar(hex): raise TypeError("hex only accepts scalar values") - if not np.isscalar(output_dimension): + if output_dimension is None: + output_dimension = 3 if geos_version < (3, 12, 0) else 4 + elif not np.isscalar(output_dimension): raise TypeError("output_dimension only accepts scalar values") if not np.isscalar(byte_order): raise TypeError("byte_order only accepts scalar values") diff --git a/shapely/tests/common.py b/shapely/tests/common.py index 431d5ecb7..033705d9e 100644 --- a/shapely/tests/common.py +++ b/shapely/tests/common.py @@ -76,6 +76,64 @@ multi_line_stringt_empty_z = shapely.multilinestrings([empty_line_string_z]) multi_polygon_empty_z = shapely.multipolygons([empty_polygon_z]) geometry_collection_empty_z = shapely.geometrycollections([empty_line_string_z]) +# XYM +point_m = shapely.from_wkt("POINT M (2 3 5)") +line_string_m = shapely.from_wkt("LINESTRING M (0 0 1, 1 0 2, 1 1 3)") +linear_ring_m = shapely.from_wkt("LINEARRING M (0 0 1, 1 0 2, 1 1 3, 0 1 2, 0 0 1)") +polygon_m = shapely.from_wkt("POLYGON M ((0 0 1, 2 0 2, 2 2 3, 0 2 2, 0 0 1))") +polygon_with_hole_m = shapely.from_wkt( + """POLYGON M ((0 0 1, 0 10 2, 10 10 3, 10 0 2, 0 0 1), + (2 2 6, 2 4 5, 4 4 4, 4 2 5, 2 2 6))""" +) +multi_point_m = shapely.from_wkt("MULTIPOINT M ((0 0 3), (1 2 5))") +multi_line_string_m = shapely.from_wkt("MULTILINESTRING M ((0 0 3, 1 2 5))") +multi_polygon_m = shapely.from_wkt( + """MULTIPOLYGON M (((0 0 1, 2 0 2, 2 2 3, 0 2 2, 0 0 1)), + ((2.1 2.1 1.1, 2.2 2.1 1.2, 2.2 2.2 1.3, 2.1 2.2 1.4, 2.1 2.1 1.1)))""" +) +geometry_collection_m = shapely.GeometryCollection([point_m, line_string_m]) +empty_geometry_collection_m = shapely.from_wkt("GEOMETRYCOLLECTION M EMPTY") +empty_point_m = shapely.from_wkt("POINT M EMPTY") +empty_line_string_m = shapely.from_wkt("LINESTRING M EMPTY") +empty_polygon_m = shapely.from_wkt("POLYGON M EMPTY") +empty_multi_point_m = shapely.from_wkt("MULTIPOINT M EMPTY") +empty_multi_line_string_m = shapely.from_wkt("MULTILINESTRING M EMPTY") +empty_multi_polygon_m = shapely.from_wkt("MULTIPOLYGON M EMPTY") +multi_point_empty_m = shapely.multipoints([empty_point_m]) +multi_line_stringt_empty_m = shapely.multilinestrings([empty_line_string_m]) +multi_polygon_empty_m = shapely.multipolygons([empty_polygon_m]) +geometry_collection_empty_m = shapely.geometrycollections([empty_line_string_m]) +# XYZM +point_zm = shapely.from_wkt("POINT ZM (2 3 4 5)") +line_string_zm = shapely.from_wkt("LINESTRING ZM (0 0 4 1, 1 0 4 2, 1 1 4 3)") +linear_ring_zm = shapely.from_wkt( + "LINEARRING ZM (0 0 1 8, 1 0 2 7, 1 1 3 6, 0 1 2 9, 0 0 1 8)" +) +polygon_zm = shapely.from_wkt( + "POLYGON ZM ((0 0 4 1, 2 0 4 2, 2 2 4 3, 0 2 4 2, 0 0 4 1))" +) +polygon_with_hole_zm = shapely.from_wkt( + """POLYGON ZM ((0 0 4 1, 0 10 4 2, 10 10 4 3, 10 0 4 2, 0 0 4 1), + (2 2 4 6, 2 4 4 5, 4 4 4 4, 4 2 4 5, 2 2 4 6))""" +) +multi_point_zm = shapely.from_wkt("MULTIPOINT ZM ((0 0 4 3), (1 2 4 5))") +multi_line_string_zm = shapely.from_wkt("MULTILINESTRING ZM ((0 0 4 3, 1 2 4 5))") +multi_polygon_zm = shapely.from_wkt( + """MULTIPOLYGON ZM (((0 0 4 1, 2 0 4 2, 2 2 4 3, 0 2 4 2, 0 0 4 1)), + ((2.1 2.1 4 1.1, 2.2 2.1 4 1.2, 2.2 2.2 4 1.3, 2.1 2.2 4 1.4, 2.1 2.1 4 1.1)))""" +) +geometry_collection_zm = shapely.GeometryCollection([point_zm, line_string_zm]) +empty_geometry_collection_zm = shapely.from_wkt("GEOMETRYCOLLECTION ZM EMPTY") +empty_point_zm = shapely.from_wkt("POINT ZM EMPTY") +empty_line_string_zm = shapely.from_wkt("LINESTRING ZM EMPTY") +empty_polygon_zm = shapely.from_wkt("POLYGON ZM EMPTY") +empty_multi_point_zm = shapely.from_wkt("MULTIPOINT ZM EMPTY") +empty_multi_line_string_zm = shapely.from_wkt("MULTILINESTRING ZM EMPTY") +empty_multi_polygon_zm = shapely.from_wkt("MULTIPOLYGON ZM EMPTY") +multi_point_empty_zm = shapely.multipoints([empty_point_zm]) +multi_line_stringt_empty_zm = shapely.multilinestrings([empty_line_string_zm]) +multi_polygon_empty_zm = shapely.multipolygons([empty_polygon_zm]) +geometry_collection_empty_zm = shapely.geometrycollections([empty_line_string_zm]) all_types = ( point, @@ -122,6 +180,50 @@ multi_polygon_empty_z, geometry_collection_empty_z, ) +all_types_m = ( + point_m, + line_string_m, + linear_ring_m, + polygon_m, + polygon_with_hole_m, + multi_point_m, + multi_line_string_m, + multi_polygon_m, + geometry_collection_m, + empty_geometry_collection_m, + empty_point_m, + empty_line_string_m, + empty_polygon_m, + empty_multi_point_m, + empty_multi_line_string_m, + empty_multi_polygon_m, + multi_point_empty_m, + multi_line_stringt_empty_m, + multi_polygon_empty_m, + geometry_collection_empty_m, +) +all_types_zm = ( + point_zm, + line_string_zm, + linear_ring_zm, + polygon_zm, + polygon_with_hole_zm, + multi_point_zm, + multi_line_string_zm, + multi_polygon_zm, + geometry_collection_zm, + empty_geometry_collection_zm, + empty_point_zm, + empty_line_string_zm, + empty_polygon_zm, + empty_multi_point_zm, + empty_multi_line_string_zm, + empty_multi_polygon_zm, + multi_point_empty_zm, + multi_line_stringt_empty_zm, + multi_polygon_empty_zm, + geometry_collection_empty_zm, +) @contextmanager diff --git a/shapely/tests/test_io.py b/shapely/tests/test_io.py index 21a750bd8..db6f0a72e 100644 --- a/shapely/tests/test_io.py +++ b/shapely/tests/test_io.py @@ -21,29 +21,51 @@ from shapely.testing import assert_geometries_equal from shapely.tests.common import ( all_types, + all_types_m, all_types_z, + all_types_zm, empty_point, + empty_point_m, empty_point_z, + empty_point_zm, equal_geometries_abnormally_yield_unequal, multi_point_empty, + multi_point_empty_m, multi_point_empty_z, + multi_point_empty_zm, point, + point_m, point_z, + point_zm, polygon_z, ) EWKBZ = 0x80000000 +EWKBM = 0x40000000 +EWKBZM = EWKBZ | EWKBM ISOWKBZ = 1000 +ISOWKBM = 2000 +ISOWKBZM = ISOWKBZ + ISOWKBM POINT11_WKB = struct.pack("= (3, 12, 0): + assert shapely.to_wkt(point, output_dimension=4) == "POINT Z (1 2 3)" + + +def test_to_wkt_m(): + point = shapely.from_wkt("POINT M (1 2 4)") + + assert shapely.to_wkt(point, output_dimension=2) == "POINT (1 2)" + + if shapely.geos_version < (3, 12, 0): + # previous behavior was to incorrectly parse M as Z + assert shapely.to_wkt(point) == "POINT Z (1 2 4)" + assert shapely.to_wkt(point, output_dimension=3) == "POINT Z (1 2 4)" + assert shapely.to_wkt(point, old_3d=True) == "POINT (1 2 4)" + else: + assert shapely.to_wkt(point) == "POINT M (1 2 4)" + assert shapely.to_wkt(point, output_dimension=3) == "POINT M (1 2 4)" + assert shapely.to_wkt(point, output_dimension=4) == "POINT M (1 2 4)" + assert shapely.to_wkt(point, old_3d=True) == "POINT M (1 2 4)" + + +def test_to_wkt_zm(): + point = shapely.from_wkt("POINT ZM (1 2 3 4)") + + assert shapely.to_wkt(point, output_dimension=2) == "POINT (1 2)" + assert shapely.to_wkt(point, output_dimension=3) == "POINT Z (1 2 3)" + + if shapely.geos_version < (3, 12, 0): + # previous behavior was to parse and ignore M + assert shapely.to_wkt(point) == "POINT Z (1 2 3)" + assert shapely.to_wkt(point, old_3d=True) == "POINT (1 2 3)" + else: + assert shapely.to_wkt(point) == "POINT ZM (1 2 3 4)" + assert shapely.to_wkt(point, output_dimension=4) == "POINT ZM (1 2 3 4)" + assert shapely.to_wkt(point, old_3d=True) == "POINT (1 2 3 4)" + def test_to_wkt_none(): # None propagates @@ -461,6 +549,15 @@ def test_repr(): assert repr(point_z) == "" +@pytest.mark.skipif( + shapely.geos_version < (3, 12, 0), + reason="M coordinates not supported with GEOS < 3.12", +) +def test_repr_m(): + assert repr(point_m) == "" + assert repr(point_zm) == "" + + def test_repr_max_length(): # the repr is limited to 80 characters geom = shapely.linestrings(np.arange(1000), np.arange(1000)) @@ -487,6 +584,18 @@ def test_repr_point_z_empty(): assert repr(empty_point_z) == "" +@pytest.mark.xfail( + reason="TODO: fix WKT for empty M and ZM geometries; see GH-2004", strict=True +) +@pytest.mark.skipif( + shapely.geos_version < (3, 12, 0), + reason="M coordinates not supported with GEOS < 3.12", +) +def test_repr_point_m_empty(): + assert repr(empty_point_m) == "" + assert repr(empty_point_zm) == "" + + def test_to_wkb(): point = shapely.points(1, 1) actual = shapely.to_wkb(point, byte_order=1) @@ -511,6 +620,45 @@ def test_to_wkb_z(): assert shapely.to_wkb(point, byte_order=1) == expected_wkb_z assert shapely.to_wkb(point, output_dimension=2, byte_order=1) == expected_wkb assert shapely.to_wkb(point, output_dimension=3, byte_order=1) == expected_wkb_z + if shapely.geos_version >= (3, 12, 0): + assert shapely.to_wkb(point, output_dimension=4, byte_order=1) == expected_wkb_z + + +def test_to_wkb_m(): + # POINT M (1 2 4) + point = shapely.from_wkb(struct.pack("= (3, 12, 0): + assert shapely.to_wkb(point, output_dimension=4, byte_order=1) == expected_wkb_m + + +def test_to_wkb_zm(): + # POINT ZM (1 2 3 4) + point = shapely.from_wkb(struct.pack("= (3, 12, 0): + assert ( + shapely.to_wkb(point, output_dimension=4, byte_order=1) == expected_wkb_zm + ) def test_to_wkb_none(): @@ -570,6 +718,24 @@ def test_to_wkb_flavor(): assert actual.hex()[2:10] == struct.pack("ptr; char trim = 1; int precision = 3; +#if GEOS_SINCE_3_12_0 + int dimension = 4; +#else int dimension = 3; - int use_old_3d = 0; +#endif + // int use_old_3d = 0; // default is false if (geom == NULL) { Py_INCREF(Py_None); @@ -114,9 +118,13 @@ static PyObject* GeometryObject_ToWKT(GeometryObject* obj) { } GEOSWKTWriter_setRoundingPrecision_r(ctx, writer, precision); +#if !GEOS_SINCE_3_12_0 + // Override defaults only for older versions + // See https://github.com/libgeos/geos/pull/915 GEOSWKTWriter_setTrim_r(ctx, writer, trim); GEOSWKTWriter_setOutputDimension_r(ctx, writer, dimension); - GEOSWKTWriter_setOld3D_r(ctx, writer, use_old_3d); + // GEOSWKTWriter_setOld3D_r(ctx, writer, use_old_3d); +#endif // !GEOS_SINCE_3_12_0 // Check if the above functions caused a GEOS exception if (last_error[0] != 0) { @@ -175,8 +183,12 @@ static PyObject* GeometryObject_ToWKB(GeometryObject* obj) { errstate = PGERR_GEOS_EXCEPTION; goto finish; } - // Allow 3D output and include SRID +#if !GEOS_SINCE_3_12_0 + // Allow 3D output for GEOS<3.12 (it is default 4 afterwards) + // See https://github.com/libgeos/geos/pull/908 GEOSWKBWriter_setOutputDimension_r(ctx, writer, 3); +#endif // !GEOS_SINCE_3_12_0 + // include SRID GEOSWKBWriter_setIncludeSRID_r(ctx, writer, 1); // Check if the above functions caused a GEOS exception if (last_error[0] != 0) { From 2beabdace83f53cd5d6739af559e8b82292edec1 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 6 Mar 2024 08:31:42 +0100 Subject: [PATCH 04/11] COMPAT: fix assert_geometries_equal to work with numpy 2.0 copy changes (#2007) --- shapely/testing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shapely/testing.py b/shapely/testing.py index e98ac5ac1..8219232a7 100644 --- a/shapely/testing.py +++ b/shapely/testing.py @@ -106,8 +106,8 @@ def assert_geometries_equal( if normalize: x = shapely.normalize(x) y = shapely.normalize(y) - x = np.array(x, copy=False) - y = np.array(y, copy=False) + x = np.asarray(x) + y = np.asarray(y) is_scalar = x.ndim == 0 or y.ndim == 0 From 377e1becc929ae1042fc53c6710ccec0d049f497 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 6 Mar 2024 08:37:02 +0100 Subject: [PATCH 05/11] BLD: update circleci image for arm wheel building (#2006) --- .circleci/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d39636b71..c50d3f0fd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,7 +4,7 @@ jobs: linux-aarch64-wheels: working_directory: ~/linux-aarch64-wheels machine: - image: ubuntu-2004:2022.04.1 + image: default # resource_class is what tells CircleCI to use an ARM worker for native arm builds # https://circleci.com/product/features/resource-classes/ resource_class: arm.medium @@ -39,5 +39,6 @@ workflows: only: - main - maint-2.0 + - wheels-linux-aarch64 tags: only: /.*/ From e344003a4a2c9002986f54e03e30fed645f7f2ac Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 6 Mar 2024 08:37:18 +0100 Subject: [PATCH 06/11] DOC: document how to install nightly wheels (#1999) --- docs/installation.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/installation.rst b/docs/installation.rst index 9a77aa32a..d2fd2d8af 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -25,6 +25,15 @@ Shapely is available on the conda-forge channel. Install as follows:: $ conda install shapely --channel conda-forge +Installation of the development version using nightly wheels +------------------------------------------------------------ + +If you want to test the latest development version of Shapely, the easiest way +to get this version is by installing it from the Scientific Python index of +nightly wheel packages:: + + python -m pip install --pre --upgrade --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple shapely + Installation from source with custom GEOS libary ------------------------------------------------ From c439168330cfd79d36b1674060a1b358148608c3 Mon Sep 17 00:00:00 2001 From: Mike Taves Date: Mon, 11 Mar 2024 03:48:47 +1300 Subject: [PATCH 07/11] FIX: to_wkt with empty Z geometry arrays (#2012) --- shapely/tests/test_io.py | 9 +++++++++ src/ufuncs.c | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/shapely/tests/test_io.py b/shapely/tests/test_io.py index db6f0a72e..48a93fb21 100644 --- a/shapely/tests/test_io.py +++ b/shapely/tests/test_io.py @@ -415,6 +415,15 @@ def test_to_wkt_none(): assert shapely.to_wkt(None) is None +def test_to_wkt_array_with_empty_z(): + # See GH-2004 + empty_wkt = ["POINT Z EMPTY", None, "POLYGON Z EMPTY"] + empty_geoms = shapely.from_wkt(empty_wkt) + if shapely.geos_version < (3, 9, 0): + empty_wkt = ["POINT EMPTY", None, "POLYGON EMPTY"] + assert list(shapely.to_wkt(empty_geoms)) == empty_wkt + + def test_to_wkt_exceptions(): with pytest.raises(TypeError): shapely.to_wkt(1) diff --git a/src/ufuncs.c b/src/ufuncs.c index cc4606fdf..1ffe32fb6 100644 --- a/src/ufuncs.c +++ b/src/ufuncs.c @@ -3386,8 +3386,9 @@ static void to_wkt_func(char** args, const npy_intp* dimensions, const npy_intp* goto finish; } if (wkt != NULL) { + Py_XDECREF(*out); *out = PyUnicode_FromString(wkt); - goto finish; + continue; } #else From 0772979307c0f2375eddaa1780453e9eb857dc00 Mon Sep 17 00:00:00 2001 From: Mike Taves Date: Mon, 11 Mar 2024 04:24:47 +1300 Subject: [PATCH 08/11] ENH: add has_m (#2008) --- docs/manual.rst | 19 +++++++++++-- shapely/_geometry.py | 12 ++++++-- shapely/geometry/base.py | 8 ++++-- shapely/predicates.py | 35 +++++++++++++++++++++-- shapely/tests/test_io.py | 19 ++++++------- shapely/tests/test_predicates.py | 49 ++++++++++++++++++++++++++++---- src/ufuncs.c | 7 +++++ 7 files changed, 125 insertions(+), 24 deletions(-) diff --git a/docs/manual.rst b/docs/manual.rst index 411f35b26..7936dd98f 100644 --- a/docs/manual.rst +++ b/docs/manual.rst @@ -914,8 +914,8 @@ example will be shown for each. .. attribute:: object.has_z - Returns ``True`` if the feature has not only `x` and `y`, but also `z` - coordinates for 3D (or so-called, 2.5D) geometries. + Returns ``True`` if the feature has `z` coordinates, either with XYZ or XYZM + coordinate types. .. code-block:: pycon @@ -924,6 +924,21 @@ example will be shown for each. >>> Point(0, 0, 0).has_z True +.. attribute:: object.has_m + + Returns ``True`` if the feature has `m` coordinates, either with XYM or XYZM + coordinate types. + + `New in version 2.1 with GEOS 3.12`. + +.. code-block:: pycon + + >>> Point(0, 0, 0).has_m + False + >>> from shapely import from_wkt + >>> from_wkt("POINT M (0 0 0)").has_m + True + .. attribute:: object.is_ccw Returns ``True`` if coordinates are in counter-clockwise order (bounding a diff --git a/shapely/_geometry.py b/shapely/_geometry.py index d270de3e4..8399dd74d 100644 --- a/shapely/_geometry.py +++ b/shapely/_geometry.py @@ -121,12 +121,18 @@ def get_dimensions(geometry, **kwargs): @multithreading_enabled def get_coordinate_dimension(geometry, **kwargs): - """Returns the dimensionality of the coordinates in a geometry (2 or 3). + """Returns the dimensionality of the coordinates in a geometry (2, 3 or 4). - Returns -1 for missing geometries (``None`` values). + The return value can be one of the following: + + * Return 2 for geometries with XY coordinate types, + * Return 3 for XYZ or XYM coordinate types + (distinguished by :meth:`has_z` or :meth:`has_m`), + * Return 4 for XYZM coordinate types, + * Return -1 for missing geometries (``None`` values). Note that with GEOS < 3.12, if the first Z coordinate equals ``nan``, this function - will return ``2``. + will return ``2``. Geometries with M coordinates are supported with GEOS >= 3.12. Parameters ---------- diff --git a/shapely/geometry/base.py b/shapely/geometry/base.py index 1a72e6563..4f64b0381 100644 --- a/shapely/geometry/base.py +++ b/shapely/geometry/base.py @@ -619,10 +619,14 @@ def union(self, other, grid_size=None): @property def has_z(self): - """True if the geometry's coordinate sequence(s) have z values (are - 3-dimensional)""" + """True if the geometry's coordinate sequence(s) have z values""" return bool(shapely.has_z(self)) + @property + def has_m(self): + """True if the geometry's coordinate sequence(s) have m values""" + return bool(shapely.has_m(self)) + @property def is_empty(self): """True if the set of points in this geometry is empty, else False""" diff --git a/shapely/predicates.py b/shapely/predicates.py index bb261be49..2b221e6a8 100644 --- a/shapely/predicates.py +++ b/shapely/predicates.py @@ -7,6 +7,7 @@ __all__ = [ "has_z", + "has_m", "is_ccw", "is_closed", "is_empty", @@ -40,7 +41,7 @@ @multithreading_enabled def has_z(geometry, **kwargs): - """Returns True if a geometry has a Z coordinate. + """Returns True if a geometry has Z coordinates. Note that for GEOS < 3.12 this function returns False if the (first) Z coordinate equals NaN. @@ -53,7 +54,7 @@ def has_z(geometry, **kwargs): See also -------- - get_coordinate_dimension + get_coordinate_dimension, has_m Examples -------- @@ -68,6 +69,36 @@ def has_z(geometry, **kwargs): return lib.has_z(geometry, **kwargs) +@multithreading_enabled +@requires_geos("3.12.0") +def has_m(geometry, **kwargs): + """Returns True if a geometry has M coordinates. + + Parameters + ---------- + geometry : Geometry or array_like + **kwargs + See :ref:`NumPy ufunc docs ` for other keyword arguments. + + See also + -------- + get_coordinate_dimension, has_z + + Examples + -------- + >>> from shapely import from_wkt + >>> has_m(from_wkt("POINT (0 0)")) + False + >>> has_m(from_wkt("POINT Z (0 0 0)")) + False + >>> has_m(from_wkt("POINT M (0 0 0)")) + True + >>> has_m(from_wkt("POINT ZM (0 0 0 0)")) + True + """ + return lib.has_m(geometry, **kwargs) + + @multithreading_enabled def is_ccw(geometry, **kwargs): """Returns True if a linestring or linearring is counterclockwise. diff --git a/shapely/tests/test_io.py b/shapely/tests/test_io.py index 48a93fb21..d40ac7ad9 100644 --- a/shapely/tests/test_io.py +++ b/shapely/tests/test_io.py @@ -1037,7 +1037,7 @@ def test_from_wkb_point_empty_m(wkb, expected_type): assert shapely.get_type_id(geom) == expected_type assert shapely.get_coordinate_dimension(geom) == 3 assert not shapely.has_z(geom) - # TODO: assert shapely.has_m(geom) + assert shapely.has_m(geom) @pytest.mark.skipif( @@ -1062,7 +1062,7 @@ def test_from_wkb_point_empty_zm(wkb, expected_type): assert shapely.get_type_id(geom) == expected_type assert shapely.get_coordinate_dimension(geom) == 4 assert shapely.has_z(geom) - # TODO: assert shapely.has_m(geom) + assert shapely.has_m(geom) def test_to_wkb_point_empty_srid(): @@ -1086,10 +1086,10 @@ def test_pickle_z(geom): pickled = pickle.dumps(geom) actual = pickle.loads(pickled) assert_geometries_equal(actual, geom, tolerance=0) - if actual.is_empty: - pass # GEOSHasZ with EMPTY geometries is inconsistent - else: + if not actual.is_empty: # GEOSHasZ with EMPTY geometries is inconsistent assert actual.has_z + if shapely.geos_version >= (3, 12, 0): + assert not actual.has_m @pytest.mark.skipif( @@ -1104,7 +1104,8 @@ def test_pickle_m(geom): actual = pickle.loads(pickled) assert_geometries_equal(actual, geom, tolerance=0) assert not actual.has_z - # TODO: assert actual.has_m + if not actual.is_empty: # GEOSHasM with EMPTY geometries is inconsistent + assert actual.has_m @pytest.mark.skipif( @@ -1118,11 +1119,9 @@ def test_pickle_zm(geom): pickled = pickle.dumps(geom) actual = pickle.loads(pickled) assert_geometries_equal(actual, geom, tolerance=0) - if actual.is_empty: - pass # GEOSHasZ with EMPTY geometries is inconsistent - else: + if not actual.is_empty: # GEOSHasZ with EMPTY geometries is inconsistent assert actual.has_z - # TODO: assert actual.has_m + assert actual.has_m @pytest.mark.parametrize("geom", all_types + (point_z, empty_point)) diff --git a/shapely/tests/test_predicates.py b/shapely/tests/test_predicates.py index 5101908da..1529880a1 100644 --- a/shapely/tests/test_predicates.py +++ b/shapely/tests/test_predicates.py @@ -7,7 +7,9 @@ from shapely import LinearRing, LineString, Point from shapely.tests.common import ( all_types, + all_types_m, all_types_z, + all_types_zm, empty, geometry_collection, ignore_invalid, @@ -18,6 +20,13 @@ ) UNARY_PREDICATES = ( + shapely.has_z, + pytest.param( + shapely.has_m, + marks=pytest.mark.skipif( + shapely.geos_version < (3, 12, 0), reason="GEOS < 3.12" + ), + ), shapely.is_empty, shapely.is_simple, shapely.is_ring, @@ -218,17 +227,47 @@ def test_dwithin(): @pytest.mark.parametrize("geometry", all_types) -def test_has_z_all_types(geometry): +def test_has_z_has_m_all_types(geometry): assert not shapely.has_z(geometry) + if shapely.geos_version >= (3, 12, 0): + assert not shapely.has_m(geometry) + + +# The next few tests skip has_z/has_m with empty geometries +# See https://github.com/libgeos/geos/issues/888 @pytest.mark.parametrize("geometry", all_types_z) -def test_has_z_all_types_z(geometry): +def test_has_z_has_m_all_types_z(geometry): if shapely.is_empty(geometry): - # https://github.com/libgeos/geos/issues/888 pytest.skip("GEOSHasZ with EMPTY geometries is inconsistent") - else: - assert shapely.has_z(geometry) + assert shapely.has_z(geometry) + if shapely.geos_version >= (3, 12, 0): + assert not shapely.has_m(geometry) + + +@pytest.mark.skipif( + shapely.geos_version < (3, 12, 0), + reason="M coordinates not supported with GEOS < 3.12", +) +@pytest.mark.parametrize("geometry", all_types_m) +def test_has_m_all_types_m(geometry): + if shapely.is_empty(geometry): + pytest.skip("GEOSHasM with EMPTY geometries is inconsistent") + assert not shapely.has_z(geometry) + assert shapely.has_m(geometry) + + +@pytest.mark.skipif( + shapely.geos_version < (3, 12, 0), + reason="M coordinates not supported with GEOS < 3.12", +) +@pytest.mark.parametrize("geometry", all_types_zm) +def test_has_z_has_m_all_types_zm(geometry): + if shapely.is_empty(geometry): + pytest.skip("GEOSHasZ with EMPTY geometries is inconsistent") + assert shapely.has_z(geometry) + assert shapely.has_m(geometry) @pytest.mark.parametrize( diff --git a/src/ufuncs.c b/src/ufuncs.c index 1ffe32fb6..7d562db18 100644 --- a/src/ufuncs.c +++ b/src/ufuncs.c @@ -121,6 +121,9 @@ static char GEOSisSimpleAllTypes_r(void* context, void* geom) { static void* is_simple_data[1] = {GEOSisSimpleAllTypes_r}; static void* is_ring_data[1] = {GEOSisRing_r}; static void* has_z_data[1] = {GEOSHasZ_r}; +#if GEOS_SINCE_3_12_0 +static void* has_m_data[1] = {GEOSHasM_r}; +#endif /* the GEOSisClosed_r function fails on non-linestrings */ static char GEOSisClosedAllTypes_r(void* context, void* geom) { int type = GEOSGeomTypeId_r(context, geom); @@ -3832,6 +3835,10 @@ int init_ufuncs(PyObject* m, PyObject* d) { DEFINE_CUSTOM(concave_hull, 3); #endif +#if GEOS_SINCE_3_12_0 + DEFINE_Y_b(has_m); +#endif + Py_DECREF(ufunc); return 0; } From ab56a0947e7928273aa2f4e18b8062635330ed16 Mon Sep 17 00:00:00 2001 From: quassy <369996+quassy@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:09:21 +0100 Subject: [PATCH 09/11] Add shapely.geometry.polygon.orient to __all__ (#2003) --- shapely/geometry/polygon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shapely/geometry/polygon.py b/shapely/geometry/polygon.py index efec427ce..a480856ec 100644 --- a/shapely/geometry/polygon.py +++ b/shapely/geometry/polygon.py @@ -10,7 +10,7 @@ from shapely.geometry.linestring import LineString from shapely.geometry.point import Point -__all__ = ["Polygon", "LinearRing"] +__all__ = ["orient", "Polygon", "LinearRing"] def _unpickle_linearring(wkb): From 031211e4368d727c1717fc5ac4677d63be283632 Mon Sep 17 00:00:00 2001 From: Mike Taves Date: Tue, 12 Mar 2024 10:17:44 +1300 Subject: [PATCH 10/11] FIX: fix WKT for empty M and ZM geometries (#2014) --- shapely/tests/test_io.py | 3 --- src/geos.c | 6 +++--- src/geos.h | 4 ++-- src/pygeom.c | 15 ++++++++------- src/ufuncs.c | 16 ++++++++-------- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/shapely/tests/test_io.py b/shapely/tests/test_io.py index d40ac7ad9..637e18a40 100644 --- a/shapely/tests/test_io.py +++ b/shapely/tests/test_io.py @@ -593,9 +593,6 @@ def test_repr_point_z_empty(): assert repr(empty_point_z) == "" -@pytest.mark.xfail( - reason="TODO: fix WKT for empty M and ZM geometries; see GH-2004", strict=True -) @pytest.mark.skipif( shapely.geos_version < (3, 12, 0), reason="M coordinates not supported with GEOS < 3.12", diff --git a/src/geos.c b/src/geos.c index c7db029f9..8bbecca68 100644 --- a/src/geos.c +++ b/src/geos.c @@ -434,13 +434,13 @@ char check_to_wkt_trim_compatible(GEOSContextHandle_t ctx, const GEOSGeometry* g } #endif // !GEOS_SINCE_3_13_0 -#if GEOS_SINCE_3_9_0 +#if GEOS_SINCE_3_9_0 && !GEOS_SINCE_3_12_0 /* Checks whether the geometry is a 3D empty geometry and, if so, create the WKT string * * GEOS 3.9.* is able to distiguish 2D and 3D simple geometries (non-collections). But the * but the WKT serialization never writes a 3D empty geometry. This function fixes that. - * It only makes sense to use this for GEOS versions >= 3.9. + * It only makes sense to use this for GEOS versions >= 3.9 && < 3.12. * * Pending GEOS ticket: https://trac.osgeo.org/geos/ticket/1129 * @@ -500,7 +500,7 @@ char wkt_empty_3d_geometry(GEOSContextHandle_t ctx, GEOSGeometry* geom, char** w return PGERR_SUCCESS; } -#endif // GEOS_SINCE_3_9_0 +#endif // GEOS_SINCE_3_9_0 && !GEOS_SINCE_3_12_0 /* GEOSInterpolate_r and GEOSInterpolateNormalized_r segfault on empty * geometries and also on collections with the first geometry empty. diff --git a/src/geos.h b/src/geos.h index 26367a1c8..6fbb68dc8 100644 --- a/src/geos.h +++ b/src/geos.h @@ -177,10 +177,10 @@ extern char check_to_wkt_compatible(GEOSContextHandle_t ctx, GEOSGeometry* geom) #if !GEOS_SINCE_3_13_0 extern char check_to_wkt_trim_compatible(GEOSContextHandle_t ctx, const GEOSGeometry* geom, int dimension); #endif // !GEOS_SINCE_3_13_0 -#if GEOS_SINCE_3_9_0 +#if GEOS_SINCE_3_9_0 && !GEOS_SINCE_3_12_0 extern char wkt_empty_3d_geometry(GEOSContextHandle_t ctx, GEOSGeometry* geom, char** wkt); -#endif // GEOS_SINCE_3_9_0 +#endif // GEOS_SINCE_3_9_0 && !GEOS_SINCE_3_12_0 extern char geos_interpolate_checker(GEOSContextHandle_t ctx, GEOSGeometry* geom); extern int init_geos(PyObject* m); diff --git a/src/pygeom.c b/src/pygeom.c index 53bfdf6c9..2da75dcce 100644 --- a/src/pygeom.c +++ b/src/pygeom.c @@ -94,7 +94,14 @@ static PyObject* GeometryObject_ToWKT(GeometryObject* obj) { } #endif // !GEOS_SINCE_3_13_0 -#if GEOS_SINCE_3_9_0 +#if !GEOS_SINCE_3_9_0 + // Before GEOS 3.9.0, there was as segfault on e.g. MULTIPOINT (1 1, EMPTY) + errstate = check_to_wkt_compatible(ctx, geom); + if (errstate != PGERR_SUCCESS) { + goto finish; + } +#elif !GEOS_SINCE_3_12_0 + // Since GEOS 3.9.0 and before 3.12.0 further handling required errstate = wkt_empty_3d_geometry(ctx, geom, &wkt); if (errstate != PGERR_SUCCESS) { goto finish; @@ -103,12 +110,6 @@ static PyObject* GeometryObject_ToWKT(GeometryObject* obj) { result = PyUnicode_FromString(wkt); goto finish; } -#else - // Before GEOS 3.9.0, there was as segfault on e.g. MULTIPOINT (1 1, EMPTY) - errstate = check_to_wkt_compatible(ctx, geom); - if (errstate != PGERR_SUCCESS) { - goto finish; - } #endif GEOSWKTWriter* writer = GEOSWKTWriter_create_r(ctx); diff --git a/src/ufuncs.c b/src/ufuncs.c index 7d562db18..99d4e4534 100644 --- a/src/ufuncs.c +++ b/src/ufuncs.c @@ -3383,7 +3383,14 @@ static void to_wkt_func(char** args, const npy_intp* dimensions, const npy_intp* } } #endif // !GEOS_SINCE_3_13_0 -#if GEOS_SINCE_3_9_0 +#if !GEOS_SINCE_3_9_0 + // Before GEOS 3.9.0, there was as segfault on e.g. MULTIPOINT (1 1, EMPTY) + errstate = check_to_wkt_compatible(ctx, in1); + if (errstate != PGERR_SUCCESS) { + goto finish; + } +#elif !GEOS_SINCE_3_12_0 + // Since GEOS 3.9.0 and before 3.12.0 further handling required errstate = wkt_empty_3d_geometry(ctx, in1, &wkt); if (errstate != PGERR_SUCCESS) { goto finish; @@ -3393,13 +3400,6 @@ static void to_wkt_func(char** args, const npy_intp* dimensions, const npy_intp* *out = PyUnicode_FromString(wkt); continue; } - -#else - // Before GEOS 3.9.0, there was as segfault on e.g. MULTIPOINT (1 1, EMPTY) - errstate = check_to_wkt_compatible(ctx, in1); - if (errstate != PGERR_SUCCESS) { - goto finish; - } #endif wkt = GEOSWKTWriter_write_r(ctx, writer, in1); if (wkt == NULL) { From 72a440df4f6b6424f8d52002c128a8afeaac8fc0 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 11 Mar 2024 22:17:59 +0100 Subject: [PATCH 11/11] BLD: use geos INSTALL_NAME_DIR for MacOS instead of specifying custom LDFLAGS with rpath (#2016) --- .github/workflows/release.yml | 1 - ci/install_geos.sh | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b792271c9..c6697173a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,6 @@ jobs: CIBW_ENVIRONMENT_MACOS: GEOS_INSTALL=${{ runner.temp }}/geos-${{ env.GEOS_VERSION }} GEOS_CONFIG=${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}/bin/geos-config - LDFLAGS=-Wl,-rpath,${{ runner.temp }}/geos-${{ env.GEOS_VERSION }}/lib MACOSX_DEPLOYMENT_TARGET=10.9 CMAKE_OSX_ARCHITECTURES='${{ matrix.cmake_osx_architectures }}' CIBW_ENVIRONMENT_WINDOWS: diff --git a/ci/install_geos.sh b/ci/install_geos.sh index 3ba98e8cc..bc374b6a5 100755 --- a/ci/install_geos.sh +++ b/ci/install_geos.sh @@ -56,6 +56,7 @@ build_geos(){ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=${GEOS_INSTALL} \ -DCMAKE_INSTALL_LIBDIR=lib \ + -DCMAKE_INSTALL_NAME_DIR=${GEOS_INSTALL}/lib \ ${BUILD_TESTING} \ .. cmake --build . -j 4