From ba94d6421cd815dcf28c39e02f72ddcc01620817 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:43:58 +0100 Subject: [PATCH 01/24] Update __init__.py --- src/shapefile/__init__.py | 46 +++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index b690d30..4053830 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -49,17 +49,41 @@ # Create named logger logger = logging.getLogger(__name__) - - - - - - - - - - - +from .reader import Reader +from .helpers import fsdecode_if_pathlike, _Array +from .shapes import ( + Shape, + NullShape, + Point, + Polyline, + Polygon, + # ...add other shape classes as needed +) +from .types import ( + Point2D, + Point3D, + PointMT, + PointZT, + Coord, + Coords, + PointT, + PointsT, + BBox, + MBox, + ZBox, + WriteableBinStream, + ReadableBinStream, + WriteSeekableBinStream, + ReadSeekableBinStream, + ReadWriteSeekableBinStream, + BinaryFileT, + BinaryFileStreamT, + FieldTypeT, + FieldType, + FIELD_TYPE_ALIASES, + RecordValueNotDate, + RecordValue, +) def main() -> None: """ From 1e2f2fe1c617edb886b2632a5fb8134156dda14a Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:47:14 +0100 Subject: [PATCH 02/24] Update _doctest_runner.py --- src/shapefile/_doctest_runner.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shapefile/_doctest_runner.py b/src/shapefile/_doctest_runner.py index 59d5d66..36f47a6 100644 --- a/src/shapefile/_doctest_runner.py +++ b/src/shapefile/_doctest_runner.py @@ -1,6 +1,10 @@ import doctest +import sys +from typing import Iterable, Iterator +from urllib.parse import urlparse, urlunparse + +from .constants import REPLACE_REMOTE_URLS_WITH_LOCALHOST -# Begin Testing def _get_doctests() -> doctest.DocTest: # run tests with open("README.md", "rb") as fobj: From fdb65af367b4e1ea453e6fe8ee0845d7e35822c0 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:48:24 +0100 Subject: [PATCH 03/24] Update constants.py --- src/shapefile/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shapefile/constants.py b/src/shapefile/constants.py index 37f4b48..a4fb556 100644 --- a/src/shapefile/constants.py +++ b/src/shapefile/constants.py @@ -1,3 +1,4 @@ +import os # Module settings VERBOSE = True From f0ebb62213c60c6ae86fe5428b507a7e4355e301 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:50:40 +0100 Subject: [PATCH 04/24] Update types.py --- src/shapefile/types.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shapefile/types.py b/src/shapefile/types.py index c23f521..67da2f0 100644 --- a/src/shapefile/types.py +++ b/src/shapefile/types.py @@ -1,3 +1,17 @@ +from typing import ( + Any, + Final, + IO, + Literal, + Optional, + Protocol, + TypeVar, + Union, +) +from datetime import date +import io +from os import PathLike + ## Custom type variables From 68958251279858d0b830eee5f77d839c24a3e2c9 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:52:23 +0100 Subject: [PATCH 05/24] Update helpers.py --- src/shapefile/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shapefile/helpers.py b/src/shapefile/helpers.py index aa28394..d9b22cb 100644 --- a/src/shapefile/helpers.py +++ b/src/shapefile/helpers.py @@ -1,10 +1,11 @@ - import array import os from os import PathLike from struct import Struct from typing import overload, TypeVar, Generic, Any +from .types import T + # Helpers From a5d6213ebf647db399735232f7f89c3485de39c0 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:59:51 +0100 Subject: [PATCH 06/24] Update classes.py --- src/shapefile/classes.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/shapefile/classes.py b/src/shapefile/classes.py index 3a9901a..5b1a8af 100644 --- a/src/shapefile/classes.py +++ b/src/shapefile/classes.py @@ -1,6 +1,10 @@ -from typing import NamedTuple +from __future__ import annotations +from datetime import date +from typing import NamedTuple, overload, SupportsIndex, Iterable, Any, Optional -from shapefile.types import FieldTypeT +from shapefile.constants import FIELD_TYPE_ALIASES, FieldType, ShapefileException +from shapefile.types import FieldTypeT, RecordValue, RecordValueNotDate, GeoJSONFeature, GeoJSONFeatureCollection, GeoJSONGeometryCollection +from shapefile.shapes import Shape, NULL # Use functional syntax to have an attribute named type, a Python keyword class Field(NamedTuple): From 79af96df6f8353f76af55f1f56aad3a7f0acdb37 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:00:39 +0100 Subject: [PATCH 07/24] Update geometric_calculations.py --- src/shapefile/geometric_calculations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shapefile/geometric_calculations.py b/src/shapefile/geometric_calculations.py index 8dc1ad6..c64bc38 100644 --- a/src/shapefile/geometric_calculations.py +++ b/src/shapefile/geometric_calculations.py @@ -1,4 +1,5 @@ - +from .types import PointsT, PointT, Point2D, BBox +from typing import Reversible, Iterable, Iterator def signed_area( coords: PointsT, From cbd7a57b12f6a816087f24bbea37193c207722f2 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:06:09 +0100 Subject: [PATCH 08/24] Fix geojson.py --- src/shapefile/__init__.py | 2 +- src/shapefile/exceptions.py | 5 ++++ src/shapefile/geojson.py | 37 +++++++++++++++++++++---- src/shapefile/geometric_calculations.py | 6 ++-- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 4053830..e3948ef 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -46,7 +46,7 @@ # overload, # ) -# Create named logger + logger = logging.getLogger(__name__) from .reader import Reader diff --git a/src/shapefile/exceptions.py b/src/shapefile/exceptions.py index f0f34f4..20d5b9b 100644 --- a/src/shapefile/exceptions.py +++ b/src/shapefile/exceptions.py @@ -1,4 +1,9 @@ +class RingSamplingError(Exception): + pass + +class GeoJSON_Error(Exception): + pass class ShapefileException(Exception): """An exception to handle shapefile specific problems.""" diff --git a/src/shapefile/geojson.py b/src/shapefile/geojson.py index 0037cb5..5a01495 100644 --- a/src/shapefile/geojson.py +++ b/src/shapefile/geojson.py @@ -1,6 +1,31 @@ - -class GeoJSON_Error(Exception): - pass +from __future__ import annotations + +import logging +from typing import (Any, TypedDict, Union, Protocol, Literal, cast, Self) + +from .constants import ( + NULL, + POINT, + POINTM, + POINTZ, + MULTIPOINT, + MULTIPOINTM, + MULTIPOINTZ, + POLYLINE, + POLYLINEM, + POLYLINEZ, + POLYGON, + POLYGONM, + POLYGONZ, + SHAPETYPE_LOOKUP, + VERBOSE +) +from .exceptions import GeoJSON_Error +from .geometric_calculations import is_cw, rewind, organize_polygon_rings +from .types import PointT, PointsT + + +logger = logging.getLogger(__name__) class HasGeoInterface(Protocol): @property @@ -209,8 +234,8 @@ def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: f'Shape type "{SHAPETYPE_LOOKUP[self.shapeType]}" cannot be represented as GeoJSON.' ) - @staticmethod - def _from_geojson(geoj: GeoJSONHomogeneousGeometryObject) -> Shape: + @classmethod + def _from_geojson(cls, geoj: GeoJSONHomogeneousGeometryObject) -> Self: # create empty shape # set shapeType geojType = geoj["type"] if geoj else "Null" @@ -277,4 +302,4 @@ def _from_geojson(geoj: GeoJSONHomogeneousGeometryObject) -> Shape: points.extend(ext_or_hole) parts.append(index) index += len(ext_or_hole) - return Shape(shapeType=shapeType, points=points, parts=parts) \ No newline at end of file + return cls(shapeType=shapeType, points=points, parts=parts) \ No newline at end of file diff --git a/src/shapefile/geometric_calculations.py b/src/shapefile/geometric_calculations.py index c64bc38..d166804 100644 --- a/src/shapefile/geometric_calculations.py +++ b/src/shapefile/geometric_calculations.py @@ -1,6 +1,8 @@ -from .types import PointsT, PointT, Point2D, BBox from typing import Reversible, Iterable, Iterator +from .types import PointsT, PointT, Point2D, BBox +from .exceptions import RingSamplingError + def signed_area( coords: PointsT, fast: bool = False, @@ -102,8 +104,6 @@ def ring_contains_point(coords: PointsT, p: Point2D) -> bool: return inside_flag -class RingSamplingError(Exception): - pass def ring_sample(coords: PointsT, ccw: bool = False) -> Point2D: From a4924aed94cc67ee27ab5b6a10932f8980fa7bf8 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:15:20 +0100 Subject: [PATCH 09/24] Update reader.py --- src/shapefile/reader.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/shapefile/reader.py b/src/shapefile/reader.py index f0afa4b..1581f9a 100644 --- a/src/shapefile/reader.py +++ b/src/shapefile/reader.py @@ -1,8 +1,26 @@ - +from __future__ import annotations + +from datetime import date +import io +import os +from os import PathLike +from struct import Struct, calcsize, error, pack, unpack +import sys +import tempfile +from types import TracebackType +from typing import (Any, IO, Union, cast, Iterator, Container, Iterable) from urllib.error import HTTPError from urllib.parse import urlparse, urlunparse from urllib.request import Request, urlopen - +import zipfile + +from .classes import _Record, Shapes, ShapeRecord, ShapeRecords, GeoJSONFeatureCollectionWithBBox, _Array, FIELD_TYPE_ALIASES +from .constants import (NODATA, SHAPETYPE_LOOKUP, SHAPE_CLASS_FROM_SHAPETYPE) +from .exceptions import ShapefileException +from .helpers import fsdecode_if_pathlike, unpack_2_int32_be +from .shapes import Shape +from .types import (BBox, ZBox, BinaryFileT, BinaryFileStreamT, + Field, FieldType, T, ReadSeekableBinStream) class _NoShpSentinel: """For use as a default value for shp to preserve the From 9d6f665a73d7468858ac0bc34d46f179eb26fc25 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:22:40 +0100 Subject: [PATCH 10/24] Update writer.py --- src/shapefile/writer.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/shapefile/writer.py b/src/shapefile/writer.py index 94f0dda..11d03eb 100644 --- a/src/shapefile/writer.py +++ b/src/shapefile/writer.py @@ -1,3 +1,37 @@ +from __future__ import annotations + +import io +import os +import time +from datetime import date +from os import PathLike +from struct import calcsize, error, pack, unpack +from types import TracebackType +from typing import ( + Any, + Literal, + NoReturn, + TypeVar, + Union, + cast, + overload, +) + + +from .classes import Field, RecordValue +from .constants import NULL, MISSING, SHAPETYPE_LOOKUP +from .exceptions import ShapefileException +from .geojson import HasGeoInterface, GeoJSONHomogeneousGeometryObject +from .helpers import fsdecode_if_pathlike +from .shapes import (Shape, NullShape, + Point, PointM, PointZ, + MultiPoint, MultiPointM, MultiPointZ, + Polyline, PolylineM, PolylineZ, + Polygon, PolygonM, PolygonZ, + MultiPatch, + Polyline_HasM, _HasZ, _HasM, _CanHaveBBox_shapeTypes, + PointM_shapeTypes, PointZ_shapeTypes, _HasM_shapeTypes, _HasZ_shapeTypes, SHAPE_CLASS_FROM_SHAPETYPE, SHAPETYPE_LOOKUP) +from .types import (WriteSeekableBinStream, BBox, ZBox, MBox, BinaryFileStreamT, Field, ReadWriteSeekableBinStream, PointsT, FieldTypeT) class Writer: """Provides write support for ESRI Shapefiles.""" From 6a3a5ea65678607a38e1f7ad731e9379e0194561 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:27:41 +0100 Subject: [PATCH 11/24] Update shapes.py --- src/shapefile/shapes.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/shapefile/shapes.py b/src/shapefile/shapes.py index 5611ea5..3e5401b 100644 --- a/src/shapefile/shapes.py +++ b/src/shapefile/shapes.py @@ -1,3 +1,43 @@ +from __future__ import annotations +from typing import (Sequence, Iterator, Iterable, TypedDict, Final, cast, Union) +from struct import pack, unpack, error + +from .classes import _Array +from .constants import ( + NULL, + NODATA, + POINT, + POINTM, + POINTZ, + POLYLINE, + POLYLINEM, + POLYLINEZ, + POLYGON, + POLYGONM, + POLYGONZ, + MULTIPOINT, + MULTIPOINTM, + MULTIPOINTZ, + MULTIPATCH, + SHAPETYPE_LOOKUP, + SHAPETYPENUM_LOOKUP, +) +from .exceptions import ShapefileException +from .geojson import GeoJSONSerisalizableShape +from .geometric_calculations import bbox_overlap +from .types import ( + PointT, + Point2D, + Point3D, + PointMT, + PointZT, + PointsT, + BBox, + MBox, + ZBox, + ReadSeekableBinStream, + WriteableBinStream, ReadableBinStream,WriteSeekableBinStream +) class _NoShapeTypeSentinel: """For use as a default value for Shape.__init__ to From 91028481a07e3415c577929e850e9e2736e75573 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:29:19 +0100 Subject: [PATCH 12/24] Ruff check . --fix --- src/shapefile/__init__.py | 42 ++++++++++--------- src/shapefile/_doctest_runner.py | 3 +- src/shapefile/classes.py | 15 +++++-- src/shapefile/geojson.py | 21 +++++----- src/shapefile/geometric_calculations.py | 5 ++- src/shapefile/helpers.py | 2 +- src/shapefile/reader.py | 36 ++++++++++++----- src/shapefile/shapes.py | 37 +++++++++-------- src/shapefile/types.py | 9 ++--- src/shapefile/writer.py | 54 +++++++++++++++++++------ 10 files changed, 142 insertions(+), 82 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index e3948ef..555cb6a 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -13,11 +13,12 @@ ] +import logging +import sys + from .__version__ import __version__ from ._doctest_runner import _test -import logging -import sys # import io # import os # import tempfile @@ -49,42 +50,43 @@ logger = logging.getLogger(__name__) +from .helpers import _Array, fsdecode_if_pathlike from .reader import Reader -from .helpers import fsdecode_if_pathlike, _Array from .shapes import ( - Shape, NullShape, Point, - Polyline, Polygon, # ...add other shape classes as needed + Polyline, + Shape, ) from .types import ( + FIELD_TYPE_ALIASES, + BBox, + BinaryFileStreamT, + BinaryFileT, + Coord, + Coords, + FieldType, + FieldTypeT, + MBox, Point2D, Point3D, PointMT, - PointZT, - Coord, - Coords, - PointT, PointsT, - BBox, - MBox, - ZBox, - WriteableBinStream, + PointT, + PointZT, ReadableBinStream, - WriteSeekableBinStream, ReadSeekableBinStream, ReadWriteSeekableBinStream, - BinaryFileT, - BinaryFileStreamT, - FieldTypeT, - FieldType, - FIELD_TYPE_ALIASES, - RecordValueNotDate, RecordValue, + RecordValueNotDate, + WriteableBinStream, + WriteSeekableBinStream, + ZBox, ) + def main() -> None: """ Doctests are contained in the file 'README.md', and are tested using the built-in diff --git a/src/shapefile/_doctest_runner.py b/src/shapefile/_doctest_runner.py index 36f47a6..fb0bd86 100644 --- a/src/shapefile/_doctest_runner.py +++ b/src/shapefile/_doctest_runner.py @@ -1,10 +1,11 @@ import doctest import sys -from typing import Iterable, Iterator +from collections.abc import Iterable, Iterator from urllib.parse import urlparse, urlunparse from .constants import REPLACE_REMOTE_URLS_WITH_LOCALHOST + def _get_doctests() -> doctest.DocTest: # run tests with open("README.md", "rb") as fobj: diff --git a/src/shapefile/classes.py b/src/shapefile/classes.py index 5b1a8af..4131efa 100644 --- a/src/shapefile/classes.py +++ b/src/shapefile/classes.py @@ -1,10 +1,19 @@ from __future__ import annotations + +from collections.abc import Iterable from datetime import date -from typing import NamedTuple, overload, SupportsIndex, Iterable, Any, Optional +from typing import Any, NamedTuple, Optional, SupportsIndex, overload from shapefile.constants import FIELD_TYPE_ALIASES, FieldType, ShapefileException -from shapefile.types import FieldTypeT, RecordValue, RecordValueNotDate, GeoJSONFeature, GeoJSONFeatureCollection, GeoJSONGeometryCollection -from shapefile.shapes import Shape, NULL +from shapefile.shapes import NULL, Shape +from shapefile.types import ( + FieldTypeT, + GeoJSONFeature, + GeoJSONFeatureCollection, + GeoJSONGeometryCollection, + RecordValue, +) + # Use functional syntax to have an attribute named type, a Python keyword class Field(NamedTuple): diff --git a/src/shapefile/geojson.py b/src/shapefile/geojson.py index 5a01495..b4e1d20 100644 --- a/src/shapefile/geojson.py +++ b/src/shapefile/geojson.py @@ -1,29 +1,28 @@ from __future__ import annotations import logging -from typing import (Any, TypedDict, Union, Protocol, Literal, cast, Self) +from typing import Any, Literal, Protocol, Self, TypedDict, Union, cast from .constants import ( + MULTIPOINT, + MULTIPOINTM, + MULTIPOINTZ, NULL, POINT, POINTM, POINTZ, - MULTIPOINT, - MULTIPOINTM, - MULTIPOINTZ, - POLYLINE, - POLYLINEM, - POLYLINEZ, POLYGON, POLYGONM, POLYGONZ, + POLYLINE, + POLYLINEM, + POLYLINEZ, SHAPETYPE_LOOKUP, - VERBOSE + VERBOSE, ) from .exceptions import GeoJSON_Error -from .geometric_calculations import is_cw, rewind, organize_polygon_rings -from .types import PointT, PointsT - +from .geometric_calculations import is_cw, organize_polygon_rings, rewind +from .types import PointsT, PointT logger = logging.getLogger(__name__) diff --git a/src/shapefile/geometric_calculations.py b/src/shapefile/geometric_calculations.py index d166804..fabbcea 100644 --- a/src/shapefile/geometric_calculations.py +++ b/src/shapefile/geometric_calculations.py @@ -1,7 +1,8 @@ -from typing import Reversible, Iterable, Iterator +from collections.abc import Iterable, Iterator, Reversible -from .types import PointsT, PointT, Point2D, BBox from .exceptions import RingSamplingError +from .types import BBox, Point2D, PointsT, PointT + def signed_area( coords: PointsT, diff --git a/src/shapefile/helpers.py b/src/shapefile/helpers.py index d9b22cb..3c6e1b0 100644 --- a/src/shapefile/helpers.py +++ b/src/shapefile/helpers.py @@ -2,7 +2,7 @@ import os from os import PathLike from struct import Struct -from typing import overload, TypeVar, Generic, Any +from typing import Any, Generic, TypeVar, overload from .types import T diff --git a/src/shapefile/reader.py b/src/shapefile/reader.py index 1581f9a..a4a19d7 100644 --- a/src/shapefile/reader.py +++ b/src/shapefile/reader.py @@ -1,26 +1,44 @@ from __future__ import annotations -from datetime import date import io import os -from os import PathLike -from struct import Struct, calcsize, error, pack, unpack import sys import tempfile +import zipfile +from collections.abc import Container, Iterable, Iterator +from datetime import date +from os import PathLike +from struct import Struct, calcsize, unpack from types import TracebackType -from typing import (Any, IO, Union, cast, Iterator, Container, Iterable) +from typing import IO, Any, Union, cast from urllib.error import HTTPError from urllib.parse import urlparse, urlunparse from urllib.request import Request, urlopen -import zipfile -from .classes import _Record, Shapes, ShapeRecord, ShapeRecords, GeoJSONFeatureCollectionWithBBox, _Array, FIELD_TYPE_ALIASES -from .constants import (NODATA, SHAPETYPE_LOOKUP, SHAPE_CLASS_FROM_SHAPETYPE) +from .classes import ( + FIELD_TYPE_ALIASES, + GeoJSONFeatureCollectionWithBBox, + ShapeRecord, + ShapeRecords, + Shapes, + _Array, + _Record, +) +from .constants import NODATA, SHAPE_CLASS_FROM_SHAPETYPE, SHAPETYPE_LOOKUP from .exceptions import ShapefileException from .helpers import fsdecode_if_pathlike, unpack_2_int32_be from .shapes import Shape -from .types import (BBox, ZBox, BinaryFileT, BinaryFileStreamT, - Field, FieldType, T, ReadSeekableBinStream) +from .types import ( + BBox, + BinaryFileStreamT, + BinaryFileT, + Field, + FieldType, + ReadSeekableBinStream, + T, + ZBox, +) + class _NoShpSentinel: """For use as a default value for shp to preserve the diff --git a/src/shapefile/shapes.py b/src/shapefile/shapes.py index 3e5401b..83a5ad1 100644 --- a/src/shapefile/shapes.py +++ b/src/shapefile/shapes.py @@ -1,24 +1,26 @@ from __future__ import annotations -from typing import (Sequence, Iterator, Iterable, TypedDict, Final, cast, Union) -from struct import pack, unpack, error + +from collections.abc import Iterable, Iterator, Sequence +from struct import error, pack, unpack +from typing import Final, TypedDict, Union, cast from .classes import _Array from .constants import ( - NULL, + MULTIPATCH, + MULTIPOINT, + MULTIPOINTM, + MULTIPOINTZ, NODATA, + NULL, POINT, POINTM, POINTZ, - POLYLINE, - POLYLINEM, - POLYLINEZ, POLYGON, POLYGONM, POLYGONZ, - MULTIPOINT, - MULTIPOINTM, - MULTIPOINTZ, - MULTIPATCH, + POLYLINE, + POLYLINEM, + POLYLINEZ, SHAPETYPE_LOOKUP, SHAPETYPENUM_LOOKUP, ) @@ -26,19 +28,20 @@ from .geojson import GeoJSONSerisalizableShape from .geometric_calculations import bbox_overlap from .types import ( - PointT, + BBox, + MBox, Point2D, - Point3D, PointMT, - PointZT, PointsT, - BBox, - MBox, - ZBox, + PointT, + PointZT, + ReadableBinStream, ReadSeekableBinStream, - WriteableBinStream, ReadableBinStream,WriteSeekableBinStream + WriteableBinStream, + ZBox, ) + class _NoShapeTypeSentinel: """For use as a default value for Shape.__init__ to preserve old behaviour for anyone who explictly diff --git a/src/shapefile/types.py b/src/shapefile/types.py index 67da2f0..37ae241 100644 --- a/src/shapefile/types.py +++ b/src/shapefile/types.py @@ -1,17 +1,16 @@ +import io +from datetime import date +from os import PathLike from typing import ( + IO, Any, Final, - IO, Literal, Optional, Protocol, TypeVar, Union, ) -from datetime import date -import io -from os import PathLike - ## Custom type variables diff --git a/src/shapefile/writer.py b/src/shapefile/writer.py index 11d03eb..3db5e9c 100644 --- a/src/shapefile/writer.py +++ b/src/shapefile/writer.py @@ -5,7 +5,7 @@ import time from datetime import date from os import PathLike -from struct import calcsize, error, pack, unpack +from struct import error, pack from types import TracebackType from typing import ( Any, @@ -17,21 +17,49 @@ overload, ) - from .classes import Field, RecordValue -from .constants import NULL, MISSING, SHAPETYPE_LOOKUP +from .constants import MISSING, NULL, SHAPETYPE_LOOKUP from .exceptions import ShapefileException -from .geojson import HasGeoInterface, GeoJSONHomogeneousGeometryObject +from .geojson import GeoJSONHomogeneousGeometryObject, HasGeoInterface from .helpers import fsdecode_if_pathlike -from .shapes import (Shape, NullShape, - Point, PointM, PointZ, - MultiPoint, MultiPointM, MultiPointZ, - Polyline, PolylineM, PolylineZ, - Polygon, PolygonM, PolygonZ, - MultiPatch, - Polyline_HasM, _HasZ, _HasM, _CanHaveBBox_shapeTypes, - PointM_shapeTypes, PointZ_shapeTypes, _HasM_shapeTypes, _HasZ_shapeTypes, SHAPE_CLASS_FROM_SHAPETYPE, SHAPETYPE_LOOKUP) -from .types import (WriteSeekableBinStream, BBox, ZBox, MBox, BinaryFileStreamT, Field, ReadWriteSeekableBinStream, PointsT, FieldTypeT) +from .shapes import ( + SHAPE_CLASS_FROM_SHAPETYPE, + SHAPETYPE_LOOKUP, + MultiPatch, + MultiPoint, + MultiPointM, + MultiPointZ, + NullShape, + Point, + PointM, + PointM_shapeTypes, + PointZ, + PointZ_shapeTypes, + Polygon, + PolygonM, + PolygonZ, + Polyline, + PolylineM, + PolylineZ, + Shape, + _CanHaveBBox_shapeTypes, + _HasM, + _HasM_shapeTypes, + _HasZ, + _HasZ_shapeTypes, +) +from .types import ( + BBox, + BinaryFileStreamT, + Field, + FieldTypeT, + MBox, + PointsT, + ReadWriteSeekableBinStream, + WriteSeekableBinStream, + ZBox, +) + class Writer: """Provides write support for ESRI Shapefiles.""" From 12343a1cbeb60ae970072617a869c26040e31332 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:32:26 +0100 Subject: [PATCH 13/24] Update __init__.py --- src/shapefile/__init__.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 555cb6a..6f3e825 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -19,34 +19,6 @@ from .__version__ import __version__ from ._doctest_runner import _test -# import io -# import os -# import tempfile -# import time -# import zipfile -# from collections.abc import Container, Iterable, Iterator, Reversible, Sequence -# from datetime import date -# from os import PathLike -# from struct import Struct, calcsize, error, pack, unpack -# from types import TracebackType -# from typing import ( -# IO, -# Any, -# Final, -# Generic, -# Literal, -# NamedTuple, -# NoReturn, -# Optional, -# Protocol, -# SupportsIndex, -# TypedDict, -# TypeVar, -# Union, -# cast, -# overload, -# ) - logger = logging.getLogger(__name__) From d72f16e405799f7b8eb6365de2d69639d16ef485 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:32:30 +0100 Subject: [PATCH 14/24] Update writer.py --- src/shapefile/writer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shapefile/writer.py b/src/shapefile/writer.py index 3db5e9c..8d816cc 100644 --- a/src/shapefile/writer.py +++ b/src/shapefile/writer.py @@ -24,7 +24,6 @@ from .helpers import fsdecode_if_pathlike from .shapes import ( SHAPE_CLASS_FROM_SHAPETYPE, - SHAPETYPE_LOOKUP, MultiPatch, MultiPoint, MultiPointM, @@ -51,7 +50,6 @@ from .types import ( BBox, BinaryFileStreamT, - Field, FieldTypeT, MBox, PointsT, From a3436b7084da5f74431129617e1c59383e66fc56 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:36:00 +0100 Subject: [PATCH 15/24] Ruff checked and formatted. --- src/shapefile/__init__.py | 83 ++++++++++++++++++++++--- src/shapefile/__version__.py | 3 +- src/shapefile/_doctest_runner.py | 2 +- src/shapefile/classes.py | 2 +- src/shapefile/constants.py | 2 +- src/shapefile/exceptions.py | 4 +- src/shapefile/geojson.py | 6 +- src/shapefile/geometric_calculations.py | 4 +- src/shapefile/helpers.py | 2 +- src/shapefile/shapes.py | 4 +- src/shapefile/types.py | 1 - src/shapefile/writer.py | 2 +- 12 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 6f3e825..9f54c95 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -8,29 +8,37 @@ from __future__ import annotations -__all__ = [ - "__version__" - -] - import logging import sys from .__version__ import __version__ from ._doctest_runner import _test - - -logger = logging.getLogger(__name__) - from .helpers import _Array, fsdecode_if_pathlike from .reader import Reader from .shapes import ( + SHAPE_CLASS_FROM_SHAPETYPE, + MultiPatch, + MultiPoint, + MultiPointM, + MultiPointZ, NullShape, Point, + PointM, + PointM_shapeTypes, + PointZ, + PointZ_shapeTypes, Polygon, - # ...add other shape classes as needed + PolygonM, + PolygonZ, Polyline, + PolylineM, + PolylineZ, Shape, + _CanHaveBBox_shapeTypes, + _HasM, + _HasM_shapeTypes, + _HasZ, + _HasZ_shapeTypes, ) from .types import ( FIELD_TYPE_ALIASES, @@ -58,6 +66,61 @@ ZBox, ) +__all__ = [ + "__version__", + "Reader", + "fsdecode_if_pathlike", + "_Array", + "Shape", + "NullShape", + "Point", + "Polyline", + "Polygon", + "MultiPoint", + "MultiPointM", + "MultiPointZ", + "PolygonM", + "PolygonZ", + "PolylineM", + "PolylineZ", + "MultiPatch", + "PointM", + "PointZ", + "SHAPE_CLASS_FROM_SHAPETYPE", + "PointM_shapeTypes", + "PointZ_shapeTypes", + "_CanHaveBBox_shapeTypes", + "_HasM", + "_HasM_shapeTypes", + "_HasZ", + "_HasZ_shapeTypes", + "Point2D", + "Point3D", + "PointMT", + "PointZT", + "Coord", + "Coords", + "PointT", + "PointsT", + "BBox", + "MBox", + "ZBox", + "WriteableBinStream", + "ReadableBinStream", + "WriteSeekableBinStream", + "ReadSeekableBinStream", + "ReadWriteSeekableBinStream", + "BinaryFileT", + "BinaryFileStreamT", + "FieldTypeT", + "FieldType", + "FIELD_TYPE_ALIASES", + "RecordValueNotDate", + "RecordValue", +] + +logger = logging.getLogger(__name__) + def main() -> None: """ diff --git a/src/shapefile/__version__.py b/src/shapefile/__version__.py index 7234b3c..131942e 100644 --- a/src/shapefile/__version__.py +++ b/src/shapefile/__version__.py @@ -1,2 +1 @@ - -__version__ = "3.0.2" \ No newline at end of file +__version__ = "3.0.2" diff --git a/src/shapefile/_doctest_runner.py b/src/shapefile/_doctest_runner.py index fb0bd86..c5cc5f0 100644 --- a/src/shapefile/_doctest_runner.py +++ b/src/shapefile/_doctest_runner.py @@ -144,4 +144,4 @@ def _test(args: list[str] = sys.argv[1:], verbosity: bool = False) -> int: elif failure_count > 0: runner.summarize(verbosity) - return failure_count \ No newline at end of file + return failure_count diff --git a/src/shapefile/classes.py b/src/shapefile/classes.py index 4131efa..fddce3b 100644 --- a/src/shapefile/classes.py +++ b/src/shapefile/classes.py @@ -283,4 +283,4 @@ def __geo_interface__(self) -> GeoJSONFeatureCollection: return GeoJSONFeatureCollection( type="FeatureCollection", features=[shaperec.__geo_interface__ for shaperec in self], - ) \ No newline at end of file + ) diff --git a/src/shapefile/constants.py b/src/shapefile/constants.py index a4fb556..fa399c6 100644 --- a/src/shapefile/constants.py +++ b/src/shapefile/constants.py @@ -61,4 +61,4 @@ MISSING = (None, "") # Don't make a set, as user input may not be Hashable -NODATA = -10e38 # as per the ESRI shapefile spec, only used for m-values. \ No newline at end of file +NODATA = -10e38 # as per the ESRI shapefile spec, only used for m-values. diff --git a/src/shapefile/exceptions.py b/src/shapefile/exceptions.py index 20d5b9b..0f49645 100644 --- a/src/shapefile/exceptions.py +++ b/src/shapefile/exceptions.py @@ -1,10 +1,10 @@ - class RingSamplingError(Exception): pass + class GeoJSON_Error(Exception): pass + class ShapefileException(Exception): """An exception to handle shapefile specific problems.""" - diff --git a/src/shapefile/geojson.py b/src/shapefile/geojson.py index b4e1d20..7d9ea33 100644 --- a/src/shapefile/geojson.py +++ b/src/shapefile/geojson.py @@ -25,7 +25,8 @@ from .types import PointsT, PointT logger = logging.getLogger(__name__) - + + class HasGeoInterface(Protocol): @property def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: ... @@ -119,6 +120,7 @@ class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection): # (PyShp's resisted having any other dependencies so far!) bbox: list[float] + class GeoJSONSerisalizableShape: @property def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: @@ -301,4 +303,4 @@ def _from_geojson(cls, geoj: GeoJSONHomogeneousGeometryObject) -> Self: points.extend(ext_or_hole) parts.append(index) index += len(ext_or_hole) - return cls(shapeType=shapeType, points=points, parts=parts) \ No newline at end of file + return cls(shapeType=shapeType, points=points, parts=parts) diff --git a/src/shapefile/geometric_calculations.py b/src/shapefile/geometric_calculations.py index fabbcea..d746873 100644 --- a/src/shapefile/geometric_calculations.py +++ b/src/shapefile/geometric_calculations.py @@ -105,8 +105,6 @@ def ring_contains_point(coords: PointsT, p: Point2D) -> bool: return inside_flag - - def ring_sample(coords: PointsT, ccw: bool = False) -> Point2D: """Return a sample point guaranteed to be within a ring, by efficiently finding the first centroid of a coordinate triplet whose orientation @@ -288,4 +286,4 @@ def organize_polygon_rings( exteriors = holes # add as single exterior without any holes polys = [[ext] for ext in exteriors] - return polys \ No newline at end of file + return polys diff --git a/src/shapefile/helpers.py b/src/shapefile/helpers.py index 3c6e1b0..dba7f2d 100644 --- a/src/shapefile/helpers.py +++ b/src/shapefile/helpers.py @@ -35,4 +35,4 @@ class _Array(array.array, Generic[ARR_TYPE]): # type: ignore[type-arg] Used to unpack different shapefile header parts.""" def __repr__(self) -> str: - return str(self.tolist()) \ No newline at end of file + return str(self.tolist()) diff --git a/src/shapefile/shapes.py b/src/shapefile/shapes.py index 83a5ad1..dbafc41 100644 --- a/src/shapefile/shapes.py +++ b/src/shapefile/shapes.py @@ -84,6 +84,8 @@ class CanHaveBboxNoLinesKwargs(TypedDict, total=False): z: Sequence[float] | None mbox: MBox | None zbox: ZBox | None + + class Shape(GeoJSONSerisalizableShape): def __init__( self, @@ -256,8 +258,6 @@ def _mbox_from_ms(self) -> MBox: def _zbox_from_zs(self) -> ZBox: return min(self.z), max(self.z) - - @property def oid(self) -> int: """The index position of the shape in the original shapefile""" diff --git a/src/shapefile/types.py b/src/shapefile/types.py index 37ae241..2705708 100644 --- a/src/shapefile/types.py +++ b/src/shapefile/types.py @@ -93,7 +93,6 @@ class FieldType: FIELD_TYPE_ALIASES[c.encode("ascii").upper()] = c - RecordValueNotDate = Union[bool, int, float, str] # A Possible value in a Shapefile dbf record, i.e. L, N, M, F, C, or D types diff --git a/src/shapefile/writer.py b/src/shapefile/writer.py index 8d816cc..1c19371 100644 --- a/src/shapefile/writer.py +++ b/src/shapefile/writer.py @@ -781,4 +781,4 @@ def field( "Shapefile Writer reached maximum number of fields: 2046." ) field_ = Field.from_unchecked(name, field_type, size, decimal) - self.fields.append(field_) \ No newline at end of file + self.fields.append(field_) From b7e4becabb31d1d774fe271e7488f23a165c9936 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:58:40 +0100 Subject: [PATCH 16/24] Mypy strict and ruff check passed --- src/shapefile/classes.py | 13 +- src/shapefile/geojson.py | 306 --------------------------------- src/shapefile/geojson_types.py | 106 ++++++++++++ src/shapefile/reader.py | 13 +- src/shapefile/shapes.py | 202 +++++++++++++++++++++- src/shapefile/writer.py | 5 +- 6 files changed, 320 insertions(+), 325 deletions(-) delete mode 100644 src/shapefile/geojson.py create mode 100644 src/shapefile/geojson_types.py diff --git a/src/shapefile/classes.py b/src/shapefile/classes.py index fddce3b..36d9c87 100644 --- a/src/shapefile/classes.py +++ b/src/shapefile/classes.py @@ -4,13 +4,18 @@ from datetime import date from typing import Any, NamedTuple, Optional, SupportsIndex, overload -from shapefile.constants import FIELD_TYPE_ALIASES, FieldType, ShapefileException -from shapefile.shapes import NULL, Shape -from shapefile.types import ( - FieldTypeT, +from .constants import NULL +from .exceptions import ShapefileException +from .geojson_types import ( GeoJSONFeature, GeoJSONFeatureCollection, GeoJSONGeometryCollection, +) +from .shapes import Shape +from .types import ( + FIELD_TYPE_ALIASES, + FieldType, + FieldTypeT, RecordValue, ) diff --git a/src/shapefile/geojson.py b/src/shapefile/geojson.py deleted file mode 100644 index 7d9ea33..0000000 --- a/src/shapefile/geojson.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any, Literal, Protocol, Self, TypedDict, Union, cast - -from .constants import ( - MULTIPOINT, - MULTIPOINTM, - MULTIPOINTZ, - NULL, - POINT, - POINTM, - POINTZ, - POLYGON, - POLYGONM, - POLYGONZ, - POLYLINE, - POLYLINEM, - POLYLINEZ, - SHAPETYPE_LOOKUP, - VERBOSE, -) -from .exceptions import GeoJSON_Error -from .geometric_calculations import is_cw, organize_polygon_rings, rewind -from .types import PointsT, PointT - -logger = logging.getLogger(__name__) - - -class HasGeoInterface(Protocol): - @property - def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: ... - - -class GeoJSONPoint(TypedDict): - type: Literal["Point"] - # We fix to a tuple (to statically check the length is 2, 3 or 4) but - # RFC7946 only requires: "A position is an array of numbers. There MUST be two or more - # elements. " - # RFC7946 also requires long/lat easting/northing which we do not enforce, - # and despite the SHOULD NOT, we may use a 4th element for Shapefile M Measures. - coordinates: PointT | tuple[()] - - -class GeoJSONMultiPoint(TypedDict): - type: Literal["MultiPoint"] - coordinates: PointsT - - -class GeoJSONLineString(TypedDict): - type: Literal["LineString"] - # "Two or more positions" not enforced by type checker - # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4 - coordinates: PointsT - - -class GeoJSONMultiLineString(TypedDict): - type: Literal["MultiLineString"] - coordinates: list[PointsT] - - -class GeoJSONPolygon(TypedDict): - type: Literal["Polygon"] - # Other requirements for Polygon not enforced by type checker - # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6 - coordinates: list[PointsT] - - -class GeoJSONMultiPolygon(TypedDict): - type: Literal["MultiPolygon"] - coordinates: list[list[PointsT]] - - -GeoJSONHomogeneousGeometryObject = Union[ - GeoJSONPoint, - GeoJSONMultiPoint, - GeoJSONLineString, - GeoJSONMultiLineString, - GeoJSONPolygon, - GeoJSONMultiPolygon, -] - -GEOJSON_TO_SHAPETYPE: dict[str, int] = { - "Null": NULL, - "Point": POINT, - "LineString": POLYLINE, - "Polygon": POLYGON, - "MultiPoint": MULTIPOINT, - "MultiLineString": POLYLINE, - "MultiPolygon": POLYGON, -} - - -class GeoJSONGeometryCollection(TypedDict): - type: Literal["GeometryCollection"] - geometries: list[GeoJSONHomogeneousGeometryObject] - - -# RFC7946 3.1 -GeoJSONObject = Union[GeoJSONHomogeneousGeometryObject, GeoJSONGeometryCollection] - - -class GeoJSONFeature(TypedDict): - type: Literal["Feature"] - properties: ( - dict[str, Any] | None - ) # RFC7946 3.2 "(any JSON object or a JSON null value)" - geometry: GeoJSONObject | None - - -class GeoJSONFeatureCollection(TypedDict): - type: Literal["FeatureCollection"] - features: list[GeoJSONFeature] - - -class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection): - # bbox is technically optional under the spec but this seems - # a very minor improvement that would require NotRequired - # from the typing-extensions backport for Python 3.9 - # (PyShp's resisted having any other dependencies so far!) - bbox: list[float] - - -class GeoJSONSerisalizableShape: - @property - def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: - if self.shapeType in {POINT, POINTM, POINTZ}: - # point - if len(self.points) == 0: - # the shape has no coordinate information, i.e. is 'empty' - # the geojson spec does not define a proper null-geometry type - # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries - return {"type": "Point", "coordinates": ()} - - return {"type": "Point", "coordinates": self.points[0]} - - if self.shapeType in {MULTIPOINT, MULTIPOINTM, MULTIPOINTZ}: - if len(self.points) == 0: - # the shape has no coordinate information, i.e. is 'empty' - # the geojson spec does not define a proper null-geometry type - # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries - return {"type": "MultiPoint", "coordinates": []} - - # multipoint - return { - "type": "MultiPoint", - "coordinates": self.points, - } - - if self.shapeType in {POLYLINE, POLYLINEM, POLYLINEZ}: - if len(self.parts) == 0: - # the shape has no coordinate information, i.e. is 'empty' - # the geojson spec does not define a proper null-geometry type - # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries - return {"type": "LineString", "coordinates": []} - - if len(self.parts) == 1: - # linestring - return { - "type": "LineString", - "coordinates": self.points, - } - - # multilinestring - ps = None - coordinates = [] - for part in self.parts: - if ps is None: - ps = part - continue - - coordinates.append(list(self.points[ps:part])) - ps = part - - # assert len(self.parts) > 1 - # from previous if len(self.parts) checks so part is defined - coordinates.append(list(self.points[part:])) - return {"type": "MultiLineString", "coordinates": coordinates} - - if self.shapeType in {POLYGON, POLYGONM, POLYGONZ}: - if len(self.parts) == 0: - # the shape has no coordinate information, i.e. is 'empty' - # the geojson spec does not define a proper null-geometry type - # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries - return {"type": "Polygon", "coordinates": []} - - # get all polygon rings - rings = [] - for i, start in enumerate(self.parts): - # get indexes of start and end points of the ring - try: - end = self.parts[i + 1] - except IndexError: - end = len(self.points) - - # extract the points that make up the ring - ring = list(self.points[start:end]) - rings.append(ring) - - # organize rings into list of polygons, where each polygon is defined as list of rings. - # the first ring is the exterior and any remaining rings are holes (same as GeoJSON). - polys = organize_polygon_rings(rings, self._errors) - - # if VERBOSE is True, issue detailed warning about any shape errors - # encountered during the Shapefile to GeoJSON conversion - if VERBOSE and self._errors: - header = f"Possible issue encountered when converting Shape #{self.oid} to GeoJSON: " - orphans = self._errors.get("polygon_orphaned_holes", None) - if orphans: - msg = ( - header - + "Shapefile format requires that all polygon interior holes be contained by an exterior ring, \ -but the Shape contained interior holes (defined by counter-clockwise orientation in the shapefile format) that were \ -orphaned, i.e. not contained by any exterior rings. The rings were still included but were \ -encoded as GeoJSON exterior rings instead of holes." - ) - logger.warning(msg) - only_holes = self._errors.get("polygon_only_holes", None) - if only_holes: - msg = ( - header - + "Shapefile format requires that polygons contain at least one exterior ring, \ -but the Shape was entirely made up of interior holes (defined by counter-clockwise orientation in the shapefile format). The rings were \ -still included but were encoded as GeoJSON exterior rings instead of holes." - ) - logger.warning(msg) - - # return as geojson - if len(polys) == 1: - return {"type": "Polygon", "coordinates": polys[0]} - - return {"type": "MultiPolygon", "coordinates": polys} - - raise GeoJSON_Error( - f'Shape type "{SHAPETYPE_LOOKUP[self.shapeType]}" cannot be represented as GeoJSON.' - ) - - @classmethod - def _from_geojson(cls, geoj: GeoJSONHomogeneousGeometryObject) -> Self: - # create empty shape - # set shapeType - geojType = geoj["type"] if geoj else "Null" - if geojType in GEOJSON_TO_SHAPETYPE: - shapeType = GEOJSON_TO_SHAPETYPE[geojType] - else: - raise GeoJSON_Error(f"Cannot create Shape from GeoJSON type '{geojType}'") - - coordinates = geoj["coordinates"] - - if coordinates == (): - raise GeoJSON_Error(f"Cannot create non-Null Shape from: {coordinates=}") - - points: PointsT - parts: list[int] - - # set points and parts - if geojType == "Point": - points = [cast(PointT, coordinates)] - parts = [0] - elif geojType in ("MultiPoint", "LineString"): - points = cast(PointsT, coordinates) - parts = [0] - elif geojType == "Polygon": - points = [] - parts = [] - index = 0 - for i, ext_or_hole in enumerate(cast(list[PointsT], coordinates)): - # although the latest GeoJSON spec states that exterior rings should have - # counter-clockwise orientation, we explicitly check orientation since older - # GeoJSONs might not enforce this. - if i == 0 and not is_cw(ext_or_hole): - # flip exterior direction - ext_or_hole = rewind(ext_or_hole) - elif i > 0 and is_cw(ext_or_hole): - # flip hole direction - ext_or_hole = rewind(ext_or_hole) - points.extend(ext_or_hole) - parts.append(index) - index += len(ext_or_hole) - elif geojType == "MultiLineString": - points = [] - parts = [] - index = 0 - for linestring in cast(list[PointsT], coordinates): - points.extend(linestring) - parts.append(index) - index += len(linestring) - elif geojType == "MultiPolygon": - points = [] - parts = [] - index = 0 - for polygon in cast(list[list[PointsT]], coordinates): - for i, ext_or_hole in enumerate(polygon): - # although the latest GeoJSON spec states that exterior rings should have - # counter-clockwise orientation, we explicitly check orientation since older - # GeoJSONs might not enforce this. - if i == 0 and not is_cw(ext_or_hole): - # flip exterior direction - ext_or_hole = rewind(ext_or_hole) - elif i > 0 and is_cw(ext_or_hole): - # flip hole direction - ext_or_hole = rewind(ext_or_hole) - points.extend(ext_or_hole) - parts.append(index) - index += len(ext_or_hole) - return cls(shapeType=shapeType, points=points, parts=parts) diff --git a/src/shapefile/geojson_types.py b/src/shapefile/geojson_types.py new file mode 100644 index 0000000..3fc9259 --- /dev/null +++ b/src/shapefile/geojson_types.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import Any, Literal, Protocol, TypedDict, Union + +from .constants import ( + MULTIPOINT, + NULL, + POINT, + POLYGON, + POLYLINE, +) +from .types import PointsT, PointT + + +class HasGeoInterface(Protocol): + @property + def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: ... + + +class GeoJSONPoint(TypedDict): + type: Literal["Point"] + # We fix to a tuple (to statically check the length is 2, 3 or 4) but + # RFC7946 only requires: "A position is an array of numbers. There MUST be two or more + # elements. " + # RFC7946 also requires long/lat easting/northing which we do not enforce, + # and despite the SHOULD NOT, we may use a 4th element for Shapefile M Measures. + coordinates: PointT | tuple[()] + + +class GeoJSONMultiPoint(TypedDict): + type: Literal["MultiPoint"] + coordinates: PointsT + + +class GeoJSONLineString(TypedDict): + type: Literal["LineString"] + # "Two or more positions" not enforced by type checker + # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4 + coordinates: PointsT + + +class GeoJSONMultiLineString(TypedDict): + type: Literal["MultiLineString"] + coordinates: list[PointsT] + + +class GeoJSONPolygon(TypedDict): + type: Literal["Polygon"] + # Other requirements for Polygon not enforced by type checker + # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6 + coordinates: list[PointsT] + + +class GeoJSONMultiPolygon(TypedDict): + type: Literal["MultiPolygon"] + coordinates: list[list[PointsT]] + + +GeoJSONHomogeneousGeometryObject = Union[ + GeoJSONPoint, + GeoJSONMultiPoint, + GeoJSONLineString, + GeoJSONMultiLineString, + GeoJSONPolygon, + GeoJSONMultiPolygon, +] + +GEOJSON_TO_SHAPETYPE: dict[str, int] = { + "Null": NULL, + "Point": POINT, + "LineString": POLYLINE, + "Polygon": POLYGON, + "MultiPoint": MULTIPOINT, + "MultiLineString": POLYLINE, + "MultiPolygon": POLYGON, +} + + +class GeoJSONGeometryCollection(TypedDict): + type: Literal["GeometryCollection"] + geometries: list[GeoJSONHomogeneousGeometryObject] + + +# RFC7946 3.1 +GeoJSONObject = Union[GeoJSONHomogeneousGeometryObject, GeoJSONGeometryCollection] + + +class GeoJSONFeature(TypedDict): + type: Literal["Feature"] + properties: ( + dict[str, Any] | None + ) # RFC7946 3.2 "(any JSON object or a JSON null value)" + geometry: GeoJSONObject | None + + +class GeoJSONFeatureCollection(TypedDict): + type: Literal["FeatureCollection"] + features: list[GeoJSONFeature] + + +class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection): + # bbox is technically optional under the spec but this seems + # a very minor improvement that would require NotRequired + # from the typing-extensions backport for Python 3.9 + # (PyShp's resisted having any other dependencies so far!) + bbox: list[float] diff --git a/src/shapefile/reader.py b/src/shapefile/reader.py index a4a19d7..02be0c1 100644 --- a/src/shapefile/reader.py +++ b/src/shapefile/reader.py @@ -16,23 +16,22 @@ from urllib.request import Request, urlopen from .classes import ( - FIELD_TYPE_ALIASES, - GeoJSONFeatureCollectionWithBBox, + Field, ShapeRecord, ShapeRecords, Shapes, - _Array, _Record, ) -from .constants import NODATA, SHAPE_CLASS_FROM_SHAPETYPE, SHAPETYPE_LOOKUP +from .constants import NODATA, SHAPETYPE_LOOKUP from .exceptions import ShapefileException -from .helpers import fsdecode_if_pathlike, unpack_2_int32_be -from .shapes import Shape +from .geojson_types import GeoJSONFeatureCollectionWithBBox +from .helpers import _Array, fsdecode_if_pathlike, unpack_2_int32_be +from .shapes import SHAPE_CLASS_FROM_SHAPETYPE, Shape from .types import ( + FIELD_TYPE_ALIASES, BBox, BinaryFileStreamT, BinaryFileT, - Field, FieldType, ReadSeekableBinStream, T, diff --git a/src/shapefile/shapes.py b/src/shapefile/shapes.py index dbafc41..40d03d4 100644 --- a/src/shapefile/shapes.py +++ b/src/shapefile/shapes.py @@ -1,10 +1,10 @@ from __future__ import annotations +import logging from collections.abc import Iterable, Iterator, Sequence from struct import error, pack, unpack -from typing import Final, TypedDict, Union, cast +from typing import Final, Self, TypedDict, Union, cast -from .classes import _Array from .constants import ( MULTIPATCH, MULTIPOINT, @@ -23,10 +23,15 @@ POLYLINEZ, SHAPETYPE_LOOKUP, SHAPETYPENUM_LOOKUP, + VERBOSE, ) -from .exceptions import ShapefileException -from .geojson import GeoJSONSerisalizableShape -from .geometric_calculations import bbox_overlap +from .exceptions import GeoJSON_Error, ShapefileException +from .geojson_types import ( + GEOJSON_TO_SHAPETYPE, + GeoJSONHomogeneousGeometryObject, +) +from .geometric_calculations import bbox_overlap, is_cw, organize_polygon_rings, rewind +from .helpers import _Array from .types import ( BBox, MBox, @@ -41,6 +46,8 @@ ZBox, ) +logger = logging.getLogger(__name__) + class _NoShapeTypeSentinel: """For use as a default value for Shape.__init__ to @@ -86,7 +93,7 @@ class CanHaveBboxNoLinesKwargs(TypedDict, total=False): zbox: ZBox | None -class Shape(GeoJSONSerisalizableShape): +class Shape: def __init__( self, shapeType: int | _NoShapeTypeSentinel = _NO_SHAPE_TYPE_SENTINEL, @@ -273,6 +280,189 @@ def __repr__(self) -> str: return f"Shape #{self.__oid}: {self.shapeTypeName}" return f"{class_name} #{self.__oid}" + @property + def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: + if self.shapeType in {POINT, POINTM, POINTZ}: + # point + if len(self.points) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "Point", "coordinates": ()} + + return {"type": "Point", "coordinates": self.points[0]} + + if self.shapeType in {MULTIPOINT, MULTIPOINTM, MULTIPOINTZ}: + if len(self.points) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "MultiPoint", "coordinates": []} + + # multipoint + return { + "type": "MultiPoint", + "coordinates": self.points, + } + + if self.shapeType in {POLYLINE, POLYLINEM, POLYLINEZ}: + if len(self.parts) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "LineString", "coordinates": []} + + if len(self.parts) == 1: + # linestring + return { + "type": "LineString", + "coordinates": self.points, + } + + # multilinestring + ps = None + coordinates = [] + for part in self.parts: + if ps is None: + ps = part + continue + + coordinates.append(list(self.points[ps:part])) + ps = part + + # assert len(self.parts) > 1 + # from previous if len(self.parts) checks so part is defined + coordinates.append(list(self.points[part:])) + return {"type": "MultiLineString", "coordinates": coordinates} + + if self.shapeType in {POLYGON, POLYGONM, POLYGONZ}: + if len(self.parts) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "Polygon", "coordinates": []} + + # get all polygon rings + rings = [] + for i, start in enumerate(self.parts): + # get indexes of start and end points of the ring + try: + end = self.parts[i + 1] + except IndexError: + end = len(self.points) + + # extract the points that make up the ring + ring = list(self.points[start:end]) + rings.append(ring) + + # organize rings into list of polygons, where each polygon is defined as list of rings. + # the first ring is the exterior and any remaining rings are holes (same as GeoJSON). + polys = organize_polygon_rings(rings, self._errors) + + # if VERBOSE is True, issue detailed warning about any shape errors + # encountered during the Shapefile to GeoJSON conversion + if VERBOSE and self._errors: + header = f"Possible issue encountered when converting Shape #{self.oid} to GeoJSON: " + orphans = self._errors.get("polygon_orphaned_holes", None) + if orphans: + msg = ( + header + + "Shapefile format requires that all polygon interior holes be contained by an exterior ring, \ +but the Shape contained interior holes (defined by counter-clockwise orientation in the shapefile format) that were \ +orphaned, i.e. not contained by any exterior rings. The rings were still included but were \ +encoded as GeoJSON exterior rings instead of holes." + ) + logger.warning(msg) + only_holes = self._errors.get("polygon_only_holes", None) + if only_holes: + msg = ( + header + + "Shapefile format requires that polygons contain at least one exterior ring, \ +but the Shape was entirely made up of interior holes (defined by counter-clockwise orientation in the shapefile format). The rings were \ +still included but were encoded as GeoJSON exterior rings instead of holes." + ) + logger.warning(msg) + + # return as geojson + if len(polys) == 1: + return {"type": "Polygon", "coordinates": polys[0]} + + return {"type": "MultiPolygon", "coordinates": polys} + + raise GeoJSON_Error( + f'Shape type "{SHAPETYPE_LOOKUP[self.shapeType]}" cannot be represented as GeoJSON.' + ) + + @classmethod + def _from_geojson(cls, geoj: GeoJSONHomogeneousGeometryObject) -> Self: + # create empty shape + # set shapeType + geojType = geoj["type"] if geoj else "Null" + if geojType in GEOJSON_TO_SHAPETYPE: + shapeType = GEOJSON_TO_SHAPETYPE[geojType] + else: + raise GeoJSON_Error(f"Cannot create Shape from GeoJSON type '{geojType}'") + + coordinates = geoj["coordinates"] + + if coordinates == (): + raise GeoJSON_Error(f"Cannot create non-Null Shape from: {coordinates=}") + + points: PointsT + parts: list[int] + + # set points and parts + if geojType == "Point": + points = [cast(PointT, coordinates)] + parts = [0] + elif geojType in ("MultiPoint", "LineString"): + points = cast(PointsT, coordinates) + parts = [0] + elif geojType == "Polygon": + points = [] + parts = [] + index = 0 + for i, ext_or_hole in enumerate(cast(list[PointsT], coordinates)): + # although the latest GeoJSON spec states that exterior rings should have + # counter-clockwise orientation, we explicitly check orientation since older + # GeoJSONs might not enforce this. + if i == 0 and not is_cw(ext_or_hole): + # flip exterior direction + ext_or_hole = rewind(ext_or_hole) + elif i > 0 and is_cw(ext_or_hole): + # flip hole direction + ext_or_hole = rewind(ext_or_hole) + points.extend(ext_or_hole) + parts.append(index) + index += len(ext_or_hole) + elif geojType == "MultiLineString": + points = [] + parts = [] + index = 0 + for linestring in cast(list[PointsT], coordinates): + points.extend(linestring) + parts.append(index) + index += len(linestring) + elif geojType == "MultiPolygon": + points = [] + parts = [] + index = 0 + for polygon in cast(list[list[PointsT]], coordinates): + for i, ext_or_hole in enumerate(polygon): + # although the latest GeoJSON spec states that exterior rings should have + # counter-clockwise orientation, we explicitly check orientation since older + # GeoJSONs might not enforce this. + if i == 0 and not is_cw(ext_or_hole): + # flip exterior direction + ext_or_hole = rewind(ext_or_hole) + elif i > 0 and is_cw(ext_or_hole): + # flip hole direction + ext_or_hole = rewind(ext_or_hole) + points.extend(ext_or_hole) + parts.append(index) + index += len(ext_or_hole) + return cls(shapeType=shapeType, points=points, parts=parts) + # Need unused arguments to keep the same call signature for # different implementations of from_byte_stream and write_to_byte_stream diff --git a/src/shapefile/writer.py b/src/shapefile/writer.py index 1c19371..a518058 100644 --- a/src/shapefile/writer.py +++ b/src/shapefile/writer.py @@ -17,10 +17,10 @@ overload, ) -from .classes import Field, RecordValue +from .classes import Field from .constants import MISSING, NULL, SHAPETYPE_LOOKUP from .exceptions import ShapefileException -from .geojson import GeoJSONHomogeneousGeometryObject, HasGeoInterface +from .geojson_types import GeoJSONHomogeneousGeometryObject, HasGeoInterface from .helpers import fsdecode_if_pathlike from .shapes import ( SHAPE_CLASS_FROM_SHAPETYPE, @@ -54,6 +54,7 @@ MBox, PointsT, ReadWriteSeekableBinStream, + RecordValue, WriteSeekableBinStream, ZBox, ) From 7bf4fc3952795139e5dbbeba7ca4f6808228e48b Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:02:38 +0100 Subject: [PATCH 17/24] Point hatch dynamic version tool to __version__.py --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 795b3b4..9e62221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ only-include = ["src"] sources = {"src" = ""} # move from "src" directory for wheel [tool.hatch.version] -path = "src/shapefile.py" +path = "src/shapefile/__version__.py" [tool.pytest.ini_options] markers = [ From 49fca59b326cc3285eb274ed762935f11f4f8088 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:24:57 +0100 Subject: [PATCH 18/24] Checked it does build locally in uv. Bump version. Add Py.typed --- src/shapefile/__version__.py | 2 +- src/shapefile/py.typed | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/shapefile/py.typed diff --git a/src/shapefile/__version__.py b/src/shapefile/__version__.py index 131942e..20cc3a9 100644 --- a/src/shapefile/__version__.py +++ b/src/shapefile/__version__.py @@ -1 +1 @@ -__version__ = "3.0.2" +__version__ = "3.0.3rc.dev2" diff --git a/src/shapefile/py.typed b/src/shapefile/py.typed new file mode 100644 index 0000000..e69de29 From b3d32a0a6778ae6349bde0be654171bb4c87dd1e Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:46:31 +0100 Subject: [PATCH 19/24] Add project.script to launch the doctest runner. --- pyproject.toml | 3 +++ src/shapefile/__init__.py | 49 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e62221..9aee3df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ test = ["pytest"] [project.urls] Repository = "https://github.com/GeospatialPython/pyshp" +[project.scripts] +shapefile="__init__:main" + [tool.hatch.build.targets.sdist] only-include = ["src", "shapefiles", "test_shapefile.py"] diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 9f54c95..a4c19b0 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -13,6 +13,27 @@ from .__version__ import __version__ from ._doctest_runner import _test +from .classes import Field, ShapeRecord, ShapeRecords, Shapes +from .constants import ( + MULTIPATCH, + MULTIPOINT, + MULTIPOINTM, + MULTIPOINTZ, + NULL, + POINT, + POINTM, + POINTZ, + POLYGON, + POLYGONM, + POLYGONZ, + POLYLINE, + POLYLINEM, + POLYLINEZ, + REPLACE_REMOTE_URLS_WITH_LOCALHOST, + SHAPETYPE_LOOKUP, +) +from .exceptions import GeoJSON_Error, RingSamplingError, ShapefileException +from .geometric_calculations import bbox_overlap from .helpers import _Array, fsdecode_if_pathlike from .reader import Reader from .shapes import ( @@ -65,10 +86,28 @@ WriteSeekableBinStream, ZBox, ) +from .writer import Writer __all__ = [ "__version__", + "NULL", + "POINT", + "POLYLINE", + "POLYGON", + "MULTIPOINT", + "POINTZ", + "POLYLINEZ", + "POLYGONZ", + "MULTIPOINTZ", + "POINTM", + "POLYLINEM", + "POLYGONM", + "MULTIPOINTM", + "MULTIPATCH", + "SHAPETYPE_LOOKUP", + "REPLACE_REMOTE_URLS_WITH_LOCALHOST", "Reader", + "Writer", "fsdecode_if_pathlike", "_Array", "Shape", @@ -117,6 +156,14 @@ "FIELD_TYPE_ALIASES", "RecordValueNotDate", "RecordValue", + "ShapefileException", + "RingSamplingError", + "GeoJSON_Error", + "Field", + "Shapes", + "ShapeRecord", + "ShapeRecords", + "bbox_overlap", ] logger = logging.getLogger(__name__) @@ -131,5 +178,3 @@ def main() -> None: sys.exit(failure_count) -if __name__ == "__main__": - main() From 9a5cec3a52b6d4a5b731ce4577a4d668e6cd5621 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:48:01 +0100 Subject: [PATCH 20/24] Ruff format. --- src/shapefile/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index a4c19b0..5fe43ae 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -176,5 +176,3 @@ def main() -> None: """ failure_count = _test() sys.exit(failure_count) - - From 945f3d98ffd5e5bcb9702b99cfbbcd5142a5c9be Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:11:22 +0100 Subject: [PATCH 21/24] Fix doctests and project script --- pyproject.toml | 4 ++-- src/shapefile/__init__.py | 33 +++++++++++++++++++++----------- src/shapefile/__main__.py | 16 ++++++++++++++++ src/shapefile/_doctest_runner.py | 3 ++- 4 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 src/shapefile/__main__.py diff --git a/pyproject.toml b/pyproject.toml index 9aee3df..7bcb24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,14 +30,14 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pyshp[test]", "pre-commit", "ruff"] +dev = ["pyshp[test]", "pre-commit", "ruff", "mypy"] test = ["pytest"] [project.urls] Repository = "https://github.com/GeospatialPython/pyshp" [project.scripts] -shapefile="__init__:main" +shapefile="shapefile.__main__:main" [tool.hatch.build.targets.sdist] only-include = ["src", "shapefiles", "test_shapefile.py"] diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 5fe43ae..0f0ab54 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -9,17 +9,22 @@ from __future__ import annotations import logging -import sys +from .__main__ import main from .__version__ import __version__ -from ._doctest_runner import _test from .classes import Field, ShapeRecord, ShapeRecords, Shapes from .constants import ( + FIRST_RING, + INNER_RING, + MISSING, MULTIPATCH, MULTIPOINT, MULTIPOINTM, MULTIPOINTZ, + NODATA, NULL, + OUTER_RING, + PARTTYPE_LOOKUP, POINT, POINTM, POINTZ, @@ -30,7 +35,11 @@ POLYLINEM, POLYLINEZ, REPLACE_REMOTE_URLS_WITH_LOCALHOST, + RING, SHAPETYPE_LOOKUP, + SHAPETYPENUM_LOOKUP, + TRIANGLE_FAN, + TRIANGLE_STRIP, ) from .exceptions import GeoJSON_Error, RingSamplingError, ShapefileException from .geometric_calculations import bbox_overlap @@ -106,6 +115,16 @@ "MULTIPATCH", "SHAPETYPE_LOOKUP", "REPLACE_REMOTE_URLS_WITH_LOCALHOST", + "SHAPETYPENUM_LOOKUP", + "TRIANGLE_STRIP", + "TRIANGLE_FAN", + "OUTER_RING", + "INNER_RING", + "FIRST_RING", + "RING", + "PARTTYPE_LOOKUP", + "MISSING", + "NODATA", "Reader", "Writer", "fsdecode_if_pathlike", @@ -164,15 +183,7 @@ "ShapeRecord", "ShapeRecords", "bbox_overlap", + "main", ] logger = logging.getLogger(__name__) - - -def main() -> None: - """ - Doctests are contained in the file 'README.md', and are tested using the built-in - testing libraries. - """ - failure_count = _test() - sys.exit(failure_count) diff --git a/src/shapefile/__main__.py b/src/shapefile/__main__.py new file mode 100644 index 0000000..ac2d2f3 --- /dev/null +++ b/src/shapefile/__main__.py @@ -0,0 +1,16 @@ +import sys + +from ._doctest_runner import _test + + +def main() -> None: + """ + Doctests are contained in the file 'README.md', and are tested using the built-in + testing libraries. + """ + failure_count = _test() + sys.exit(failure_count) + + +if __name__ == "__main__": + main() diff --git a/src/shapefile/_doctest_runner.py b/src/shapefile/_doctest_runner.py index c5cc5f0..3c4412b 100644 --- a/src/shapefile/_doctest_runner.py +++ b/src/shapefile/_doctest_runner.py @@ -1,6 +1,7 @@ import doctest import sys from collections.abc import Iterable, Iterator +from pathlib import Path from urllib.parse import urlparse, urlunparse from .constants import REPLACE_REMOTE_URLS_WITH_LOCALHOST @@ -8,7 +9,7 @@ def _get_doctests() -> doctest.DocTest: # run tests - with open("README.md", "rb") as fobj: + with Path("README.md").open("rb") as fobj: tests = doctest.DocTestParser().get_doctest( string=fobj.read().decode("utf8").replace("\r\n", "\n"), globs={}, From c9b27f01465f963cbf935e8670e15ec698a28c11 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:14:49 +0100 Subject: [PATCH 22/24] Add future annotations imports --- src/shapefile/_doctest_runner.py | 2 ++ src/shapefile/constants.py | 2 ++ src/shapefile/geometric_calculations.py | 2 ++ src/shapefile/helpers.py | 2 ++ src/shapefile/types.py | 2 ++ 5 files changed, 10 insertions(+) diff --git a/src/shapefile/_doctest_runner.py b/src/shapefile/_doctest_runner.py index 3c4412b..2b0c0ec 100644 --- a/src/shapefile/_doctest_runner.py +++ b/src/shapefile/_doctest_runner.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import doctest import sys from collections.abc import Iterable, Iterator diff --git a/src/shapefile/constants.py b/src/shapefile/constants.py index fa399c6..c46ace7 100644 --- a/src/shapefile/constants.py +++ b/src/shapefile/constants.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os # Module settings diff --git a/src/shapefile/geometric_calculations.py b/src/shapefile/geometric_calculations.py index d746873..f2dbabb 100644 --- a/src/shapefile/geometric_calculations.py +++ b/src/shapefile/geometric_calculations.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Iterable, Iterator, Reversible from .exceptions import RingSamplingError diff --git a/src/shapefile/helpers.py b/src/shapefile/helpers.py index dba7f2d..c66d15f 100644 --- a/src/shapefile/helpers.py +++ b/src/shapefile/helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import array import os from os import PathLike diff --git a/src/shapefile/types.py b/src/shapefile/types.py index 2705708..a84e254 100644 --- a/src/shapefile/types.py +++ b/src/shapefile/types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import io from datetime import date from os import PathLike From 99bb06a8898b8343ed8ca93677c49e63ac208f3f Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:18:22 +0100 Subject: [PATCH 23/24] Update __init__.py --- src/shapefile/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 0f0ab54..04db272 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -41,6 +41,7 @@ TRIANGLE_FAN, TRIANGLE_STRIP, ) +from ._doctest_runner import _replace_remote_url from .exceptions import GeoJSON_Error, RingSamplingError, ShapefileException from .geometric_calculations import bbox_overlap from .helpers import _Array, fsdecode_if_pathlike @@ -184,6 +185,7 @@ "ShapeRecords", "bbox_overlap", "main", + "_replace_remote_url", ] logger = logging.getLogger(__name__) From 0b1f16ac39e1d5d420dc34acae87f4132168f3ab Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:23:12 +0100 Subject: [PATCH 24/24] Replace Self with Shape. Reorder imports --- src/shapefile/__init__.py | 2 +- src/shapefile/shapes.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shapefile/__init__.py b/src/shapefile/__init__.py index 04db272..b37ac6b 100644 --- a/src/shapefile/__init__.py +++ b/src/shapefile/__init__.py @@ -12,6 +12,7 @@ from .__main__ import main from .__version__ import __version__ +from ._doctest_runner import _replace_remote_url from .classes import Field, ShapeRecord, ShapeRecords, Shapes from .constants import ( FIRST_RING, @@ -41,7 +42,6 @@ TRIANGLE_FAN, TRIANGLE_STRIP, ) -from ._doctest_runner import _replace_remote_url from .exceptions import GeoJSON_Error, RingSamplingError, ShapefileException from .geometric_calculations import bbox_overlap from .helpers import _Array, fsdecode_if_pathlike diff --git a/src/shapefile/shapes.py b/src/shapefile/shapes.py index 40d03d4..61ba4b0 100644 --- a/src/shapefile/shapes.py +++ b/src/shapefile/shapes.py @@ -3,7 +3,7 @@ import logging from collections.abc import Iterable, Iterator, Sequence from struct import error, pack, unpack -from typing import Final, Self, TypedDict, Union, cast +from typing import Final, TypedDict, Union, cast from .constants import ( MULTIPATCH, @@ -394,7 +394,7 @@ def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: ) @classmethod - def _from_geojson(cls, geoj: GeoJSONHomogeneousGeometryObject) -> Self: + def _from_geojson(cls, geoj: GeoJSONHomogeneousGeometryObject) -> Shape: # create empty shape # set shapeType geojType = geoj["type"] if geoj else "Null"