Source code for shapelib.core.handlers.shapefile_handler
from pathlib import Path
from typing import TYPE_CHECKING, Any
import fiona
import geopandas as gpd
from shapelib.core.handlers.geo_file_handler import GeoFileHandler
if TYPE_CHECKING:
from shapelib.core.geometries.base import BaseShapes
from shapelib.factories.registry import register_handler
[docs]
@register_handler(file_extension=".shp")
class ShapefileHandler(GeoFileHandler):
"""
A handler for ESRI Shapefile (.shp) format.
This class provides functionality for reading, validating, and managing shapefiles
using the Geopandas library. It attempts to load shapefiles with different encodings
and handles exceptions related to file decoding.
Attributes
----------
driver : str
The driver used for handling shapefiles ("ESRI Shapefile").
extension : str
The file extension associated with shapefiles (".shp").
encoding_list : list of str
A list of possible encodings to try when loading the shapefile.
Methods
-------
load_data()
Loads the shapefile into a GeoDataFrame, trying different encodings.
validate_file()
Validates the shapefile to ensure it can be read.
create_obj()
Creates and returns a geometry object based on the shapefile's contents.
"""
driver = "ESRI Shapefile"
extension = ".shp"
encoding_list = ["utf-8", "iso8859_15", "latin1", "cp1252", "ascii"]
engines = ["pyogrio", "fiona"]
def __init__(self, path: Path) -> None:
"""
Initialize the ShapefileHandler with a given shapefile path.
This method sets the file path, validates the shapefile, and loads the data
using the `load_data` method.
Parameters
----------
path : Path
The path to the shapefile (.shp) to be processed.
Raises
------
FileNotFoundError
If the provided shapefile does not exist or is not a valid file.
"""
super().__init__(path)
self.validate_file()
[docs]
def load_data(self, **kwargs: Any) -> gpd.GeoDataFrame:
"""
Attempt to load the shapefile using different encodings.
This method iterates over the encodings in `encoding_list` and attempts to read
the shapefile using each encoding. If successful, the file is returned as a
GeoDataFrame.
Returns
-------
gpd.GeoDataFrame
The loaded shapefile as a GeoDataFrame.
Raises
------
ValueError
If none of the encodings are able to decode the file.
Examples
--------
>>> handler = ShapefileHandler(path=Path("/path/to/shapefile.shp"))
>>> gdf = handler.load_data()
"""
for engine in self.engines:
for encoding in self.encoding_list:
try:
return gpd.read_file(self.path, encoding=encoding, engine=engine, **kwargs)
except UnicodeDecodeError:
# If the current encoding fails, try the next one
continue
# If all encodings fail and all engines fail, raise a ValueError
raise ValueError(
f"Failed to read the shapefile with the encodings: {self.encoding_list} andengines: {self.engines}"
)
[docs]
def validate_file(self) -> None:
"""
Validate the shapefile to ensure it exists and is readable.
This method checks whether the shapefile and its associated files (e.g., .shx,
.dbf) exist and can be read by GDAL.
Raises
------
FileNotFoundError
If the shapefile or one of its associated files (.shx, .dbf) is missing.
GDALException
If GDAL cannot read the shapefile or if the shapefile is invalid.
"""
self.check_empty_file()
self.check_missing_parts()
[docs]
def check_empty_file(self) -> None:
"""
Check if the shapefile is empty based on its file size.
This method checks if the shapefile is empty by evaluating its file size.
Shapefiles that are exactly 100 bytes in size are considered empty.
Returns
-------
None
The method prints a message if the file is empty.
Examples
--------
>>> handler.check_empty_file()
Empty Shapefile!
"""
if self.path.stat().st_size == 100: # The size of an empty shapefile
print("Empty Shapefile!")
[docs]
def check_missing_parts(self) -> None:
"""
Check for the existence of essential auxiliary files associated with the
shapefile.
This method checks if the following auxiliary files are missing:
- .shx (shape index file)
- .cpg (code page file for character encoding)
- .dbf (attribute data file)
- .prj (projection file)
If any of these files are missing, the corresponding handler method is invoked
to handle the missing file.
Returns
-------
None
The method invokes the handler methods if files are missing.
Examples
--------
>>> handler.check_missing_parts()
"""
if not self.path.with_suffix(suffix=".shx").exists():
self.handle_missing_shx()
if not self.path.with_suffix(suffix=".cpg").exists():
self.handle_missing_cpg()
if not self.path.with_suffix(suffix=".dbf").exists():
self.handle_missing_dbf()
if not self.path.with_suffix(suffix=".prj").exists():
self.handle_missing_prj()
[docs]
def handle_missing_shx(self) -> None:
"""
Attempt to recreate the missing .shx file using Fiona.
This method uses GDAL's (via Fiona) to recreate the missing .shx file
associated with the shapefile. GDAL's ESRI Shapefile driver can regenerate
the .shx when the configuration option SHAPE_RESTORE_SHX=YES is set.
Raises
------
RuntimeError
If the .shx file cannot be recreated, a RuntimeError is raised.
Examples
--------
>>> handler.handle_missing_shx()
Failed to recreate .shx file: [error message]
"""
target_shx = self.path.with_suffix(".shx")
try:
with fiona.Env(SHAPE_RESTORE_SHX="YES"), fiona.open(self.path, "r") as _src: # noqa: F841
pass
if not target_shx.exists():
raise RuntimeError("GDAL failed to regenerate the .shx index.")
except Exception as e:
raise RuntimeError(f"Failed to recreate .shx file: {e}") from e
[docs]
def handle_missing_cpg(self) -> None:
"""
Handle the missing .cpg file by notifying the user.
The .cpg file specifies character encoding for the shapefile. While it is useful
for ensuring correct character display, it is not mandatory for the core
functionality of the shapefile. This method simply warns the user if the .cpg
file is missing.
Returns
-------
None
The method prints a warning message if the .cpg file is missing.
Examples
--------
>>> handler.handle_missing_cpg()
Missing .cpg. Ignoring it since it's not mandatory for the functionality of the
Shapefile.
"""
print(
"Missing .cpg. Ignoring it since it's not mandatory for the functionality of \
the Shapefile."
)
[docs]
def handle_missing_dbf(self) -> None:
"""
Handle the missing .dbf file by notifying the user.
The .dbf file holds attribute data (e.g., table data associated with the
shapefile). While this information is useful, its absence does not prevent the
shapefile from functioning. This method prints a warning message if the .dbf file
is missing.
Returns
-------
None
The method prints a warning message if the .dbf file is missing.
Examples
--------
>>> handler.handle_missing_dbf()
Missing .dbf. Ignoring it since it's not mandatory for the functionality of the
Shapefile.
"""
print("Missing .dbf. Ignoring it since it's not mandatory for the functionality of the Shapefile.")
[docs]
def handle_missing_prj(self) -> None:
"""
Handle the missing .prj file by notifying the user.
The .prj file holds the shapefile's CRS. Some producers leave it out (e.g.
prescription maps exported for agricultural machinery), so its absence is not
fatal here: the data is read without a CRS, and `create_geometry_instance`
assumes WGS84 when the coordinates are lon/lat or raises a `ValueError`
otherwise.
Returns
-------
None
The method prints a notice if the .prj file is missing.
Examples
--------
>>> handler.handle_missing_prj()
Missing .prj. The CRS will be inferred from the coordinates (WGS84 if they are lon/lat).
"""
print("Missing .prj. The CRS will be inferred from the coordinates (WGS84 if they are lon/lat).")
[docs]
def create_obj(self, **kwargs: Any) -> "BaseShapes":
"""
Create an object representing the shapefile's geometries based on the most common
type.
This method analyzes the most common geometry type in the shapefile and returns an
appropriate object (`Polygons`, `Lines`, `Points`, or `Empty`). If the geometry
type is not recognized, a `TypeError` may be raised.
Returns
-------
object
A geometry object corresponding to the shapefile's data, such as `Polygons`,
`Lines`, `Points`, or `Empty`.
Raises
------
TypeError
If an unexpected geometry type is encountered.
Examples
--------
>>> geom_obj = handler.create_obj()
>>> isinstance(geom_obj, Polygons)
True
"""
data = self.load_data(**kwargs)
return self.create_geometry_instance(data=data, driver=self.driver)