[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