@@ -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
235273class 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