Repository: django/django

Issue:
# Refs #36036 -- Added m dimension to GEOSCoordSeq.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36036

#### Branch description
As per the suggestion in https://github.com/django/django/pull/19013#issuecomment-2816751388 this adds M dimension support to `coordseq`.


#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/contrib/gis/geos/coordseq.py b/django/contrib/gis/geos/coordseq.py
index dec3495d25c9..febeeebfa3bc 100644
--- a/django/contrib/gis/geos/coordseq.py
+++ b/django/contrib/gis/geos/coordseq.py
@@ -9,7 +9,7 @@
 from django.contrib.gis.geos import prototypes as capi
 from django.contrib.gis.geos.base import GEOSBase
 from django.contrib.gis.geos.error import GEOSException
-from django.contrib.gis.geos.libgeos import CS_PTR
+from django.contrib.gis.geos.libgeos import CS_PTR, geos_version_tuple
 from django.contrib.gis.shortcuts import numpy
 
 
@@ -20,6 +20,8 @@ class GEOSCoordSeq(GEOSBase):
 
     def __init__(self, ptr, z=False):
         "Initialize from a GEOS pointer."
+        # TODO when dropping support for GEOS 3.13 the z argument can be
+        # deprecated in favor of using the GEOS function GEOSCoordSeq_hasZ.
         if not isinstance(ptr, CS_PTR):
             raise TypeError("Coordinate sequence should initialize with a CS_PTR.")
         self._ptr = ptr
@@ -58,6 +60,12 @@ def __setitem__(self, index, value):
         if self.dims == 3 and self._z:
             n_args = 3
             point_setter = self._set_point_3d
+        elif self.dims == 3 and self.hasm:
+            n_args = 3
+            point_setter = self._set_point_3d_m
+        elif self.dims == 4 and self._z and self.hasm:
+            n_args = 4
+            point_setter = self._set_point_4d
         else:
             n_args = 2
             point_setter = self._set_point_2d
@@ -74,7 +82,7 @@ def _checkindex(self, index):
 
     def _checkdim(self, dim):
         "Check the given dimension."
-        if dim < 0 or dim > 2:
+        if dim < 0 or dim > 3:
             raise GEOSException(f'Invalid ordinate dimension: "{dim:d}"')
 
     def _get_x(self, index):
@@ -86,6 +94,9 @@ def _get_y(self, index):
     def _get_z(self, index):
         return capi.cs_getz(self.ptr, index, byref(c_double()))
 
+    def _get_m(self, index):
+        return capi.cs_getm(self.ptr, index, byref(c_double()))
+
     def _set_x(self, index, value):
         capi.cs_setx(self.ptr, index, value)
 
@@ -95,9 +106,18 @@ def _set_y(self, index, value):
     def _set_z(self, index, value):
         capi.cs_setz(self.ptr, index, value)
 
+    def _set_m(self, index, value):
+        capi.cs_setm(self.ptr, index, value)
+
     @property
     def _point_getter(self):
-        return self._get_point_3d if self.dims == 3 and self._z else self._get_point_2d
+        if self.dims == 3 and self._z:
+            return self._get_point_3d
+        elif self.dims == 3 and self.hasm:
+            return self._get_point_3d_m
+        elif self.dims == 4 and self._z and self.hasm:
+            return self._get_point_4d
+        return self._get_point_2d
 
     def _get_point_2d(self, index):
         return (self._get_x(index), self._get_y(index))
@@ -105,6 +125,17 @@ def _get_point_2d(self, index):
     def _get_point_3d(self, index):
         return (self._get_x(index), self._get_y(index), self._get_z(index))
 
+    def _get_point_3d_m(self, index):
+        return (self._get_x(index), self._get_y(index), self._get_m(index))
+
+    def _get_point_4d(self, index):
+        return (
+            self._get_x(index),
+            self._get_y(index),
+            self._get_z(index),
+            self._get_m(index),
+        )
+
     def _set_point_2d(self, index, value):
         x, y = value
         self._set_x(index, x)
@@ -116,6 +147,19 @@ def _set_point_3d(self, index, value):
         self._set_y(index, y)
         self._set_z(index, z)
 
+    def _set_point_3d_m(self, index, value):
+        x, y, m = value
+        self._set_x(index, x)
+        self._set_y(index, y)
+        self._set_m(index, m)
+
+    def _set_point_4d(self, index, value):
+        x, y, z, m = value
+        self._set_x(index, x)
+        self._set_y(index, y)
+        self._set_z(index, z)
+        self._set_m(index, m)
+
     # #### Ordinate getting and setting routines ####
     def getOrdinate(self, dimension, index):
         "Return the value for the given dimension and index."
@@ -153,6 +197,14 @@ def setZ(self, index, value):
         "Set Z with the value at the given index."
         self.setOrdinate(2, index, value)
 
