Skip to content

Commit 7708b60

Browse files
authored
Merge pull request #441 from JamesParrott/Encoding_from_cpg_file
Support reading encodings from .cpg files
2 parents 54f7b81 + 21b15ae commit 7708b60

4 files changed

Lines changed: 136 additions & 55 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ The Python Shapefile Library (PyShp) reads and writes ESRI Shapefiles in pure Py
88

99
- **Author**: [Joel Lawhead](https://github.com/GeospatialPython)
1010
- **Maintainers**: [James Parrott](https://github.com/JamesParrott) & [Karim Bahgat](https://github.com/karimbahgat)
11-
- **Version**: 3.1.6.dev
12-
- **Date**: 22nd July 2026
11+
- **Version**: 3.1.6
12+
- **Date**: 25th July 2026
1313
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
1414

1515
## Contents
@@ -93,6 +93,10 @@ part of your geospatial project.
9393

9494
# Version Changes
9595

96+
## 3.1.6
97+
### Feature
98+
- Encodings can now be read from .cpg files (and optionally written to them).
99+
96100
## 3.1.5
97101
### Bug fix
98102
- Fixed another bug causing dates before the year 1000 to be encoded as less than 8 chars under "%Y%m%d (found by [Thomas Beierlein](https://github.com/GeospatialPython/pyshp/issues/435))

changelog.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
VERSION 3.1.6
2+
2026-07-25
3+
* Encodings can now be read from .cpg files (and optionally written to them).
4+
15
VERSION 3.1.5
26
2026-07-22
37
* Fixed another bug causing dates before the year 1000 to be encoded as less than 8 chars under "%Y%m%d (found by [Thomas Beierlein](https://github.com/GeospatialPython/pyshp/issues/435))

src/shapefile.py

Lines changed: 79 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from __future__ import annotations
1010

11-
__version__ = "3.1.6.dev"
11+
__version__ = "3.1.6"
1212

1313
import abc
1414
import array
@@ -2819,25 +2819,6 @@ def _try_to_download_binary_file(
28192819
return initial_bytes, cast(ReadableBinStream, resp)
28202820

28212821

2822-
def _try_get_open_constituent_file(
2823-
file: Path,
2824-
ext: Literal[".shp", ".shx", ".dbf"],
2825-
) -> IO[bytes] | None:
2826-
"""
2827-
Attempts to open a .shp, .dbf or .shx file,
2828-
with both lower case and upper case file extensions,
2829-
and return it. If it was not possible to open the file, None is returned.
2830-
"""
2831-
exts = {ext, ext.upper(), ext.lower()}
2832-
2833-
for candidate_ext in exts:
2834-
try:
2835-
return file.with_suffix(candidate_ext).open("rb")
2836-
except OSError:
2837-
pass
2838-
return None
2839-
2840-
28412822
def ensure_within_bounds(i: int, num_records: int) -> int:
28422823
"""Provides list-like handling of a record index with a clearer
28432824
error message if the index is out of bounds."""
@@ -3359,7 +3340,7 @@ def shape_lengths_B(self) -> list[int]:
33593340

33603341

33613342
class ShpReader(_HasCheckedReadableFile):
3362-
"""Reads an shp file."""
3343+
"""Reads a .shp file."""
33633344

33643345
FileProto = ReadSeekableBinStream
33653346
new_file_obj_mode = "rb"
@@ -3618,21 +3599,23 @@ def __init__(
36183599
shapefile_path: str | PathLike[Any] = "",
36193600
/,
36203601
*,
3621-
encoding: str = "utf-8",
3602+
encoding: str | None = None,
36223603
encodingErrors: str = "strict",
36233604
shp: _NoShpSentinel | BinaryFileT | None = _NoShpSentinel(),
36243605
shx: BinaryFileT | None = None,
36253606
dbf: BinaryFileT | None = None,
3607+
cpg: BinaryFileT | None = None,
36263608
# Keep kwargs even though unused, to preserve PyShp 2.4 API
36273609
**kwargs: Any,
36283610
):
36293611
super().__init__()
36303612
# Store encoding info to use if lazy loading DbfReader later.
3631-
self.encoding = encoding
3613+
self._user_specified_encoding = encoding
36323614
self.encodingErrors = encodingErrors
36333615
self._shp = None
36343616
self._shx = None
36353617
self._dbf = None
3618+
self._cpg = None
36363619
self.shapeName = "Not specified"
36373620
self.numShapes: int = 0
36383621
self.path: str | os.PathLike[Any] | None = None
@@ -3651,13 +3634,15 @@ def __init__(
36513634
discarded_kwargs["shx"] = shx
36523635
if dbf is not None:
36533636
discarded_kwargs["dbf"] = dbf
3637+
if cpg is not None:
3638+
discarded_kwargs["cpg"] = cpg
36543639
if discarded_kwargs:
36553640
raise TypeError(
36563641
"Please be specific about the shapefile you want to load. "
36573642
f"Got: {shapefile_path}, plus the following unusable "
36583643
" kwargs: {discarded_kwargs} which previous versions of PyShp ignored. \n"
36593644
"Only either: i) exactly one positional arg \n"
3660-
" or: ii) one or both of shp and dbf, optionally plus shx kwargs\n"
3645+
" or: ii) one or both of shp and dbf, optionally plus shx and cpg kwargs\n"
36613646
"is currently supported. All other kwargs may be set (or not). "
36623647
)
36633648
self.path = shapefile_path
@@ -3684,6 +3669,7 @@ def __init__(
36843669
zipfileobj = self._download_binary_file_from_url(
36853670
url_info,
36863671
".zip",
3672+
add_to_exit_stack=False,
36873673
suppress_http_errors=False,
36883674
)
36893675
if zipfileobj is None:
@@ -3722,12 +3708,14 @@ def __init__(
37223708
self._shp = self._seek_0_on_file_obj_wrap_or_open_from_name(".shp", shp)
37233709
self._shx = self._seek_0_on_file_obj_wrap_or_open_from_name(".shx", shx)
37243710

3711+
self._cpg = self._seek_0_on_file_obj_wrap_or_open_from_name(".cpg", cpg)
37253712
self._dbf = self._seek_0_on_file_obj_wrap_or_open_from_name(".dbf", dbf)
37263713

37273714
# Load the files
37283715
if self._shp:
37293716
self._get_shp_reader()
37303717
if self._dbf:
3718+
# Sets self.encoding
37313719
self._get_dbf_reader()
37323720
if self._shx:
37333721
self._get_shx_reader()
@@ -3761,12 +3749,37 @@ def _get_dbf_reader(self) -> DbfReader:
37613749
raise ShapefileException(
37623750
"DbfReader requires a .dbf file or file-like object."
37633751
)
3752+
self._set_encoding()
37643753
return DbfReader(
37653754
dbf=self._dbf,
37663755
encoding=self.encoding,
37673756
encodingErrors=self.encodingErrors,
37683757
)
37693758

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+
37703783
@property
37713784
def shp_reader(self) -> ShpReader:
37723785
return self._get_shp_reader()
@@ -3854,20 +3867,41 @@ def iterRecords(
38543867
) -> Iterator[_Record | None]:
38553868
return self.dbf_reader.iterRecords(fields, start, stop, deleted_as_None)
38563869

3870+
def _try_get_open_constituent_file(
3871+
self,
3872+
file: Path,
3873+
ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
3874+
) -> IO[bytes] | None:
3875+
"""
3876+
Attempts to open a .shp, .dbf or .shx file,
3877+
with both lower case and upper case file extensions,
3878+
and return it. If it was not possible to open the file, None is returned.
3879+
"""
3880+
exts = {ext, ext.upper(), ext.lower()}
3881+
3882+
for candidate_ext in exts:
3883+
try:
3884+
file_obj = file.with_suffix(candidate_ext).open("rb")
3885+
except OSError:
3886+
continue
3887+
self.exit_stack.enter_context(file_obj)
3888+
return file_obj
3889+
return None
3890+
38573891
def _seek_0_on_file_obj_wrap_or_open_from_name(
38583892
self,
3859-
ext: Literal[".shp", ".shx", ".dbf"],
3893+
ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
38603894
file: BinaryFileT | None,
38613895
) -> None | IO[bytes]:
38623896
if file is None:
38633897
return None
38643898

38653899
if isinstance(file, (str, PathLike)):
3866-
file_obj = _try_get_open_constituent_file(Path(file), ext)
3867-
if file_obj is not None:
3868-
self.exit_stack.enter_context(file_obj)
3869-
return file_obj
3900+
# Added to exit stack if opened.
3901+
return self._try_get_open_constituent_file(Path(file), ext)
38703902

3903+
# Other user-opened file objects not added to exit stack.
3904+
# The user must close them.
38713905
if hasattr(file, "read"):
38723906
# Copy if required
38733907
try:
@@ -3884,7 +3918,8 @@ def _seek_0_on_file_obj_wrap_or_open_from_name(
38843918
def _download_binary_file_from_url(
38853919
self,
38863920
urlinfo: SplitResult,
3887-
ext: Literal[".shp", ".shx", ".dbf", ".zip"],
3921+
ext: Literal[".shp", ".shx", ".dbf", ".cpg", ".zip"],
3922+
add_to_exit_stack: bool = True,
38883923
suppress_http_errors: bool = True,
38893924
) -> tempfile._TemporaryFileWrapper[bytes] | None:
38903925
sniffed_bytes, resp = _try_to_download_binary_file(
@@ -3896,26 +3931,19 @@ def _download_binary_file_from_url(
38963931
return None
38973932
# Use tempfile as source for url data.
38983933
fileobj = _save_to_named_tmp_file(resp, initial_bytes=sniffed_bytes)
3934+
if add_to_exit_stack:
3935+
self.exit_stack.enter_context(fileobj)
38993936
return fileobj
39003937

39013938
def _load_from_url(self, urlinfo: SplitResult) -> None:
39023939
# Shapefile is from a url
39033940
# Download each file to temporary path and treat as normal shapefile path
39043941
self._shp = self._download_binary_file_from_url(urlinfo, ".shp")
39053942
self._shx = self._download_binary_file_from_url(urlinfo, ".shx")
3943+
self._cpg = self._download_binary_file_from_url(urlinfo, ".cpg")
39063944
self._dbf = self._download_binary_file_from_url(urlinfo, ".dbf")
39073945

3908-
shp_or_dbf_loaded = False
3909-
if self._shx is not None:
3910-
self.exit_stack.enter_context(self._shx)
3911-
if self._shp is not None:
3912-
self.exit_stack.enter_context(self._shp)
3913-
shp_or_dbf_loaded = True
3914-
if self._dbf is not None:
3915-
self.exit_stack.enter_context(self._dbf)
3916-
shp_or_dbf_loaded = True
3917-
3918-
if not shp_or_dbf_loaded:
3946+
if self._shp is None and self._dbf is None:
39193947
raise ShapefileException(
39203948
f"Failed to download .shp or .dbf from: {urlunsplit(urlinfo)}"
39213949
)
@@ -3924,7 +3952,7 @@ def _load_file_from_zip_to_tmp_file(
39243952
self,
39253953
archive: zipfile.ZipFile,
39263954
file: Path,
3927-
ext: Literal[".shp", ".shx", ".dbf"],
3955+
ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
39283956
) -> tempfile._TemporaryFileWrapper[bytes] | None:
39293957
for cased_ext in {ext.lower(), ext.upper(), ext}:
39303958
try:
@@ -3978,7 +4006,7 @@ def _load_from_zipfileobj(
39784006
constituent_files = (
39794007
Path(name)
39804008
for name in archive.namelist()
3981-
if name.lower().endswith((".shp", ".dbf", ".shx"))
4009+
if name.lower().endswith((".shp", ".dbf", ".shx", ".cpg"))
39824010
)
39834011

39844012
def without_ext(path: Path) -> Path:
@@ -4001,6 +4029,7 @@ def without_ext(path: Path) -> Path:
40014029
# Try to extract file-like objects from zipfile
40024030
self._shp = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".shp")
40034031
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")
40044033
self._dbf = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".dbf")
40054034

40064035
def load(self, file: str | os.PathLike[Any]) -> None:
@@ -4024,27 +4053,25 @@ def load_shp(self, file: Path) -> None:
40244053
"""
40254054
Attempts to load file with .shp extension as both lower and upper case
40264055
"""
4027-
self._shp = _try_get_open_constituent_file(file, ".shp")
4028-
if self._shp:
4029-
self.exit_stack.enter_context(self._shp)
4056+
self._shp = self._try_get_open_constituent_file(file, ".shp")
4057+
if self._shp is not None:
40304058
self._get_shp_reader()
40314059

40324060
def load_shx(self, file: Path) -> None:
40334061
"""
40344062
Attempts to load file with .shx extension as both lower and upper case
40354063
"""
4036-
self._shx = _try_get_open_constituent_file(file, ".shx")
4037-
if self._shx:
4038-
self.exit_stack.enter_context(self._shx)
4064+
self._shx = self._try_get_open_constituent_file(file, ".shx")
4065+
if self._shx is not None:
40394066
self._get_shx_reader()
40404067

40414068
def load_dbf(self, file: Path) -> None:
40424069
"""
40434070
Attempts to load file with .dbf extension as both lower and upper case
40444071
"""
4045-
self._dbf = _try_get_open_constituent_file(file, ".dbf")
4046-
if self._dbf:
4047-
self.exit_stack.enter_context(self._dbf)
4072+
self._dbf = self._try_get_open_constituent_file(file, ".dbf")
4073+
if self._dbf is not None:
4074+
self._cpg = self._try_get_open_constituent_file(file, ".cpg")
40484075
self._get_dbf_reader()
40494076

40504077
def __len__(self) -> int:

tests/test_shapefile.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import json
99
import os.path
1010
from pathlib import Path
11+
import shutil
1112

1213
# third party imports
1314
import pytest
@@ -2164,4 +2165,49 @@ def test_encode_dbf_field_values(value,encoded_len,codec,errors):
21642165
with context:
21652166
r = shapefile.DbfReader(stream, encoding=codec, encodingErrors=errors, strict=False)
21662167
assert r.record(0)[0] == value
2167-
r.close()
2168+
r.close()
2169+
2170+
@pytest.fixture
2171+
def tmp_latin1_shapefile_shp(tmp_path):
2172+
name = "latin1"
2173+
test_shapefile_dir = tmp_path / name
2174+
test_shapefile_dir.mkdir()
2175+
for file in shapefiles_dir.glob("latin1.*"):
2176+
shutil.copy(file, test_shapefile_dir)
2177+
test_shapefile = test_shapefile_dir / f"{name}.shp"
2178+
return test_shapefile
2179+
2180+
ENCODINGS_AND_CONTEXTS = [
2181+
("latin1", contextlib.nullcontext()),
2182+
("utf8", pytest.raises(shapefile.dbfFileException)),
2183+
]
2184+
@pytest.mark.parametrize("encoding, context", ENCODINGS_AND_CONTEXTS)
2185+
def test_read_latin1_shapefile(encoding, context, tmp_latin1_shapefile_shp):
2186+
""" Extend the smoke test in README.md doctests """
2187+
2188+
assert tmp_latin1_shapefile_shp.is_file()
2189+
2190+
r = shapefile.Reader(tmp_latin1_shapefile_shp, encoding=encoding)
2191+
with context:
2192+
rec = r.record(0)
2193+
r.close()
2194+
if encoding == "latin1":
2195+
assert rec == [2, u'Ñandú']
2196+
2197+
@pytest.mark.parametrize("encoding, context", ENCODINGS_AND_CONTEXTS)
2198+
def test_read_latin1_shapefile_cpg_file(encoding, context, tmp_latin1_shapefile_shp):
2199+
""" Extend the smoke test in README.md doctests """
2200+
2201+
assert tmp_latin1_shapefile_shp.is_file()
2202+
2203+
cpg_file = tmp_latin1_shapefile_shp.with_suffix(".cpg")
2204+
cpg_file.write_text(encoding.upper().replace("_","-"))
2205+
2206+
r = shapefile.Reader(tmp_latin1_shapefile_shp)
2207+
with context:
2208+
rec = r.record(0)
2209+
r.close()
2210+
if encoding == "latin1":
2211+
assert rec == [2, u'Ñandú']
2212+
2213+

0 commit comments

Comments
 (0)