[Git][debian-gis-team/pyshp][upstream] New upstream version 3.0.11
Bas Couwenberg (@sebastic)
gitlab at salsa.debian.org
Fri Jun 5 19:14:54 BST 2026
Bas Couwenberg pushed to branch upstream at Debian GIS Project / pyshp
Commits:
86e5499c by Bas Couwenberg at 2026-06-05T20:05:28+02:00
New upstream version 3.0.11
- - - - -
8 changed files:
- .github/actions/test/action.yml
- .github/workflows/run_checks_build_and_test.yml
- README.md
- changelog.txt
- pyproject.toml
- src/shapefile.py
- tests/hypothesis_tests.py
- tests/run_doctests.py
Changes:
=====================================
.github/actions/test/action.yml
=====================================
@@ -7,7 +7,7 @@ description:
inputs:
extra_args:
description: Extra command line args for Pytest and python shapefile.py
- default: '-m "not network"'
+ default: '-m "not network and not hypothesis"'
required: false
replace_remote_urls_with_localhost:
description: true or false. Test loading shapefiles from a url, without overloading an external server from 30 parallel workflows.
@@ -17,6 +17,11 @@ inputs:
description: Path to where the PyShp repo was checked out to (to keep separate from Shapefiles & artefacts repo).
required: false
default: '.'
+ run_doctests:
+ description: Whether to run the doctests or not.
+ required: false
+ default: 'yes'
+
@@ -81,6 +86,7 @@ runs:
python -m pip install $WHEEL_NAME --group ../Pyshp/pyproject.toml:test
- name: Doctests
+ if: ${{ inputs.run_doctests == 'yes' }}
shell: bash
working-directory: ${{ inputs.pyshp_repo_directory }}
env:
=====================================
.github/workflows/run_checks_build_and_test.yml
=====================================
@@ -51,7 +51,36 @@ jobs:
- name: Build wheel from the project repo
uses: ./.github/actions/build_wheel_and_sdist
- test_on_all_platforms:
+ property-based_tests:
+ needs: build_wheel_and_sdist
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: [
+ "3.14",
+ ]
+ os: [
+ "ubuntu-24.04",
+ ]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/setup-python at v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - uses: actions/checkout at v6
+ with:
+ path: ./Pyshp
+
+ - name: "Hypothesis tests"
+ if:
+ uses: ./Pyshp/.github/actions/test
+ with:
+ extra_args: '-m hypothesis'
+ run_doctests: 'no'
+ pyshp_repo_directory: ./Pyshp
+
+ Pytest_and_doctests:
needs: build_wheel_and_sdist
strategy:
fail-fast: false
@@ -94,3 +123,4 @@ jobs:
replace_remote_urls_with_localhost: ${{ !(matrix.os == 'ubuntu-24.04' && matrix.python-version == '3.14') }}
# Checkout to ./PyShp, as the test job also needs to check out the artefact repo
pyshp_repo_directory: ./Pyshp
+
=====================================
README.md
=====================================
@@ -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.10
-- **Date**: 4th June 2026
+- **Version**: 3.0.11
+- **Date**: 5th June 2026
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
## Contents
@@ -93,6 +93,19 @@ part of your geospatial project.
# Version Changes
+## 3.0.11
+### Edge case handling
+ - Raise ShapefileException i) when creating Non-null Shapes without (or with empty) points
+ and ii) when creating Null Shapes with non-empty points.
+ - Ensure Shape.z and Shape.partTypes are _Arrays.
+ - Make Shape stricter about its args, e.g. only points or lines, only one point for Points.
+
+### Bug fixes
+ - Multipoints with only a single point, now have their bbox calculated.
+
+### Testing
+ - Round trip property-based tests for Multipoints (passes).
+
## 3.0.10
### Bug fix
- Convert directly supplied m values to None if they are strictly below ISDATA_LOWER_BOUND (-1e38).
=====================================
changelog.txt
=====================================
@@ -1,3 +1,18 @@
+VERSION 3.0.11
+
+2026-06-04
+ Edge case handling
+ * Raise ShapefileException i) when creating Non-null Shapes without (or with empty) points
+ and ii) when creating Null Shapes with non-empty points.
+ * Ensure Shape.z and Shape.partTypes are _Arrays.
+ * Make Shape stricter about its args, e.g. only points or lines, only one point for Points.
+
+ Bug fixes
+ * Multipoints with only a single point, now have their bbox calculated.
+
+ Testing
+ * Round trip property-based tests for Multipoints (passes).
+
VERSION 3.0.10
2026-06-04
=====================================
pyproject.toml
=====================================
@@ -55,9 +55,7 @@ mypy_path = "src"
explicit_package_bases = true
exclude_gitignore = true
exclude=[ # Mypy requires regexes, not globs:
- 'tests/test_shapefile\.py',
- 'tests/run_benchmarks\.py',
- 'tests/run_doctests\.py',
+ 'tests/.*\.py',
]
@@ -108,6 +106,7 @@ exclude = [
"node_modules",
"site-packages",
"venv",
+ "tests",
]
# Same as Black.
=====================================
src/shapefile.py
=====================================
@@ -8,7 +8,7 @@ Compatible with Python versions >=3.9
from __future__ import annotations
-__version__ = "3.0.10"
+__version__ = "3.0.11"
import abc
import array
@@ -773,11 +773,56 @@ class Shape:
self.shapeType = shapeType
if partTypes is not None:
- self.partTypes = partTypes
+ if self.shapeType != MULTIPATCH:
+ raise ShapefileException(
+ f"Only a Multipatch shape supports partTypes, not: {self.__class__.__name__} "
+ f" (shape type: {self.shapeTypeName}) "
+ f"Got: {partTypes=}"
+ )
+ self.partTypes = _Array[int]("i", partTypes)
default_points: PointsT = []
default_parts: list[int] = []
+ if points and lines:
+ raise ShapefileException(
+ "Constructing meaningful Shapes unambiguously from both "
+ "points and lines is not supported. Provide one only. "
+ f" Got: {points=} and {lines=}"
+ )
+ elif not points and not lines:
+ if self.shapeType != NULL:
+ raise ShapefileException(
+ f"Shape: {self.__class__.__name__} or shape type: {self.shapeTypeName} "
+ "requires non-empty points or non-empty lines."
+ f" Got: {points=} and {lines=}"
+ )
+ elif self.shapeType == NULL:
+ raise ShapefileException(
+ f"NullShape or shape type: {self.shapeTypeName} "
+ "must have zero points and zero lines (or neither set, or both None). "
+ f" Got: {points=} and {lines=}"
+ )
+ elif self.shapeType in Point_shapeTypes:
+ if not points or len(points) >= 2:
+ raise ShapefileException(
+ f"Single point Shape: {self.__class__.__name__}, shape type: {self.shapeTypeName} "
+ "requires one or points (and possibly a z co-ordinate and m value), not "
+ f"lines. Got: {points=} and {lines=}"
+ )
+ if lines:
+ raise ShapefileException(
+ f"Single point shape: {self.__class__.__name__}, shape type: {self.shapeTypeName} "
+ f"does not support lines. Got: {lines=}"
+ )
+ elif self.shapeType in MultiPoint_shapeTypes and lines and len(lines) >= 2:
+ raise ShapefileException(
+ f"Multipoint shape: {self.__class__.__name__}, shape type: {self.shapeTypeName} "
+ f"is a single part shape, but was given multiple parts - got {lines=}. "
+ "Point clouds can be constructed from a list of list points supplied to lines "
+ "(instead of points) but only one single 'line' is supported. "
+ )
+
if lines is not None:
if self.shapeType in Polygon_shapeTypes:
lines = list(lines)
@@ -800,20 +845,19 @@ class Shape:
# _from_geojson.
default_parts = [0]
+ # PyShp 2 API compatibility requires self.points = []
+ # on NullShapes (and self.parts = []).
self.points: PointsT = points or default_points
-
self.parts: Sequence[int] = parts or default_parts
- # and a dict to silently record any errors encountered in GeoJSON
+ # and a dict to record any captured errors encountered in GeoJSON
self._errors: dict[str, int] = {}
# add oid
self.__oid: int = -1 if oid is None else oid
- if bbox is not None:
- self.bbox: BBox = bbox
- elif len(self.points) >= 2:
- self.bbox = self._bbox_from_points()
+ if self.shapeType != NULL and self.shapeType not in Point_shapeTypes:
+ self.bbox: BBox = bbox or self._bbox_from_points()
ms_found = True
if m:
@@ -829,9 +873,9 @@ class Shape:
zs_found = True
if z:
- self.z: Sequence[float] = z
+ self.z: Sequence[float] = _Array[float]("d", z)
elif self.shapeType in _HasZ_shapeTypes:
- self.z = [_z_from_point(p) for p in self.points]
+ self.z = _Array[float]("d", (_z_from_point(p) for p in self.points))
elif self.shapeType == POINTZ:
self.z = (_z_from_point(self.points[0]),)
else:
@@ -847,6 +891,21 @@ class Shape:
elif zs_found:
self.zbox = self._zbox_from_zs()
+ @property
+ def oid(self) -> int:
+ """The index position of the shape in the original shapefile"""
+ return self.__oid
+
+ @property
+ def shapeTypeName(self) -> str:
+ return SHAPETYPE_LOOKUP[self.shapeType]
+
+ def __repr__(self) -> str:
+ class_name = self.__class__.__name__
+ if class_name == "Shape":
+ return f"Shape #{self.__oid}: {self.shapeTypeName}"
+ return f"{class_name} #{self.__oid}"
+
@staticmethod
def _ensure_polygon_rings_closed(
parts: list[PointsT], # Mutated
@@ -1080,21 +1139,6 @@ still included but were encoded as GeoJSON exterior rings instead of holes."
index += len(ext_or_hole)
return Shape(shapeType=shapeType, points=points, parts=parts)
- @property
- def oid(self) -> int:
- """The index position of the shape in the original shapefile"""
- return self.__oid
-
- @property
- def shapeTypeName(self) -> str:
- return SHAPETYPE_LOOKUP[self.shapeType]
-
- def __repr__(self) -> str:
- class_name = self.__class__.__name__
- if class_name == "Shape":
- return f"Shape #{self.__oid}: {self.shapeTypeName}"
- return f"{class_name} #{self.__oid}"
-
# Need unused arguments to keep the same call signature for
# different implementations of from_byte_stream and write_to_byte_stream
@@ -1116,6 +1160,11 @@ class NullShape(Shape):
oid: int | None = None,
bbox: BBox | None = None,
) -> NullShape:
+ """In the ESRI spec, Null shapes are defined in .shp files
+ entirely by a single integer encoding shape type 0
+ (this happens in ShpWriter._shp_record, amongst the shape
+ record header code).
+ """
# Shape.__init__ sets self.points = points or []
return NullShape(oid=oid)
@@ -1125,6 +1174,7 @@ class NullShape(Shape):
s: Shape,
i: int,
) -> int:
+ """No op (see above)."""
return 0
@@ -1602,13 +1652,13 @@ class _HasZ(_CanHaveBBox):
num_bytes_written = b_io.write(pack("<2d", *zbox))
except StructError:
raise ShapefileException(
- f"Failed to write elevation extremes for record {i}. Expected floats."
+ f"Failed to write elevation extremes (ZBox) for record {i}. Expected floats."
)
try:
num_bytes_written += b_io.write(pack(f"<{len(s.z)}d", *s.z))
except StructError:
raise ShapefileException(
- f"Failed to write elevation values for record {i}. Expected floats."
+ f"Failed to write elevation values (z) for record {i}. Expected floats."
)
return num_bytes_written
=====================================
tests/hypothesis_tests.py
=====================================
@@ -1,31 +1,61 @@
+from __future__ import annotations
+
import io
import pytest
-from hypothesis import given
+from hypothesis import HealthCheck, given, settings
from hypothesis.strategies import (
builds,
+ composite,
floats,
integers,
+ just,
+ lists,
none,
one_of,
+ tuples,
)
import shapefile as shp
float_nums = floats(allow_nan=False, allow_infinity=False)
-
-points_2D = builds(shp.Point, float_nums, float_nums, one_of(none(), integers()))
-pointMs = builds(
+xs = float_nums
+ys = float_nums
+ms = one_of(none(), float_nums)
+zs = one_of(just(0.0), float_nums)
+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(
shp.PointM,
- float_nums,
- float_nums,
- one_of(none(), float_nums),
- one_of(none(), integers()),
+ x=xs,
+ y=ys,
+ m=ms,
+ oid=oid,
+)
+pointZ = builds(
+ shp.PointZ,
+ x=xs,
+ y=ys,
+ z=zs,
+ m=ms,
+ oid=oid,
)
+def coords_2D_list(
+ min_size: int = 1,
+ max_size: int | None = None,
+):
+ return lists(
+ tuples(xs, ys),
+ min_size=min_size,
+ max_size=max_size,
+ )
+
+
@pytest.mark.hypothesis
- at given(expected=points_2D, i=integers(min_value=1))
+ at given(expected=point_2D, i=integers(min_value=1))
def test_Point_2D_roundtrips(
expected: shp.Point,
i: int,
@@ -47,8 +77,8 @@ def test_Point_2D_roundtrips(
@pytest.mark.hypothesis
- at given(expected=pointMs, i=integers(min_value=1))
-def test_Point_M_roundtrips(
+ at given(expected=pointM, i=integers(min_value=1))
+def test_PointM_roundtrips(
expected: shp.Point,
i: int,
) -> None:
@@ -67,3 +97,122 @@ def test_Point_M_roundtrips(
assert actual.points == expected.points
assert actual.m == expected.m
assert actual.oid == expected.oid
+
+
+ at pytest.mark.hypothesis
+ at given(expected=pointZ, i=integers(min_value=1))
+def test_PointZ_roundtrips(
+ expected: shp.Point,
+ i: int,
+) -> None:
+ stream = io.BytesIO()
+ n = shp.PointZ.write_to_byte_stream(b_io=stream, s=expected, i=i)
+ assert n == stream.tell()
+ stream.seek(0)
+ actual = shp.PointZ.from_byte_stream(
+ shapeType=shp.POINTZ,
+ b_io=stream,
+ next_shape_pos=n,
+ oid=expected.oid,
+ bbox=None,
+ )
+ assert isinstance(actual, shp.PointM)
+ assert actual.points == expected.points
+ assert actual.z == expected.z
+ assert actual.m == expected.m
+ assert actual.oid == expected.oid
+
+
+multipoint = builds(shp.MultiPoint, points=coords_2D_list())
+
+
+ at pytest.mark.hypothesis
+ at given(expected=multipoint, i=integers(min_value=1))
+def test_MultiPoint_roundtrips(
+ expected: shp.MultiPoint,
+ i: int,
+) -> None:
+ stream = io.BytesIO()
+ n = shp.MultiPoint.write_to_byte_stream(b_io=stream, s=expected, i=i)
+ assert n == stream.tell()
+ stream.seek(0)
+ actual = shp.MultiPoint.from_byte_stream(
+ shapeType=shp.MULTIPOINT,
+ b_io=stream,
+ next_shape_pos=n,
+ oid=expected.oid,
+ bbox=None,
+ )
+ assert isinstance(actual, shp.MultiPoint)
+ assert actual.points == expected.points
+ assert actual.oid == expected.oid
+
+
+ at composite
+def multipointM(draw):
+ N = draw(PointsLengths)
+ return shp.MultiPointM(
+ points=draw(coords_2D_list(min_size=N, max_size=N)),
+ m=draw(lists(ms, min_size=N, max_size=N)),
+ oid=oid,
+ )
+
+
+ at pytest.mark.hypothesis
+ at settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
+ at given(expected=multipointM(), i=integers(min_value=1))
+def test_MultiPointM_roundtrips(
+ expected: shp.MultiPointM,
+ i: int,
+) -> None:
+ stream = io.BytesIO()
+ n = shp.MultiPointM.write_to_byte_stream(b_io=stream, s=expected, i=i)
+ assert n == stream.tell()
+ stream.seek(0)
+ actual = shp.MultiPointM.from_byte_stream(
+ shapeType=shp.MULTIPOINTM,
+ b_io=stream,
+ next_shape_pos=n,
+ oid=expected.oid,
+ bbox=None,
+ )
+ assert isinstance(actual, shp.MultiPointM)
+ assert actual.points == expected.points
+ assert actual.m == expected.m
+ assert actual.oid == expected.oid
+
+
+ at composite
+def multipointZ(draw):
+ N = draw(PointsLengths)
+ return shp.MultiPointZ(
+ points=draw(coords_2D_list(min_size=N, max_size=N)),
+ z=draw(lists(zs, min_size=N, max_size=N)),
+ m=draw(lists(ms, min_size=N, max_size=N)),
+ oid=oid,
+ )
+
+
+ at pytest.mark.hypothesis
+ at settings(suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
+ at given(expected=multipointZ(), i=integers(min_value=1))
+def test_MultiPointZ_roundtrips(
+ expected: shp.MultiPointZ,
+ i: int,
+) -> None:
+ stream = io.BytesIO()
+ n = shp.MultiPointZ.write_to_byte_stream(b_io=stream, s=expected, i=i)
+ assert n == stream.tell()
+ stream.seek(0)
+ actual = shp.MultiPointZ.from_byte_stream(
+ shapeType=shp.MULTIPOINTZ,
+ b_io=stream,
+ next_shape_pos=n,
+ oid=expected.oid,
+ bbox=None,
+ )
+ assert isinstance(actual, shp.MultiPointZ)
+ assert actual.points == expected.points
+ 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
=====================================
tests/run_doctests.py
=====================================
@@ -199,7 +199,7 @@ def main() -> None:
temp_dir=Path(td).as_posix(),
readme=namespace.readme,
include_network=namespace.m.lower().strip() == "network",
- include_non_network=namespace.m.lower().strip() == "not network",
+ include_non_network="not network" in namespace.m.lower(),
)
sys.exit(failure_count)
View it on GitLab: https://salsa.debian.org/debian-gis-team/pyshp/-/commit/86e5499c4694fc0d89ec641246d3e24bc8c0530f
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/pyshp/-/commit/86e5499c4694fc0d89ec641246d3e24bc8c0530f
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20260605/9db5fe5d/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list