+    def getM(self, index):
+        "Get M with the value at the given index."
+        return self.getOrdinate(3, index)
+
+    def setM(self, index, value):
+        "Set M with the value at the given index."
+        self.setOrdinate(3, index, value)
+
     # ### Dimensions ###
     @property
     def size(self):
@@ -172,6 +224,18 @@ def hasz(self):
         """
         return self._z
 
+    @property
+    def hasm(self):
+        """
+        Return whether this coordinate sequence has M dimension.
+        """
+        if geos_version_tuple() >= (3, 14):
+            return capi.cs_hasm(self._ptr)
+        else:
+            raise NotImplementedError(
+                "GEOSCoordSeq with an M dimension requires GEOS 3.14+."
+            )
+
     # ### Other Methods ###
     def clone(self):
         "Clone this coordinate sequence."
@@ -180,16 +244,13 @@ def clone(self):
     @property
     def kml(self):
         "Return the KML representation for the coordinates."
-        # Getting the substitution string depending on whether the coordinates
-        # have a Z dimension.
         if self.hasz:
-            substr = "%s,%s,%s "
+            coords = [f"{coord[0]},{coord[1]},{coord[2]}" for coord in self]
         else:
-            substr = "%s,%s,0 "
-        return (
-            "<coordinates>%s</coordinates>"
-            % "".join(substr % self[i] for i in range(len(self))).strip()
-        )
+            coords = [f"{coord[0]},{coord[1]},0" for coord in self]
+
+        coordinate_string = " ".join(coords)
+        return f"<coordinates>{coordinate_string}</coordinates>"
 
     @property
     def tuple(self):
diff --git a/django/contrib/gis/geos/prototypes/__init__.py b/django/contrib/gis/geos/prototypes/__init__.py
index 6b0da37ee676..cac0b3fdf46b 100644
--- a/django/contrib/gis/geos/prototypes/__init__.py
+++ b/django/contrib/gis/geos/prototypes/__init__.py
@@ -8,12 +8,15 @@
     create_cs,
     cs_clone,
     cs_getdims,
+    cs_getm,
     cs_getordinate,
     cs_getsize,
     cs_getx,
     cs_gety,
     cs_getz,
+    cs_hasm,
     cs_is_ccw,
+    cs_setm,
     cs_setordinate,
     cs_setx,
     cs_sety,
diff --git a/django/contrib/gis/geos/prototypes/coordseq.py b/django/contrib/gis/geos/prototypes/coordseq.py
index cfc242c00dd1..eadaf8dfcf1e 100644
--- a/django/contrib/gis/geos/prototypes/coordseq.py
+++ b/django/contrib/gis/geos/prototypes/coordseq.py
@@ -1,7 +1,15 @@
 from ctypes import POINTER, c_byte, c_double, c_int, c_uint
 
-from django.contrib.gis.geos.libgeos import CS_PTR, GEOM_PTR, GEOSFuncFactory
-from django.contrib.gis.geos.prototypes.errcheck import GEOSException, last_arg_byref
+from django.contrib.gis.geos.libgeos import (
+    CS_PTR,
+    GEOM_PTR,
+    GEOSFuncFactory,
+)
+from django.contrib.gis.geos.prototypes.errcheck import (
+    GEOSException,
+    check_predicate,
+    last_arg_byref,
+)
 
 
 # ## Error-checking routines specific to coordinate sequences. ##
@@ -67,6 +75,12 @@ def errcheck(result, func, cargs):
         return result
 
 
+class CsUnaryPredicate(GEOSFuncFactory):
+    argtypes = [CS_PTR]
+    restype = c_byte
+    errcheck = staticmethod(check_predicate)
+
+
 # ## Coordinate Sequence ctypes prototypes ##
 
 # Coordinate Sequence constructors & cloning.
@@ -78,20 +92,25 @@ def errcheck(result, func, cargs):
 cs_getordinate = CsOperation("GEOSCoordSeq_getOrdinate", ordinate=True, get=True)
 cs_setordinate = CsOperation("GEOSCoordSeq_setOrdinate", ordinate=True)
 
-# For getting, x, y, z
+# For getting, x, y, z, m
 cs_getx = CsOperation("GEOSCoordSeq_getX", get=True)
 cs_gety = CsOperation("GEOSCoordSeq_getY", get=True)
 cs_getz = CsOperation("GEOSCoordSeq_getZ", get=True)
+cs_getm = CsOperation("GEOSCoordSeq_getM", get=True)
 
-# For setting, x, y, z
+# For setting, x, y, z, m
 cs_setx = CsOperation("GEOSCoordSeq_setX")
 cs_sety = CsOperation("GEOSCoordSeq_setY")
 cs_setz = CsOperation("GEOSCoordSeq_setZ")
+cs_setm = CsOperation("GEOSCoordSeq_setM")
 
 # These routines return size & dimensions.
 cs_getsize = CsInt("GEOSCoordSeq_getSize")
 cs_getdims = CsInt("GEOSCoordSeq_getDimensions")
 
+# Unary Predicates
+cs_hasm = CsUnaryPredicate("GEOSCoordSeq_hasM")
+
 cs_is_ccw = GEOSFuncFactory(
     "GEOSCoordSeq_isCCW", restype=c_int, argtypes=[CS_PTR, POINTER(c_byte)]
 )


Test patch:
diff --git a/tests/gis_tests/geos_tests/test_coordseq.py b/tests/gis_tests/geos_tests/test_coordseq.py
index b6f5136dd19c..37c421f5c3b8 100644
--- a/tests/gis_tests/geos_tests/test_coordseq.py
+++ b/tests/gis_tests/geos_tests/test_coordseq.py
@@ -1,4 +1,11 @@
-from django.contrib.gis.geos import LineString
+import math
+from unittest import skipIf
+from unittest.mock import patch
+
+from django.contrib.gis.geos import GEOSGeometry, LineString
+from django.contrib.gis.geos import prototypes as capi
+from django.contrib.gis.geos.coordseq import GEOSCoordSeq
+from django.contrib.gis.geos.libgeos import geos_version_tuple
 from django.test import SimpleTestCase
 
 
@@ -13,3 +20,130 @@ def test_getitem(self):
             with self.subTest(i):
                 with self.assertRaisesMessage(IndexError, msg):
                     coord_seq[i]
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_has_m(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertIs(coord_seq.hasm, True)
+
+        geom = GEOSGeometry("POINT Z (1 2 3)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertIs(coord_seq.hasm, False)
+
+        geom = GEOSGeometry("POINT M (1 2 3)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertIs(coord_seq.hasm, True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_get_set_m(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.tuple, (1, 2, 3, 4))
+        self.assertEqual(coord_seq.getM(0), 4)
+        coord_seq.setM(0, 10)
+        self.assertEqual(coord_seq.tuple, (1, 2, 3, 10))
+        self.assertEqual(coord_seq.getM(0), 10)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.tuple, (1, 2, 4))
+        self.assertEqual(coord_seq.getM(0), 4)
+        coord_seq.setM(0, 10)
+        self.assertEqual(coord_seq.tuple, (1, 2, 10))
+        self.assertEqual(coord_seq.getM(0), 10)
+        self.assertIs(math.isnan(coord_seq.getZ(0)), True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_setitem(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        coord_seq[0] = (10, 20, 30, 40)
+        self.assertEqual(coord_seq.tuple, (10, 20, 30, 40))
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        coord_seq[0] = (10, 20, 40)
+        self.assertEqual(coord_seq.tuple, (10, 20, 40))
+        self.assertEqual(coord_seq.getM(0), 40)
+        self.assertIs(math.isnan(coord_seq.getZ(0)), True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_kml_m_dimension(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.kml, "<coordinates>1.0,2.0,3.0</coordinates>")
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.kml, "<coordinates>1.0,2.0,0</coordinates>")
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_clone_m_dimension(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        clone = coord_seq.clone()
+        self.assertEqual(clone.tuple, (1, 2, 3, 4))
+        self.assertIs(clone.hasz, True)
+        self.assertIs(clone.hasm, True)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        clone = coord_seq.clone()
+        self.assertEqual(clone.tuple, (1, 2, 4))
+        self.assertIs(clone.hasz, False)
+        self.assertIs(clone.hasm, True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_dims(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.dims, 4)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.dims, 3)
+
+        geom = GEOSGeometry("POINT Z (1 2 3)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.dims, 3)
+
+        geom = GEOSGeometry("POINT (1 2)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.dims, 2)
+
+    def test_size(self):
+        geom = GEOSGeometry("POINT (1 2)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.size, 1)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.size, 1)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_iscounterclockwise(self):
+        geom = GEOSGeometry("LINEARRING ZM (0 0 3 0, 1 0 0 2, 0 1 1 3, 0 0 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(
+            coord_seq.tuple,
+            (
+                (0.0, 0.0, 3.0, 0.0),
+                (1.0, 0.0, 0.0, 2.0),
+                (0.0, 1.0, 1.0, 3.0),
+                (0.0, 0.0, 3.0, 4.0),
+            ),
+        )
+        self.assertIs(coord_seq.is_counterclockwise, True)
+
+    def test_m_support_error(self):
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        msg = "GEOSCoordSeq with an M dimension requires GEOS 3.14+."
+
+        # mock geos_version_tuple to be 3.13.13
+        with patch(
+            "django.contrib.gis.geos.coordseq.geos_version_tuple",
+            return_value=(3, 13, 13),
+        ):
+            with self.assertRaisesMessage(NotImplementedError, msg):
+                coord_seq.hasm