Skip to content

Commit 2772856

Browse files
committed
Make Field roundtrippable. Allow opting out of whitespace underscore replacement and stripping. Rename mangled class methods.
1 parent 912be01 commit 2772856

2 files changed

Lines changed: 113 additions & 56 deletions

File tree

src/shapefile.py

Lines changed: 90 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,42 @@ def _largest_valid_truncated_encoding(
269269
)
270270

271271

272-
# Use functional syntax to have an attribute named type, a Python keyword
273272
class Field(NamedTuple):
274273
name: str
275274
field_type: FieldTypeT
276275
size: int
277276
decimal: int
278277

278+
@classmethod
279+
@functools.cache
280+
def get_struct(cls) -> Struct:
281+
# En/decoding the name as "<10sx" embeds the null terminator.
282+
return Struct("<10sxc4xBB14x")
283+
284+
@classmethod
285+
def from_byte_stream(
286+
cls,
287+
b_io: ReadableBinStream,
288+
strict: bool = False,
289+
encoding: str = "utf8",
290+
encodingErrors: str = "strict",
291+
strip_leading_whitespace: bool = True,
292+
) -> Field:
293+
encoded_field_tuple: tuple[bytes, bytes, int, int]
294+
encoded_field_tuple = cls.get_struct().unpack(b_io.read(32))
295+
encoded_name, encoded_type_char, size, decimal = encoded_field_tuple
296+
297+
encoded_name, __, ___ = encoded_name.partition(b"\x00")
298+
name = encoded_name.decode(encoding, encodingErrors)
299+
if strip_leading_whitespace:
300+
name = name.lstrip()
301+
302+
field_type = FIELD_TYPE_ALIASES[encoded_type_char]
303+
304+
return cls.from_unchecked(
305+
name, field_type, size, decimal, strict, encoding, encodingErrors
306+
)
307+
279308
@classmethod
280309
def from_unchecked(
281310
cls,
@@ -287,6 +316,17 @@ def from_unchecked(
287316
encoding: str = "utf8",
288317
encodingErrors: str = "strict",
289318
) -> Field:
319+
320+
if "\x00" in name:
321+
msg = (
322+
"Field names should contain null characters "
323+
"as null bytes are used to pad them in the header. "
324+
f"Got: {name=} "
325+
)
326+
if strict:
327+
raise dbfFileException(msg)
328+
warnings.warn(msg, category=PossibleDataLoss)
329+
290330
try:
291331
type_ = FIELD_TYPE_ALIASES[field_type]
292332
except KeyError:
@@ -307,22 +347,25 @@ def from_unchecked(
307347
name=str(name), field_type=type_, size=int(size), decimal=int(decimal)
308348
)
309349

310-
inst.encode_field_descriptor(strict, encoding, encodingErrors)
350+
inst.encode_field_descriptor(
351+
strict=True, encoding=encoding, encodingErrors=encodingErrors
352+
)
311353
return inst
312354

313355
@functools.cache
314356
def encode_field_descriptor(
315357
self,
316-
strict: bool,
317-
encoding: str,
318-
encodingErrors: str,
358+
strict: bool = False,
359+
encoding: str = "utf8",
360+
encodingErrors: str = "strict",
361+
replace_ascii_spaces_with_underscores: bool = True,
319362
) -> bytes:
320363
encoded_name = self.name.encode(encoding, encodingErrors)
321-
encoded_name = encoded_name.replace(b" ", b"_")
364+
if replace_ascii_spaces_with_underscores:
365+
encoded_name = encoded_name.replace(b" ", b"_")
322366
encoded_name = encoded_name[:10].ljust(10, b"\x00")
323367
encoded_field_type = self.field_type.encode("ascii")
324-
return pack(
325-
"<10sxc4xBB14x", # Packing the name as "<10sx" adds the null terminator.
368+
return self.get_struct().pack(
326369
encoded_name,
327370
encoded_field_type,
328371
self.size,
@@ -938,7 +981,7 @@ def __init__(
938981
self._errors: dict[str, int] = {}
939982

940983
# add oid
941-
self.__oid: int = -1 if oid is None else oid
984+
self._oid: int = -1 if oid is None else oid
942985

943986
if self.shapeType != NULL and self.shapeType not in Point_shapeTypes:
944987
self.bbox: BBox = bbox or self._bbox_from_points()
@@ -978,7 +1021,7 @@ def __init__(
9781021
@property
9791022
def oid(self) -> int:
9801023
"""The index position of the shape in the original shapefile"""
981-
return self.__oid
1024+
return self._oid
9821025

9831026
@property
9841027
def shapeTypeName(self) -> str:
@@ -998,8 +1041,8 @@ def points_3D(self) -> list[Point3D]:
9981041
def __repr__(self) -> str:
9991042
class_name = self.__class__.__name__
10001043
if class_name == "Shape":
1001-
return f"Shape #{self.__oid}: {self.shapeTypeName}"
1002-
return f"{class_name} #{self.__oid}"
1044+
return f"Shape #{self._oid}: {self.shapeTypeName}"
1045+
return f"{class_name} #{self._oid}"
10031046

10041047
def _bbox_from_points(self) -> BBox:
10051048
xs: list[float] = []
@@ -2129,11 +2172,11 @@ def __init__(
21292172
:param values: A sequence of values
21302173
:param oid: The object id, an int (optional)
21312174
"""
2132-
self.__field_positions = field_positions
2175+
self._field_positions = field_positions
21332176
if oid is not None:
2134-
self.__oid = oid
2177+
self._oid = oid
21352178
else:
2136-
self.__oid = -1
2179+
self._oid = -1
21372180
list.__init__(self, values)
21382181

21392182
def __getattr__(self, item: str) -> RecordValue:
@@ -2150,7 +2193,7 @@ def __getattr__(self, item: str) -> RecordValue:
21502193
try:
21512194
if item == "__setstate__": # Prevent infinite loop from copy.deepcopy()
21522195
raise AttributeError("_Record does not implement __setstate__")
2153-
index = self.__field_positions[item]
2196+
index = self._field_positions[item]
21542197
return list.__getitem__(self, index)
21552198
except KeyError:
21562199
raise AttributeError(f"{item} is not a field name")
@@ -2170,7 +2213,7 @@ def __setattr__(self, key: str, value: RecordValue) -> None:
21702213
if key.startswith("_"): # Prevent infinite loop when setting mangled attribute
21712214
return list.__setattr__(self, key, value)
21722215
try:
2173-
index = self.__field_positions[key]
2216+
index = self._field_positions[key]
21742217
return list.__setitem__(self, index, value)
21752218
except KeyError:
21762219
raise AttributeError(f"{key} is not a field name")
@@ -2198,7 +2241,7 @@ def __getitem__(
21982241
return list.__getitem__(self, cast(Union[SupportsIndex, slice], item))
21992242
except TypeError:
22002243
try:
2201-
index = self.__field_positions[cast(str, item)]
2244+
index = self._field_positions[cast(str, item)]
22022245
except KeyError:
22032246
index = None
22042247
if index is not None:
@@ -2232,7 +2275,7 @@ def __setitem__(
22322275
list.__setitem__(self, *cast(ValidKVTuple, (key, value)))
22332276
return
22342277
except TypeError:
2235-
index = self.__field_positions.get(cast(str, key))
2278+
index = self._field_positions.get(cast(str, key))
22362279
if index is not None:
22372280
list.__setitem__(self, index, cast(RecordValue, value))
22382281
return
@@ -2242,22 +2285,22 @@ def __setitem__(
22422285
@property
22432286
def oid(self) -> int:
22442287
"""The index position of the record in the original shapefile"""
2245-
return self.__oid
2288+
return self._oid
22462289

22472290
def as_dict(self, date_strings: bool = False) -> dict[str, RecordValue]:
22482291
"""
22492292
Returns this Record as a dictionary using the field names as keys
22502293
:return: dict
22512294
"""
2252-
dct = {f: self[i] for f, i in self.__field_positions.items()}
2295+
dct = {f: self[i] for f, i in self._field_positions.items()}
22532296
if date_strings:
22542297
for k, v in dct.items():
22552298
if isinstance(v, date):
22562299
dct[k] = f"{v.year:04d}{v.month:02d}{v.day:02d}"
22572300
return dct
22582301

22592302
def __repr__(self) -> str:
2260-
return f"Record #{self.__oid}: {list(self)}"
2303+
return f"Record #{self._oid}: {list(self)}"
22612304

22622305
def __dir__(self) -> list[str]:
22632306
"""
@@ -2270,13 +2313,13 @@ def __dir__(self) -> list[str]:
22702313
dir(type(self))
22712314
) # default list methods and attributes of this class
22722315
fnames = list(
2273-
self.__field_positions.keys()
2316+
self._field_positions.keys()
22742317
) # plus field names (random order if Python version < 3.6)
22752318
return default + fnames
22762319

22772320
def __eq__(self, other: Any) -> bool:
22782321
if isinstance(other, _Record):
2279-
if self.__field_positions != other.__field_positions:
2322+
if self._field_positions != other._field_positions:
22802323
return False
22812324
return list.__eq__(self, other)
22822325

@@ -2631,14 +2674,16 @@ def __init__(
26312674
*,
26322675
encoding: str = "utf-8",
26332676
encodingErrors: str = "strict",
2677+
strict: bool = False,
26342678
):
26352679
super().__init__(file=dbf)
26362680

26372681
self.encoding = encoding
26382682
self.encodingErrors = encodingErrors
2683+
self.strict = strict
26392684

26402685
self.fields: list[Field] = []
2641-
self.__fieldLookup: dict[str, int] = {}
2686+
self._fieldLookup: dict[str, int] = {}
26422687

26432688
self._dbfHeader()
26442689

@@ -2653,49 +2698,41 @@ def _dbfHeader(self) -> None:
26532698

26542699
# read relevant header parts
26552700
self.file.seek(0)
2656-
self.numRecords, self.__dbfHdrLength, self._record_length = cast(
2701+
self.numRecords, self._dbfHdrLength, self._record_length = cast(
26572702
tuple[int, int, int], unpack("<xxxxLHH20x", self.file.read(32))
26582703
)
26592704

26602705
# read fields
2661-
numFields = (self.__dbfHdrLength - 33) // 32
2662-
for __field in range(numFields):
2663-
encoded_field_tuple: tuple[bytes, bytes, int, int] = unpack(
2664-
# Historically the name is a 10 char, null-terminated byte string.
2665-
# For clarity we now unpack it as <10sx,
2666-
# (instead of <11s, and then having to remove the null
2667-
# terminator that never needed to be unpacked in the first place).
2668-
"<10sxc4xBB14x",
2669-
self.file.read(32),
2706+
numFields = (self._dbfHdrLength - 33) // 32
2707+
for __ in range(numFields):
2708+
self.fields.append(
2709+
Field.from_byte_stream(
2710+
b_io=self.file,
2711+
strict=self.strict,
2712+
encoding=self.encoding,
2713+
encodingErrors=self.encodingErrors,
2714+
)
26702715
)
2671-
encoded_name, encoded_type_char, size, decimal = encoded_field_tuple
2672-
2673-
encoded_name, __, ___ = encoded_name.partition(b"\x00")
2674-
name = encoded_name.decode(self.encoding, self.encodingErrors)
2675-
name = name.lstrip()
2676-
2677-
field_type = FIELD_TYPE_ALIASES[encoded_type_char]
26782716

2679-
self.fields.append(Field(name, field_type, size, decimal))
26802717
terminator = self.file.read(1)
26812718
if terminator != b"\r":
2682-
raise ShapefileException(
2683-
"Shapefile dbf header lacks expected terminator. (likely corrupt?)"
2719+
raise dbfFileException(
2720+
"Dbf header lacks expected terminator. (likely corrupt?)"
26842721
)
26852722

26862723
# insert deletion field at start
26872724
self.fields.insert(0, Field("DeletionFlag", FieldType.C, 1, 0))
26882725

26892726
# store all field positions for easy lookups
26902727
# note: fieldLookup gives the index position of a field inside Reader.fields
2691-
self.__fieldLookup = {f.name: i for i, f in enumerate(self.fields)}
2728+
self._fieldLookup = {f.name: i for i, f in enumerate(self.fields)}
26922729

26932730
# by default, read all fields except the deletion flag, hence "[1:]"
26942731
# note: recLookup gives the index position of a field inside a _Record list
26952732
fieldnames = [f.name for f in self.fields[1:]]
26962733
__fieldTuples, recLookup, recStruct = self._record_fields(fieldnames)
2697-
self.__fullRecStruct = recStruct
2698-
self.__fullRecLookup = recLookup
2734+
self._fullRecStruct = recStruct
2735+
self._fullRecLookup = recLookup
26992736

27002737
def _record_fmt(self, fields: Container[str] | None = None) -> tuple[str, int]:
27012738
"""Calculates the format and size of a .dbf record. Optional 'fields' arg
@@ -2739,7 +2776,7 @@ def _record_fields(
27392776
recStruct = Struct(fmt)
27402777
# make sure the given fieldnames exist
27412778
for name in unique_fields:
2742-
if name not in self.__fieldLookup or name == "DeletionFlag":
2779+
if name not in self._fieldLookup or name == "DeletionFlag":
27432780
raise ValueError(f'"{name}" is not a valid field name')
27442781
# fetch relevant field info tuples
27452782
fieldTuples = []
@@ -2752,8 +2789,8 @@ def _record_fields(
27522789
else:
27532790
# use all the dbf fields
27542791
fieldTuples = self.fields[1:] # sans deletion flag
2755-
recStruct = self.__fullRecStruct
2756-
recLookup = self.__fullRecLookup
2792+
recStruct = self._fullRecStruct
2793+
recLookup = self._fullRecLookup
27572794
return fieldTuples, recLookup, recStruct
27582795

27592796
def _record(
@@ -2867,7 +2904,7 @@ def record(self, i: int = 0, fields: list[str] | None = None) -> _Record | None:
28672904
i = ensure_within_bounds(i, self.numRecords)
28682905
recSize = self._record_length
28692906
self.file.seek(0)
2870-
self.file.seek(self.__dbfHdrLength + (i * recSize))
2907+
self.file.seek(self._dbfHdrLength + (i * recSize))
28712908
fieldTuples, recLookup, recStruct = self._record_fields(fields)
28722909
return self._record(
28732910
oid=i, fieldTuples=fieldTuples, recLookup=recLookup, recStruct=recStruct
@@ -2939,7 +2976,7 @@ def iterRecords(
29392976
elif stop < 0:
29402977
stop = range(self.numRecords)[stop]
29412978
recSize = self._record_length
2942-
self.file.seek(self.__dbfHdrLength + (start * recSize))
2979+
self.file.seek(self._dbfHdrLength + (start * recSize))
29432980
fieldTuples, recLookup, recStruct = self._record_fields(fields)
29442981
for i in range(start, stop):
29452982
r = self._record(

tests/hypothesis_tests.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -536,12 +536,15 @@ def test_shx_reader_writer_roundtrip(codes_and_shapes)-> None:
536536
}
537537

538538
@composite
539-
def dbf_field(draw):
539+
def dbf_fields(draw):
540540
field_type, bounds_dict = draw(sampled_from(list(DBF_FIELD_TYPES.items())))
541541

542542
name = draw(
543543
text(
544-
alphabet=characters(codec="ascii"),
544+
alphabet=characters(
545+
codec="ascii",
546+
exclude_characters=["\x00"],
547+
),
545548
min_size=1,
546549
max_size=10,
547550
)
@@ -556,6 +559,23 @@ def dbf_field(draw):
556559

557560
return {"name": name, "field_type": field_type, "size": size, "decimal": decimal}
558561

562+
563+
@pytest.mark.hypothesis
564+
@given(field_kwargs=dbf_fields())
565+
def test_dbf_Field_roundtrips(
566+
field_kwargs: dict,
567+
) -> None:
568+
expected = shp.Field.from_unchecked(**field_kwargs)
569+
stream = io.BytesIO()
570+
encoded = expected.encode_field_descriptor(replace_ascii_spaces_with_underscores=False)
571+
stream.write(encoded)
572+
stream.seek(0)
573+
actual = shp.Field.from_byte_stream(stream, strip_leading_whitespace=False)
574+
assert isinstance(actual, shp.Field)
575+
assert actual.name == expected.name
576+
assert actual[1:] == expected[1:]
577+
578+
559579
ascii_printable = string.ascii_letters + string.digits + string.punctuation + " "
560580

561581
def record_value_for_field(name: str, field_type: str, size: int, decimal: int = 0):
@@ -596,7 +616,7 @@ def _dbf_fields_and_record_strategy(
596616
max_records=20,
597617
):
598618

599-
fields = draw(lists(dbf_field(), min_size=1, max_size=max_fields))
619+
fields = draw(lists(dbf_fields(), min_size=1, max_size=max_fields))
600620

601621
record_strategy = tuples(*(record_value_for_field(**field) for field in fields))
602622

0 commit comments

Comments
 (0)