Skip to content

Commit 9649c5d

Browse files
authored
Merge pull request #424 from JamesParrott/Test_utf16_encoded_field_names
v3.1.1 Warn if text encoding ends in pad bytes. Minimise pad bytes used in decodings.
2 parents 493534a + a0ccbb7 commit 9649c5d

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)