Repository: SciTools/cartopy

Issue:
cartopy coastlines issue with shapely 1.5.13
With shapely 1.5.13 the following cartopy example generates an incomplete figure:

```
import os
import matplotlib.pyplot as plt

from cartopy import config
import cartopy.crs as ccrs


fig = plt.figure(figsize=(8, 12))

# get the path of the file. It can be found in the repo data directory.
fname = os.path.join(config["repo_data_dir"],
                     'raster', 'sample', 'Miriam.A2012270.2050.2km.jpg'
                     )
img_extent = (-120.67660000000001, -106.32104523100001, 13.2301484511245, 30.766899999999502)
img = plt.imread(fname)

ax = plt.axes(projection=ccrs.PlateCarree())
plt.title('Hurricane Miriam from the Aqua/MODIS satellite\n'
          '2012 09/26/2012 20:50 UTC')

# set a margin around the data
ax.set_xmargin(0.05)
ax.set_ymargin(0.10)

# add the image. Because this image was a tif, the "origin" of the image is in the
# upper left corner
ax.imshow(img, origin='upper', extent=img_extent, transform=ccrs.PlateCarree())
ax.coastlines(resolution='50m', color='black', linewidth=1)

# mark a known place to help us geo-locate ourselves
ax.plot(-117.1625, 32.715, 'bo', markersize=7, transform=ccrs.Geodetic())
ax.text(-117, 33, 'San Diego', transform=ccrs.Geodetic())

plt.show()
```

I think the error comes from the ax.coastlines line. The error goes away by reverting back to shapely 1.5.12. 
Otherwise I'm using cartopy 0.13.0 and matplotlib 1.4.3 on CentOS 6.
The code comes from http://scitools.org.uk/cartopy/docs/latest/matplotlib/advanced_plotting.html.

Thought this might be useful to know.



Discussion:
I can confirm that.  Cartopy crashes with `TypeError: unhashable type: 'Polygon'`

I guess that the culprit is:

https://github.com/Toblerity/Shapely/commit/8df2b1b718c89e7d644b246ab07ad3670d25aa6a

