Source code for shapelib.core.handlers.zipped_shapefile_handler
from pathlib import Path
from typing import TYPE_CHECKING, Any
from zipfile import BadZipFile, ZipFile
import geopandas as gpd
from shapelib.core.handlers.geo_file_handler import GeoFileHandler
from shapelib.factories.registry import register_handler
if TYPE_CHECKING:
from shapelib.core.geometries.base import BaseShapes
MANDATORY_SHAPEFILE_COMPONENTS = (".shp", ".shx", ".dbf")
[docs]
@register_handler(file_extension=".zip")
class ZippedShapefileHandler(GeoFileHandler):
"""Handler for shapefiles stored inside a local .zip archive."""
driver = "ESRI Shapefile"
extension = ".zip"
encoding_list = ["utf-8", "iso8859_15", "latin1", "cp1252", "ascii"]
engines = ["pyogrio", "fiona"]
def __init__(self, path: Path) -> None:
super().__init__(path)
self.validate_file()
[docs]
def validate_file(self) -> None:
"""
Validate that the archive is readable and contains at least one .shp file.
Raises
------
ValueError
If the archive is invalid or contains no shapefile.
"""
try:
with ZipFile(self.path) as zip_archive:
shapefiles = [
member_name for member_name in zip_archive.namelist() if member_name.lower().endswith(".shp")
]
except BadZipFile as error:
raise ValueError(f"Invalid ZIP archive: {self.path.as_posix()}") from error
if not shapefiles:
raise ValueError(f"The ZIP archive does not contain a shapefile (.shp): {self.path.as_posix()}")
def _find_missing_components(self) -> list[str]:
"""
List the mandatory shapefile components absent from the archive.
Returns
-------
list[str]
Missing extensions, in ``MANDATORY_SHAPEFILE_COMPONENTS`` order.
"""
with ZipFile(self.path) as zip_archive:
member_names = [member_name.lower() for member_name in zip_archive.namelist()]
return [
extension
for extension in MANDATORY_SHAPEFILE_COMPONENTS
if not any(member_name.endswith(extension) for member_name in member_names)
]
def _fiona_zip_uri(self) -> str:
"""
Build the canonical zip URI required by Fiona.
Returns
-------
str
URI in zip:// format.
"""
return f"zip://{self.path.resolve().as_posix()}"
[docs]
def load_data(self, **kwargs: Any) -> gpd.GeoDataFrame:
"""
Load geospatial data from ZIP, trying engines and encodings.
Parameters
----------
**kwargs : Any
Additional arguments passed to ``geopandas.read_file``.
Returns
-------
gpd.GeoDataFrame
Loaded geospatial data.
Raises
------
ValueError
If a mandatory component is missing from the archive, or if loading
fails across all engine and encoding combinations. The last
underlying error is included in the message and chained.
"""
missing_components = self._find_missing_components()
if missing_components:
raise ValueError(
f"Zipped shapefile is missing mandatory component(s) {', '.join(missing_components)}: "
f"{self.path.as_posix()}"
)
last_error: Exception | None = None
for engine in self.engines:
source: Path | str = self.path if engine == "pyogrio" else self._fiona_zip_uri()
for encoding in self.encoding_list:
try:
return gpd.read_file(source, encoding=encoding, engine=engine, **kwargs)
except UnicodeDecodeError as error:
last_error = error
continue
except Exception as error: # noqa: BLE001
last_error = error
break
message = f"Failed to read zipped shapefile: {self.path.as_posix()}"
if last_error is None:
raise ValueError(message)
raise ValueError(f"{message}: {last_error}") from last_error
[docs]
def create_obj(self, **kwargs: Any) -> "BaseShapes":
"""
Create geometry wrapper object from zipped shapefile data.
Parameters
----------
**kwargs : Any
Additional arguments passed to ``load_data``.
Returns
-------
BaseShapes
Geometry-specific wrapper.
"""
data = self.load_data(**kwargs)
return self.create_geometry_instance(data=data, driver=self.driver)