Skip to content

Commit 912be01

Browse files
committed
Refactor field descriptor array encoding into Field.encode_field_descriptor
1 parent f9f6a0c commit 912be01

2 files changed

Lines changed: 97 additions & 24 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1347,7 +1347,11 @@ applies to both reading and writing.
13471347
True
13481348
>>> r.close()
13491349

1350-
1350+
Valid values for encodingErrors are those supported by both `bytes.decode` and `str.encode` in the Python
1351+
version being used, e.g. 'strict', 'ignore' or 'replace'
1352+
in [CPython 3.9 - 3.14](https://docs.python.org/3/library/stdtypes.html#bytes.decode)
1353+
('xmlcharrefreplace' and 'backslashreplace' are only supported by
1354+
[`str.encode`](https://docs.python.org/3/library/stdtypes.html#str.encode)).
13511355

13521356
## Reading Large Shapefiles
13531357

src/shapefile.py

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,44 @@ class FieldType:
231231
FIELD_TYPE_ALIASES[c.encode("ascii").upper()] = c
232232

233233

234+
class PossibleDataLoss(Warning):
235+
pass
236+
237+
238+
def _largest_valid_truncated_encoding(
239+
s: str,
240+
max_bytes: int,
241+
strict: bool,
242+
encoding: str = "utf8",
243+
encodingErrors: str = "strict",
244+
) -> tuple[bytes, str]:
245+
N = len(s)
246+
for i in reversed(range(0, N + 1)):
247+
trimmed = s[:i]
248+
encoded = trimmed.encode(encoding, encodingErrors)
249+
if len(encoded) <= max_bytes:
250+
if i <= N - 1:
251+
msg = (
252+
f"Dropped {N - i} code points (e.g. characters)! "
253+
f"{s} was truncated to {trimmed} (discarding: {s[i:]}), "
254+
f"in order to encode it under {max_bytes} bytes for the field or field name. "
255+
f"Used: {encoding=} and {encodingErrors=}. "
256+
)
257+
if strict:
258+
raise ValueError(f"Data loss. {strict=}.\n{msg}")
259+
else:
260+
warnings.warn(
261+
msg,
262+
category=PossibleDataLoss,
263+
)
264+
return encoded, trimmed
265+
raise ValueError(
266+
f"Maximum truncation not sufficient to encode below {max_bytes=}. "
267+
f"Could not encode first code point (e.g. character): {s[0]} "
268+
f"to a short enough byte string, using {encoding=}, {encodingErrors=}"
269+
)
270+
271+
234272
# Use functional syntax to have an attribute named type, a Python keyword
235273
class Field(NamedTuple):
236274
name: str
@@ -245,6 +283,9 @@ def from_unchecked(
245283
field_type: str | bytes | FieldTypeT = "C",
246284
size: int = 50,
247285
decimal: int = 0,
286+
strict: bool = False,
287+
encoding: str = "utf8",
288+
encodingErrors: str = "strict",
248289
) -> Field:
249290
try:
250291
type_ = FIELD_TYPE_ALIASES[field_type]
@@ -262,10 +303,32 @@ def from_unchecked(
262303

263304
# A doctest in README.md previously passed in a string ('40') for size,
264305
# so explictly convert name to str, and size and decimal to ints.
265-
return cls(
306+
inst = cls(
266307
name=str(name), field_type=type_, size=int(size), decimal=int(decimal)
267308
)
268309

310+
inst.encode_field_descriptor(strict, encoding, encodingErrors)
311+
return inst
312+
313+
@functools.cache
314+
def encode_field_descriptor(
315+
self,
316+
strict: bool,
317+
encoding: str,
318+
encodingErrors: str,
319+
) -> bytes:
320+
encoded_name = self.name.encode(encoding, encodingErrors)
321+
encoded_name = encoded_name.replace(b" ", b"_")
322+
encoded_name = encoded_name[:10].ljust(10, b"\x00")
323+
encoded_field_type = self.field_type.encode("ascii")
324+
return pack(
325+
"<10sxc4xBB14x", # Packing the name as "<10sx" adds the null terminator.
326+
encoded_name,
327+
encoded_field_type,
328+
self.size,
329+
self.decimal,
330+
)
331+
269332
def __repr__(self) -> str:
270333
return f'Field(name="{self.name}", field_type=FieldType.{self.field_type}, size={self.size}, decimal={self.decimal})'
271334

@@ -2598,15 +2661,16 @@ def _dbfHeader(self) -> None:
25982661
numFields = (self.__dbfHdrLength - 33) // 32
25992662
for __field in range(numFields):
26002663
encoded_field_tuple: tuple[bytes, bytes, int, int] = unpack(
2601-
"<11sc4xBB14x", self.file.read(32)
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),
26022670
)
26032671
encoded_name, encoded_type_char, size, decimal = encoded_field_tuple
26042672

2605-
if b"\x00" in encoded_name:
2606-
idx = encoded_name.index(b"\x00")
2607-
else:
2608-
idx = len(encoded_name) - 1
2609-
encoded_name = encoded_name[:idx]
2673+
encoded_name, __, ___ = encoded_name.partition(b"\x00")
26102674
name = encoded_name.decode(self.encoding, self.encodingErrors)
26112675
name = name.lstrip()
26122676

@@ -2624,11 +2688,11 @@ def _dbfHeader(self) -> None:
26242688

26252689
# store all field positions for easy lookups
26262690
# note: fieldLookup gives the index position of a field inside Reader.fields
2627-
self.__fieldLookup = {f[0]: i for i, f in enumerate(self.fields)}
2691+
self.__fieldLookup = {f.name: i for i, f in enumerate(self.fields)}
26282692

26292693
# by default, read all fields except the deletion flag, hence "[1:]"
26302694
# note: recLookup gives the index position of a field inside a _Record list
2631-
fieldnames = [f[0] for f in self.fields[1:]]
2695+
fieldnames = [f.name for f in self.fields[1:]]
26322696
__fieldTuples, recLookup, recStruct = self._record_fields(fieldnames)
26332697
self.__fullRecStruct = recStruct
26342698
self.__fullRecLookup = recLookup
@@ -3831,16 +3895,18 @@ def __init__(
38313895
encoding: str = "utf-8",
38323896
encodingErrors: str = "strict",
38333897
max_num_fields: int = 2046,
3898+
strict: bool = False,
38343899
# Keep kwargs even though unused, to preserve PyShp 2.4 API
38353900
**kwargs: Any,
38363901
):
38373902
super().__init__(file=dbf)
38383903

38393904
self.encoding = encoding
38403905
self.encodingErrors = encodingErrors
3906+
self.max_num_fields = max_num_fields
3907+
self.strict = strict
38413908

38423909
self.fields: list[Field] = []
3843-
self.max_num_fields = max_num_fields
38443910
self.recNum = 0
38453911

38463912
def field(
@@ -3856,7 +3922,15 @@ def field(
38563922
raise dbfFileException(
38573923
f".dbf Shapefile Writer reached maximum number of fields: {self.max_num_fields}."
38583924
)
3859-
field_ = Field.from_unchecked(name, field_type, size, decimal)
3925+
field_ = Field.from_unchecked(
3926+
name=name,
3927+
field_type=field_type,
3928+
size=size,
3929+
decimal=decimal,
3930+
encoding=self.encoding,
3931+
encodingErrors=self.encodingErrors,
3932+
strict=self.strict,
3933+
)
38603934
self.fields.append(field_)
38613935

38623936
def _header(self) -> None:
@@ -3892,21 +3966,16 @@ def _header(self) -> None:
38923966
recordLength,
38933967
)
38943968
f.write(header)
3969+
38953970
# Field descriptors
38963971
for field in fields:
3897-
encoded_name = field.name.encode(self.encoding, self.encodingErrors)
3898-
encoded_name = encoded_name.replace(b" ", b"_")
3899-
encoded_name = encoded_name[:10].ljust(11).replace(b" ", b"\x00")
3900-
encodedFieldType = field.field_type.encode("ascii")
3901-
fld = pack(
3902-
"<11sc4xBB14x",
3903-
encoded_name,
3904-
encodedFieldType,
3905-
field.size,
3906-
field.decimal,
3972+
f.write(
3973+
field.encode_field_descriptor(
3974+
self.strict, self.encoding, self.encodingErrors
3975+
)
39073976
)
3908-
f.write(fld)
3909-
# Terminator
3977+
3978+
# Terminator (0x0d from dbf spec https://en.wikipedia.org/wiki/.dbf#File_header)
39103979
f.write(b"\r")
39113980

39123981
def record(

0 commit comments

Comments
 (0)