Skip to content

Commit 008a425

Browse files
authored
Merge pull request #437 from JamesParrott/3.1.5_fix_date_str_bug_no_strftime
3.1.5 fix date str bug no strftime
2 parents 3d6bb0b + a1b9cf8 commit 008a425

6 files changed

Lines changed: 163 additions & 66 deletions

File tree

.github/workflows/run_checks_build_and_test.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,20 @@ jobs:
5858
matrix:
5959
python-version: [
6060
"3.14",
61+
"3.13",
62+
"3.12",
63+
"3.15-dev",
64+
"3.11",
65+
"3.10",
66+
"3.9",
6167
]
6268
os: [
6369
"ubuntu-24.04",
6470
]
71+
include:
72+
- python-version: "3.14"
73+
- os: "ubuntu-latest"
74+
6575
runs-on: ${{ matrix.os }}
6676
steps:
6777
- uses: actions/setup-python@v6
@@ -73,7 +83,6 @@ jobs:
7383
path: ./Pyshp
7484

7585
- name: "Hypothesis tests"
76-
if:
7786
uses: ./Pyshp/.github/actions/test
7887
with:
7988
extra_args: '-m hypothesis'

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.4
12-
- **Date**: 29th June 2026
11+
- **Version**: 3.1.5
12+
- **Date**: 22nd 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.5
97+
### Bug fix
98+
- 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))
99+
96100
## 3.1.4
97101
### Bug fix
98102
- Fix bug causing dates supplied as length 8 strings of digits to be encoded by the custom encoding, not ascii.

changelog.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
VERSION 3.1.5
2+
2026-07-22
3+
* 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))
4+
15

26
VERSION 3.1.4
37
2026-06-29

src/shapefile.py

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

99
from __future__ import annotations
1010

11-
__version__ = "3.1.4"
11+
__version__ = "3.1.5"
1212

1313
import abc
1414
import array
@@ -4431,7 +4431,9 @@ def _record(self, record: list[RecordValue]) -> None:
44314431
if isinstance(value, list) and len(value) == 3:
44324432
value = date(*value)
44334433
if isinstance(value, date):
4434-
str_val = value.strftime("%Y%m%d")
4434+
# In Pythons using certain glibc versions.
4435+
# date.strftime does not preppend zeros.
4436+
str_val = value.strftime("%Y%m%d").zfill(8)
44354437
# b"".join(ord(c).to_bytes() for c in s)
44364438
elif value in MISSING:
44374439
str_val = "0" * 8 # QGIS NULL for date type

tests/hypothesis_tests.py

Lines changed: 105 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import datetime
5+
import functools
56
import io
67
import itertools
78
import os
@@ -66,6 +67,7 @@ def ignore_warnings(category=None):
6667
)
6768

6869

