Skip to content

Commit a5bba23

Browse files
authored
Merge pull request #408 v3.0.11 Stricter shapes. PointZ and Multipoints round trip.
v3.0.11 Stricter shapes. PointZ and Multipoints round trip.
2 parents d62307c + bcf462d commit a5bba23

8 files changed

Lines changed: 308 additions & 46 deletions

File tree

.github/actions/test/action.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description:
77
inputs:
88
extra_args:
99
description: Extra command line args for Pytest and python shapefile.py
10-
default: '-m "not network"'
10+
default: '-m "not network and not hypothesis"'
1111
required: false
1212
replace_remote_urls_with_localhost:
1313
description: true or false. Test loading shapefiles from a url, without overloading an external server from 30 parallel workflows.
@@ -17,6 +17,11 @@ inputs:
1717
description: Path to where the PyShp repo was checked out to (to keep separate from Shapefiles & artefacts repo).
1818
required: false
1919
default: '.'
20+
run_doctests:
21+
description: Whether to run the doctests or not.
22+
required: false
23+
default: 'yes'
24+
2025

2126

2227

@@ -81,6 +86,7 @@ runs:
8186
python -m pip install $WHEEL_NAME --group ../Pyshp/pyproject.toml:test
8287
8388
- name: Doctests
89+
if: ${{ inputs.run_doctests == 'yes' }}
8490
shell: bash
8591
working-directory: ${{ inputs.pyshp_repo_directory }}
8692
env:

.github/workflows/run_checks_build_and_test.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,36 @@ jobs:
5151
- name: Build wheel from the project repo
5252
uses: ./.github/actions/build_wheel_and_sdist
5353

54-
test_on_all_platforms:
54+
property-based_tests:
55+
needs: build_wheel_and_sdist
56+
strategy:
57+
fail-fast: false
58+
matrix:
59+
python-version: [
60+
"3.14",
61+
]
62+
os: [
63+
"ubuntu-24.04",
64+
]
65+
runs-on: ${{ matrix.os }}
66+
steps:
67+
- uses: actions/setup-python@v6
68+
with:
69+
python-version: ${{ matrix.python-version }}
70+
71+
- uses: actions/checkout@v6
72+
with:
73+
path: ./Pyshp
74+
75+
- name: "Hypothesis tests"
76+
if:
77+
uses: ./Pyshp/.github/actions/test
78+
with:
79+
extra_args: '-m hypothesis'
80+
run_doctests: 'no'
81+
pyshp_repo_directory: ./Pyshp
82+
83+
Pytest_and_doctests:
5584
needs: build_wheel_and_sdist
5685
strategy:
5786
fail-fast: false
@@ -94,3 +123,4 @@ jobs:
94123
replace_remote_urls_with_localhost: ${{ !(matrix.os == 'ubuntu-24.04' && matrix.python-version == '3.14') }}
95124
# Checkout to ./PyShp, as the test job also needs to check out the artefact repo
96125
pyshp_repo_directory: ./Pyshp
126+

README.md

Lines changed: 15 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.10
12-
- **Date**: 4th June 2026
11+
- **Version**: 3.0.11
12+
- **Date**: 5th June 2026
1313
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
1414

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

9494
# Version Changes
9595

96+
## 3.0.11
97+
### Edge case handling
98+
- Raise ShapefileException i) when creating Non-null Shapes without (or with empty) points
99+
and ii) when creating Null Shapes with non-empty points.
100+
- Ensure Shape.z and Shape.partTypes are _Arrays.
101+
- Make Shape stricter about its args, e.g. only points or lines, only one point for Points.
102+
103+
### Bug fixes
104+
- Multipoints with only a single point, now have their bbox calculated.
105+
106+
### Testing
107+
- Round trip property-based tests for Multipoints (passes).
108+
96109
## 3.0.10
97110
### Bug fix
98111
- Convert directly supplied m values to None if they are strictly below ISDATA_LOWER_BOUND (-1e38).

changelog.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
VERSION 3.0.11
2+
3+
2026-06-04
4+
Edge case handling
5+
* Raise ShapefileException i) when creating Non-null Shapes without (or with empty) points
6+
and ii) when creating Null Shapes with non-empty points.
7+
* Ensure Shape.z and Shape.partTypes are _Arrays.
8+
* Make Shape stricter about its args, e.g. only points or lines, only one point for Points.
9+
10+
Bug fixes
11+
* Multipoints with only a single point, now have their bbox calculated.
12+
13+
Testing
14+
* Round trip property-based tests for Multipoints (passes).
15+
116
VERSION 3.0.10
217

