@@ -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
273272class 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 (
0 commit comments