Ping @rsignell-usgs who is having this issue with some of his [notebooks](https://raw.githubusercontent.com/rsignell-usgs/notebook/master/WFS/WFS_plot_in_Cartopy.ipynb).

I narrowed it down to the `weakref.WeakKeyDictionary()`  [`_geometry_to_path_cache`](https://github.com/SciTools/cartopy/blob/b2f8fd38d1ecba73a9c1dc40807293a7ce7766e9/lib/cartopy/mpl/feature_artist.py#L40)

Now that Shapely polygons are no longer hashable we cannot do:

``` python
mapping = FeatureArtist._geometry_to_path_cache.setdefault(geom, {})
```

Sorry about the breakage, folks. Making Shapely geoms hashable was a mistake, but in hindsight the fix might better have been left to Shapely 1.6.

A workaround shouldn't be hard - I'll post something soon.


Resolving patch:
diff --git a/lib/cartopy/mpl/feature_artist.py b/lib/cartopy/mpl/feature_artist.py
--- a/lib/cartopy/mpl/feature_artist.py
+++ b/lib/cartopy/mpl/feature_artist.py
@@ -31,16 +31,43 @@
 import cartopy.mpl.patch as cpatch
 
 
+class _GeomKey(object):
+    """
+    Provide id() based equality and hashing for geometries.
+
+    Instances of this class must be treated as immutable for the caching
+    to operate correctly.
+
+    A workaround for Shapely polygons no longer being hashable as of 1.5.13.
+
+    """
+    def __init__(self, geom):
+        self._id = id(geom)
+
+    def __eq__(self, other):
+        return self._id == other._id
+
+    def __hash__(self):
+        return hash(self._id)
+
+
 class FeatureArtist(matplotlib.artist.Artist):
     """
     A subclass of :class:`~matplotlib.artist.Artist` capable of
     drawing a :class:`cartopy.feature.Feature`.
 
     """
-    _geometry_to_path_cache = weakref.WeakKeyDictionary()
+
+    _geom_key_to_geometry_cache = weakref.WeakValueDictionary()
     """
-    A nested mapping from geometry and target projection to the
-    resulting transformed matplotlib paths::
+    A mapping from _GeomKey to geometry to assist with the caching of
+    transformed matplotlib paths.
+
+    """
+    _geom_key_to_path_cache = weakref.WeakKeyDictionary()
+    """
+    A nested mapping from geometry (converted to a _GeomKey) and target
+    projection to the resulting transformed matplotlib paths::
 
         {geom: {target_projection: list_of_paths}}
 
@@ -48,6 +75,7 @@ class FeatureArtist(matplotlib.artist.Artist):
     same projection.
 
     """
+
     def __init__(self, feature, **kwargs):
         """
         Args:
@@ -109,8 +137,22 @@ def draw(self, renderer, *args, **kwargs):
         paths = []
         key = ax.projection
         for geom in geoms:
-            mapping = FeatureArtist._geometry_to_path_cache.setdefault(geom,
-                                                                       {})
+            # As Shapely geometries cannot be relied upon to be
+            # hashable, we have to use a WeakValueDictionary to manage
+            # their weak references. The key can then be a simple,
+            # "disposable", hashable geom-key object that just uses the
+            # id() of a geometry to determine equality and hash value.
+            # The only persistent, strong reference to the geom-key is
+            # in the WeakValueDictionary, so when the geometry is
+            # garbage collected so is the geom-key.
+            # The geom-key is also used to access the WeakKeyDictionary
+            # cache of transformed geometries. So when the geom-key is
+            # garbage collected so are the transformed geometries.
+            geom_key = _GeomKey(geom)
+            FeatureArtist._geom_key_to_geometry_cache.setdefault(
+                geom_key, geom)
+            mapping = FeatureArtist._geom_key_to_path_cache.setdefault(
+                geom_key, {})
             geom_paths = mapping.get(key)
             if geom_paths is None:
                 if ax.projection != feature_crs:


Test patch:
diff --git a/lib/cartopy/tests/mpl/test_caching.py b/lib/cartopy/tests/mpl/test_caching.py
--- a/lib/cartopy/tests/mpl/test_caching.py
+++ b/lib/cartopy/tests/mpl/test_caching.py
@@ -117,8 +117,10 @@ def test_shapefile_transform_cache():
     ax = plt.axes(projection=ccrs.Robinson())
 
     # Empty the cache.
-    FeatureArtist._geometry_to_path_cache.clear()
-    assert len(FeatureArtist._geometry_to_path_cache) == 0
+    FeatureArtist._geom_key_to_geometry_cache.clear()
+    FeatureArtist._geom_key_to_path_cache.clear()
+    assert len(FeatureArtist._geom_key_to_geometry_cache) == 0
+    assert len(FeatureArtist._geom_key_to_path_cache) == 0
 
     counter = CallCounter(ax.projection, 'project_geometry')
     with counter:
@@ -135,14 +137,16 @@ def test_shapefile_transform_cache():
                                      ''.format(n_geom, n_geom, counter.count))
 
     # Check the cache has an entry for each geometry.
-    assert len(FeatureArtist._geometry_to_path_cache) == n_geom
+    assert len(FeatureArtist._geom_key_to_geometry_cache) == n_geom
+    assert len(FeatureArtist._geom_key_to_path_cache) == n_geom
 
     # Check that the cache is empty again once we've dropped all references
     # to the source paths.
     plt.clf()
     del geoms
     gc.collect()
-    assert len(FeatureArtist._geometry_to_path_cache) == 0
+    assert len(FeatureArtist._geom_key_to_geometry_cache) == 0
+    assert len(FeatureArtist._geom_key_to_path_cache) == 0
 
     plt.close()