Repository: Toblerity/Shapely (python)

Issue:
shapely.geometry.shape does not work with LinearRing
Shapely 1.7.1, Python 3.9.6 on mac

The `shape` function knows how to process Polygons but not LinearRings. So I need to convert my LinearRing into a Polygon to use it with certain libraries that attempt to work with various geometry types by using `shape`.

To remedy, the `shape` function should be taught about LinearRing.

```python

>>> from shapely.geometry import LinearRing, shape, Polygon
>>> l = LinearRing([(0,0), (0,1), (1,0)])
>>> p = Polygon([(0,0), (0,1), (1,0)])
>>> shape(p)
<shapely.geometry.polygon.Polygon object at 0x1032e2b80>
>>> shape(l)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".venv/lib/python3.9/site-packages/shapely/geometry/geo.py", line 116, in shape
    raise ValueError("Unknown geometry type: %s" % geom_type)
ValueError: Unknown geometry type: linearring
```

Pull request description:
Support LinearRing objects in the shape() function
Closes #1179

Note that LinearRing type geometry is not actually supported in the GeoJSON spec, and the `__geo_interface__` spec doesn't explicitly mention it (https://gist.github.com/sgillies/2217756). 

But since we create python dicts with "LinearRing" type in `mapping(linearring)` or `linearring.__geo_interface__`, it seems most logical to indeed support the roundtrip in `shape(..)` as well.

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 14db836..8f30851 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -73,6 +73,7 @@ version 2.0.0.
 - geometry .empty()
 - geometry .ctypes and .__array_interface__
 - multi-part geometry .__len__
+- setting custom attributes on geometry objects
 
 Geometry objects will become immutable in version 2.0.0.
 
diff --git a/docs/migration.rst b/docs/migration.rst
index ce80b49..217170c 100644
--- a/docs/migration.rst
+++ b/docs/migration.rst
@@ -68,9 +68,13 @@ Currently you can do::
     >>> line.name
     'my_geometry'
 
-This doesn't raise a warning in Shapely 1.8, but will start raising an
+In Shapely 1.8, this will start raising a warning, and will raise an
 AttributeError in Shapely 2.0.
 
+**How do I update my code?** There is no direct alternative for adding custom
+attributes to geometry objects. You can use other Python data structures such as
+(GeoJSON-like) dictionaries or GeoPandas' GeoDataFrames to store attributes
+alongside geometry features. 
 
 Multi-part geometries will no longer be "sequences" (length, iterable, indexable)
 =================================================================================
diff --git a/shapely/geometry/base.py b/shapely/geometry/base.py
index be5ff0c..f9866d9 100644
--- a/shapely/geometry/base.py
+++ b/shapely/geometry/base.py
@@ -14,6 +14,7 @@ import math
 import sys
 from warnings import warn
 from functools import wraps
+import warnings
 
 from shapely.affinity import affine_transform
 from shapely.coords import CoordinateSequence
@@ -241,6 +242,24 @@ class BaseGeometry:
         self._is_empty = val in [EMPTY, None]
         self.__geom__ = val
 
+    def __setattr__(self, name, value):
+        # first try regular attribute access via __getattribute__, so that
+        # our own (existing) attributes don't raise a warning
+        try:
+            object.__getattribute__(self, name)
+            super().__setattr__(name, value)
+            return
+        except AttributeError:
+            pass
+
+        # if custom atribute, raise the warning
+        warn(
+            "Setting custom attributes on geometry objects is deprecated, "
+            "and will raise an AttributeError in Shapely 2.0",
+            ShapelyDeprecationWarning, stacklevel=2
+        )
+        super().__setattr__(name, value)
+
     # Operators
     # ---------
 
diff --git a/shapely/geometry/geo.py b/shapely/geometry/geo.py
index b5fac5f..39f5316 100644
--- a/shapely/geometry/geo.py
+++ b/shapely/geometry/geo.py
@@ -8,7 +8,7 @@ from shapely.errors import ShapelyDeprecationWarning
 
 from .point import Point, asPoint
 from .linestring import LineString, asLineString
