2323from pysus .api .errors import ConversionError , FormatError
2424from pysus .api .metadata .models import Column
2525from pysus .api .models import BaseCompressedFile , BaseLocalFile , BaseTabularFile
26+ from pysus .data .dbf_reader import (
27+ read_dbf_fast ,
28+ read_dbf_schema ,
29+ stream_dbf_fast ,
30+ )
2631
2732from .types import FileType
2833
@@ -341,7 +346,24 @@ class DBF(BaseTabularFile):
341346 def columns (self ) -> list ["Column" ]:
342347 """Return the column metadata from the DBF file."""
343348
344- reader = DBFReader (self .path , load = False )
349+ try :
350+ schema = read_dbf_schema (self .path )
351+ except Exception : # noqa: B902 — fallback to dbfread
352+ reader = DBFReader (self .path , load = False )
353+ _DBF_DTYPE = {
354+ "C" : "VARCHAR" ,
355+ "N" : "INTEGER" ,
356+ "F" : "FLOAT" ,
357+ "D" : "DATE" ,
358+ "L" : "BOOLEAN" ,
359+ "M" : "VARCHAR" ,
360+ }
361+ return [
362+ Column .from_schema (
363+ name = f .name , dtype = _DBF_DTYPE .get (f .type , "VARCHAR" )
364+ )
365+ for f in reader .fields
366+ ]
345367 _DBF_DTYPE = {
346368 "C" : "VARCHAR" ,
347369 "N" : "INTEGER" ,
@@ -352,15 +374,18 @@ def columns(self) -> list["Column"]:
352374 }
353375 return [
354376 Column .from_schema (
355- name = f .name , dtype = _DBF_DTYPE .get (f .type , "VARCHAR" )
377+ name = f .name , dtype = _DBF_DTYPE .get (f .type_ , "VARCHAR" )
356378 )
357- for f in reader .fields
379+ for f in schema .fields
358380 ]
359381
360382 @property
361383 def rows (self ) -> int :
362384 """Return the number of records in the DBF file."""
363- return len (DBFReader (self .path , load = False ))
385+ try :
386+ return read_dbf_schema (self .path ).num_records
387+ except Exception : # noqa: B902 — fallback to dbfread
388+ return len (DBFReader (self .path , load = False ))
364389
365390 def decode_column (self , value ):
366391 """Decode a raw DBF value, handling byte strings and null bytes.
@@ -386,22 +411,56 @@ def decode_column(self, value):
386411 return value .replace ("\x00 " , "" ).strip ()
387412 return value
388413
389- async def load (self ) -> pd .DataFrame :
390- """Read the entire DBF file into a DataFrame."""
414+ def _load_dbfread (self ) -> pd .DataFrame :
415+ """Read the entire DBF file into a DataFrame using dbfread."""
416+ dbf = DBFReader (self .path , encoding = "cp1252" , raw = True )
417+ df = pd .DataFrame (iter (dbf ))
418+ return df .map (self .decode_column )
391419
392- def _load ():
393- """Read the DBF file synchronously in a thread."""
394- dbf = DBFReader (self .path , encoding = "cp1252" , raw = True )
395- df = pd .DataFrame (iter (dbf ))
396- return df .map (self .decode_column )
420+ async def load (self , fast : bool = True ) -> pd .DataFrame :
421+ """Read the entire DBF file into a DataFrame.
397422
398- return await to_thread .run_sync (_load )
423+ Parameters
424+ ----------
425+ fast : bool
426+ If ``True`` use the byte-level reader (default), falling back
427+ to dbfread on failure.
428+ If ``False`` use dbfread.
429+ """
430+
431+ if fast :
432+ try :
433+ return await to_thread .run_sync (read_dbf_fast , self .path )
434+ except Exception : # noqa: B902 — fallback to dbfread
435+ pass
436+
437+ return await to_thread .run_sync (self ._load_dbfread )
399438
400439 async def stream (
401440 self ,
402441 chunk_size : int = 30000 ,
442+ fast : bool = True ,
403443 ) -> AsyncGenerator [pd .DataFrame , None ]:
404- """Yield the DBF records in chunks of the given size."""
444+ """Yield the DBF records in chunks of the given size.
445+
446+ Parameters
447+ ----------
448+ chunk_size : int
449+ Number of rows per chunk.
450+ fast : bool
451+ If ``True`` use the byte-level reader (default), falling back
452+ to dbfread on failure.
453+ If ``False`` use dbfread.
454+ """
455+
456+ if fast :
457+ try :
458+ for chunk in stream_dbf_fast (self .path , chunk_size ):
459+ yield chunk
460+ await asyncio .sleep (0 )
461+ return
462+ except Exception : # noqa: B902 — fallback to dbfread
463+ pass
405464
406465 def _get_db ():
407466 """Open the DBF reader synchronously in a thread."""
@@ -424,8 +483,20 @@ async def to_parquet(
424483 output_path : str | Path | None = None ,
425484 chunk_size : int = 30000 ,
426485 callback : Callable [[int , int ], None ] | None = None ,
486+ fast : bool = True ,
427487 ) -> "Parquet" :
428- """Convert the DBF file to Parquet format."""
488+ """Convert the DBF file to Parquet format.
489+
490+ Parameters
491+ ----------
492+ output_path : str or Path, optional
493+ chunk_size : int
494+ Rows per chunk when building Parquet.
495+ callback : callable, optional
496+ fast : bool
497+ If ``True`` use the byte-level reader (default).
498+ If ``False`` use dbfread.
499+ """
429500 from pysus .api .extensions import ExtensionFactory
430501
431502 out = (
@@ -440,53 +511,98 @@ async def to_parquet(
440511 raise ConversionError (f"Could not parse { out } to Parquet" )
441512 return file
442513
443- async def _stream_to_single_file ():
444- dbf_reader = DBFReader (self .path , encoding = "cp1252" , raw = True )
445- total_rows = len (dbf_reader )
446- writer = None
447- records = []
448-
514+ if fast :
449515 try :
450- for i , record in enumerate (dbf_reader ):
451- records .append (record )
452- current_count = i + 1
453-
454- if current_count % chunk_size == 0 :
455- df = pd .DataFrame (records ).map (self .decode_column )
456- table = pa .Table .from_pandas (df )
457- if writer is None :
458- writer = pq .ParquetWriter (str (out ), table .schema )
459- writer .write_table (table )
460- records = []
461-
462- if callback :
463- callback (current_count , total_rows )
464- await asyncio .sleep (0 )
465-
466- if records :
516+ await self ._to_parquet_fast (out , chunk_size , callback )
517+ except Exception : # noqa: B902 — fallback to dbfread
518+ await self ._to_parquet_dbfread (out , chunk_size , callback )
519+ else :
520+ await self ._to_parquet_dbfread (out , chunk_size , callback )
521+
522+ file = await ExtensionFactory .instantiate (out )
523+ if not isinstance (file , Parquet ):
524+ raise ConversionError (f"Could not parse { out } to Parquet" )
525+ return file
526+
527+ async def _to_parquet_fast (
528+ self ,
529+ out : Path ,
530+ chunk_size : int ,
531+ callback : Callable [[int , int ], None ] | None ,
532+ ):
533+ schema = read_dbf_schema (self .path )
534+ total_rows = schema .num_records
535+ writer = None
536+ processed = 0
537+
538+ try :
539+ for chunk in stream_dbf_fast (self .path , chunk_size ):
540+ table = pa .Table .from_pandas (chunk )
541+ if writer is None :
542+ writer = pq .ParquetWriter (str (out ), table .schema )
543+ writer .write_table (table )
544+ processed += len (chunk )
545+
546+ if callback :
547+ callback (processed , total_rows )
548+ await asyncio .sleep (0 )
549+
550+ if writer is None :
551+ df_empty = pd .DataFrame (
552+ columns = pd .Index ([f .name for f in schema .fields ])
553+ )
554+ table_empty = pa .Table .from_pandas (df_empty )
555+ writer = pq .ParquetWriter (str (out ), table_empty .schema )
556+ finally :
557+ if writer :
558+ writer .close ()
559+
560+ async def _to_parquet_dbfread (
561+ self ,
562+ out : Path ,
563+ chunk_size : int ,
564+ callback : Callable [[int , int ], None ] | None ,
565+ ):
566+ dbf_reader = DBFReader (self .path , encoding = "cp1252" , raw = True )
567+ total_rows = len (dbf_reader )
568+ writer = None
569+ records = []
570+
571+ try :
572+ for i , record in enumerate (dbf_reader ):
573+ records .append (record )
574+ current_count = i + 1
575+
576+ if current_count % chunk_size == 0 :
467577 df = pd .DataFrame (records ).map (self .decode_column )
468578 table = pa .Table .from_pandas (df )
469579 if writer is None :
470580 writer = pq .ParquetWriter (str (out ), table .schema )
471581 writer .write_table (table )
582+ records = []
472583
473584 if callback :
474- callback (total_rows , total_rows )
585+ callback (current_count , total_rows )
586+ await asyncio .sleep (0 )
475587
588+ if records :
589+ df = pd .DataFrame (records ).map (self .decode_column )
590+ table = pa .Table .from_pandas (df )
476591 if writer is None :
477- df_empty = pd .DataFrame (columns = pd .Index (self .columns ))
478- table_empty = pa .Table .from_pandas (df_empty )
479- writer = pq .ParquetWriter (str (out ), table_empty .schema )
592+ writer = pq .ParquetWriter (str (out ), table .schema )
593+ writer .write_table (table )
480594
481- finally :
482- if writer :
483- writer .close ()
595+ if callback :
596+ callback (total_rows , total_rows )
484597
485- await _stream_to_single_file ()
486- file = await ExtensionFactory .instantiate (out )
487- if not isinstance (file , Parquet ):
488- raise ConversionError (f"Could not parse { out } to Parquet" )
489- return file
598+ if writer is None :
599+ df_empty = pd .DataFrame (columns = pd .Index (self .columns ))
600+ table_empty = pa .Table .from_pandas (df_empty )
601+ writer = pq .ParquetWriter (str (out ), table_empty .schema )
602+
603+ finally :
604+ if writer :
605+ writer .close ()
490606
491607
492608class DBC (BaseTabularFile ):
0 commit comments