Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ The Python Shapefile Library (PyShp) reads and writes ESRI Shapefiles in pure Py

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

## Contents
Expand Down Expand Up @@ -93,6 +93,12 @@ part of your geospatial project.

# Version Changes

## 3.0.13
### Bug fix
- Fix bug when reading empty shp files.
### Testing
- Add round trip tests for Multipatch and shp files (both passed).

## 3.0.12
### Data consistency
- Add Shape.points_2D and Shape.points_3D properties - lists of guaranteed length tuples (2 and 3 respectively).
Expand Down
10 changes: 10 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
VERSION 3.0.13

2026-06-19
Bug fix
* Fix bug when reading empty shp files.

Testing
* Add round trip tests for Multipatch and shp files (both passed).


VERSION 3.0.12

2026-06-05
Expand Down
6 changes: 4 additions & 2 deletions src/shapefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from __future__ import annotations

__version__ = "3.0.12"
__version__ = "3.0.13"

import abc
import array
Expand Down Expand Up @@ -3141,6 +3141,8 @@ def iterShapes(
# MAYBE: check if more left of file or exit early?
return

n = 0 # counter for num of actual shapes found

# No shx file, unknown nr of shapes
# Instead iterate until reach end of file
# Calculate the offset indices during iteration
Expand All @@ -3149,10 +3151,10 @@ def iterShapes(
shape = self._shape(shape_len_B=shape_len_B, oid=i, bbox=bbox)
# # pos = self.file.tell()
# pos += num_bytes
n += 1
if shape is not None or outside_bbox_as_None:
yield shape

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


Expand Down
105 changes: 83 additions & 22 deletions tests/hypothesis_tests.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import io
import itertools

import pytest
from hypothesis import HealthCheck, given, settings
Expand All @@ -27,14 +28,14 @@
PointsLengths = integers(min_value=1, max_value=8000) # length of points
oid = one_of(none(), integers(min_value=0))
point_2D = builds(shp.Point, x=xs, y=ys, oid=oid)
pointM = builds(
pointm = builds(
shp.PointM,
x=xs,
y=ys,
m=ms,
oid=oid,
)
pointZ = builds(
pointz = builds(
shp.PointZ,
x=xs,
y=ys,
Expand Down Expand Up @@ -79,7 +80,7 @@ def test_Point_2D_roundtrips(


@pytest.mark.hypothesis
@given(expected=pointM, i=integers(min_value=1))
@given(expected=pointm, i=integers(min_value=1))
def test_PointM_roundtrips(
expected: shp.Point,
i: int,
Expand All @@ -103,7 +104,7 @@ def test_PointM_roundtrips(


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

multipointM = builds(multipointM_from_xyms, lists(tuples(xs, ys, ms), min_size=1), oid)
multipointm = builds(multipointM_from_xyms, lists(tuples(xs, ys, ms), min_size=1), oid)

@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=multipointM, i=integers(min_value=1))
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=multipointm, i=integers(min_value=1))
def test_MultiPointM_roundtrips(
expected: shp.MultiPointM,
i: int,
Expand Down Expand Up @@ -195,7 +196,7 @@ def multipointZ_from_xyzms(pointz_ms: tuple[float, float, float, float | None],


@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=multipointz, i=integers(min_value=1))
def test_MultiPointZ_roundtrips(
expected: shp.MultiPointZ,
Expand Down Expand Up @@ -247,7 +248,7 @@ def test_Polyline_roundtrips(
assert actual.oid == expected.oid

@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=polylinem, i=integers(min_value=1))
def test_PolylineM_roundtrips(
expected: shp.PolylineM,
Expand All @@ -272,7 +273,7 @@ def test_PolylineM_roundtrips(
assert actual.oid == expected.oid

@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=polylinez, i=integers(min_value=1))
def test_PolylineZ_roundtrips(
expected: shp.PolylineZ,
Expand Down Expand Up @@ -326,7 +327,7 @@ def test_Polygon_roundtrips(
assert actual.oid == expected.oid

@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=polygonm, i=integers(min_value=1))
def test_PolygonM_roundtrips(
expected: shp.PolygonM,
Expand All @@ -351,7 +352,7 @@ def test_PolygonM_roundtrips(
assert actual.oid == expected.oid

@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=polygonz, i=integers(min_value=1))
def test_PolygonZ_roundtrips(
expected: shp.PolygonZ,
Expand Down Expand Up @@ -385,20 +386,14 @@ def multipatch_from_xyzms_and_types(
xyzm_vals, p_types = zip(*xyzms_and_types)
return shp.MultiPatch(lines = xyzm_vals, partTypes = p_types, oid=oid)

multipatches = builds(
multipatch = builds(
multipatch_from_xyzms_and_types,
lists(tuples(lists(tuples(xs, ys, zs, ms), min_size=1), part_types), min_size=1), oid)
# @composite
# def multipatches(draw):
# N = draw(PointsLengths)
# p_types = draw(lists(part_types, min_size=N, max_size=N))
# patches = draw(lists(lists(tuples(xs, ys, zs, ms), min_size=1), min_size=N, max_size=N))
# return shp.MultiPatch(lines = patches, partTypes = p_types, oid=oid)


@pytest.mark.hypothesis
@settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=multipatches, i=integers(min_value=1))
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(expected=multipatch, i=integers(min_value=1))
def test_MultiPatch_roundtrips(
expected: shp.MultiPatch,
i: int,
Expand All @@ -421,4 +416,70 @@ def test_MultiPatch_roundtrips(
assert actual.m == expected.m, f"{type(actual.m)=}, {type(expected.m)=}"
assert actual.z == expected.z, f"{type(actual.z)=}, {type(expected.z)=}"
assert actual.oid == expected.oid
assert actual.partTypes == expected.partTypes, f"{type(actual.partTypes)=}, {type(expected.partTypes)=}"
assert actual.partTypes == expected.partTypes, f"{type(actual.partTypes)=}, {type(expected.partTypes)=}"


shape_codes_names_and_strategies = [
# (0, "Null Shape"),
(1, "Point", point_2D),
(3, "PolyLine", polyline),
(5, "Polygon", polygon),
(8, "MultiPoint", multipoint),
(11, "PointZ", pointz),
(13, "PolyLineZ", polylinez),
(15, "PolygonZ", polygonz),
(18, "MultiPointZ", multipointz),
(21, "PointM", pointm),
(23, "PolyLineM", polylinem),
(25, "PolygonM", polygonm),
(28, "MultiPointM", multipointm),
(31, "MultiPatch", multipatch),
]

def code_and_shape_strat_from_triple(t):
x, _name, shapes = t
return tuples(just(x), lists(shapes, min_size = 0)) # Empty shp files are in the esri spec.

codes_and_shapes_strats = [
code_and_shape_strat_from_triple(t)
for t in shape_codes_names_and_strategies
]

codes_and_shapes = one_of(codes_and_shapes_strats)

@pytest.mark.hypothesis
# @settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
@given(codes_and_shapes=codes_and_shapes)
def test_shp_reader_writer_roundtrip(codes_and_shapes)-> None:
code_ex, expected_shapes = codes_and_shapes
stream = io.BytesIO()
with shp.ShpWriter(shp=stream, shapeType=code_ex) as w:
for shape in expected_shapes:
w.shape(shape)
stream.seek(0)
with shp.ShpReader(shp=stream) as r:
assert r.shapeType == code_ex

for actual, expected in itertools.zip_longest(r.shapes(), expected_shapes):

assert isinstance(actual, shp.SHAPE_CLASS_FROM_SHAPETYPE[code_ex])
assert actual.points_3D == expected.points_3D
# Don't assert actual.oid == expected.oid it's defined by
# actual.oid indicates the order actual was written in, expected.oid
# is not currently encoded (as we'd have to resort the entire Shapefile after each shape)
assert actual.parts == expected.parts, f"{type(actual.parts)=}, {type(expected.parts)=}"

if (m := getattr(actual, "m", None)):
assert m == expected.m, f"{type(m)=}, {type(expected.m)=}"
else:
assert not hasattr(expected, "m")

if (z := getattr(actual, "z", None)):
assert z == expected.z, f"{type(z)=}, {type(expected.z)=}"
else:
assert not hasattr(expected, "z")

if (partTypes := getattr(actual, "partTypes", None)):
assert actual.partTypes == expected.partTypes, f"{type(actual.partTypes)=}, {type(expected.partTypes)=}"
else:
assert not hasattr(expected, "partTypes")
Loading