-from .polygon import Polygon, asPolygon
+from .polygon import LinearRing, Polygon, asPolygon
 from .multipoint import MultiPoint, asMultiPoint
 from .multilinestring import MultiLineString, asMultiLineString
 from .multipolygon import MultiPolygon, MultiPolygonAdapter
@@ -106,6 +106,8 @@ def shape(context):
         return Point(ob["coordinates"])
     elif geom_type == "linestring":
         return LineString(ob["coordinates"])
+    elif geom_type == "linearring":
+        return LinearRing(ob["coordinates"])
     elif geom_type == "polygon":
         return Polygon(ob["coordinates"][0], ob["coordinates"][1:])
     elif geom_type == "multipoint":
diff --git a/shapely/geometry/proxy.py b/shapely/geometry/proxy.py
index 49de53a..f51d636 100644
--- a/shapely/geometry/proxy.py
+++ b/shapely/geometry/proxy.py
@@ -38,6 +38,11 @@ class CachingGeometryProxy:
     def gtag(self):
         return hash(repr(self.context))
 
+    def __setattr__(self, name, value):
+        # to override the custom one in BaseGeometry, so we don't warn
+        # for the proxy classes, which are already deprecated itself
+        object.__setattr__(self, name, value)
+
 
 class PolygonProxy(CachingGeometryProxy):
 


Test patch:
diff --git a/tests/test_geointerface.py b/tests/test_geointerface.py
index fae15b7..a83f8f1 100644
--- a/tests/test_geointerface.py
+++ b/tests/test_geointerface.py
@@ -6,7 +6,7 @@ from shapely.geometry import asShape, shape
 from shapely.geometry.multipoint import MultiPoint, MultiPointAdapter
 from shapely.geometry.linestring import LineString, LineStringAdapter
 from shapely.geometry.multilinestring import MultiLineString, MultiLineStringAdapter
-from shapely.geometry.polygon import Polygon, PolygonAdapter
+from shapely.geometry.polygon import LinearRing, Polygon, PolygonAdapter
 from shapely.geometry.multipolygon import MultiPolygon, MultiPolygonAdapter
 from shapely import wkt
 
@@ -94,6 +94,17 @@ class GeoInterfaceTestCase(unittest.TestCase):
         self.assertIsInstance(geom, LineString)
         self.assertEqual(tuple(geom.coords), ((-1.0, -1.0), (1.0, 1.0)))
 
+        # Check linearring
+        geom = shape(
+            {'type': 'LinearRing', 
+             'coordinates': 
+                 ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, -1.0), (0.0, 0.0))}
+        )
+        self.assertIsInstance(geom, LinearRing)
+        self.assertEqual(
+            tuple(geom.coords),
+            ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, -1.0), (0.0, 0.0)))
+
         # polygon
         geom = shape(
             {'type': 'Polygon',
diff --git a/tests/test_geometry_base.py b/tests/test_geometry_base.py
index 9b4c68e..7c72079 100644
--- a/tests/test_geometry_base.py
+++ b/tests/test_geometry_base.py
@@ -1,4 +1,7 @@
 from shapely import geometry
+from shapely.errors import ShapelyDeprecationWarning
+
+import pytest
 
 
 def test_polygon():
@@ -15,3 +18,19 @@ def test_point():
 
 def test_geometry_collection():
     assert bool(geometry.GeometryCollection()) is False
+
+
+@pytest.mark.parametrize("geom", [
+    geometry.Point(1, 1),
+    geometry.LinearRing([(0, 0), (1, 1), (0, 1), (0, 0)]),
+    geometry.LineString([(0, 0), (1, 1), (0, 1), (0, 0)]),
+    geometry.Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
+    geometry.MultiPoint([(1, 1)]),
+    geometry.MultiLineString([[(0, 0), (1, 1), (0, 1), (0, 0)]]),
+    geometry.MultiPolygon([geometry.Polygon([(0, 0), (1, 1), (0, 1), (0, 0)])]),
+    geometry.GeometryCollection([geometry.Point(1, 1)]),
+])
+def test_setattr_disallowed(geom):
+    with pytest.warns(ShapelyDeprecationWarning, match="Setting custom attributes"):
+        geom.name = "test"
+    assert geom.name == "test"