Skip to content

Commit f1e1773

Browse files
committed
v3.0.12. 3D Point coords
Polylines and Polygons round trip Base Shape.m and Shape.z on points, not self.points (2D only) Add points_2D and points_3D properties to Shape. Restore self.points to user specifiable Update README.md v3.0.12. 3D Point coords!
1 parent a5bba23 commit f1e1773

4 files changed

Lines changed: 252 additions & 65 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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.11
11+
- **Version**: 3.0.12
1212
- **Date**: 5th June 2026
1313
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
1414

@@ -93,6 +93,13 @@ part of your geospatial project.
9393

9494
# Version Changes
9595

96+
## 3.0.12
97+
### Data consistency
98+
- Add Shape.points_2D and Shape.points_3D properties - lists of guaranteed length tuples (2 and 3 respectively).
99+
100+
### Testing
101+
- Round trip property-based tests for Polylines and Polygons (both pass).
102+
96103
## 3.0.11
97104
### Edge case handling
98105
- Raise ShapefileException i) when creating Non-null Shapes without (or with empty) points
@@ -597,13 +604,14 @@ index which is 7.
597604

598605

599606
>>> # Read the bbox of the 8th shape to verify
607+
>>> s.bbox
608+
BBox(xmin=-122.449637, ymin=37.80149, xmax=-122.442109, ymax=37.807958)
600609
>>> # Round coordinates to 3 decimal places
601-
>>> ['%.3f' % coord for coord in s.bbox]
610+
>>> [f'{coord:.3f}' for coord in s.bbox]
602611
['-122.450', '37.801', '-122.442', '37.808']
603612

604613
Each shape record (except Points) contains the following attributes. Records of
605614
shapeType Point do not have a bounding box 'bbox'.
606-
# TODO!! Fix attributes
607615

608616
>>> for name in dir(shapes[3]):
609617
... if not name.startswith('_'):
@@ -613,6 +621,8 @@ shapeType Point do not have a bounding box 'bbox'.
613621
'oid'
614622
'parts'
615623
'points'
624+
'points_2D'
625+
'points_3D'
616626
'shapeType'
617627
'shapeTypeName'
618628
'write_to_byte_stream'
@@ -636,16 +646,17 @@ shapeType Point do not have a bounding box 'bbox'.
636646
>>> shapes[3].shapeTypeName
637647
'POLYGON'
638648

639-
* `bbox`: If the shape type contains multiple points this tuple describes the
649+
* `bbox`: If the shape type contains multiple points this named tuple describes the
640650
lower left (x,y) coordinate and upper right corner coordinate creating a
641651
complete box around the points. If the shapeType is a
642652
Null (shapeType == 0) then an AttributeError is raised.
643653

644654

645655
>>> # Get the bounding box of the 4th shape.
656+
>>> shapes[3].bbox
657+
BBox(xmin=-122.485792, ymin=37.786931, xmax=-122.446285, ymax=37.811019)
646658
>>> # Round coordinates to 3 decimal places
647-
>>> bbox = shapes[3].bbox
648-
>>> ['%.3f' % coord for coord in bbox]
659+
>>> [f'{coord:.3f}' for coord in shapes[3].bbox]
649660
['-122.486', '37.787', '-122.446', '37.811']
650661

651662
* `parts`: Parts simply group collections of points into shapes. If the shape
@@ -657,16 +668,16 @@ shapeType Point do not have a bounding box 'bbox'.
657668
>>> shapes[3].parts
658669
[0]
659670

660-
* `points`: The points attribute contains a list of tuples containing an
661-
(x,y) coordinate for each point in the shape.
671+
* `points_2D`/`points_3D`: The points_2D and points_3D attributes contain lists
672+
of tuples containing (x,y) or (x,y,z) coordinates respectively for each
673+
point in the shape. If no z data is available, z is set to 0 is used.
662674

