Skip to content

Commit 21b15ae

Browse files
committed
Support reading encodings from .cpg files (all the various ways)
1 parent 1a8a939 commit 21b15ae

2 files changed

Lines changed: 51 additions & 26 deletions

File tree

src/shapefile.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3599,24 +3599,23 @@ def __init__(
35993599
shapefile_path: str | PathLike[Any] = "",
36003600
/,
36013601
*,
3602-
encoding: str = "utf-8", # | None = None,
3602+
encoding: str | None = None,
36033603
encodingErrors: str = "strict",
36043604
shp: _NoShpSentinel | BinaryFileT | None = _NoShpSentinel(),
36053605
shx: BinaryFileT | None = None,
36063606
dbf: BinaryFileT | None = None,
3607-
# cpg: BinaryFileT | None = None,
3607+
cpg: BinaryFileT | None = None,
36083608
# Keep kwargs even though unused, to preserve PyShp 2.4 API
36093609
**kwargs: Any,
36103610
):
36113611
super().__init__()
36123612
# Store encoding info to use if lazy loading DbfReader later.
3613-
# encoding_from_cpg =
3614-
self.encoding = encoding # encoding_from_cpg if encoding is None else encoding
3613+
self._user_specified_encoding = encoding
36153614
self.encodingErrors = encodingErrors
36163615
self._shp = None
36173616
self._shx = None
36183617
self._dbf = None
3619-
# self._cpg = None
3618+
self._cpg = None
36203619
self.shapeName = "Not specified"
36213620
self.numShapes: int = 0
36223621
self.path: str | os.PathLike[Any] | None = None
@@ -3635,15 +3634,15 @@ def __init__(
36353634
discarded_kwargs["shx"] = shx
36363635
if dbf is not None:
36373636
discarded_kwargs["dbf"] = dbf
3638-
# if cpg is not None:
3639-
# discarded_kwargs["cpg"] = cpg
3637+
if cpg is not None:
3638+
discarded_kwargs["cpg"] = cpg
36403639
if discarded_kwargs:
36413640
raise TypeError(
36423641
"Please be specific about the shapefile you want to load. "
36433642
f"Got: {shapefile_path}, plus the following unusable "
36443643
" kwargs: {discarded_kwargs} which previous versions of PyShp ignored. \n"
36453644
"Only either: i) exactly one positional arg \n"
3646-
" or: ii) one or both of shp and dbf, optionally plus shx \n" # and cpg kwargs\n"
3645+
" or: ii) one or both of shp and dbf, optionally plus shx and cpg kwargs\n"
36473646
"is currently supported. All other kwargs may be set (or not). "
36483647
)
36493648
self.path = shapefile_path
@@ -3670,6 +3669,7 @@ def __init__(
36703669
zipfileobj = self._download_binary_file_from_url(
36713670
url_info,
36723671
".zip",
3672+
add_to_exit_stack=False,
36733673
suppress_http_errors=False,
36743674
)
36753675
if zipfileobj is None:
@@ -3708,12 +3708,14 @@ def __init__(
37083708
self._shp = self._seek_0_on_file_obj_wrap_or_open_from_name(".shp", shp)
37093709
self._shx = self._seek_0_on_file_obj_wrap_or_open_from_name(".shx", shx)
37103710

3711+
self._cpg = self._seek_0_on_file_obj_wrap_or_open_from_name(".cpg", cpg)
37113712
self._dbf = self._seek_0_on_file_obj_wrap_or_open_from_name(".dbf", dbf)
37123713

37133714
# Load the files
37143715
if self._shp:
37153716
self._get_shp_reader()
37163717
if self._dbf:
3718+
# Sets self.encoding
37173719
self._get_dbf_reader()
37183720
if self._shx:
37193721
self._get_shx_reader()
@@ -3747,12 +3749,37 @@ def _get_dbf_reader(self) -> DbfReader:
37473749
raise ShapefileException(
37483750
"DbfReader requires a .dbf file or file-like object."
37493751
)
3752+
self._set_encoding()
37503753
return DbfReader(
37513754
dbf=self._dbf,
37523755
encoding=self.encoding,
37533756
encodingErrors=self.encodingErrors,
37543757
)
37553758

3759+
@functools.cache
3760+
def _set_encoding(self) -> None:
3761+
if self._cpg is None:
3762+
encoding_from_cpg = ""
3763+
else:
3764+
encoding_from_cpg = (
3765+
self._cpg.read().decode().lower().replace("-", "_").strip()
3766+
)
3767+
if not encoding_from_cpg:
3768+
warnings.warn("Empty .cpg file (no encoding found). ")
3769+
3770+
if self._user_specified_encoding is None:
3771+
encoding = encoding_from_cpg
3772+
else:
3773+
encoding = self._user_specified_encoding.lower().replace("-", "_").strip()
3774+
3775+
if encoding_from_cpg and encoding != encoding_from_cpg:
3776+
warnings.warn(
3777+
f"Specified encoding: {encoding} "
3778+
"different to encoding read from "
3779+
f".cpg file: {encoding_from_cpg}",
3780+
)
3781+
self.encoding = encoding or "utf-8"
3782+
37563783
@property
37573784
def shp_reader(self) -> ShpReader:
37583785
return self._get_shp_reader()
@@ -3843,7 +3870,7 @@ def iterRecords(
38433870
def _try_get_open_constituent_file(
38443871
self,
38453872
file: Path,
3846-
ext: Literal[".shp", ".shx", ".dbf"],
3873+
ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
38473874
) -> IO[bytes] | None:
38483875
"""
38493876
Attempts to open a .shp, .dbf or .shx file,
@@ -3863,15 +3890,18 @@ def _try_get_open_constituent_file(
38633890

38643891
def _seek_0_on_file_obj_wrap_or_open_from_name(
38653892
self,
3866-
ext: Literal[".shp", ".shx", ".dbf"],
3893+
ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
38673894
file: BinaryFileT | None,
38683895
) -> None | IO[bytes]:
38693896
if file is None:
38703897
return None
38713898

38723899
if isinstance(file, (str, PathLike)):
3900+
# Added to exit stack if opened.
38733901
return self._try_get_open_constituent_file(Path(file), ext)
38743902

3903+
# Other user-opened file objects not added to exit stack.
3904+
# The user must close them.
38753905
if hasattr(file, "read"):
38763906
# Copy if required
38773907
try:
@@ -3888,7 +3918,8 @@ def _seek_0_on_file_obj_wrap_or_open_from_name(
38883918
def _download_binary_file_from_url(
38893919
self,
38903920
urlinfo: SplitResult,
3891-
ext: Literal[".shp", ".shx", ".dbf", ".zip"],
3921+
ext: Literal[".shp", ".shx", ".dbf", ".cpg", ".zip"],
3922+
add_to_exit_stack: bool = True,
38923923
suppress_http_errors: bool = True,
38933924
) -> tempfile._TemporaryFileWrapper[bytes] | None:
38943925
sniffed_bytes, resp = _try_to_download_binary_file(
@@ -3900,26 +3931,19 @@ def _download_binary_file_from_url(
39003931
return None
39013932
# Use tempfile as source for url data.
39023933
fileobj = _save_to_named_tmp_file(resp, initial_bytes=sniffed_bytes)
3934+
if add_to_exit_stack:
3935+
self.exit_stack.enter_context(fileobj)
39033936
return fileobj
39043937

39053938
def _load_from_url(self, urlinfo: SplitResult) -> None:
39063939
# Shapefile is from a url
39073940
# Download each file to temporary path and treat as normal shapefile path
39083941
self._shp = self._download_binary_file_from_url(urlinfo, ".shp")
39093942
self._shx = self._download_binary_file_from_url(urlinfo, ".shx")
3943+
self._cpg = self._download_binary_file_from_url(urlinfo, ".cpg")
39103944
self._dbf = self._download_binary_file_from_url(urlinfo, ".dbf")
39113945

3912-
shp_or_dbf_loaded = False
3913-
if self._shx is not None:
3914-
self.exit_stack.enter_context(self._shx)
3915-
if self._shp is not None:
3916-
self.exit_stack.enter_context(self._shp)
3917-
shp_or_dbf_loaded = True
3918-
if self._dbf is not None:
3919-
self.exit_stack.enter_context(self._dbf)
3920-
shp_or_dbf_loaded = True
3921-
3922-
if not shp_or_dbf_loaded:
3946+
if self._shp is None and self._dbf is None:
39233947
raise ShapefileException(
39243948
f"Failed to download .shp or .dbf from: {urlunsplit(urlinfo)}"
39253949
)
@@ -3928,7 +3952,7 @@ def _load_file_from_zip_to_tmp_file(
39283952
self,
39293953
archive: zipfile.ZipFile,
39303954
file: Path,
3931-
ext: Literal[".shp", ".shx", ".dbf"],
3955+
ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
39323956
) -> tempfile._TemporaryFileWrapper[bytes] | None:
39333957
for cased_ext in {ext.lower(), ext.upper(), ext}:
39343958
try:
@@ -3982,7 +4006,7 @@ def _load_from_zipfileobj(
39824006
constituent_files = (
39834007
Path(name)
39844008
for name in archive.namelist()
3985-
if name.lower().endswith((".shp", ".dbf", ".shx"))
4009+
if name.lower().endswith((".shp", ".dbf", ".shx", ".cpg"))
39864010
)
39874011

39884012
def without_ext(path: Path) -> Path:
@@ -4005,6 +4029,7 @@ def without_ext(path: Path) -> Path:
40054029
# Try to extract file-like objects from zipfile
40064030
self._shp = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".shp")
40074031
self._shx = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".shx")
4032+
self._cpg = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".cpg")
40084033
self._dbf = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".dbf")
40094034

40104035
def load(self, file: str | os.PathLike[Any]) -> None:
@@ -4046,6 +4071,7 @@ def load_dbf(self, file: Path) -> None:
40464071
"""
40474072
self._dbf = self._try_get_open_constituent_file(file, ".dbf")
40484073
if self._dbf is not None:
4074+
self._cpg = self._try_get_open_constituent_file(file, ".cpg")
40494075
self._get_dbf_reader()
40504076

40514077
def __len__(self) -> int:

tests/test_shapefile.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2194,8 +2194,7 @@ def test_read_latin1_shapefile(encoding, context, tmp_latin1_shapefile_shp):
21942194
if encoding == "latin1":
21952195
assert rec == [2, u'Ñandú']
21962196

2197-
@pytest.mark.xfail(reason="Support for reading encodings from .cpg files not implemented yet")
2198-
@pytest.mark.parametrize("encoding, context", ENCODINGS_AND_CONTEXTS[:1])
2197+
@pytest.mark.parametrize("encoding, context", ENCODINGS_AND_CONTEXTS)
21992198
def test_read_latin1_shapefile_cpg_file(encoding, context, tmp_latin1_shapefile_shp):
22002199
""" Extend the smoke test in README.md doctests """
22012200

0 commit comments

Comments
 (0)