Skip to content

Commit c695e3d

Browse files
committed
More robust+informative Shape to geojson warnings, additional geo interface tests
1 parent c616893 commit c695e3d

File tree

2 files changed

+104
-11
lines changed

2 files changed

+104
-11
lines changed

shapefile.py

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
import time
1515
import array
1616
import tempfile
17-
import warnings
17+
import logging
1818
import io
1919
from datetime import date
2020

2121

22+
# Module settings
23+
VERBOSE = True
24+
2225
# Constants for shape types
2326
NULL = 0
2427
POINT = 1
@@ -279,10 +282,11 @@ def ring_contains_ring(coords1, coords2):
279282
'''
280283
return all((ring_contains_point(coords1, p2) for p2 in coords2))
281284

282-
def organize_polygon_rings(rings):
285+
def organize_polygon_rings(rings, return_errors=None):
283286
'''Organize a list of coordinate rings into one or more polygons with holes.
284287
Returns a list of polygons, where each polygon is composed of a single exterior
285-
ring, and one or more interior holes.
288+
ring, and one or more interior holes. If a return_errors dict is provided (optional),
289+
any errors encountered will be added to it.
286290
287291
Rings must be closed, and cannot intersect each other (non-self-intersecting polygon).
288292
Rings are determined as exteriors if they run in clockwise direction, or interior
@@ -360,7 +364,6 @@ def organize_polygon_rings(rings):
360364
orphan_holes = []
361365
for hole_i,exterior_candidates in list(hole_exteriors.items()):
362366
if not exterior_candidates:
363-
warnings.warn('Shapefile shape has invalid polygon: found orphan hole (not contained by any of the exteriors); interpreting as exterior.')
364367
orphan_holes.append( hole_i )
365368
del hole_exteriors[hole_i]
366369
continue
@@ -384,11 +387,15 @@ def organize_polygon_rings(rings):
384387
poly = [ext]
385388
polys.append(poly)
386389

390+
if orphan_holes and return_errors is not None:
391+
return_errors['polygon_orphaned_holes'] = len(orphan_holes)
392+
387393
return polys
388394

389395
# no exteriors, be nice and assume due to incorrect winding order
390396
else:
391-
warnings.warn('Shapefile shape has invalid polygon: no exterior rings found (must have clockwise orientation); interpreting holes as exteriors.')
397+
if return_errors is not None:
398+
return_errors['polygon_only_holes'] = len(holes)
392399
exteriors = holes # could potentially reverse their order, but in geojson winding order doesn't matter
393400
polys = [[ext] for ext in exteriors]
394401
return polys
@@ -411,6 +418,8 @@ def __init__(self, shapeType=NULL, points=None, parts=None, partTypes=None):
411418
self.parts = parts or []
412419
if partTypes:
413420
self.partTypes = partTypes
421+
# and a dict to silently record any errors encountered
422+
self._errors = {}
414423

415424
@property
416425
def __geo_interface__(self):
@@ -490,7 +499,25 @@ def __geo_interface__(self):
490499

491500
# organize rings into list of polygons, where each polygon is defined as list of rings.
492501
# the first ring is the exterior and any remaining rings are holes (same as GeoJSON).
493-
polys = organize_polygon_rings(rings)
502+
polys = organize_polygon_rings(rings, self._errors)
503+
504+
# if VERBOSE is True, issue detailed warning about any shape errors
505+
# encountered during the Shapefile to GeoJSON conversion
506+
if VERBOSE and self._errors:
507+
header = 'Possible issue encountered when converting Shape to GeoJSON: '
508+
orphans = self._errors.get('polygon_orphaned_holes', None)
509+
if orphans:
510+
msg = header + 'GeoJSON format requires that all polygon interior holes be contained by an exterior ring, \
511+
but the Shape contained interior holes (defined by counter-clockwise orientation in the shapefile format) that were \
512+
orphaned, i.e. not contained by any exterior rings. The rings were still included but were \
513+
encoded as GeoJSON exterior rings instead of holes.'
514+
logging.warning(msg)
515+
only_holes = self._errors.get('polygon_only_holes', None)
516+
if only_holes:
517+
msg = header + 'GeoJSON format requires that polygons contain at least one exterior ring, \
518+
but the Shape was entirely made up of interior holes (defined by counter-clockwise orientation in the shapefile format). The rings were \
519+
still included but were encoded as GeoJSON exterior rings instead of holes.'
520+
logging.warning(msg)
494521