318
2026-06-04

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,7 @@ mypy_path = "src"
5555
explicit_package_bases = true
5656
exclude_gitignore = true
5757
exclude=[ # Mypy requires regexes, not globs:
58-
'tests/test_shapefile\.py',
59-
'tests/run_benchmarks\.py',
60-
'tests/run_doctests\.py',
58+
'tests/.*\.py',
6159
]
6260

6361

@@ -108,6 +106,7 @@ exclude = [
108106
"node_modules",
109107
"site-packages",
110108
"venv",
109+
"tests",
111110
]
112111

113112
# Same as Black.

src/shapefile.py

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

99
from __future__ import annotations
1010

11-
__version__ = "3.0.10"
11+
__version__ = "3.0.11"
1212

1313
import abc
1414
import array
@@ -773,11 +773,56 @@ def __init__(
773773
self.shapeType = shapeType
774774

775775
if partTypes is not None:
776-
self.partTypes = partTypes
776+
if self.shapeType != MULTIPATCH:
777+
raise ShapefileException(
778+
f"Only a Multipatch shape supports partTypes, not: {self.__class__.__name__} "
779+
f" (shape type: {self.shapeTypeName}) "
780+
f"Got: {partTypes=}"
781+
)
782+
self.partTypes = _Array[int]("i", partTypes)
777783

778784
default_points: PointsT = []
779785
default_parts: list[int] = []
780786

787+
if points and lines:
788+
raise ShapefileException(
789+
"Constructing meaningful Shapes unambiguously from both "
790+
"points and lines is not supported. Provide one only. "
791+
f" Got: {points=} and {lines=}"
792+
)
793+
elif not points and not lines:
794+
if self.shapeType != NULL:
795+
raise ShapefileException(
796+
f"Shape: {self.__class__.__name__} or shape type: {self.shapeTypeName} "
797+
"requires non-empty points or non-empty lines."
798+
f" Got: {points=} and {lines=}"
799+
)
800+
elif self.shapeType == NULL:
801+
raise ShapefileException(
802+
f"NullShape or shape type: {self.shapeTypeName} "
803+
"must have zero points and zero lines (or neither set, or both None). "
804+
f" Got: {points=} and {lines=}"
805+
)
806+
elif self.shapeType in Point_shapeTypes:
807+
if not points or len(points) >= 2:
808+
raise ShapefileException(
809+
f"Single point Shape: {self.__class__.__name__}, shape type: {self.shapeTypeName} "
810+
"requires one or points (and possibly a z co-ordinate and m value), not "
811+
f"lines. Got: {points=} and {lines=}"
812+
)
813+
if lines:
814+
raise ShapefileException(
815+
f"Single point shape: {self.__class__.__name__}, shape type: {self.shapeTypeName} "
816+
f"does not support lines. Got: {lines=}"
817+
)
818+
elif self.shapeType in MultiPoint_shapeTypes and lines and len(lines) >= 2:
819+
raise ShapefileException(
820+
f"Multipoint shape: {self.__class__.__name__}, shape type: {self.shapeTypeName} "
821+
f"is a single part shape, but was given multiple parts - got {lines=}. "
822+
"Point clouds can be constructed from a list of list points supplied to lines "
823+
"(instead of points) but only one single 'line' is supported. "
824+
)
825+
781826
if lines is not None:
782827
if self.shapeType in Polygon_shapeTypes:
783828
lines = list(lines)
@@ -800,20 +845,19 @@ def __init__(
800845
# _from_geojson.
801846
default_parts = [0]
802847

848+
# PyShp 2 API compatibility requires self.points = []
849+
# on NullShapes (and self.parts = []).
803850
self.points: PointsT = points or default_points
804-
805851
self.parts: Sequence[int] = parts or default_parts
806852

807-
# and a dict to silently record any errors encountered in GeoJSON
853+
# and a dict to record any captured errors encountered in GeoJSON
808854
self._errors: dict[str, int] = {}
809855

810856
# add oid
811857
self.__oid: int = -1 if oid is None else oid
812858

813-
if bbox is not None:
814-
self.bbox: BBox = bbox
815-
elif len(self.points) >= 2:
816-
self.bbox = self._bbox_from_points()
859+
if self.shapeType != NULL and self.shapeType not in Point_shapeTypes:
860+
self.bbox: BBox = bbox or self._bbox_from_points()
817861

818862
ms_found = True
819863
if m:
@@ -829,9 +873,9 @@ def __init__(
829873

830874
zs_found = True
831875
if z:
832-
self.z: Sequence[float] = z
876+
self.z: Sequence[float] = _Array[float]("d", z)
833877
elif self.shapeType in _HasZ_shapeTypes:
834-
self.z = [_z_from_point(p) for p in self.points]
878+
self.z = _Array[float]("d", (_z_from_point(p) for p in self.points))
835879
elif self.shapeType == POINTZ:
836880
self.z = (_z_from_point(self.points[0]),)
837881
else:
@@ -847,6 +891,21 @@ def __init__(
847891
elif zs_found:
848892
self.zbox = self._zbox_from_zs()
849893

894+
@property
895+
def oid(self) -> int:
896+
"""The index position of the shape in the original shapefile"""
897+
return self.__oid
898+
899+
@property
900+
def shapeTypeName(self) -> str:
901+
return SHAPETYPE_LOOKUP[self.shapeType]
902+
903+
def __repr__(self) -> str:
904+
class_name = self.__class__.__name__
905+
if class_name == "Shape":
906+
return f"Shape #{self.__oid}: {self.shapeTypeName}"
907+
return f"{class_name} #{self.__oid}"
908+
850909
@staticmethod
851910
def _ensure_polygon_rings_closed(
852911
parts: list[PointsT], # Mutated
@@ -1080,21 +1139,6 @@ def _from_geojson(geoj: GeoJSONHomogeneousGeometryObject) -> Shape:
10801139
index += len(ext_or_hole)
10811140
return Shape(shapeType=shapeType, points=points, parts=parts)
10821141

1083-
@property
1084-
def oid(self) -> int:
1085-
"""The index position of the shape in the original shapefile"""
1086-
return self.__oid
1087-
1088-
@property
1089-
def shapeTypeName(self) -> str:
1090-
return SHAPETYPE_LOOKUP[self.shapeType]
1091-
1092-
def __repr__(self) -> str:
1093-
class_name = self.__class__.__name__
1094-
if class_name == "Shape":
1095-
return f"Shape #{self.__oid}: {self.shapeTypeName}"
1096-
return f"{class_name} #{self.__oid}"
1097-
10981142

10991143
# Need unused arguments to keep the same call signature for
11001144
# different implementations of from_byte_stream and write_to_byte_stream
@@ -1116,6 +1160,11 @@ def from_byte_stream(
11161160
oid: int | None = None,
11171161
bbox: BBox | None = None,
11181162
) -> NullShape:
1163+
"""In the ESRI spec, Null shapes are defined in .shp files
1164+
entirely by a single integer encoding shape type 0
1165+
(this happens in ShpWriter._shp_record, amongst the shape
1166+
record header code).
1167+
"""
11191168
# Shape.__init__ sets self.points = points or []
11201169
return NullShape(oid=oid)
11211170

@@ -1125,6 +1174,7 @@ def write_to_byte_stream(
11251174
s: Shape,
11261175
i: int,
11271176
) -> int:
1177+
"""No op (see above)."""
11281178
return 0
11291179

11301180

@@ -1602,13 +1652,13 @@ def _write_zs_to_byte_stream(
16021652
num_bytes_written = b_io.write(pack("<2d", *zbox))
16031653
except StructError:
16041654
raise ShapefileException(
1605-
f"Failed to write elevation extremes for record {i}. Expected floats."
1655+
f"Failed to write elevation extremes (ZBox) for record {i}. Expected floats."
16061656
)
16071657
try:
16081658
num_bytes_written += b_io.write(pack(f"<{len(s.z)}d", *s.z))
16091659
except StructError:
16101660
raise ShapefileException(
1611-
f"Failed to write elevation values for record {i}. Expected floats."
1661+
f"Failed to write elevation values (z) for record {i}. Expected floats."
16121662
)
16131663

16141664
return num_bytes_written

0 commit comments

Comments
 (0)