Skip to content

Commit e3b2040

Browse files
authored
Merge pull request #413 from JamesParrott/Test_shp_files_roundtrip
v3.0.13. Don't error when reading empty shp files (bug fix).
2 parents 6853fad + 70e2ec9 commit e3b2040

4 files changed

Lines changed: 105 additions & 26 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ The Python Shapefile Library (PyShp) reads and writes ESRI Shapefiles in pure Py
88

99
- **Author**: [Joel Lawhead](https://github.com/GeospatialPython)
1010
- **Maintainers**: [James Parrott](https://github.com/JamesParrott) & [Karim Bahgat](https://github.com/karimbahgat)
11-
- **Version**: 3.0.12
12-
- **Date**: 5th June 2026
11+
- **Version**: 3.0.13
12+
- **Date**: 19th June 2026
1313
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
1414

1515
## Contents
@@ -93,6 +93,12 @@ part of your geospatial project.
9393

9494
# Version Changes
9595

96+
## 3.0.13
97+
### Bug fix
98+
- Fix bug when reading empty shp files.
99+
### Testing
100+
- Add round trip tests for Multipatch and shp files (both passed).
101+
96102
## 3.0.12
97103
### Data consistency
98104
- Add Shape.points_2D and Shape.points_3D properties - lists of guaranteed length tuples (2 and 3 respectively).

changelog.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
VERSION 3.0.13
2+
3+
2026-06-19
4+
Bug fix
5+
* Fix bug when reading empty shp files.
6+
7+
Testing
8+
* Add round trip tests for Multipatch and shp files (both passed).
9+
10+
111
VERSION 3.0.12
212

313
2026-06-05

src/shapefile.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from __future__ import annotations
1010

11-
__version__ = "3.0.12"
11+
__version__ = "3.0.13"
1212

1313
import abc
1414
import array
@@ -3141,6 +3141,8 @@ def iterShapes(
31413141
# MAYBE: check if more left of file or exit early?
31423142
return
31433143

3144+
n = 0 # counter for num of actual shapes found
3145+
31443146
# No shx file, unknown nr of shapes
31453147
# Instead iterate until reach end of file
31463148
# Calculate the offset indices during iteration
@@ -3149,10 +3151,10 @@ def iterShapes(
31493151
shape = self._shape(shape_len_B=shape_len_B, oid=i, bbox=bbox)
31503152
# # pos = self.file.tell()
31513153
# pos += num_bytes
3154+
n += 1
31523155
if shape is not None or outside_bbox_as_None:
31533156
yield shape
31543157

3155-
n = i + 1 # num shapes yielded, having iterated over entire shp file.
31563158
assert n == len(self.headers_cache), f"{n=}, {len(self.headers_cache)=}"
31573159

31583160

tests/hypothesis_tests.py

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import io
4+
import itertools
45

56
import pytest
67
from hypothesis import HealthCheck, given, settings
@@ -27,14 +28,14 @@
2728
PointsLengths = integers(min_value=1, max_value=8000) # length of points
2829
oid = one_of(none(), integers(min_value=0))
2930
point_2D = builds(shp.Point, x=xs, y=ys, oid=oid)
30-
pointM = builds(
31+
pointm = builds(
3132
shp.PointM,
3233
x=xs,
3334
y=ys,
3435
m=ms,
3536
oid=oid,
3637
)
37-
pointZ = builds(
38+
pointz = builds(
3839
shp.PointZ,
3940
x=xs,
4041
y=ys,
@@ -79,7 +80,7 @@ def test_Point_2D_roundtrips(
7980

8081

8182
@pytest.mark.hypothesis
82-
@given(expected=pointM, i=integers(min_value=1))
83+
@given(expected=pointm, i=integers(min_value=1))
8384
def test_PointM_roundtrips(
8485
expected: shp.Point,
8586
i: int,
@@ -103,7 +104,7 @@ def test_PointM_roundtrips(
103104

104105

105106
@pytest.mark.hypothesis
106-
@given(expected=pointZ, i=integers(min_value=1))
107+
@given(expected=pointz, i=integers(min_value=1))
107108
def test_PointZ_roundtrips(
108109
expected: shp.Point,
109110
i: int,
@@ -159,11 +160,11 @@ def multipointM_from_xyms(point_ms: tuple[float, float, float | None], oid_: int
159160
xy_vals = zip(x_vals, y_vals)
160161
return shp.MultiPointM(points=list(xy_vals), m=list(m_vals), oid=oid_)
161162

162-
multipointM = builds(multipointM_from_xyms, lists(tuples(xs, ys, ms), min_size=1), oid)
163+
multipointm = builds(multipointM_from_xyms, lists(tuples(xs, ys, ms), min_size=1), oid)
163164

164165
@pytest.mark.hypothesis
165-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
166-
@given(expected=multipointM, i=integers(min_value=1))
166+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
167+
@given(expected=multipointm, i=integers(min_value=1))
167168
def test_MultiPointM_roundtrips(
168169
expected: shp.MultiPointM,
169170
i: int,
@@ -195,7 +196,7 @@ def multipointZ_from_xyzms(pointz_ms: tuple[float, float, float, float | None],
195196

196197

197198
@pytest.mark.hypothesis
198-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
199+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
199200
@given(expected=multipointz, i=integers(min_value=1))
200201
def test_MultiPointZ_roundtrips(
201202
expected: shp.MultiPointZ,
@@ -247,7 +248,7 @@ def test_Polyline_roundtrips(
247248
assert actual.oid == expected.oid
248249

249250
@pytest.mark.hypothesis
250-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
251+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
251252
@given(expected=polylinem, i=integers(min_value=1))
252253
def test_PolylineM_roundtrips(
253254
expected: shp.PolylineM,
@@ -272,7 +273,7 @@ def test_PolylineM_roundtrips(
272273
assert actual.oid == expected.oid
273274

274275
@pytest.mark.hypothesis
275-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
276+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
276277
@given(expected=polylinez, i=integers(min_value=1))
277278
def test_PolylineZ_roundtrips(
278279
expected: shp.PolylineZ,
@@ -326,7 +327,7 @@ def test_Polygon_roundtrips(
326327
assert actual.oid == expected.oid
327328

328329
@pytest.mark.hypothesis
329-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
330+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
330331
@given(expected=polygonm, i=integers(min_value=1))
331332
def test_PolygonM_roundtrips(
332333
expected: shp.PolygonM,
@@ -351,7 +352,7 @@ def test_PolygonM_roundtrips(
351352
assert actual.oid == expected.oid
352353

353354
@pytest.mark.hypothesis
354-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
355+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
355356
@given(expected=polygonz, i=integers(min_value=1))
356357
def test_PolygonZ_roundtrips(
357358
expected: shp.PolygonZ,
@@ -385,20 +386,14 @@ def multipatch_from_xyzms_and_types(
385386
xyzm_vals, p_types = zip(*xyzms_and_types)
386387
return shp.MultiPatch(lines = xyzm_vals, partTypes = p_types, oid=oid)
387388

388-
multipatches = builds(
389+
multipatch = builds(
389390
multipatch_from_xyzms_and_types,
390391
lists(tuples(lists(tuples(xs, ys, zs, ms), min_size=1), part_types), min_size=1), oid)
391-
# @composite
392-
# def multipatches(draw):
393-
# N = draw(PointsLengths)
394-
# p_types = draw(lists(part_types, min_size=N, max_size=N))
395-
# patches = draw(lists(lists(tuples(xs, ys, zs, ms), min_size=1), min_size=N, max_size=N))
396-
# return shp.MultiPatch(lines = patches, partTypes = p_types, oid=oid)
397392

398393

399394
@pytest.mark.hypothesis
400-
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
401-
@given(expected=multipatches, i=integers(min_value=1))
395+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
396+
@given(expected=multipatch, i=integers(min_value=1))
402397
def test_MultiPatch_roundtrips(
403398
expected: shp.MultiPatch,
404399
i: int,
@@ -421,4 +416,70 @@ def test_MultiPatch_roundtrips(
421416
assert actual.m == expected.m, f"{type(actual.m)=}, {type(expected.m)=}"
422417
assert actual.z == expected.z, f"{type(actual.z)=}, {type(expected.z)=}"
423418
assert actual.oid == expected.oid
424-
assert actual.partTypes == expected.partTypes, f"{type(actual.partTypes)=}, {type(expected.partTypes)=}"
419+
assert actual.partTypes == expected.partTypes, f"{type(actual.partTypes)=}, {type(expected.partTypes)=}"
420+
421+
422+
shape_codes_names_and_strategies = [
423+
# (0, "Null Shape"),
424+
(1, "Point", point_2D),
425+
(3, "PolyLine", polyline),
426+
(5, "Polygon", polygon),
427+
(8, "MultiPoint", multipoint),
428+
(11, "PointZ", pointz),
429+
(13, "PolyLineZ", polylinez),
430+
(15, "PolygonZ", polygonz),
431+
(18, "MultiPointZ", multipointz),
432+
(21, "PointM", pointm),
433+
(23, "PolyLineM", polylinem),
434+
(25, "PolygonM", polygonm),
435+
(28, "MultiPointM", multipointm),
436+
(31, "MultiPatch", multipatch),
437+
]
438+
439+
def code_and_shape_strat_from_triple(t):
440+
x, _name, shapes = t
441+
return tuples(just(x), lists(shapes, min_size = 0)) # Empty shp files are in the esri spec.
442+
443+
codes_and_shapes_strats = [
444+
code_and_shape_strat_from_triple(t)
445+
for t in shape_codes_names_and_strategies
446+
]
447+
448+
codes_and_shapes = one_of(codes_and_shapes_strats)
449+
450+
@pytest.mark.hypothesis
451+
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
452+
@given(codes_and_shapes=codes_and_shapes)
453+
def test_shp_reader_writer_roundtrip(codes_and_shapes)-> None:
454+
code_ex, expected_shapes = codes_and_shapes
455+
stream = io.BytesIO()
456+
with shp.ShpWriter(shp=stream, shapeType=code_ex) as w:
457+
for shape in expected_shapes:
458+
w.shape(shape)
459+
stream.seek(0)
460+
with shp.ShpReader(shp=stream) as r:
461+
assert r.shapeType == code_ex
462+
463+
for actual, expected in itertools.zip_longest(r.shapes(), expected_shapes):
464+
465+
assert isinstance(actual, shp.SHAPE_CLASS_FROM_SHAPETYPE[code_ex])
466+
assert actual.points_3D == expected.points_3D
467+
# Don't assert actual.oid == expected.oid it's defined by
468+
# actual.oid indicates the order actual was written in, expected.oid
469+
# is not currently encoded (as we'd have to resort the entire Shapefile after each shape)
470+
assert actual.parts == expected.parts, f"{type(actual.parts)=}, {type(expected.parts)=}"
471+
472+
if (m := getattr(actual, "m", None)):
473+
assert m == expected.m, f"{type(m)=}, {type(expected.m)=}"
474+
else:
475+
assert not hasattr(expected, "m")
476+
477+
if (z := getattr(actual, "z", None)):
478+
assert z == expected.z, f"{type(z)=}, {type(expected.z)=}"
479+
else:
480+
assert not hasattr(expected, "z")
481+
482+
if (partTypes := getattr(actual, "partTypes", None)):
483+
assert actual.partTypes == expected.partTypes, f"{type(actual.partTypes)=}, {type(expected.partTypes)=}"
484+
else:
485+
assert not hasattr(expected, "partTypes")

0 commit comments

Comments
 (0)