495522
# return as geojson
496523
if len(polys) == 1:
@@ -749,8 +776,9 @@ def __repr__(self):
749776
def __geo_interface__(self):
750777
# Note: currently this will fail if any of the shapes are null-geometries
751778
# could be fixed by storing the shapefile shapeType upon init, returning geojson type with empty coords
752-
return {'type': 'GeometryCollection',
753-
'geometries': [shape.__geo_interface__ for shape in self]}
779+
collection = {'type': 'GeometryCollection',
780+
'geometries': [shape.__geo_interface__ for shape in self]}
781+
return collection
754782

755783
class ShapeRecords(list):
756784
"""A class to hold a list of ShapeRecord objects. Subclasses list to ensure compatibility with
@@ -763,13 +791,50 @@ def __repr__(self):
763791

764792
@property
765793
def __geo_interface__(self):
766-
return {'type': 'FeatureCollection',
767-
'features': [shaperec.__geo_interface__ for shaperec in self]}
794+
collection = {'type': 'FeatureCollection',
795+
'features': [shaperec.__geo_interface__ for shaperec in self]}
796+
return collection
768797

769798
class ShapefileException(Exception):
770799
"""An exception to handle shapefile specific problems."""
771800
pass
772801

802+
# def warn_geojson_collection(shapes):
803+
# # collect information about any potential errors with the GeoJSON
804+
# errors = {}
805+
# for i,shape in enumerate(shapes):
806+
# shape_errors = shape._errors
807+
# if shape_errors:
808+
# for error in shape_errors.keys():
809+
# errors[error] = errors[error] + [i] if error in errors else []
810+
811+
# # warn if any errors were found
812+
# if errors:
813+
# messages = ['Summary of possibles issues encountered during shapefile to GeoJSON conversion:']
814+
815+
# # polygon orphan holes
816+
# orphans = errors.get('polygon_orphaned_holes', None)
817+
# if orphans:
818+
# msg = 'GeoJSON format requires that all interior holes be contained by an exterior ring, \
819+
# but the Shapefile contained {} records of polygons where some of its interior holes were \
820+
# orphaned (not contained by any other rings). The rings were still included but were \
821+
# encoded as GeoJSON exterior rings instead of holes. Shape ids: {}'.format(len(orphans), orphans)
822+
# messages.append(msg)
823+
824+
# # polygon only holes/wrong orientation
825+
# only_holes = errors.get('polygon_only_holes', None)
826+
# if only_holes:
827+
# msg = 'GeoJSON format requires that polygons contain at least one exterior ring, but \
828+
# the Shapefile contained {} records of polygons where all of its component rings were stored as interior \
829+
# holes. The rings were still included but were encoded as GeoJSON exterior rings instead of holes. \
830+
# Shape ids: {}'.format(len(only_holes), only_holes)
831+
# messages.append(msg)
832+
833+
# if len(messages) > 1:
834+
# # more than just the "Summary of..." header
835+
# msg = '\n'.join(messages)
836+
# logging.warning(msg)
837+
773838
class Reader(object):
774839
"""Reads the three files of a shapefile as a unit or
775840
separately. If one of the three files (.shp, .shx,

test_shapefile.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,35 @@ def test_expected_shape_geo_interface(typ, points, parts, expected):
211211
shape = shapefile.Shape(typ, points, parts)
212212
geoj = shape.__geo_interface__
213213
assert geoj == expected
214-
214+
215+
216+
def test_reader_geo_interface():
217+
with shapefile.Reader("shapefiles/blockgroups") as r:
218+
geoj = r.__geo_interface__
219+
assert geoj['type'] == 'FeatureCollection'
220+
assert 'bbox' in geoj
221+
assert json.dumps(geoj)
222+
223+
224+
def test_shapes_geo_interface():
225+
with shapefile.Reader("shapefiles/blockgroups") as r:
226+
geoj = r.shapes().__geo_interface__
227+
assert geoj['type'] == 'GeometryCollection'
228+
assert json.dumps(geoj)
229+
230+
231+
def test_shaperecords_geo_interface():
232+
with shapefile.Reader("shapefiles/blockgroups") as r:
233+
geoj = r.shapeRecords().__geo_interface__
234+
assert geoj['type'] == 'FeatureCollection'
235+
assert json.dumps(geoj)
236+
237+
238+
def test_shaperecord_geo_interface():
239+
with shapefile.Reader("shapefiles/blockgroups") as r:
240+
for shaperec in r:
241+
assert json.dumps(shaperec.__geo_interface__)
242+
215243

216244
def test_reader_context_manager():
217245
"""

0 commit comments

Comments
 (0)