Skip to content

Commit a0ccbb7

Browse files
committed
v3.1.1 Warn if text encoding ends in pad bytes. Minimise pad bytes used in decodings.
v3.1.1 Bump version Only catch warnings in dbf record tests that encode ascii to utf-16-le Trim all trailing null bytes, not just the first one (lots appear in test files) Check string encodings end in pad bytes, and use minimum of them in decoding. Remove unnecessary stream.seek(0)s and warning captures Stop corruption! Of b" " in encoded field names (only corrupt U+0020 in strings) Add test for unicode field string corruption
1 parent 493534a commit a0ccbb7

5 files changed

Lines changed: 234 additions & 74 deletions

File tree

README.md

Lines changed: 7 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.0.13
12-
- **Date**: 19th June 2026
11+
- **Version**: 3.1.1
12+
- **Date**: 24th June 2026
1313
- **License**: [MIT](https://github.com/GeospatialPython/pyshp/blob/master/LICENSE.TXT)
1414

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

9494
# Version Changes
9595

96+
## 3.1.1
97+
### Unicode support made even more robust and yet another encoding bug fixed!
98+
- When reading, only use minimum number of pad bytes to decode text successfully (fixes issue 423).
99+
- When writing, warn (or raise in strict mode) if the text's encoding ends in pad bytes.
100+
96101
## 3.1.0
97102
### Unicode support made more robust and encoding bugs fixed
98103
- Truncation of field names and text fields now respects unicode code point boundaries (fixes issues -

changelog.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
VERSION 3.1.1
2+
Unicode support made even more robust and yet another encoding bug fixed!
3+
* When reading, only use minimum number of pad bytes to decode text successfully (fixes issue 423).
4+
* When writing, warn (or raise in strict mode) if the text's encoding ends in pad bytes.
5+
16
VERSION 3.1.0
27

38
2026-06-23

src/shapefile.py

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

99
from __future__ import annotations
1010

11-
__version__ = "3.1.0"
11+
__version__ = "3.1.1"
1212

1313
import abc
1414
import array
@@ -251,6 +251,38 @@ def __call__(
251251
) -> str: ...
252252

253253

254+
def _warn_if_string_ends_with_decoded_pad_bytes(
255+
s: str,
256+
pad_byte: bytes,
257+
encoding: str = "utf-8",
258+
encodingErrors: str = "strict",
259+
) -> None:
260+
"""Warns if e.g. the encoding is utf-16-le, and the
261+
decoded text ends in "†", which encodes to a pair of
262+
ascii spaces (b" ", the pad byte for C and M fields).
263+
"""
264+
# Max code unit size under UTF-8, UTF-16, and UTF-32 is 4 bytes.
265+
for n in range(1, 5):
266+
# TODO: test for encodings ending in a null terminator preceded
267+
# by pad bytes, that are exactly the field's size (length).
268+
pad_bytes = pad_byte * n
269+
try:
270+
decoded_pad_bytes: str = pad_bytes.decode(encoding, encodingErrors)
271+
except UnicodeDecodeError:
272+
continue
273+
if s.endswith(decoded_pad_bytes):
274+
msg = (
275+
f"Under the given encoding: {encoding}, "
276+
f" the text (field name or 'C' or 'M' field): {s!r} "
277+
f" ends with {decoded_pad_bytes!r}, which coincidentally"
278+
f"encodes to the pad bytes: {pad_bytes!r}. "
279+
"The real end of the actual data may be earlier. "
280+
)
281+
282+
warnings.warn(msg, category=PossibleDataLoss)
283+
break
284+
285+
254286
def _encode_dbf_string(
255287
s: str,
256288
size: int,
@@ -273,6 +305,8 @@ def _encode_dbf_string(
273305
N = len(s)
274306
trimmed: str
275307
encoded: bytes
308+
309+
# i - num of characters to keep. Starts by trying to keep all N.
276310
for i in reversed(range(0, N + 1)):
277311
trimmed = s[:i]
278312
encoded = trimmed.encode(encoding, encodingErrors)
@@ -300,16 +334,27 @@ def _encode_dbf_string(
300334
f"to a short enough byte string, using {encoding=}, {encodingErrors=}"
301335
)
302336

337+
if pad_byte is not None:
338+
_warn_if_string_ends_with_decoded_pad_bytes(
339+
s=trimmed,
340+
pad_byte=pad_byte,
341+
encoding=encoding,
342+
encodingErrors=encodingErrors,
343+
)
344+
303345
if len(encoded) < size and pad_byte is not None:
304346
padded = encoded.ljust(size, pad_byte)
305347
else:
306348
padded = encoded
307349

308-
decoded = decode(
309-
b=padded,
310-
encoding=encoding,
311-
encodingErrors=encodingErrors,
312-
)
350+
with warnings.catch_warnings():
351+
warnings.simplefilter("ignore")
352+
decoded = decode(
353+
b=padded,
354+
encoding=encoding,
355+
encodingErrors=encodingErrors,
356+
)
357+
313358
if decoded != trimmed:
314359
msg = f"Padded value: {padded!r} does not decode to {trimmed!r} using PyShp's decoder: {decode.__name__}"
315360
if len(trimmed) < len(s):
@@ -324,20 +369,67 @@ def _encode_dbf_string(
324369
return padded, trimmed
325370

326371

372+
def _try_to_decode_dbf_name_or_text_field(
373+
b: bytes,
374+
pad_bytes: bytes, # Pad bytes will be trimmed (from the R of b) in their order in the byte-string
375+
encoding: str = "utf8",
376+
encodingErrors: str = "strict",
377+
) -> str:
378+
N = len(b)
379+
decoded: str
380+
trimmed = b
381+
num_trailing_pad_bytes = N - len(b.rstrip(pad_bytes))
382+
383+
# Test if we need to restore any pad_bytes to
384+
# correctly decode the remaining bytes to a string.
385+
# num_to_trim starts from num_trailing_pad_bytes
386+
# - initially trimming all trailing pad bytes
387+
for num_to_trim in reversed(range(num_trailing_pad_bytes + 1)):
388+
i = N - num_to_trim
389+
trimmed = b[:i]
390+
try:
391+
decoded = trimmed.decode(encoding, encodingErrors)
392+
except UnicodeDecodeError:
393+
continue
394+
if num_to_trim < num_trailing_pad_bytes:
395+
warnings.warn(
396+
f"Used {num_trailing_pad_bytes - num_to_trim} pad bytes ({pad_bytes!r}) "
397+
f"from padding to decode raw field: {b!r} "
398+
f"to: {decoded!r} ({encoding=}, {encodingErrors=}) ",
399+
category=PossibleDataLoss,
400+
)
401+
return decoded
402+
403+
raise dbfFileException(
404+
f"Could not decode field name or text/memo field: {b!r} using {encoding=} and {encodingErrors=}"
405+
" no matter how many trailing pad bytes (if any) ({pad_bytes!r}) were used. "
406+
)
407+
408+
327409
def _decode_C_or_M_field(
328410
b: bytes,
329411
encoding: str = "utf8",
330412
encodingErrors: str = "strict",
331413
strict: bool = True,
332414
) -> str:
333-
retval = b.decode(encoding, encodingErrors).rstrip("\x00").rstrip(" ")
334-
if retval.rstrip("\x00") != retval and strict:
415+
retval = _try_to_decode_dbf_name_or_text_field(
416+
b=b,
417+
pad_bytes=b" \x00",
418+
encoding=encoding,
419+
encodingErrors=encodingErrors,
420+
)
421+
422+
if not strict:
423+
return retval
424+
425+
if retval.rstrip("\x00") != retval:
335426
msg = (
336-
f"More Trailing Null chars in: {b!r}"
337-
" after removing trailing null chars and ascii spaces"
338-
f", resulting in {retval!r}"
427+
f"More trailing null chars in: {retval!r}"
428+
" after removing one trailing null char and ascii spaces"
429+
f" from {b!r}, and decoding (codec: {encoding}, errors: {encodingErrors}). "
339430
)
340431
warnings.warn(msg, category=PossibleDataLoss)
432+
341433
return retval
342434

343435

@@ -360,34 +452,15 @@ def decode_name(
360452
encodingErrors: str = "strict",
361453
strict: bool = True,
362454
) -> str:
363-
N = len(b)
364-
decoded: str
365-
num_trailing_null_bytes = N - len(b.rstrip(b"\x00"))
366-
367-
# Test if we need to restore any of those null bytes to
368-
# correctly decode the remaining bytes to a string.
369-
for num_to_trim in reversed(range(num_trailing_null_bytes + 1)):
370-
i = N - num_to_trim
371-
trimmed = b[:i]
372-
try:
373-
decoded = trimmed.decode(encoding, encodingErrors)
374-
except UnicodeDecodeError:
375-
continue
376-
if strict and num_to_trim < num_trailing_null_bytes:
377-
warnings.warn(
378-
f"Used {num_trailing_null_bytes - num_to_trim} null bytes "
379-
f"from padding to decode {b!r} "
380-
f"to: {decoded!r} ({encoding=}, {encodingErrors=}) ",
381-
category=PossibleDataLoss,
382-
)
383-
if not strict:
384-
decoded = decoded.lstrip()
385-
return decoded
386-
387-
raise dbfFileException(
388-
f"Could not decode field name: {b!r} using {encoding=} and {encodingErrors=}"
389-
" no matter how many trailing null-bytes (if any) were used. "
455+
decoded = _try_to_decode_dbf_name_or_text_field(
456+
b=b,
457+
pad_bytes=b"\x00",
458+
encoding=encoding,
459+
encodingErrors=encodingErrors,
390460
)
461+
if not strict:
462+
decoded = decoded.lstrip()
463+
return decoded
391464

392465
@classmethod
393466
def from_byte_stream(
@@ -445,6 +518,14 @@ def from_unchecked(
445518
size = 1
446519
decimal = 0
447520

521+
if not strict and " " in name:
522+
warnings.warn(
523+
f"Replacing ascii spaces (0x20, ' 's) with underscores ('_'s) in {name!r}. "
524+
"Use a Writer(file, strict=True) to preserve the field name as it is. ",
525+
category=PossibleDataLoss,
526+
)
527+
name = name.replace(" ", "_")
528+
448529
# Only use the portion of the name that we are able to encode to
449530
# 10 bytes or less.
450531
_encoded_name, trimmed_name = cls.trim_name_until_encodable(
@@ -502,13 +583,6 @@ def encode_field_descriptor(
502583
encodingErrors=encodingErrors,
503584
strict=strict,
504585
)
505-
if not strict and b" " in encoded_name:
506-
warnings.warn(
507-
"Replacing ascii spaces (0x20) with underscores "
508-
f"in encoded bytes: {encoded_name!r}",
509-
category=PossibleDataLoss,
510-
)
511-
encoded_name = encoded_name.replace(b" ", b"_")
512586

513587
encoded_field_type = self.field_type.encode("ascii")
514588
return self.get_struct().pack(

tests/hypothesis_tests.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,14 @@ def code_and_shape_strat_from_triple(t):
461461
@pytest.mark.hypothesis
462462
@given(codes_and_shapes=codes_and_shapes)
463463
def test_shp_reader_writer_roundtrip(codes_and_shapes)-> None:
464+
464465
code_ex, expected_shapes = codes_and_shapes
465466
stream = io.BytesIO()
467+
466468
with shp.ShpWriter(shp=stream, shapeType=code_ex) as w:
467469
for shape in expected_shapes:
468470
w.shape(shape)
469-
stream.seek(0)
471+
470472
with shp.ShpReader(shp=stream) as r:
471473
assert r.shapeType == code_ex
472474

@@ -495,8 +497,6 @@ def test_shp_reader_writer_roundtrip(codes_and_shapes)-> None:
495497
assert not hasattr(expected, "partTypes")
496498

497499

498-
499-
500500
@pytest.mark.hypothesis
501501
@given(codes_and_shapes=codes_and_shapes)
502502
def test_shx_reader_writer_roundtrip(codes_and_shapes)-> None:
@@ -516,8 +516,6 @@ def test_shx_reader_writer_roundtrip(codes_and_shapes)-> None:
516516
offsets_B.append(offset_B)
517517
shx_w._shx_record(offset_B, size_B)
518518

519-
shx_stream.seek(0)
520-
521519
with shp.ShxReader(shx=shx_stream) as r:
522520
assert r.numShapes == len(expected_shapes)
523521
assert r.offsets == offsets_B
@@ -655,7 +653,6 @@ def test_dbf_reader_writer_roundtrip(fields_and_records)-> None:
655653
written_records.append(record)
656654

657655

658-
stream.seek(0)
659656
with shp.DbfReader(dbf=stream) as r:
660657
actual_fields = iter(r.fields)
661658
next(actual_fields) # skip deletion flag

0 commit comments

Comments
 (0)