diff --git a/.github/workflows/run_checks_build_and_test.yml b/.github/workflows/run_checks_build_and_test.yml index 0d47126..abdca0c 100644 --- a/.github/workflows/run_checks_build_and_test.yml +++ b/.github/workflows/run_checks_build_and_test.yml @@ -13,14 +13,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - uses: pre-commit/action@v3.0.1 + mypy-strict: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install mypy + run: pip install mypy + - name: Run mypy --strict + run: mypy --strict ./src/shapefile.py + + build_wheel_and_sdist: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Build wheel from the project repo uses: ./.github/actions/build_wheel_and_sdist diff --git a/changelog.txt b/changelog.txt index 8376f2c..01f919b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,7 @@ +VERSION WIP + Improvements: + * LICENSE.TXT re-encoded in UTF-8 (@musicinmybrain) + VERSION 3.0.0 2025-08-03 @@ -15,13 +19,13 @@ VERSION 3.0.0 Code quality * Statically typed and checked with Mypy - * Checked with Ruff. + * Checked with Ruff. (@mwtoews) * Type hints * f-strings * Remove Python 2 specific functions. * Run doctests against wheels. * Testing of wheels before publishing them - * pyproject.toml src layout + * Updated metadata, changed build backend to Hatch, and restructured repor into pyproject.toml src layout (@mwtoews) * Slow test marked. Improvements: diff --git a/run_benchmarks.py b/run_benchmarks.py index ac31e1c..961357f 100644 --- a/run_benchmarks.py +++ b/run_benchmarks.py @@ -9,7 +9,7 @@ from collections.abc import Callable from pathlib import Path from tempfile import TemporaryFile as TempF -from typing import Union +from typing import Iterable, Union, cast import shapefile @@ -63,8 +63,9 @@ def write_shapefile_with_PyShp(target: Union[str, os.PathLike]): for field_info_tuple in fields[target]: w.field(*field_info_tuple) for shapeRecord in shapeRecords[target]: - w.shape(shapeRecord.shape) - w.record(*shapeRecord.record) + w.shape(cast(shapefile.Shape, shapeRecord.shape)) + record = cast(Iterable, shapeRecord.record) + w.record(*record) SHAPEFILES = { diff --git a/src/shapefile.py b/src/shapefile.py index 05eb93a..00578bb 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -21,6 +21,7 @@ import zipfile from datetime import date from struct import Struct, calcsize, error, pack, unpack +from types import TracebackType from typing import ( IO, Any, @@ -36,6 +37,7 @@ Protocol, Reversible, Sequence, + SupportsIndex, TypedDict, TypeVar, Union, @@ -130,30 +132,30 @@ class WriteableBinStream(Protocol): - def write(self, b: bytes): ... + def write(self, b: bytes) -> int: ... class ReadableBinStream(Protocol): - def read(self, size: int = -1): ... + def read(self, size: int = -1) -> bytes: ... class WriteSeekableBinStream(Protocol): - def write(self, b: bytes): ... - def seek(self, offset: int, whence: int = 0): ... - def tell(self): ... + def write(self, b: bytes) -> int: ... + def seek(self, offset: int, whence: int = 0) -> int: ... + def tell(self) -> int: ... class ReadSeekableBinStream(Protocol): - def seek(self, offset: int, whence: int = 0): ... - def tell(self): ... - def read(self, size: int = -1): ... + def seek(self, offset: int, whence: int = 0) -> int: ... + def tell(self) -> int: ... + def read(self, size: int = -1) -> bytes: ... class ReadWriteSeekableBinStream(Protocol): - def write(self, b: bytes): ... - def seek(self, offset: int, whence: int = 0): ... - def tell(self): ... - def read(self, size: int = -1): ... + def write(self, b: bytes) -> int: ... + def seek(self, offset: int, whence: int = 0) -> int: ... + def tell(self) -> int: ... + def read(self, size: int = -1) -> bytes: ... # File name, file object or anything with a read() method that returns bytes. @@ -230,7 +232,7 @@ def __repr__(self) -> str: return f'Field(name="{self.name}", field_type=FieldType.{self.field_type}, size={self.size}, decimal={self.decimal})' -RecordValueNotDate = Union[bool, int, float, str, date] +RecordValueNotDate = Union[bool, int, float, str] # A Possible value in a Shapefile dbf record, i.e. L, N, M, F, C, or D types RecordValue = Union[RecordValueNotDate, date] @@ -339,10 +341,10 @@ class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection): @overload -def fsdecode_if_pathlike(path: os.PathLike) -> str: ... +def fsdecode_if_pathlike(path: os.PathLike[Any]) -> str: ... @overload def fsdecode_if_pathlike(path: T) -> T: ... -def fsdecode_if_pathlike(path): +def fsdecode_if_pathlike(path: Any) -> Any: if isinstance(path, os.PathLike): return os.fsdecode(path) # str @@ -351,12 +353,16 @@ def fsdecode_if_pathlike(path): # Begin +ARR_TYPE = TypeVar("ARR_TYPE", int, float) -class _Array(array.array, Generic[T]): + +# In Python 3.12 we can do: +# class _Array(array.array[ARR_TYPE], Generic[ARR_TYPE]): +class _Array(array.array, Generic[ARR_TYPE]): # type: ignore[type-arg] """Converts python tuples to lists of the appropriate type. Used to unpack different shapefile header parts.""" - def __repr__(self): + def __repr__(self) -> str: return str(self.tolist()) @@ -372,7 +378,7 @@ def signed_area( xs, ys = map(list, list(zip(*coords))[:2]) # ignore any z or m values xs.append(xs[1]) ys.append(ys[1]) - area2 = sum(xs[i] * (ys[i + 1] - ys[i - 1]) for i in range(1, len(coords))) + area2: float = sum(xs[i] * (ys[i + 1] - ys[i - 1]) for i in range(1, len(coords))) if fast: return area2 @@ -474,7 +480,7 @@ def ring_sample(coords: PointsT, ccw: bool = False) -> Point2D: """ triplet = [] - def itercoords(): + def itercoords() -> Iterator[PointT]: # iterate full closed ring yield from coords # finally, yield the second coordinate to the end to allow checking the last triplet @@ -660,6 +666,9 @@ class _NoShapeTypeSentinel: """ +_NO_SHAPE_TYPE_SENTINEL: Final = _NoShapeTypeSentinel() + + def _m_from_point(point: Union[PointMT, PointZT], mpos: int) -> Optional[float]: if len(point) > mpos and point[mpos] is not None: return cast(float, point[mpos]) @@ -697,7 +706,7 @@ class CanHaveBboxNoLinesKwargs(TypedDict, total=False): class Shape: def __init__( self, - shapeType: Union[int, _NoShapeTypeSentinel] = _NoShapeTypeSentinel(), + shapeType: Union[int, _NoShapeTypeSentinel] = _NO_SHAPE_TYPE_SENTINEL, points: Optional[PointsT] = None, parts: Optional[Sequence[int]] = None, # index of start point of each part lines: Optional[list[PointsT]] = None, @@ -728,8 +737,8 @@ def __init__( """ # Preserve previous behaviour for anyone who set self.shapeType = None - if not isinstance(shapeType, _NoShapeTypeSentinel): - self.shapeType = shapeType + if shapeType is not _NO_SHAPE_TYPE_SENTINEL: + self.shapeType = cast(int, shapeType) else: class_name = self.__class__.__name__ self.shapeType = SHAPETYPENUM_LOOKUP.get(class_name.upper(), NULL) @@ -980,7 +989,7 @@ def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: ) @staticmethod - def _from_geojson(geoj) -> Shape: + def _from_geojson(geoj: GeoJSONHomogeneousGeometryObject) -> Shape: # create empty shape # set shapeType geojType = geoj["type"] if geoj else "Null" @@ -989,18 +998,26 @@ def _from_geojson(geoj) -> Shape: 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 = [geoj["coordinates"]] + points = [cast(PointT, coordinates)] parts = [0] elif geojType in ("MultiPoint", "LineString"): - points = geoj["coordinates"] + points = cast(PointsT, coordinates) parts = [0] elif geojType == "Polygon": points = [] parts = [] index = 0 - for i, ext_or_hole in enumerate(geoj["coordinates"]): + 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. @@ -1017,7 +1034,7 @@ def _from_geojson(geoj) -> Shape: points = [] parts = [] index = 0 - for linestring in geoj["coordinates"]: + for linestring in cast(list[PointsT], coordinates): points.extend(linestring) parts.append(index) index += len(linestring) @@ -1025,7 +1042,7 @@ def _from_geojson(geoj) -> Shape: points = [] parts = [] index = 0 - for polygon in geoj["coordinates"]: + 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 @@ -1050,7 +1067,7 @@ def oid(self) -> int: def shapeTypeName(self) -> str: return SHAPETYPE_LOOKUP[self.shapeType] - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ if class_name == "Shape": return f"Shape #{self.__oid}: {self.shapeTypeName}" @@ -1130,7 +1147,8 @@ def _write_bbox_to_byte_stream( @staticmethod def _read_npoints_from_byte_stream(b_io: ReadableBinStream) -> int: - return unpack(" int: @@ -1275,7 +1293,8 @@ class _CanHaveParts(_CanHaveBBox): @staticmethod def _read_nparts_from_byte_stream(b_io: ReadableBinStream) -> int: - return unpack(" int: @@ -1310,8 +1329,7 @@ def __init__( Shape.__init__(self, points=[(x, y)], oid=oid) @staticmethod - def _x_y_from_byte_stream(b_io: ReadableBinStream): - # Unpack _Array too + def _x_y_from_byte_stream(b_io: ReadableBinStream) -> tuple[float, float]: x, y = unpack("<2d", b_io.read(16)) # Convert to tuple return x, y @@ -1949,7 +1967,7 @@ def __init__( } -class _Record(list): +class _Record(list[RecordValue]): """ A class to hold a record. Subclasses list to ensure compatibility with former work and to reuse all the optimizations of the builtin list. @@ -2008,7 +2026,7 @@ def __getattr__(self, item: str) -> RecordValue: f"{item} found as a field but not enough values available." ) - def __setattr__(self, key: str, value: RecordValue): + def __setattr__(self, key: str, value: RecordValue) -> None: """ Sets a value of a field attribute :param key: The field name @@ -2024,7 +2042,15 @@ def __setattr__(self, key: str, value: RecordValue): except KeyError: raise AttributeError(f"{key} is not a field name") - def __getitem__(self, item): + @overload + def __getitem__(self, i: SupportsIndex) -> RecordValue: ... + @overload + def __getitem__(self, s: slice) -> list[RecordValue]: ... + @overload + def __getitem__(self, s: str) -> RecordValue: ... + def __getitem__( + self, item: Union[SupportsIndex, slice, str] + ) -> Union[RecordValue, list[RecordValue]]: """ Extends the normal list item access with access using a fieldname @@ -2034,10 +2060,10 @@ def __getitem__(self, item): :return: the value of the field """ try: - return list.__getitem__(self, item) + return list.__getitem__(self, item) # type: ignore[index] except TypeError: try: - index = self.__field_positions[item] + index = self.__field_positions[item] # type: ignore[index] except KeyError: index = None if index is not None: @@ -2045,7 +2071,17 @@ def __getitem__(self, item): raise IndexError(f'"{item}" is not a field name and not an int') - def __setitem__(self, key, value): + @overload + def __setitem__(self, key: SupportsIndex, value: RecordValue) -> None: ... + @overload + def __setitem__(self, key: slice, value: Iterable[RecordValue]) -> None: ... + @overload + def __setitem__(self, key: str, value: RecordValue) -> None: ... + def __setitem__( + self, + key: Union[SupportsIndex, slice, str], + value: Union[RecordValue, Iterable[RecordValue]], + ) -> None: """ Extends the normal list item access with access using a fieldname @@ -2055,11 +2091,11 @@ def __setitem__(self, key, value): :param value: the new value of the field """ try: - return list.__setitem__(self, key, value) + return list.__setitem__(self, key, value) # type: ignore[misc,assignment] except TypeError: - index = self.__field_positions.get(key) + index = self.__field_positions.get(key) # type: ignore[arg-type] if index is not None: - return list.__setitem__(self, index, value) + return list.__setitem__(self, index, value) # type: ignore[misc] raise IndexError(f"{key} is not a field name and not an int") @@ -2080,7 +2116,7 @@ def as_dict(self, date_strings: bool = False) -> dict[str, RecordValue]: dct[k] = f"{v.year:04d}{v.month:02d}{v.day:02d}" return dct - def __repr__(self): + def __repr__(self) -> str: return f"Record #{self.__oid}: {list(self)}" def __dir__(self) -> list[str]: @@ -2098,8 +2134,8 @@ def __dir__(self) -> list[str]: ) # plus field names (random order if Python version < 3.6) return default + fnames - def __eq__(self, other): - if isinstance(other, self.__class__): + def __eq__(self, other: Any) -> bool: + if isinstance(other, _Record): if self.__field_positions != other.__field_positions: return False return list.__eq__(self, other) @@ -2132,7 +2168,7 @@ class Shapes(list[Optional[Shape]]): In addition to the list interface, this also provides the GeoJSON __geo_interface__ to return a GeometryCollection dictionary.""" - def __repr__(self): + def __repr__(self) -> str: return f"Shapes: {list(self)}" @property @@ -2152,7 +2188,7 @@ class ShapeRecords(list[ShapeRecord]): In addition to the list interface, this also provides the GeoJSON __geo_interface__ to return a FeatureCollection dictionary.""" - def __repr__(self): + def __repr__(self) -> str: return f"ShapeRecords: {list(self)}" @property @@ -2175,6 +2211,9 @@ class _NoShpSentinel: """ +_NO_SHP_SENTINEL = _NoShpSentinel() + + class Reader: """Reads the three files of a shapefile as a unit or separately. If one of the three files (.shp, .shx, @@ -2199,21 +2238,21 @@ class Reader: CONSTITUENT_FILE_EXTS = ["shp", "shx", "dbf"] assert all(ext.islower() for ext in CONSTITUENT_FILE_EXTS) - def _assert_ext_is_supported(self, ext: str): + def _assert_ext_is_supported(self, ext: str) -> None: assert ext in self.CONSTITUENT_FILE_EXTS def __init__( self, - shapefile_path: Union[str, os.PathLike] = "", + shapefile_path: Union[str, os.PathLike[Any]] = "", /, *, encoding: str = "utf-8", encodingErrors: str = "strict", - shp: Union[_NoShpSentinel, Optional[BinaryFileT]] = _NoShpSentinel(), + shp: Union[_NoShpSentinel, Optional[BinaryFileT]] = _NO_SHP_SENTINEL, shx: Optional[BinaryFileT] = None, dbf: Optional[BinaryFileT] = None, # Keep kwargs even though unused, to preserve PyShp 2.4 API - **kwargs, + **kwargs: Any, ): self.shp = None self.shx = None @@ -2248,7 +2287,9 @@ def __init__( zpath = path[: path.find(".zip") + 4] shapefile = path[path.find(".zip") + 4 + 1 :] - zipfileobj: Union[tempfile._TemporaryFileWrapper, io.BufferedReader] + zipfileobj: Union[ + tempfile._TemporaryFileWrapper[bytes], io.BufferedReader + ] # Create a zip file handle if zpath.startswith("http"): # Zipfile is from a url @@ -2369,7 +2410,8 @@ def __init__( self.load(path) return - if not isinstance(shp, _NoShpSentinel): + if shp is not _NO_SHP_SENTINEL: + shp = cast(Union[str, IO[bytes], None], shp) self.shp = self.__seek_0_on_file_obj_wrap_or_open_from_name("shp", shp) self.shx = self.__seek_0_on_file_obj_wrap_or_open_from_name("shx", shx) @@ -2406,7 +2448,7 @@ def __seek_0_on_file_obj_wrap_or_open_from_name( f"Could not load shapefile constituent file from: {file_}" ) - def __str__(self): + def __str__(self) -> str: """ Use some general info on the shapefile as __str__ """ @@ -2419,26 +2461,34 @@ def __str__(self): info.append(f" {len(self)} records ({len(self.fields)} fields)") return "\n".join(info) - def __enter__(self): + def __enter__(self) -> Reader: """ Enter phase of context manager. """ return self - def __exit__(self, exc_type, exc_val, exc_tb): + # def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[BaseException], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: """ Exit phase of context manager, close opened files. """ self.close() + return None - def __len__(self): + def __len__(self) -> int: """Returns the number of shapes/records in the shapefile.""" if self.dbf: # Preferably use dbf record count if self.numRecords is None: self.__dbfHeader() - return self.numRecords + # .__dbfHeader sets self.numRecords or raises Exception + return cast(int, self.numRecords) if self.shp: # Otherwise use shape count @@ -2446,7 +2496,8 @@ def __len__(self): if self.numShapes is None: self.__shxHeader() - return self.numShapes + # .__shxHeader sets self.numShapes or raises Exception + return cast(int, self.numShapes) # Index file not available, iterate all shapes to get total count if self.numShapes is None: @@ -2477,7 +2528,7 @@ def __len__(self): # No file loaded yet, treat as 'empty' shapefile return 0 - def __iter__(self): + def __iter__(self) -> Iterator[ShapeRecord]: """Iterates through the shapes/records in the shapefile.""" yield from self.iterShapeRecords() @@ -2494,7 +2545,7 @@ def __geo_interface__(self) -> GeoJSONFeatureCollectionWithBBox: def shapeTypeName(self) -> str: return SHAPETYPE_LOOKUP[self.shapeType] - def load(self, shapefile=None): + def load(self, shapefile: Optional[str] = None) -> None: """Opens a shapefile from a filename or file-like object. Normally this method would be called by the constructor with the file name as an argument.""" @@ -2510,7 +2561,7 @@ def load(self, shapefile=None): ) self._try_to_set_constituent_file_headers() - def _try_to_set_constituent_file_headers(self): + def _try_to_set_constituent_file_headers(self) -> None: if self.shp: self.__shpHeader() if self.dbf: @@ -2556,28 +2607,28 @@ def _load_constituent_file( self._files_to_close.append(shp_dbf_or_dhx_file) return shp_dbf_or_dhx_file - def load_shp(self, shapefile_name): + def load_shp(self, shapefile_name: str) -> None: """ Attempts to load file with .shp extension as both lower and upper case """ self.shp = self._load_constituent_file(shapefile_name, "shp") - def load_shx(self, shapefile_name): + def load_shx(self, shapefile_name: str) -> None: """ Attempts to load file with .shx extension as both lower and upper case """ self.shx = self._load_constituent_file(shapefile_name, "shx") - def load_dbf(self, shapefile_name): + def load_dbf(self, shapefile_name: str) -> None: """ Attempts to load file with .dbf extension as both lower and upper case """ self.dbf = self._load_constituent_file(shapefile_name, "dbf") - def __del__(self): + def __del__(self) -> None: self.close() - def close(self): + def close(self) -> None: # Close any files that the reader opened (but not those given by user) for attribute in self._files_to_close: if hasattr(attribute, "close"): @@ -2681,7 +2732,7 @@ def __shape( return shape - def __shxHeader(self): + def __shxHeader(self) -> None: """Reads the header information from a .shx file.""" shx = self.shx if not shx: @@ -2693,7 +2744,7 @@ def __shxHeader(self): shxRecordLength = (unpack(">i", shx.read(4))[0] * 2) - 100 self.numShapes = shxRecordLength // 8 - def __shxOffsets(self): + def __shxOffsets(self) -> None: """Reads the shape offset positions from a .shx file""" shx = self.shx if not shx: @@ -3184,7 +3235,7 @@ class Writer: def __init__( self, - target: Union[str, os.PathLike, None] = None, + target: Union[str, os.PathLike[Any], None] = None, shapeType: Optional[int] = None, autoBalance: bool = False, *, @@ -3194,7 +3245,7 @@ def __init__( shx: Optional[WriteSeekableBinStream] = None, dbf: Optional[WriteSeekableBinStream] = None, # Keep kwargs even though unused, to preserve PyShp 2.4 API - **kwargs, + **kwargs: Any, ): self.target = target self.autoBalance = autoBalance @@ -3241,28 +3292,34 @@ def __init__( self.encoding = encoding self.encodingErrors = encodingErrors - def __len__(self): + def __len__(self) -> int: """Returns the current number of features written to the shapefile. If shapes and records are unbalanced, the length is considered the highest of the two.""" return max(self.recNum, self.shpNum) - def __enter__(self): + def __enter__(self) -> Writer: """ Enter phase of context manager. """ return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: Optional[BaseException], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: """ Exit phase of context manager, finish writing and close the files. """ self.close() + return None - def __del__(self): + def __del__(self) -> None: self.close() - def close(self): + def close(self) -> None: """ Write final shp, shx, and dbf headers, close opened files. """ @@ -3316,7 +3373,9 @@ def __getFileObj(self, f: str) -> WriteSeekableBinStream: ... def __getFileObj(self, f: None) -> NoReturn: ... @overload def __getFileObj(self, f: WriteSeekableBinStream) -> WriteSeekableBinStream: ... - def __getFileObj(self, f): + def __getFileObj( + self, f: Union[str, None, WriteSeekableBinStream] + ) -> WriteSeekableBinStream: """Safety handler to verify file-like objects""" if not f: raise ShapefileException("No file-like object available.") @@ -3348,7 +3407,7 @@ def __shpFileLength(self) -> int: shp.seek(start) return size - def _update_file_bbox(self, s: Shape): + def _update_file_bbox(self, s: Shape) -> None: if s.shapeType == NULL: shape_bbox = None elif s.shapeType in _CanHaveBBox_shapeTypes: @@ -3358,7 +3417,7 @@ def _update_file_bbox(self, s: Shape): shape_bbox = (x, y, x, y) if shape_bbox is None: - return + return None if self._bbox: # compare with existing @@ -3371,8 +3430,9 @@ def _update_file_bbox(self, s: Shape): else: # first time bbox is being set self._bbox = shape_bbox + return None - def _update_file_zbox(self, s: Union[_HasZ, PointZ]): + def _update_file_zbox(self, s: Union[_HasZ, PointZ]) -> None: if self._zbox: # compare with existing self._zbox = (min(s.zbox[0], self._zbox[0]), max(s.zbox[1], self._zbox[1])) @@ -3380,7 +3440,7 @@ def _update_file_zbox(self, s: Union[_HasZ, PointZ]): # first time zbox is being set self._zbox = s.zbox - def _update_file_mbox(self, s: Union[_HasM, PointM]): + def _update_file_mbox(self, s: Union[_HasM, PointM]) -> None: mbox = s.mbox if self._mbox: # compare with existing @@ -3531,7 +3591,7 @@ def __dbfHeader(self) -> None: def shape( self, - s: Union[Shape, HasGeoInterface, dict], + s: Union[Shape, HasGeoInterface, dict[str, PointsT]], ) -> None: # Balance if already not balanced if self.autoBalance and self.recNum < self.shpNum: @@ -3539,9 +3599,9 @@ def shape( # Check is shape or import from geojson if not isinstance(s, Shape): if hasattr(s, "__geo_interface__"): - shape_dict = cast(dict, s.__geo_interface__) + shape_dict = cast(GeoJSONHomogeneousGeometryObject, s.__geo_interface__) if isinstance(s, dict): - shape_dict = s + shape_dict = cast(GeoJSONHomogeneousGeometryObject, s) else: raise TypeError( "Can only write Shape objects, GeoJSON dictionaries, " @@ -3969,7 +4029,7 @@ def _replace_remote_url( if path is None: path = old_parsed.path.rpartition("/")[2] - if port not in (None, ""): + if port not in (None, ""): # type: ignore[comparison-overlap] netloc = f"{netloc}:{port}" new_parsed = old_parsed._replace( @@ -4034,7 +4094,7 @@ def _test(args: list[str] = sys.argv[1:], verbosity: bool = False) -> int: return failure_count -def main(): +def main() -> None: """ Doctests are contained in the file 'README.md', and are tested using the built-in testing libraries.