Repository: Toblerity/Shapely
Pull request #852 (2020-02-22 00:19:51)

Handle for empty list/list of lists in 'coordinates'
Fixes https://github.com/Toblerity/Shapely/issues/837
Fixes https://github.com/Toblerity/Shapely/issues/840

Two questions:
- I wasn't sure about more tests, but I'm not opposed to adding a `test_shape.py`
- Does this logic also need to support `asShape`? It wasn't super clear to me how that should work.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/28891341/badge)](https://coveralls.io/builds/28891341)

Coverage decreased (-0.06%) to 80.433% when pulling **a6966243baadd75e43cc265639a83f8f69856758 on koshy1123:koshy-handle-empty-coordinates** into **053df248e4663c8a0f800a4c03e80521a28e8f2e on Toblerity:master**.

nrweir: Looks great on my end @koshy1123, thanks!

Patch:
diff --git a/appveyor.yml b/appveyor.yml
index dd3816d..0e457a8 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -41,6 +41,19 @@ environment:
       ARCH: x64
       COMPILER: msvc2017
 
+
+install:
+  # If there is a newer build queued for the same PR, cancel this one.
+  # The AppVeyor 'rollout builds' option is supposed to serve the same
+  # purpose but it is problematic because it tends to cancel builds pushed
+  # directly to master instead of just PR builds (or the converse).
+  # credits: JuliaLang developers.
+  - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod `
+        https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | `
+        Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { `
+          throw "There are newer queued builds for this pull request, failing early." }
+
+
 build_script:
   - set _INITPATH=%PATH%
 
@@ -58,13 +71,13 @@ build_script:
   - set GEOSINSTALL=C:\projects\deps\geos-%GEOSVERSION%-%COMPILER%-%ARCH%
   - set CYTHONPF=C:\\projects\\deps\\geos-%GEOSVERSION%-%COMPILER%-%ARCH%
   - ps: 'Write-Host "Checking GEOS build $env:GEOSINSTALL" -ForegroundColor Magenta'
-  - call c:\projects\shapely\ci\appveyor\build_geos.cmd
+  - call %APPVEYOR_BUILD_FOLDER%\ci\appveyor\build_geos.cmd
 
   # - ps: 'Push-AppveyorArtifact $env:GEOSINSTALL\bin\geos.dll'
   # - ps: 'Push-AppveyorArtifact $env:GEOSINSTALL\bin\geos_c.dll'
 
   - ps: 'Write-Host "Copying and checking geos_c.dll" -ForegroundColor Magenta'
-  - cd C:\projects\shapely
+  - cd %APPVEYOR_BUILD_FOLDER%
   - dir
   - if %ARCH%==x86 ( set CPDIR=DLLs_x86_VC9 )
   - if %ARCH%==x64 ( set CPDIR=DLLs_AMD64_VC9 )
diff --git a/shapely/algorithms/polylabel.py b/shapely/algorithms/polylabel.py
index 08382f1..b4e7f4b 100644
--- a/shapely/algorithms/polylabel.py
+++ b/shapely/algorithms/polylabel.py
@@ -47,7 +47,9 @@ class Cell(object):
         boundary.
         """
         inside = polygon.contains(self.centroid)
-        distance = self.centroid.distance(LineString(polygon.exterior.coords))
+        distance = self.centroid.distance(polygon.exterior)
+        for interior in polygon.interiors:
+            distance = min(distance, self.centroid.distance(interior))
         if inside:
             return distance
         return -distance
diff --git a/shapely/geometry/geo.py b/shapely/geometry/geo.py
index ce2a602..c78d355 100644
--- a/shapely/geometry/geo.py
+++ b/shapely/geometry/geo.py
@@ -1,7 +1,6 @@
 """
 Geometry factories based on the geo interface
 """
-
 from .point import Point, asPoint
 from .linestring import LineString, asLineString
 from .polygon import Polygon, asPolygon
@@ -10,6 +9,47 @@ from .multilinestring import MultiLineString, asMultiLineString
 from .multipolygon import MultiPolygon, MultiPolygonAdapter
 from .collection import GeometryCollection
 
+# numpy is an optional dependency
+try:
+    import numpy as np
+except ImportError:
+    _has_numpy = False
+else:
+    _has_numpy = True
+
+
+def _is_coordinates_empty(coordinates):
+    """Helper to identify if coordinates or subset of coordinates are empty"""
+
+    if coordinates is None:
+        return True
+
+    is_numpy_array = _has_numpy and isinstance(coordinates, np.ndarray)
+    if isinstance(coordinates, (list, tuple)) or is_numpy_array:
+        if len(coordinates) == 0:
+            return True
+        return all(map(_is_coordinates_empty, coordinates))
+    else:
+        return False
+
+
+def _empty_shape_for_no_coordinates(geom_type):
+    """Return empty counterpart for geom_type"""
+    if geom_type == 'point':
+        return Point()
+    elif geom_type == 'multipoint':
+        return MultiPoint()
+    elif geom_type == 'linestring':
+        return LineString()
+    elif geom_type == 'multilinestring':
+        return MultiLineString()
+    elif geom_type == 'polygon':
+        return Polygon()
+    elif geom_type == 'multipolygon':
+        return MultiPolygon()
+    else:
+        raise ValueError("Unknown geometry type: %s" % geom_type)
+
 
 def box(minx, miny, maxx, maxy, ccw=True):
     """Returns a rectangular polygon with configurable normal vector"""
@@ -18,6 +58,7 @@ def box(minx, miny, maxx, maxy, ccw=True):
         coords = coords[::-1]
     return Polygon(coords)
 
+
 def shape(context):
     """Returns a new, independent geometry with coordinates *copied* from the
     context.
@@ -27,15 +68,14 @@ def shape(context):
     else:
         ob = context
     geom_type = ob.get("type").lower()
-    if geom_type == "point":
+    if 'coordinates' in ob and _is_coordinates_empty(ob['coordinates']):
+        return _empty_shape_for_no_coordinates(geom_type)
+    elif geom_type == "point":
         return Point(ob["coordinates"])
     elif geom_type == "linestring":
         return LineString(ob["coordinates"])
     elif geom_type == "polygon":
-        if not ob["coordinates"]:
-            return Polygon()
-        else:
-            return Polygon(ob["coordinates"][0], ob["coordinates"][1:])
+        return Polygon(ob["coordinates"][0], ob["coordinates"][1:])
     elif geom_type == "multipoint":
         return MultiPoint(ob["coordinates"])
     elif geom_type == "multilinestring":
@@ -48,6 +88,7 @@ def shape(context):
     else:
         raise ValueError("Unknown geometry type: %s" % geom_type)
 
+
 def asShape(context):
     """Adapts the context to a geometry interface. The coordinates remain
     stored in the context.
@@ -80,6 +121,7 @@ def asShape(context):
     else:
         raise ValueError("Unknown geometry type: %s" % geom_type)
 
+
 def mapping(ob):
     """Returns a GeoJSON-like mapping"""
     return ob.__geo_interface__
diff --git a/shapely/ops.py b/shapely/ops.py
index 56e1ffb..953fcd9 100644
--- a/shapely/ops.py
+++ b/shapely/ops.py
@@ -576,7 +576,7 @@ def orient(geom, sign=1.0):
     """A properly oriented copy of the given geometry.
 
     The signed area of the result will have the given sign. A sign of
-    1.0 means that the coordinates of the product’s exterior rings will
+    1.0 means that the coordinates of the product's exterior rings will
     be oriented counter-clockwise.
 
     Parameters


Test patch:
diff --git a/tests/test_collection.py b/tests/test_collection.py
index 1e6f0a6..c87fd7a 100644
--- a/tests/test_collection.py
+++ b/tests/test_collection.py
@@ -63,6 +63,19 @@ class CollectionTestCase(unittest.TestCase):
         self.assertEqual(len(m), 0)
         self.assertEqual(m.geoms, [])
 
+    def test_empty_coordinates(self):
+
+        d = {"type": "GeometryCollection", "geometries": [
+            {"type": "Point", "coordinates": ()},
+            {"type": "LineString", "coordinates": (())}
+        ]}
+
+        # shape
+        m = shape(d)
+        self.assertEqual(m.geom_type, "GeometryCollection")
+        self.assertEqual(len(m), 0)
+        self.assertEqual(m.geoms, [])
+
 
 def test_suite():
     return unittest.TestLoader().loadTestsFromTestCase(CollectionTestCase)
diff --git a/tests/test_polylabel.py b/tests/test_polylabel.py
index 84eab12..fd89f66 100644
--- a/tests/test_polylabel.py
+++ b/tests/test_polylabel.py
@@ -70,6 +70,19 @@ class PolylabelTestCase(unittest.TestCase):
         label = polylabel(polygon)
         self.assertEqual(label.coords[:], [(32.722025, -117.201875)])
 
+    def test_polygon_with_hole(self):
+        """
+        Finds pole of inaccessibility for a polygon with a hole
+        https://github.com/Toblerity/Shapely/issues/817
+        """
+        polygon = Polygon(
+            shell=[(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)],
+            holes=[[(2, 2), (6, 2), (6, 6), (2, 6), (2, 2)]],
+        )
+        label = polylabel(polygon, 0.05)
+        self.assertAlmostEqual(label.x, 7.65625)
+        self.assertAlmostEqual(label.y, 7.65625)
+
 
 def test_suite():
     return unittest.TestLoader().loadTestsFromTestCase(PolylabelTestCase)
diff --git a/tests/test_shape.py b/tests/test_shape.py
new file mode 100644
index 0000000..cf35395
--- /dev/null
+++ b/tests/test_shape.py
@@ -0,0 +1,47 @@
+from . import unittest
+from shapely.geometry import shape, Polygon
+
+# numpy is an optional dependency
+try:
+    import numpy as np
+except ImportError:
+    _has_numpy = False
+else:
+    _has_numpy = True
+
+
+class ShapeTestCase(unittest.TestCase):
+
+    def test_polygon_no_coords(self):
+        # using None
+        d = {"type": "Polygon", "coordinates": None}
+        p = shape(d)
+        self.assertEqual(p, Polygon())
+
+        # using empty list
+        d = {"type": "Polygon", "coordinates": []}
+        p = shape(d)
+        self.assertEqual(p, Polygon())
+
+        # using empty array
+        d = {"type": "Polygon", "coordinates": np.array([])}
+        p = shape(d)
+        self.assertEqual(p, Polygon())
+
+    def test_polygon_with_coords_list(self):
+        # list
+        d = {"type": "Polygon", "coordinates": [[[5, 10], [10, 10], [10, 5]]]}
+        p = shape(d)
+        self.assertEqual(p, Polygon([(5, 10), (10, 10), (10, 5)]))
+
+        # numpy array
+        if _has_numpy:
+            d = {"type": "Polygon", "coordinates": np.array([[[5, 10],
+                                                              [10, 10],
+                                                              [10, 5]]])}
+            p = shape(d)
+            self.assertEqual(p, Polygon([(5, 10), (10, 10), (10, 5)]))
+
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromTestCase(ShapeTestCase)