[Git][debian-gis-team/pyshp][master] 4 commits: New upstream version 3.1.6
Bas Couwenberg (@sebastic)
gitlab at salsa.debian.org
Sun Jul 26 06:10:18 BST 2026
Bas Couwenberg pushed to branch master at Debian GIS Project / pyshp
Commits:
9c42cf61 by Bas Couwenberg at 2026-07-26T07:03:56+02:00
New upstream version 3.1.6
- - - - -
cea9afa3 by Bas Couwenberg at 2026-07-26T07:04:06+02:00
Update upstream source from tag 'upstream/3.1.6'
Update to upstream version '3.1.6'
with Debian dir a51c37614aed59408e2216a70f1d88b585805b88
- - - - -
899f55b8 by Bas Couwenberg at 2026-07-26T07:04:22+02:00
New upstream release.
- - - - -
9439a11f by Bas Couwenberg at 2026-07-26T07:05:38+02:00
Set distribution to unstable.
- - - - -
7 changed files:
- .github/workflows/run_checks_build_and_test.yml
- README.md
- changelog.txt
- debian/changelog
- src/shapefile.py
- tests/hypothesis_tests.py
- tests/test_shapefile.py
Changes:
=====================================
.github/workflows/run_checks_build_and_test.yml
=====================================
@@ -70,7 +70,11 @@ jobs:
]
include:
- python-version: "3.14"
- - os: "ubuntu-latest"
+ os: "ubuntu-latest"
+ - python-version: "3.14"
+ os: "windows-latest"
+ - python-version: "3.14"
+ os: "macos-latest"
runs-on: ${{ matrix.os }}
steps:
=====================================
README.md
=====================================
@@ -8,8 +8,8 @@ The Python Shapefile Library (PyShp) reads and writes ESRI Shapefiles in pure Py
- **Author**: [Joel Lawhead](https://github.com/GeospatialPython)
- **Maintainers**: [James Parrott](https://github.com/JamesParrott) & [Karim Bahgat](https://github.com/karimbahgat)
-- **Version**: 3.1.5
-- **Date**: 22nd July 2026
+- **Version**: 3.1.6
+- **Date**: 25th July 2026
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
## Contents
@@ -93,6 +93,10 @@ part of your geospatial project.
# Version Changes
+## 3.1.6
+### Feature
+ - Encodings can now be read from .cpg files (and optionally written to them).
+
## 3.1.5
### Bug fix
- 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
=====================================
@@ -1,3 +1,7 @@
+VERSION 3.1.6
+2026-07-25
+ * Encodings can now be read from .cpg files (and optionally written to them).
+
VERSION 3.1.5
2026-07-22
* 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))
=====================================
debian/changelog
=====================================
@@ -1,3 +1,10 @@
+pyshp (3.1.6-1) unstable; urgency=medium
+
+ * Team upload.
+ * New upstream release.
+
+ -- Bas Couwenberg <sebastic at debian.org> Sun, 26 Jul 2026 07:05:28 +0200
+
pyshp (3.1.5-1) unstable; urgency=medium
* Team upload.
=====================================
src/shapefile.py
=====================================
@@ -8,7 +8,7 @@ Compatible with Python versions >=3.9
from __future__ import annotations
-__version__ = "3.1.5"
+__version__ = "3.1.6"
import abc
import array
@@ -205,7 +205,11 @@ FieldTypeT = Literal["C", "D", "F", "L", "M", "N"]
# https://en.wikipedia.org/wiki/.dbf#Database_records
class FieldType:
- """A bare bones 'enum', as the enum library noticeably slows performance."""
+ """A bare bones 'enum', as the enum library used to noticeably slow performance.
+ A StrENum could be used instead, once the minimum supported Python
+ version >= 3.11. But the Public API supports passing strings into
+ Field (a NamedTuple which used to just be a plain tuple[str,str,int,int]).
+ """
C: Final = "C" # "Character" # (str)
D: Final = "D" # "Date"
@@ -1106,6 +1110,7 @@ class GeoJSON_Error(Exception):
pass
+# Could be an Enum, but an isinstance check is still needed.
class _NoShapeTypeSentinel:
"""An instance is the default value for Shape.__init__,
to preserve old behaviour for anyone who explictly
@@ -2814,25 +2819,6 @@ def _try_to_download_binary_file(
return initial_bytes, cast(ReadableBinStream, resp)
-def _try_get_open_constituent_file(
- file: Path,
- ext: Literal[".shp", ".shx", ".dbf"],
-) -> IO[bytes] | None:
- """
- Attempts to open a .shp, .dbf or .shx file,
- with both lower case and upper case file extensions,
- and return it. If it was not possible to open the file, None is returned.
- """
- exts = {ext, ext.upper(), ext.lower()}
-
- for candidate_ext in exts:
- try:
- return file.with_suffix(candidate_ext).open("rb")
- except OSError:
- pass
- return None
-
-
def ensure_within_bounds(i: int, num_records: int) -> int:
"""Provides list-like handling of a record index with a clearer
error message if the index is out of bounds."""
@@ -3354,7 +3340,7 @@ ShapeHeaderInfoT = tuple[int, int, int]
class ShpReader(_HasCheckedReadableFile):
- """Reads an shp file."""
+ """Reads a .shp file."""
FileProto = ReadSeekableBinStream
new_file_obj_mode = "rb"
@@ -3572,6 +3558,7 @@ class ShpReader(_HasCheckedReadableFile):
assert n == len(self.headers_cache), f"{n=}, {len(self.headers_cache)=}"
+# Could be an enum, but an isinstance check is still needed.
class _NoShpSentinel:
"""An instance is the default value for shp to preserve the
old behaviour (from when all keyword args were gathered
@@ -3612,21 +3599,23 @@ class Reader(_HasExitStack):
shapefile_path: str | PathLike[Any] = "",
/,
*,
- encoding: str = "utf-8",
+ encoding: str | None = None,
encodingErrors: str = "strict",
shp: _NoShpSentinel | BinaryFileT | None = _NoShpSentinel(),
shx: BinaryFileT | None = None,
dbf: BinaryFileT | None = None,
+ cpg: BinaryFileT | None = None,
# Keep kwargs even though unused, to preserve PyShp 2.4 API
**kwargs: Any,
):
super().__init__()
# Store encoding info to use if lazy loading DbfReader later.
- self.encoding = encoding
+ self._user_specified_encoding = encoding
self.encodingErrors = encodingErrors
self._shp = None
self._shx = None
self._dbf = None
+ self._cpg = None
self.shapeName = "Not specified"
self.numShapes: int = 0
self.path: str | os.PathLike[Any] | None = None
@@ -3645,13 +3634,15 @@ class Reader(_HasExitStack):
discarded_kwargs["shx"] = shx
if dbf is not None:
discarded_kwargs["dbf"] = dbf
+ if cpg is not None:
+ discarded_kwargs["cpg"] = cpg
if discarded_kwargs:
raise TypeError(
"Please be specific about the shapefile you want to load. "
f"Got: {shapefile_path}, plus the following unusable "
" kwargs: {discarded_kwargs} which previous versions of PyShp ignored. \n"
"Only either: i) exactly one positional arg \n"
- " or: ii) one or both of shp and dbf, optionally plus shx kwargs\n"
+ " or: ii) one or both of shp and dbf, optionally plus shx and cpg kwargs\n"
"is currently supported. All other kwargs may be set (or not). "
)
self.path = shapefile_path
@@ -3678,6 +3669,7 @@ class Reader(_HasExitStack):
zipfileobj = self._download_binary_file_from_url(
url_info,
".zip",
+ add_to_exit_stack=False,
suppress_http_errors=False,
)
if zipfileobj is None:
@@ -3716,12 +3708,14 @@ class Reader(_HasExitStack):
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)
+ self._cpg = self._seek_0_on_file_obj_wrap_or_open_from_name(".cpg", cpg)
self._dbf = self._seek_0_on_file_obj_wrap_or_open_from_name(".dbf", dbf)
# Load the files
if self._shp:
self._get_shp_reader()
if self._dbf:
+ # Sets self.encoding
self._get_dbf_reader()
if self._shx:
self._get_shx_reader()
@@ -3755,12 +3749,37 @@ class Reader(_HasExitStack):
raise ShapefileException(
"DbfReader requires a .dbf file or file-like object."
)
+ self._set_encoding()
return DbfReader(
dbf=self._dbf,
encoding=self.encoding,
encodingErrors=self.encodingErrors,
)
+ @functools.cache
+ def _set_encoding(self) -> None:
+ if self._cpg is None:
+ encoding_from_cpg = ""
+ else:
+ encoding_from_cpg = (
+ self._cpg.read().decode().lower().replace("-", "_").strip()
+ )
+ if not encoding_from_cpg:
+ warnings.warn("Empty .cpg file (no encoding found). ")
+
+ if self._user_specified_encoding is None:
+ encoding = encoding_from_cpg
+ else:
+ encoding = self._user_specified_encoding.lower().replace("-", "_").strip()
+
+ if encoding_from_cpg and encoding != encoding_from_cpg:
+ warnings.warn(
+ f"Specified encoding: {encoding} "
+ "different to encoding read from "
+ f".cpg file: {encoding_from_cpg}",
+ )
+ self.encoding = encoding or "utf-8"
+
@property
def shp_reader(self) -> ShpReader:
return self._get_shp_reader()
@@ -3848,20 +3867,41 @@ class Reader(_HasExitStack):
) -> Iterator[_Record | None]:
return self.dbf_reader.iterRecords(fields, start, stop, deleted_as_None)
+ def _try_get_open_constituent_file(
+ self,
+ file: Path,
+ ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
+ ) -> IO[bytes] | None:
+ """
+ Attempts to open a .shp, .dbf or .shx file,
+ with both lower case and upper case file extensions,
+ and return it. If it was not possible to open the file, None is returned.
+ """
+ exts = {ext, ext.upper(), ext.lower()}
+
+ for candidate_ext in exts:
+ try:
+ file_obj = file.with_suffix(candidate_ext).open("rb")
+ except OSError:
+ continue
+ self.exit_stack.enter_context(file_obj)
+ return file_obj
+ return None
+
def _seek_0_on_file_obj_wrap_or_open_from_name(
self,
- ext: Literal[".shp", ".shx", ".dbf"],
+ ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
file: BinaryFileT | None,
) -> None | IO[bytes]:
if file is None:
return None
if isinstance(file, (str, PathLike)):
- file_obj = _try_get_open_constituent_file(Path(file), ext)
- if file_obj is not None:
- self.exit_stack.enter_context(file_obj)
- return file_obj
+ # Added to exit stack if opened.
+ return self._try_get_open_constituent_file(Path(file), ext)
+ # Other user-opened file objects not added to exit stack.
+ # The user must close them.
if hasattr(file, "read"):
# Copy if required
try:
@@ -3878,7 +3918,8 @@ class Reader(_HasExitStack):
def _download_binary_file_from_url(
self,
urlinfo: SplitResult,
- ext: Literal[".shp", ".shx", ".dbf", ".zip"],
+ ext: Literal[".shp", ".shx", ".dbf", ".cpg", ".zip"],
+ add_to_exit_stack: bool = True,
suppress_http_errors: bool = True,
) -> tempfile._TemporaryFileWrapper[bytes] | None:
sniffed_bytes, resp = _try_to_download_binary_file(
@@ -3890,6 +3931,8 @@ class Reader(_HasExitStack):
return None
# Use tempfile as source for url data.
fileobj = _save_to_named_tmp_file(resp, initial_bytes=sniffed_bytes)
+ if add_to_exit_stack:
+ self.exit_stack.enter_context(fileobj)
return fileobj
def _load_from_url(self, urlinfo: SplitResult) -> None:
@@ -3897,19 +3940,10 @@ class Reader(_HasExitStack):
# Download each file to temporary path and treat as normal shapefile path
self._shp = self._download_binary_file_from_url(urlinfo, ".shp")
self._shx = self._download_binary_file_from_url(urlinfo, ".shx")
+ self._cpg = self._download_binary_file_from_url(urlinfo, ".cpg")
self._dbf = self._download_binary_file_from_url(urlinfo, ".dbf")
- shp_or_dbf_loaded = False
- if self._shx is not None:
- self.exit_stack.enter_context(self._shx)
- if self._shp is not None:
- self.exit_stack.enter_context(self._shp)
- shp_or_dbf_loaded = True
- if self._dbf is not None:
- self.exit_stack.enter_context(self._dbf)
- shp_or_dbf_loaded = True
-
- if not shp_or_dbf_loaded:
+ if self._shp is None and self._dbf is None:
raise ShapefileException(
f"Failed to download .shp or .dbf from: {urlunsplit(urlinfo)}"
)
@@ -3918,7 +3952,7 @@ class Reader(_HasExitStack):
self,
archive: zipfile.ZipFile,
file: Path,
- ext: Literal[".shp", ".shx", ".dbf"],
+ ext: Literal[".shp", ".shx", ".dbf", ".cpg"],
) -> tempfile._TemporaryFileWrapper[bytes] | None:
for cased_ext in {ext.lower(), ext.upper(), ext}:
try:
@@ -3972,7 +4006,7 @@ class Reader(_HasExitStack):
constituent_files = (
Path(name)
for name in archive.namelist()
- if name.lower().endswith((".shp", ".dbf", ".shx"))
+ if name.lower().endswith((".shp", ".dbf", ".shx", ".cpg"))
)
def without_ext(path: Path) -> Path:
@@ -3995,6 +4029,7 @@ class Reader(_HasExitStack):
# Try to extract file-like objects from zipfile
self._shp = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".shp")
self._shx = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".shx")
+ self._cpg = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".cpg")
self._dbf = self._load_file_from_zip_to_tmp_file(archive, shapefile, ".dbf")
def load(self, file: str | os.PathLike[Any]) -> None:
@@ -4018,27 +4053,25 @@ class Reader(_HasExitStack):
"""
Attempts to load file with .shp extension as both lower and upper case
"""
- self._shp = _try_get_open_constituent_file(file, ".shp")
- if self._shp:
- self.exit_stack.enter_context(self._shp)
+ self._shp = self._try_get_open_constituent_file(file, ".shp")
+ if self._shp is not None:
self._get_shp_reader()
def load_shx(self, file: Path) -> None:
"""
Attempts to load file with .shx extension as both lower and upper case
"""
- self._shx = _try_get_open_constituent_file(file, ".shx")
- if self._shx:
- self.exit_stack.enter_context(self._shx)
+ self._shx = self._try_get_open_constituent_file(file, ".shx")
+ if self._shx is not None:
self._get_shx_reader()
def load_dbf(self, file: Path) -> None:
"""
Attempts to load file with .dbf extension as both lower and upper case
"""
- self._dbf = _try_get_open_constituent_file(file, ".dbf")
- if self._dbf:
- self.exit_stack.enter_context(self._dbf)
+ self._dbf = self._try_get_open_constituent_file(file, ".dbf")
+ if self._dbf is not None:
+ self._cpg = self._try_get_open_constituent_file(file, ".cpg")
self._get_dbf_reader()
def __len__(self) -> int:
=====================================
tests/hypothesis_tests.py
=====================================
@@ -613,8 +613,9 @@ def _exclude_chars() -> dict[str,list[str]]:
for enc in aliases.values():
if enc in exclude_chars:
continue
- # I'm not sure why any encoding would fail on an empty string,
- # but I'd rather not have the tests get bogged by any that do exist.
+
+ # Weed out: base64_codec, bz2_codec, hex_codec,
+ # quopri_codec, rot_13, uu_codec & zlib_codec
try:
"".encode(enc)
except (UnicodeEncodeError, LookupError):
=====================================
tests/test_shapefile.py
=====================================
@@ -8,6 +8,7 @@ import io
import json
import os.path
from pathlib import Path
+import shutil
# third party imports
import pytest
@@ -2164,4 +2165,49 @@ def test_encode_dbf_field_values(value,encoded_len,codec,errors):
with context:
r = shapefile.DbfReader(stream, encoding=codec, encodingErrors=errors, strict=False)
assert r.record(0)[0] == value
- r.close()
\ No newline at end of file
+ r.close()
+
+ at pytest.fixture
+def tmp_latin1_shapefile_shp(tmp_path):
+ name = "latin1"
+ test_shapefile_dir = tmp_path / name
+ test_shapefile_dir.mkdir()
+ for file in shapefiles_dir.glob("latin1.*"):
+ shutil.copy(file, test_shapefile_dir)
+ test_shapefile = test_shapefile_dir / f"{name}.shp"
+ return test_shapefile
+
+ENCODINGS_AND_CONTEXTS = [
+ ("latin1", contextlib.nullcontext()),
+ ("utf8", pytest.raises(shapefile.dbfFileException)),
+]
+ at pytest.mark.parametrize("encoding, context", ENCODINGS_AND_CONTEXTS)
+def test_read_latin1_shapefile(encoding, context, tmp_latin1_shapefile_shp):
+ """ Extend the smoke test in README.md doctests """
+
+ assert tmp_latin1_shapefile_shp.is_file()
+
+ r = shapefile.Reader(tmp_latin1_shapefile_shp, encoding=encoding)
+ with context:
+ rec = r.record(0)
+ r.close()
+ if encoding == "latin1":
+ assert rec == [2, u'Ñandú']
+
+ at pytest.mark.parametrize("encoding, context", ENCODINGS_AND_CONTEXTS)
+def test_read_latin1_shapefile_cpg_file(encoding, context, tmp_latin1_shapefile_shp):
+ """ Extend the smoke test in README.md doctests """
+
+ assert tmp_latin1_shapefile_shp.is_file()
+
+ cpg_file = tmp_latin1_shapefile_shp.with_suffix(".cpg")
+ cpg_file.write_text(encoding.upper().replace("_","-"))
+
+ r = shapefile.Reader(tmp_latin1_shapefile_shp)
+ with context:
+ rec = r.record(0)
+ r.close()
+ if encoding == "latin1":
+ assert rec == [2, u'Ñandú']
+
+
View it on GitLab: https://salsa.debian.org/debian-gis-team/pyshp/-/compare/3e761350283e35ae581ed23a2647d7d0b206a9a5...9439a11fffbc4ca924c7055a747b322f2a280de1
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/pyshp/-/compare/3e761350283e35ae581ed23a2647d7d0b206a9a5...9439a11fffbc4ca924c7055a747b322f2a280de1
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20260726/2c688b57/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list