70+
6971
def coords_2D_list(
7072
min_size: int = 1,
7173
max_size: int | None = None,
@@ -77,6 +79,7 @@ def coords_2D_list(
7779
)
7880

7981

82+
8083
@pytest.mark.hypothesis
8184
@given(expected=point_2D, i=integers(min_value=1))
8285
def test_Point_2D_roundtrips(
@@ -563,20 +566,90 @@ def test_shx_reader_writer_roundtrip(codes_and_shapes)-> None:
563566
"utf-32-le",
564567
"cp1140",
565568
]
569+
POSSIBLY_NONINJECTIVE_CODECS = frozenset({
570+
'big5', 'big5hkscs', 'cp932', 'cp949', 'cp950',
571+
'euc_jp', 'euc_jis_2004', 'euc_kr', 'gb2312', 'gbk', 'gb18030',
572+
'hz', 'iso2022_jp', 'iso2022_kr', 'shift_jis', 'shift_jis_2004'
573+
})
574+
575+
@functools.cache
576+
def _exclude_chars() -> dict[str,list[str]]:
577+
578+
exclude_chars = {} #"iso2022" : ["\x1b"]}
579+
"""
580+
Not sure if bug in hypothesis generation, in core library, or just the way it is:
581+
582+
Python taking a short cut with Control characters in ISO_2022 stateful codecs
583+
Python 3.13.14 (main, Jul 18 2026, 17:02:37) [Clang 22.1.3 ] on linux
584+
Type "help", "copyright", "credits" or "license" for more information.
585+
>>> enc="iso2022_jp_1"
586+
>>> s="\x1b"
587+
>>> b=s.encode(enc)
588+
>>> b
589+
b'\x1b'
590+
>>> b.decode(enc)
591+
Traceback (most recent call last):
592+
File "<python-input-4>", line 1, in <module>
593+
b.decode(enc)
594+
~~~~~~~~^^^^^
595+
UnicodeDecodeError: 'iso2022_jp_1' codec can't decode byte 0x1b in position 0: incomplete multibyte sequence
596+
decoding with 'iso2022_jp_1' codec failed
597+
598+
Non injective encodings (dealt with below)
599+
>>> enc="cp950"
600+
>>> "•".encode(enc)
601+
b'\xa1E'
602+
>>> b=_
603+
>>> b.decode(enc)
604+
'‧'
605+
>>> s = _
606+
>>> s.encode(enc)
607+
b'\xa1E'
608+
609+
"""
610+
566611

567-
def _encodings() -> set[str]:
568612
from encodings.aliases import aliases
569-
encs = set()
570613
for enc in aliases.values():
571-
if enc in encs:
614+
if enc in exclude_chars:
572615
continue
616+
# I'm not sure why any encoding would fail on an empty string,
617+
# but I'd rather not have the tests get bogged by any that do exist.
573618
try:
574619
"".encode(enc)
575620
except (UnicodeEncodeError, LookupError):
576621
continue
577-
encs.add(enc)
578-
return encs
579-
# assert _encodings() == {'utf_16_le', 'iso8859_7', 'cp437', 'iso2022_jp_3', 'shift_jis', 'cp775', 'cp1140',
622+
623+
exclude_chars[enc] = []
624+
625+
if enc not in POSSIBLY_NONINJECTIVE_CODECS:
626+
continue
627+
628+
# find collisions of non-injective codecs
629+
# Iterate over BMP
630+
for code_point in range(0x10000):
631+
char = chr(code_point)
632+
try:
633+
b = char.encode(enc)
634+
except (UnicodeEncodeError, LookupError):
635+
exclude_chars[enc].append(char)
636+
continue
637+
638+
decoded = None
639+
try:
640+
decoded = b.decode(enc)
641+
except (UnicodeDecodeError, LookupError):
642+
exclude_chars[enc].append(char)
643+
continue
644+
645+
if decoded != char:
646+
exclude_chars[enc].append(char)
647+
648+
649+
650+
651+
return exclude_chars
652+
# assert set(_encodings()) == {'utf_16_le', 'iso8859_7', 'cp437', 'iso2022_jp_3', 'shift_jis', 'cp775', 'cp1140',
580653
# 'cp861', 'iso8859_11', 'iso8859_9', 'euc_jp', 'utf_16', 'cp950', 'mac_cyrillic', 'mac_turkish', 'iso2022_jp_1', 'iso8859_10',
581654
# 'iso2022_jp_2004', 'cp866', 'mac_greek', 'hz', 'cp1257', 'cp037', 'cp863', 'iso8859_4', 'utf_16_be', 'gb18030', 'cp1250',
582655
# 'cp850', 'iso8859_5', 'shift_jisx0213', 'iso8859_8', 'cp273', 'euc_jisx0213', 'cp932', 'cp862', 'tis_620', 'cp1125', 'koi8_r',
@@ -586,25 +659,29 @@ def _encodings() -> set[str]:
586659
# 'iso2022_kr', 'cp1251', 'cp1255', 'mac_iceland', 'kz1048', 'iso8859_14', 'utf_32_be', 'ptcp154', 'iso8859_6', 'mac_roman',
587660
# 'utf_32', 'iso2022_jp_2', 'iso8859_16', 'mbcs', 'cp500', 'iso8859_2', 'cp949', 'cp852', 'utf_7', 'big5hkscs', 'johab'}
588661

589-
encodings = sampled_from(list(_encodings())) # if IN_CI else ENCODINGS)
662+
663+
def strings_of_supported_code_points(encoding: str, min_size: int=1, max_size: int=10):
664+
665+
return text(
666+
alphabet=characters(
667+
codec=encoding,
668+
# https://en.wikipedia.org/wiki/Unicode_character_property#General_Category
669+
exclude_categories=["Cs", "Co", "Cn"], # Cs - surrogates
670+
exclude_characters=_exclude_chars().get(encoding, []),
671+
),
672+
min_size=min_size,
673+
max_size=max_size,
674+
)
675+
676+
677+
encodings = sampled_from(list(_exclude_chars())) # if IN_CI else ENCODINGS)
590678

591679

592680
@composite
593681
def _dbf_fields_strategy(draw, encoding: str) -> dict[str, str | int]:
594682
field_type, bounds_dict = draw(sampled_from(list(DBF_FIELD_TYPES.items())))
595683

596-
name = draw(
597-
text(
598-
alphabet=characters(
599-
codec=encoding,
600-
# https://en.wikipedia.org/wiki/Unicode_character_property#General_Category
601-
exclude_categories=["Cs", "Co", "Cn"], # Cs - surrogates
602-
# exclude_characters=[" "],
603-
),
604-
min_size=1,
605-
max_size=10,
606-
)
607-
)
684+
name = draw(strings_of_supported_code_points(encoding))
608685

609686
max_length = bounds_dict.get("max_length", 254)
610687
min_length = bounds_dict.get("min_length", 1)
@@ -688,14 +765,13 @@ def test_dbf_Field_roundtrips(encoding_and_dbf_field: dict) -> None:
688765

689766
ascii_printable = string.ascii_letters + string.digits + string.punctuation + " "
690767

691-
def record_value_for_field(name: str, field_type: str, size: int, decimal: int, encoding: str):
768+
def date_to_str(d: datetime.date) -> str:
769+
return d.strftime("%Y%m%d").zfill(8)
770+
771+
def record_value_strat_for_field(name: str, field_type: str, size: int, decimal: int, encoding: str):
692772

693773
if field_type == "C":
694-
return text(
695-
alphabet=ascii_printable,
696-
min_size=0,
697-
max_size=size,
698-
)
774+
return strings_of_supported_code_points(encoding, 0, size)
699775
if field_type in {"N", "F"}:
700776

701777
int_digits = size if decimal == 0 else size - decimal - 1
@@ -715,7 +791,7 @@ def record_value_for_field(name: str, field_type: str, size: int, decimal: int,
715791
if field_type == "L":
716792
return sampled_from([True, False, None])
717793
if field_type == "D":
718-
return one_of(dates(), dates().map(lambda d: d.strftime("%Y%m%d")))
794+
return one_of(dates(), dates().map(date_to_str))
719795

720796
raise ValueError(f"Unsupported: {field_type=}")
721797

@@ -729,7 +805,7 @@ def _dbf_encoding_fields_and_record_strategy(
729805

730806
fields = draw(lists(_dbf_fields_strategy(encoding), min_size=1, max_size=max_fields))
731807

732-
record_strategy = tuples(*(record_value_for_field(encoding=encoding, **field) for field in fields))
808+
record_strategy = tuples(*(record_value_strat_for_field(encoding=encoding, **field) for field in fields))
733809

734810
return encoding, fields, record_strategy
735811

@@ -771,9 +847,9 @@ def _assert_reader_matches_expected_records(r, fields, written_records):
771847
decimal = field["decimal"]
772848
if field_type == "D":
773849
if isinstance(expected, datetime.date):
774-
expected = expected.strftime("%Y%m%d")
850+
expected = date_to_str(expected)
775851
if isinstance(actual, datetime.date):
776-
actual = actual.strftime("%Y%m%d")
852+
actual = date_to_str(actual)
777853
elif field_type in ("N", "F") and decimal >= 1:
778854
expected = float(format(expected, f".{decimal}f"))
779855
assert actual == expected, f"{actual=}, {expected=}, {field_type=}, {type(actual)=}, {type(expected)=}"

0 commit comments

Comments
 (0)