Skip to content

Commit a1b9cf8

Browse files
committed
Test text fields against all valid code points for the codec
1 parent 48ee7d5 commit a1b9cf8

4 files changed

Lines changed: 136 additions & 61 deletions

File tree

.github/workflows/run_checks_build_and_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
include:
7272
- python-version: "3.14"
7373
- os: "ubuntu-latest"
74-
74+
7575
runs-on: ${{ matrix.os }}
7676
steps:
7777
- uses: actions/setup-python@v6

src/shapefile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4430,9 +4430,9 @@ def _record(self, record: list[RecordValue]) -> None:
44304430
# date: 8 bytes - date stored as a string in the format YYYYMMDD.
44314431
if isinstance(value, list) and len(value) == 3:
44324432
value = date(*value)
4433-
if isinstance(value, date):
4433+
if isinstance(value, date):
44344434
# In Pythons using certain glibc versions.
4435-
# date.strftime does not preppend zeros.
4435+
# date.strftime does not preppend zeros.
44364436
str_val = value.strftime("%Y%m%d").zfill(8)
44374437
# b"".join(ord(c).to_bytes() for c in s)
44384438
elif value in MISSING:

tests/hypothesis_tests.py

Lines changed: 99 additions & 26 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)
@@ -691,14 +768,10 @@ def test_dbf_Field_roundtrips(encoding_and_dbf_field: dict) -> None:
691768
def date_to_str(d: datetime.date) -> str:
692769
return d.strftime("%Y%m%d").zfill(8)
693770

694-
def record_value_for_field(name: str, field_type: str, size: int, decimal: int, encoding: str):
771+
def record_value_strat_for_field(name: str, field_type: str, size: int, decimal: int, encoding: str):
695772

696773
if field_type == "C":
697-
return text(
698-
alphabet=ascii_printable,
699-
min_size=0,
700-
max_size=size,
701-
)
774+
return strings_of_supported_code_points(encoding, 0, size)
702775
if field_type in {"N", "F"}:
703776

704777
int_digits = size if decimal == 0 else size - decimal - 1
@@ -732,7 +805,7 @@ def _dbf_encoding_fields_and_record_strategy(
732805

733806
fields = draw(lists(_dbf_fields_strategy(encoding), min_size=1, max_size=max_fields))
734807

735-
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))
736809

737810
return encoding, fields, record_strategy
738811

uv.lock

Lines changed: 34 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)