663-
664-
>>> len(shapes[3].points)
675+
>>> len(shapes[3].points_2D)
665676
173
666677
>>> # Get the 8th point of the fourth shape
667678
>>> # Truncate coordinates to 3 decimal places
668-
>>> shape = shapes[3].points[7]
669-
>>> ['%.3f' % coord for coord in shape]
679+
>>> coords = shapes[3].points_2D[7]
680+
>>> [f'{coord:.3f}' for coord in coords]
670681
['-122.471', '37.787']
671682

672683
In most cases, however, if you need to do more than just type or bounds checking, you may want
@@ -1563,6 +1574,9 @@ To examine a Z-type shapefile you can do:
15631574
>>> r.shape(0).z # flat list of Z-values
15641575
[18.0, 20.0, 22.0, 0.0, 0.0, 0.0, 0.0, 15.0, 13.0, 14.0]
15651576

1577+
>>> r.shape(0).points_3D # list of 3D coordinates incorporating the Z-values
1578+
[(1.0, 5.0, 18.0), (5.0, 5.0, 20.0), (5.0, 1.0, 22.0), (3.0, 3.0, 0.0), (1.0, 1.0, 0.0), (3.0, 2.0, 0.0), (2.0, 6.0, 0.0), (3.0, 2.0, 15.0), (2.0, 6.0, 13.0), (1.0, 9.0, 14.0)]
1579+
15661580
>>> r.close()
15671581

15681582
### 3D MultiPatch Shapefiles

‎changelog.txt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
VERSION 3.0.12
2+
3+
2026-06-05
4+
Data consistency
5+
* Add Shape.points_2D and Shape.points_3D properties - lists of guaranteed length tuples (2 and 3 respectively).
6+
7+
Testing
8+
* Round trip property-based tests for Polylines and Polygons (both pass).
9+
110
VERSION 3.0.11
211

312
2026-06-04

‎src/shapefile.py‎

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

99
from __future__ import annotations
1010

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

1313
import abc
1414
import array
@@ -133,7 +133,6 @@ class BBox(NamedTuple):
133133
ymin: float
134134
xmax: float
135135
ymax: float
136-
# = tuple[float, float, float, float]
137136

138137

139138
def _min_not_None(m1: float | None, m2: float | None) -> float | None:
@@ -162,13 +161,10 @@ def expand(self, other: MBox) -> MBox:
162161
_max_not_None(self.mmax, other.mmax),
163162
)
164163

165-
# = tuple[float, float]
166-
167164

168165
class ZBox(NamedTuple):
169166
zmin: float
170167
zmax: float
171-
# = tuple[float, float]
172168

173169

174170
class WriteableBinStream(Protocol):
@@ -720,6 +716,35 @@ def _z_from_point(point: PointT) -> float:
720716
return 0.0
721717

722718

