Skip to content

Commit 18824ee

Browse files
authored
Merge pull request #417 from JamesParrott/dbf_round_trip
Dbf round trip test
2 parents 7c269d7 + 36fc5eb commit 18824ee

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

src/shapefile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3943,9 +3943,9 @@ def record(
39433943
else:
39443944
# Blank fields for empty record
39453945
record = ["" for _ in range(fieldCount)]
3946-
self.__dbfRecord(record)
3946+
self._record(record)
39473947

3948-
def __dbfRecord(self, record: list[RecordValue]) -> None:
3948+
def _record(self, record: list[RecordValue]) -> None:
39493949
"""Writes the dbf records."""
39503950
f = self.file
39513951
if self.recNum == 0:

tests/hypothesis_tests.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import datetime
34
import io
45
import itertools
6+
import string
57

68
import pytest
79
from hypothesis import HealthCheck, given, settings
@@ -16,6 +18,9 @@
1618
one_of,
1719
tuples,
1820
sampled_from,
21+
text,
22+
characters,
23+
dates,
1924
)
2025

2126
import shapefile as shp
@@ -518,3 +523,119 @@ def test_shx_reader_writer_roundtrip(codes_and_shapes)-> None:
518523
assert r.offsets == offsets_B
519524
assert r.shape_lengths_B == sizes_B
520525

526+
527+
528+
DBF_FIELD_TYPES = {
529+
"C": {},
530+
"N": {"max_decimal" : 20, "max_length": 22}, # max length=23 to avoid error due to precision limit, e.g.:
531+
"F": {"max_decimal" : 20, "max_length": 22}, # hypothesis.errors.InvalidArgument: max_value=100000000000000000000000
532+
# cannot be exactly represented as a float of
533+
# width 64 - use max_value=1e+23 instead.
534+
"L": {"max_length": 1},
535+
"D": {"min_length": 8, "max_length": 8},
536+
}
537+
538+
@composite
539+
def dbf_field(draw):
540+
field_type, bounds_dict = draw(sampled_from(list(DBF_FIELD_TYPES.items())))
541+
542+
name = draw(
543+
text(
544+
alphabet=characters(codec="ascii"),
545+
min_size=1,
546+
max_size=10,
547+
)
548+
)
549+
550+
max_length = bounds_dict.get("max_length", 254)
551+
min_length = bounds_dict.get("min_length", 1)
552+
max_decimal = bounds_dict.get("max_decimal", 0)
553+
size = draw(integers(min_value=min_length, max_value=max_length))
554+
decimal = draw(integers(min_value=0, max_value=max(0,min(size - 3, max_decimal))))
555+
556+
557+
return {"name": name, "field_type": field_type, "size": size, "decimal": decimal}
558+
559+
ascii_printable = string.ascii_letters + string.digits + string.punctuation #+ " "
560+
561+
def record_value_for_field(name: str, field_type: str, size: int, decimal: int = 0):
562+
563+
if field_type == "C":
564+
return text(
565+
alphabet=ascii_printable,
566+
min_size=0,
567+
max_size=size,
568+
)
569+
if field_type in {"N", "F"}:
570+
571+
int_digits = size if decimal == 0 else size - decimal - 1
572+
min_int = -(10 ** (int_digits - 1) - 1)
573+
max_int = 10 ** int_digits - 1
574+
575+
if decimal == 0:
576+
return integers(min_value=min_int, max_value=max_int)
577+
578+
# Max finite float: 2**1023 * (2 - 2**(-52))
579+
return floats(
580+
min_value=min_int - 1,
581+
max_value=max_int + 1,
582+
exclude_min=True,
583+
exclude_max=True,
584+
)
585+
if field_type == "L":
586+
return sampled_from([True, False, None])
587+
if field_type == "D":
588+
return one_of(dates(), dates().map(lambda d: d.strftime("%Y%m%d")))
589+
590+
raise ValueError(f"Unsupported: {field_type=}")
591+
592+
593+
@composite
594+
def dbf_fields_and_records(
595+
draw,
596+
max_fields=10, # In DbfWriter.__init__, max_num_fields: int = 2046,
597+
max_records=20,
598+
):
599+
600+
fields = draw(lists(dbf_field(), min_size=1, max_size=max_fields))
601+
602+
record_strategy = tuples(*(record_value_for_field(**field) for field in fields))
603+
604+
records = draw(lists(record_strategy, min_size=0, max_size=max_records))
605+
606+
return fields, records
607+
608+
609+
610+
@pytest.mark.hypothesis
611+
@given(fields_and_records=dbf_fields_and_records())
612+
def test_dbf_reader_writer_roundtrip(fields_and_records)-> None:
613+
fields, records = fields_and_records
614+
stream = io.BytesIO()
615+
with shp.DbfWriter(dbf=stream) as dbf_w:
616+
for field in fields:
617+
dbf_w.field(**field)
618+
for record in records:
619+
dbf_w.record(*record)
620+
stream.seek(0)
621+
with shp.DbfReader(dbf=stream) as r:
622+
actual_fields = iter(r.fields)
623+
next(actual_fields) # skip deletion flag
624+
for f_r, f_w in itertools.zip_longest(actual_fields, fields):
625+
actual_field_dict = f_r._asdict()
626+
for k in ("field_type", "size", "decimal"):
627+
assert actual_field_dict[k] == f_w[k], f"{k=}, {actual_field_dict[k]=}, {f_w[k]=}"
628+
for exp_rec, actual_rec in itertools.zip_longest(records, r.records()):
629+
for expected, actual, field in itertools.zip_longest(exp_rec, actual_rec, fields):
630+
field_type = field["field_type"]
631+
decimal = field["decimal"]
632+
if field_type == "D":
633+
if isinstance(expected, datetime.date):
634+
expected = expected.strftime("%Y%m%d")
635+
if isinstance(actual, datetime.date):
636+
actual = actual.strftime("%Y%m%d")
637+
elif field_type in ("N", "F"):
638+
expected = float(format(expected, f".{decimal}f"))
639+
# elif field_type == "C":
640+
# expected = expected.strip()
641+
assert actual == expected, f"{actual=}, {expected=}, {field_type=}, {type(actual)=}, {type(expected)=}"

0 commit comments

Comments
 (0)