719+
def _with_polygon_rings_closed(
720+
parts: Iterable[PointsT],
721+
) -> list[PointsT]:
722+
return [part if part[0] == part[-1] else part + [part[0]] for part in parts]
723+
724+
725+
def _points_and_part_indices(
726+
parts: list[PointsT],
727+
) -> tuple[PointsT, list[int]]:
728+
# Intended for Union[Polyline, Polygon, MultiPoint, MultiPatch]
729+
"""From a list of parts (each part a list of points) return
730+
a flattened list of points, and a list of indexes into that
731+
flattened list corresponding to the start of each part.
732+
733+
Internal method for both multipoints (formed entirely by a single part),
734+
and shapes that have multiple collections of points (each one
735+
a part): (poly)lines, polygons, and multipatchs.
736+
"""
737+
part_indexes: list[int] = []
738+
points: PointsT = []
739+
740+
for part in parts:
741+
# set part index position
742+
part_indexes.append(len(points))
743+
points.extend(part)
744+
745+
return points, part_indexes
746+
747+
723748
class CanHaveBboxNoLinesKwargs(TypedDict, total=False):
724749
oid: int | None
725750
points: PointsT | None
@@ -825,21 +850,17 @@ def __init__(
825850

826851
if lines is not None:
827852
if self.shapeType in Polygon_shapeTypes:
828-
lines = list(lines)
829-
self._ensure_polygon_rings_closed(lines)
853+
lines = _with_polygon_rings_closed(lines)
830854

831-
default_points, default_parts = self._points_and_parts_indexes_from_lines(
832-
lines
833-
)
834-
elif points and self.shapeType in _CanHaveBBox_shapeTypes:
855+
default_points, default_parts = _points_and_part_indices(lines)
856+
857+
elif not parts and self.shapeType in _CanHaveBBox_shapeTypes:
835858
# TODO: Raise issue.
836859
# This ensures Polylines, Polygons and Multipatches with no part information are a single
837860
# Polyline, Polygon or Multipatch respectively.
838861
#
839-
# However this also allows MultiPoints shapes to have a single part index 0 as
840-
# documented in README.md,also when set from points
841-
# (even though this is just an artefact of initialising them as a length-1 nested
842-
# list of points via _points_and_parts_indexes_from_lines).
862+
# This is consistent with MultiPoints shapes having single part index 0 as
863+
# documented in README.md, also when set from points
843864
#
844865
# Alternatively single points could be given parts = [0] too, as they do if formed
845866
# _from_geojson.
@@ -848,7 +869,7 @@ def __init__(
848869
# PyShp 2 API compatibility requires self.points = []
849870
# on NullShapes (and self.parts = []).
850871
self.points: PointsT = points or default_points
851-
self.parts: Sequence[int] = parts or default_parts
872+
self.parts = _Array[int]("i", parts or default_parts)
852873

853874
# and a dict to record any captured errors encountered in GeoJSON
854875
self._errors: dict[str, int] = {}
@@ -900,43 +921,23 @@ def oid(self) -> int:
900921
def shapeTypeName(self) -> str:
901922
return SHAPETYPE_LOOKUP[self.shapeType]
902923

924+
@property
925+
def points_2D(self) -> list[Point2D]:
926+
return [(x, y) for (x, y, *_rest) in self.points]
927+
928+
@property
929+
def points_3D(self) -> list[Point3D]:
930+
zs = getattr(self, "z", None)
931+
if zs is None:
932+
return [(x, y, _z_from_point((x, y))) for (x, y, *_rest) in self.points]
933+
return [(x, y, z) for (x, y, *_rest), z in zip(self.points, zs)]
934+
903935
def __repr__(self) -> str:
904936
class_name = self.__class__.__name__
905937
if class_name == "Shape":
906938
return f"Shape #{self.__oid}: {self.shapeTypeName}"
907939
return f"{class_name} #{self.__oid}"
908940

909-
@staticmethod
910-
def _ensure_polygon_rings_closed(
911-
parts: list[PointsT], # Mutated
912-
) -> None:
913-
for part in parts:
914-
if part[0] != part[-1]:
915-
part.append(part[0])
916-
917-
@staticmethod
918-
def _points_and_parts_indexes_from_lines(
919-
parts: list[PointsT],
920-
) -> tuple[PointsT, list[int]]:
921-
# Intended for Union[Polyline, Polygon, MultiPoint, MultiPatch]
922-
"""From a list of parts (each part a list of points) return
923-
a flattened list of points, and a list of indexes into that
924-
flattened list corresponding to the start of each part.
925-
926-
Internal method for both multipoints (formed entirely by a single part),
927-
and shapes that have multiple collections of points (each one
928-
a part): (poly)lines, polygons, and multipatchs.
929-
"""
930-
part_indexes: list[int] = []
931-
points: PointsT = []
932-
933-
for part in parts:
934-
# set part index position
935-
part_indexes.append(len(points))
936-
points.extend(part)
937-
938-
return points, part_indexes
939-
940941
def _bbox_from_points(self) -> BBox:
941942
xs: list[float] = []
942943
ys: list[float] = []

0 commit comments

Comments
 (0)